Running Model Inference over Large Rasters

A trained model that works beautifully on 256-pixel chips has to be applied to scenes of 10,980 × 10,980 pixels or to mosaics a hundred times larger. Doing that correctly is a raster engineering problem with three requirements that pull against each other: the output must be pixel-aligned with the input, no tile boundary may be visible in the result, and peak memory must stay bounded regardless of scene size. This topic belongs to Raster Machine Learning & Model Inference and picks up where building training datasets from satellite imagery leaves off.

The core idea is a halo: read more than you write. Each tile is read with a margin of context around it, the model predicts the whole padded tile, and only the centre is written. Every output pixel therefore had a full neighbourhood available when it was predicted, and the tile edges disappear.


Prerequisites

pip install "rasterio>=1.3.0" "numpy>=1.23" "onnxruntime>=1.16" "tqdm>=4.66"
Package Minimum version Why required
rasterio 1.3.0 Windowed reads and writes, Window.intersection, profile handling
numpy 1.23 Tile arrays, batching, and the crop arithmetic
onnxruntime 1.16 Runs an exported model without a training framework in the image
tqdm 4.66 Progress over a window grid that can run for hours

Conceptually, you need a model artifact that declares its band order and scaling, as set out on the section overview, and a source raster that is internally tiled — a striped GeoTIFF forces a full-width read for every window and destroys the performance of this whole pattern. Converting the input is covered in converting a GeoTIFF to a COG with rio-cogeo.


Step-by-Step Workflow

Step 1 — Plan the read and write windows

Every tile has two windows: the write window, which tiles the scene exactly with no gaps and no overlap, and the read window, which is the write window grown by the halo and clipped to the scene. The write windows partition the output; the read windows overlap each other.

Read windows overlap, write windows do not Four write windows tile a region exactly, meeting edge to edge. The read window for the top-left tile is drawn larger, extending a halo beyond the write window on every side so that pixels on the write boundary still have full context. Only the centre of each prediction is kept, so no tile edge appears in the output. One tile: read 384 px, predict 384 px, write the middle 256 px write window write window write window write window read window write + halo, clipped to scene halo = 64 px discarded after the model call Reads cost 2.25x the pixels at this halo; the write grid is untouched, so the output has no seams.
from dataclasses import dataclass

import rasterio
from rasterio.windows import Window


@dataclass(frozen=True)
class Tile:
    read: Window
    write: Window
    crop: tuple[int, int, int, int]   # top, bottom, left, right trim in read pixels


def plan_tiles(width: int, height: int, size: int = 256, halo: int = 64):
    """Write windows tile the scene exactly; read windows add a clipped halo."""
    for row in range(0, height, size):
        for col in range(0, width, size):
            w = min(size, width - col)
            h = min(size, height - row)
            write = Window(col, row, w, h)
            r0, c0 = max(0, row - halo), max(0, col - halo)
            r1 = min(height, row + h + halo)
            c1 = min(width, col + w + halo)
            read = Window(c0, r0, c1 - c0, r1 - r0)
            yield Tile(read, write, (row - r0, r1 - (row + h), col - c0, c1 - (col + w)))

Storing the crop amounts on the tile removes the arithmetic from the hot loop, and — more importantly — makes the edge cases explicit. A tile at the top-left of the scene has no halo above or to the left, so its crop is (0, 64, 0, 64) rather than (64, 64, 64, 64).

Step 2 — Derive the output profile from the input

The output must sit on the source grid. The reliable way to guarantee that is never to construct a transform at all: copy the source profile and override only what changes.

import rasterio


def open_prediction_writer(src: rasterio.DatasetReader, path: str,
                           *, n_classes: int = 1):
    profile = src.profile | {
        "count": n_classes,
        "dtype": "uint8",
        "nodata": 0,
        "compress": "deflate",
        "predictor": 2,        # horizontal differencing suits class rasters
        "tiled": True,
        "blockxsize": 512,
        "blockysize": 512,
        "BIGTIFF": "IF_SAFER",
    }
    profile.pop("photometric", None)   # inherited RGB hints break a 1-band output
    return rasterio.open(path, "w", **profile)

Dropping photometric matters when the source is a three-band true-colour product: GDAL will otherwise refuse a single-band write with an inherited photometric=RGB. The compression choices are the same ones weighed in choosing COG compression: ZSTD vs DEFLATE, with the caveat that class rasters compress extraordinarily well under predictor=2.

Step 3 — Batch reads so the model is never starved

A model call on a batch of sixteen tiles costs barely more than a call on one, because the framework overhead is amortised. The read, however, scales linearly. Batching the reads and overlapping them with compute is what turns a half-utilised device into a saturated one.

import numpy as np


