Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Patching — streaming reconstruction (zarr accumulator)

Streaming reconstruction: zarr accumulator, then real GeoTIFFs

Two ways geopatcher handles fields that don’t fit in RAM:

  1. Part 1 — disk-backed SpatialOverlapAdd: accumulate per-chip output into an on-disk zarr store instead of a float64 in-memory array. Plus a hierarchical Patcher-of-Patchers recipe that parallelises naturally.
  2. Part 2 — real GeoTIFFs: write a synthetic raster to disk, open via RasterioReader (lazy windowed reads via GDAL), and run the same pipeline against bytes-on-disk rather than an in-memory array.

Both rely on zarr>=3; install with pip install 'geopatcher[streaming]'.

Part 1 — disk-backed SpatialOverlapAdd

In-memory baseline

arr.shape: (32, 32)
len(patches): 16
patches[0].data.shape: (8, 8)
in_memory.shape: (32, 32)
in_memory.sum(): 1024.0

Disk-backed accumulator

Pass streaming=True + target_path=... to SpatialOverlapAdd. The merge accumulates into a chunked zarr store on disk; the return value is a zarr array readable as numpy (or via xarray).

on_disk.shape: (32, 32), dtype: float32
disk-backed result matches in-memory baseline

Hierarchical Patcher-of-Patchers

A recipe, not a class: an outer Patcher chops the field into coarse super-tiles, an inner Patcher chops each super-tile into fine chips. The outer aggregation is implicit (each super-tile is written out as soon as it’s reconstructed); the inner aggregation is RAM-resident at super-tile scale.

big.shape: (64, 64)
  super-tile anchor=(0, 0) shape=(32, 32)
  super-tile anchor=(0, 32) shape=(32, 32)
  super-tile anchor=(32, 0) shape=(32, 32)
  super-tile anchor=(32, 32) shape=(32, 32)
result.shape: (64, 64)
<Figure size 640x480 with 2 Axes>

streaming_safe flag

Every SpatialAggregation advertises whether it can run one patch at a time. SpatialMedian, SpatialMode, and SpatialLearned are streaming_safe = False — they need per-cell history. When SpatialPatcher.merge is called on one, the framework emits a RuntimeWarning pointing at the streamable substitute.

SpatialMedian has streaming_safe = False — the merge is happening in-RAM. For streaming alternatives see scaling.md (Median->ApproxQuantile, Mode->HardVote or ApproxMode, Learned->two-pass).

Part 2 — streaming on real GeoTIFFs

Same pipeline, real on-disk input. Write a synthetic single-band float32 raster, open with RasterioReader, wrap as a RasterField, and run the Patcher. Each chip is a real rasterio windowed read — bytes-on-disk fetched lazily through GDAL.

truth array shape: (256, 256), dtype: float32
wrote: /var/folders/k9/_v6ywhvj0nq36tpttd3j4mq80000gn/T/geotoolz-stream-i_tajnv8/synthetic.tif  (262,612 bytes)

Open via RasterioReader and wrap as a Field

reader.shape: (1, 256, 256)
reader.dtype: float32

Patch the GeoTIFF

Chips are 64×64 with stride 64 — each chip reads exactly one tile from the tiled GeoTIFF.

expected anchors: 4 x 4 = 16
first patch anchor: (0, 0)
first patch indices: Window(col_off=0, row_off=0, width=64, height=64)
first patch data type: RasterioReader
first patch data.shape: (1, 64, 64)
materialised .values.shape: (1, 64, 64), dtype: float32

Per-chip operator

In-RAM reconstruction

in-RAM result shape: (256, 256), dtype: float64
in-RAM result matches ground truth

Disk-backed reconstruction

Swap the in-memory SpatialOverlapAdd for the streaming variant pointed at a fresh tempdir. The merge accumulates into a chunked zarr store; the return value is a zarr array readable lazily.

We force float64 end-to-end (matching the in-RAM path) so the in-RAM and disk-backed results are bit-identical, not merely close.

materialised 16 patches
patches[0].data.shape: (64, 64)
patches[0].data.dtype: float64
on-disk zarr.shape: (256, 256), dtype: float32
on-disk materialised.shape: (256, 256), dtype: float64
disk-backed result matches ground truth
in-RAM and disk-backed reconstructions agree

Visual sanity check — in-RAM vs zarr, side-by-side

Top row: truth, in-RAM reconstruction, disk-backed (zarr) reconstruction. Bottom row: per-pixel absolute differences against truth and against each other. The |in-RAM - zarr| panel quantifies the float32 ↔ float64 round-trip introduced by the streaming path’s on-disk dtype.

<Figure size 1100x700 with 7 Axes>

The |in-RAM − truth| panel is all-zero because both paths run in float64 and Boxcar/non-overlapping patches read each pixel exactly once. The |zarr − truth| panel shows the small float32 round-trip the on-disk accumulator introduces (controlled by zarr’s array dtype, configurable in SpatialOverlapAdd). For applications that need bit-exact reconstruction you can construct the on-disk store as float64 instead.

Overlap + Hann demo on the GeoTIFF

Same pipeline with stride=32 and a SpatialHann window. OverlapAdd normalises by the accumulated weights so the reconstruction recovers the squared field on the interior. Boundaries are not covered (the leftmost 32 px column and topmost 32 px row have zero Hann weight); we trim them off in the comparison.

ola_result.shape: (256, 256)
hann/zarr.shape: (256, 256), dtype: float32
Hann interior reconstruction matches ground truth (both paths)
<Figure size 1100x700 with 6 Axes>

Scaling to a real Sentinel-2 tile

The synthetic 256×256 GeoTIFF above keeps the demo fast. The same code path scales to a full Sentinel-2 tile (~10980×10980 px) by swapping in a real MPC asset URL — rasterio reads only the bytes each SpatialPatcher.Window touches.

from geostack import load_stac_items, LAKE_TAHOE_TILE
item = load_stac_items(
    "sentinel-2-l2a", (-180, -90, 180, 90), "2024-06-01/2024-07-15",
    tile=LAKE_TAHOE_TILE, max_cloud_cover=5,
)[0]
# Lazy windowed read straight from MPC — no full-tile fetch.
reader = RasterioReader(item.assets["B04"].href)
field = RasterField(reader)
# Same patcher + same `Sequential` as above; storage shape is
# `(10980, 10980)` instead of `(256, 256)`.

In production the on-disk zarr accumulator scales to whatever fits on the volume you point target_path at — useful for global inference runs where the reconstructed field is hundreds of GB.

What this proves

  • The Field Protocol cleanly bridges georeader’s existing GeoData surface to the Patcher — no new IO code is needed for rasters.
  • Each chip is a real rasterio windowed read; GDAL fetches only the bytes touched by the patch’s Window.
  • The streaming SpatialOverlapAdd writes one chip’s contribution at a time into the zarr store. The accumulator never has to hold the full reconstruction in RAM.
  • With a tiled GeoTIFF (or a COG) the zarr chunk shape can match the chip shape, so each read and write is a single block.