def batched_tiles(src, tiles, batch_size: int = 16, scale: float = 10_000.0):
    """Yield (arrays, tiles) batches of padded tiles read from the source."""
    buf_arr, buf_tile = [], []
    for tile in tiles:
        arr = src.read(window=tile.read, boundless=True,
                       fill_value=src.nodata or 0).astype("float32") / scale
        buf_arr.append(arr)
        buf_tile.append(tile)
        if len(buf_arr) == batch_size:
            yield buf_arr, buf_tile
            buf_arr, buf_tile = [], []
    if buf_arr:
        yield buf_arr, buf_tile
Serial loop versus pipelined reader In the serial loop the device waits during every read and every write, so it is busy for roughly a third of the wall clock. With a reader thread filling a bounded queue, reads for the next batch happen while the current batch is on the device, and the device row becomes almost continuous. Same work, two schedules serial device busy 33% of the wall clock — three batches take 510 units reader thread device device busy 75% — the same three batches finish in 380 units read + decode model call crop + write queue depth 2 is enough

Note that the arrays in a batch are not all the same shape: edge tiles have a smaller halo. Either pad them to the nominal read size before stacking, or group tiles by shape. Padding is simpler and the cost is negligible because edge tiles are a small fraction of any real scene.

Step 4 — Predict, crop, write

The crop is where the halo is discarded. Getting the sign of the trim wrong here produces an output that is shifted by exactly the halo width — a bug that looks like a georeferencing problem and is not.

import numpy as np


def run_inference(session, src, dst, tiles, batch_size: int = 16) -> None:
    input_name = session.get_inputs()[0].name
    for arrays, batch in batched_tiles(src, tiles, batch_size):
        padded, shapes = pad_to_common(arrays)          # (n, bands, H, W)
        logits = session.run(None, {input_name: padded})[0]
        classes = logits.argmax(axis=1).astype("uint8") + 1
        for pred, tile, (h, w) in zip(classes, batch, shapes):
            top, bottom, left, right = tile.crop
            core = pred[:h, :w][top:h - bottom, left:w - right]
            dst.write(core, 1, window=tile.write)

The + 1 after argmax keeps class 0 reserved for “not predicted”, matching the convention used throughout this section. The nodata mask is applied on the same core array before writing when the source has fill pixels.

Step 5 — Finish the file

A prediction raster is a data product, and a data product needs overviews. For a class raster those overviews must use nearest-neighbour or mode resampling — averaging class codes produces values that correspond to no class at all.

import rasterio
from rasterio.enums import Resampling

with rasterio.open("prediction.tif", "r+") as dst:
    dst.build_overviews([2, 4, 8, 16, 32], Resampling.mode)
    dst.update_tags(ns="rio_overview", resampling="mode")
    dst.update_tags(
        model_name="landcover_v3",
        model_contract_sha="9f1c2a…",
        source_scene="S2A_36NYF_20260614",
    )

Writing the model identity into the file’s tags is what makes the output traceable months later; the mechanics are the same as in reading and writing GDAL tags and band descriptions.


Scaling the Loop Across Processes and Machines

One process streaming tiles will saturate a laptop’s disk and a single GPU. Beyond that, the loop has to spread out, and the shape it takes depends entirely on which model class you are running.

Pixel-based models parallelise by window with no coordination at all. Each worker opens its own dataset handle — GDAL dataset objects are emphatically not safe to share across processes — takes a slice of the window list, and writes into its own output file. Merging the per-worker outputs afterwards is a plain mosaic of non-overlapping pieces, which is the cheapest case of merging tiles with rasterio merge.

from concurrent.futures import ProcessPoolExecutor

import rasterio


def worker(args) -> str:
    src_path, dst_path, tiles = args
    with rasterio.open(src_path) as src:          # open INSIDE the worker
        with open_prediction_writer(src, dst_path) as dst:
            run_inference(load_session(), src, dst, tiles)
    return dst_path


def run_parallel(src_path: str, tiles: list, n_workers: int = 8) -> list[str]:
    chunks = [tiles[i::n_workers] for i in range(n_workers)]
    jobs = [(src_path, f"pred_part{i}.tif", c) for i, c in enumerate(chunks)]
    with ProcessPoolExecutor(max_workers=n_workers) as pool:
        return list(pool.map(worker, jobs))

Striping the tile list with tiles[i::n_workers] rather than handing each worker a contiguous block is deliberate for a different reason than load balance: it spreads each worker’s reads across the whole file, so no two workers are fighting over the same byte ranges of a remote object at the same moment.

Convolutional models on a shared GPU want the opposite arrangement — one process owning the device, several reader threads feeding it. Threads work here where processes would not, because the read releases the GIL inside GDAL and the model call releases it inside the framework.

When a single machine can no longer finish the backlog overnight, the unit of distribution becomes the scene rather than the tile. One scene per task, each task self-contained, is the granularity that survives retries and partial failures, and it maps directly onto the job patterns in distributed processing on Coiled and AWS Batch. Resist the temptation to distribute individual tiles across machines: the scheduling overhead per tile swamps the tens of milliseconds of work, and the output assembly becomes a distributed write.


Parameter Reference

Parameter Type Default Usage note
size (write tile) int 256 Match or multiply the source’s internal block size to avoid partial block reads
halo int 64 At least half the model’s receptive field; 0 for pixel-based models
batch_size int 16 Raise until device memory is ~80% used; beyond that the gain is nil
boundless bool True Required so edge tiles keep a predictable shape
predictor int 2 Horizontal differencing; halves the size of most class rasters
BIGTIFF str IF_SAFER Prevents the 4 GB ceiling from truncating a continental output
Resampling.mode enum The only correct overview resampling for categorical output
num_threads (GDAL) int 4 Decompression threads; raise for ZSTD inputs on many-core machines

Verification & Testing

The cheapest verification is a grid assertion: the prediction must have exactly the source’s shape and transform.

import rasterio

with rasterio.open("s2_stack_36NYF.tif") as src, \
     rasterio.open("prediction.tif") as pred:
    assert pred.transform == src.transform, "output grid drifted"
    assert (pred.width, pred.height) == (src.width, src.height)
    assert pred.crs == src.crs

The second check is a seam test. Read a strip that crosses several tile boundaries and look at the class-change frequency as a function of column. If changes spike at exactly every 256th column, the halo is not being applied — either it is zero, or the crop arithmetic is discarding the wrong side.

import numpy as np
import rasterio

with rasterio.open("prediction.tif") as pred:
    strip = pred.read(1, window=((5000, 5001), (0, pred.width)))[0]
changes = np.flatnonzero(np.diff(strip) != 0)
at_seams = (changes % 256 == 0).mean()
print(f"{at_seams:.1%} of class changes fall exactly on a tile boundary")
The seam signature in a class-change histogram Class changes are counted along one row of the prediction and binned by column. Without a halo, tall spikes appear at columns 256, 512, 768 and 1024 where tiles abut, standing far above the background rate. With a halo the same histogram is flat, and the tile boundaries cannot be located from the output. Class changes per column bin along one row high 0 col 256 col 512 col 768 col 1024 Every spike sits on a multiple of the write tile size — the signature of a missing or mis-cropped halo.

A healthy run puts well under 1% of changes on tile boundaries. A broken run puts 20% or more there. This test has the great virtue of needing no reference data — it detects the defect from the output alone. The visual equivalent, and the fix when it fails, is in stitching prediction tiles without seams.

Finally, check the class histogram against expectations before publishing anything. A land-cover product where 60% of pixels are class 0 means the nodata mask swallowed the scene, usually because the scale factor was applied twice and every feature landed outside the model’s training range.


Troubleshooting

The output is shifted by exactly the halo width

The crop trims from the wrong side, or boundless padding was included in the core slice. Assert on a tile in the middle of the scene that core.shape equals (tile.write.height, tile.write.width) before writing; a mismatch shows up immediately.

rasterio.errors.WindowError: Bounds and transform are inconsistent

A read window with negative offsets reached src.read without boundless=True. Clip the read window to the dataset with Window(0, 0, src.width, src.height).intersection(read) or pass boundless=True and a fill value.

Inference is fast on the first hundred tiles then collapses

The output file is being written out of block order and GDAL is rewriting blocks. Iterate row-major over the write grid, keep the write block size a divisor of the tile size, and never write the same block twice.

onnxruntime raises about a dynamic dimension

The exported model has a fixed batch dimension and the last batch is short. Export with a dynamic batch axis, or pad the final batch up to batch_size and discard the extra predictions.

Memory climbs steadily through the run

Predictions are being accumulated instead of written, or the progress bar is holding references to arrays. Write inside the loop, and never build a list of per-tile outputs — the whole point of the pattern is that nothing larger than a batch is resident.


Frequently Asked Questions

Q: How much overlap does a segmentation model need? At least half the receptive field of the network, rounded up to a multiple of the downsampling factor. For a four-level U-Net that is typically 32 to 64 pixels per side. Too little overlap leaves seams; too much multiplies the compute for no gain.

Q: Why is my GPU only twenty per cent utilised during inference? Because reading is serialised with compute. A single loop that reads a tile, predicts it, then writes it leaves the GPU idle for the whole read. Put the reader on a thread feeding a bounded queue so the next batch is decoded while the current one is on the device.

Q: Can I write predictions straight into a Cloud-Optimized GeoTIFF? Write a tiled GeoTIFF block by block, then add overviews and re-order the header. Writing tiled output first and converting once at the end is far faster than trying to maintain COG layout while random blocks arrive.

Q: Does a pixel-based model need a halo at all? No. A model that sees one pixel at a time has a receptive field of one pixel, so set halo=0 and read exactly the write window. The halo only exists to give convolutions their context.


Deep-Dive Articles