Assign spatial blocks to train, validation and test first, then cut patches inside each block, dropping any patch whose footprint crosses a block boundary. Filter on label density, write the patches to a materialised store, and record every patch’s source window in a manifest. That order — blocks, then patches — is what separates a dataset that measures generalisation from one that measures memorisation.
This page covers the patch pipeline. For what the patches are fed into, see Deep Learning Segmentation for Satellite Imagery.
Why This Fails in Geospatial ML Pipelines
Patch-level random splitting is the default in every image-classification tutorial and it is wrong here. Two patches cut 64 pixels apart share three quarters of their pixels. Put one in train and one in validation and the validation score measures how well the model reproduces pixels it has already seen. In practice this inflates intersection-over-union by 20–30 points, which is enough to make a broken model look production-ready — the image-space version of the leakage described in reducing spatial leakage in model training.
The second failure is class starvation. Rare classes occupy a small fraction of a scene, so a naive grid of patches is overwhelmingly negative. Keep them all and most batches carry no gradient for the rare class; drop them all and the model has never seen what “not the class” looks like, so it hallucinates the class across the whole scene at inference. The fix is a controlled negative fraction, chosen and recorded rather than emergent.
Third, irreproducibility. A patch dataset cut by an ad-hoc script is a pile of arrays with no provenance: nobody can say which scene window a given patch came from, whether the fifth run used the same random seed, or why the class balance shifted between two experiments. A manifest — one row per patch with its source window, split, and label histogram — turns the dataset into an artifact that can be versioned alongside the model, as described in spatial dataset versioning with DVC and lakeFS.
Core Principles
- Blocks first, patches second. The split boundary must be much coarser than the patch.
- Discard straddlers. A patch that crosses a split boundary belongs to neither side.
- Overlap within a split only. Stride around 0.75 of the patch is a good default.
- Control the negative fraction explicitly. Do not let the grid decide it.
- Write a manifest. Source window, split, class histogram, seed.
- Materialise the patches. Re-reading GeoTIFF windows every epoch starves the GPU.
Production-Ready Code
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, asdict
import numpy as np
import rasterio
from rasterio.windows import Window
logger = logging.getLogger(__name__)
IGNORE = 255
@dataclass(frozen=True)
class PatchSpec:
patch: int = 256
stride: int = 192 # 0.75 overlap within a split
block_px: int = 2048 # split granularity
min_label_frac: float = 0.10 # at least this share of pixels labelled
negative_fraction: float = 0.2
seed: int = 0
def assign_blocks(height: int, width: int, spec: PatchSpec,
fractions=(0.7, 0.15, 0.15)) -> dict:
"""Assign whole blocks to train/val/test. Deterministic given spec.seed."""
rows = int(np.ceil(height / spec.block_px))
cols = int(np.ceil(width / spec.block_px))
ids = np.arange(rows * cols)
np.random.default_rng(spec.seed).shuffle(ids)
n_tr = int(round(fractions[0] * len(ids)))
n_va = int(round(fractions[1] * len(ids)))
return {"train": set(ids[:n_tr].tolist()),
"val": set(ids[n_tr:n_tr + n_va].tolist()),
"test": set(ids[n_tr + n_va:].tolist()),
"rows": rows, "cols": cols}
def _block_of(r: int, c: int, spec: PatchSpec, cols: int) -> int:
return (r // spec.block_px) * cols + (c // spec.block_px)
def candidate_windows(height: int, width: int, spec: PatchSpec, blocks: dict):
"""Yield (row, col, split) for patches lying entirely within one block."""
for r in range(0, height - spec.patch + 1, spec.stride):
for c in range(0, width - spec.patch + 1, spec.stride):
b0 = _block_of(r, c, spec, blocks["cols"])
b1 = _block_of(r + spec.patch - 1, c + spec.patch - 1, spec, blocks["cols"])
if b0 != b1:
continue # straddles a boundary
for split in ("train", "val", "test"):
if b0 in blocks[split]:
yield r, c, split
break
def build_patch_dataset(feature_path: str, label_path: str, out_prefix: str,
spec: PatchSpec = PatchSpec()) -> list[dict]:
"""Cut, filter and materialise patches; return the manifest rows."""
with rasterio.open(feature_path) as fsrc, rasterio.open(label_path) as lsrc:
if (fsrc.width, fsrc.height) != (lsrc.width, lsrc.height) or fsrc.crs != lsrc.crs:
raise ValueError("feature and label rasters are not on the same grid")
blocks = assign_blocks(fsrc.height, fsrc.width, spec)
rng = np.random.default_rng(spec.seed + 1)
manifest, kept_x, kept_y = [], [], []
n_seen = n_empty = n_dropped = 0
for r, c, split in candidate_windows(fsrc.height, fsrc.width, spec, blocks):
n_seen += 1
win = Window(c, r, spec.patch, spec.patch)
y = lsrc.read(1, window=win)
labelled = float((y != IGNORE).mean())
if labelled < spec.min_label_frac:
n_dropped += 1
continue
classes, counts = np.unique(y[y != IGNORE], return_counts=True)
is_negative = len(classes) == 1 and classes[0] == 0
if is_negative:
n_empty += 1
if rng.random() > spec.negative_fraction:
continue
x = fsrc.read(window=win).astype("float32")
kept_x.append(x)
kept_y.append(y)
manifest.append({
"index": len(manifest), "split": split, "row": int(r), "col": int(c),
"labelled_frac": round(labelled, 4), "negative": bool(is_negative),
"classes": {int(k): int(v) for k, v in zip(classes, counts)},
})
np.save(f"{out_prefix}_x.npy", np.stack(kept_x))
np.save(f"{out_prefix}_y.npy", np.stack(kept_y))
with open(f"{out_prefix}_manifest.json", "w") as fh:
json.dump({"spec": asdict(spec), "patches": manifest}, fh)
logger.info("%d candidate windows -> %d patches (%d sparse dropped, %d negatives seen)",
n_seen, len(manifest), n_dropped, n_empty)
return manifestStep-by-Step Walkthrough
Step 1 — Verify the grids before cutting anything
import rasterio
with rasterio.open("features.tif") as f, rasterio.open("labels.tif") as l:
assert f.crs == l.crs and (f.width, f.height) == (l.width, l.height)
assert f.transform.almost_equals(l.transform, precision=1e-6)An offset here becomes an offset in every patch; the diagnosis is in aligning rasterized labels to an existing feature grid.
Step 2 — Cut, and read the summary line
spec = PatchSpec(patch=256, stride=192, block_px=2048,
min_label_frac=0.1, negative_fraction=0.2, seed=0)
manifest = build_patch_dataset("features.tif", "labels.tif", "data/patches", spec)The log line is the health check. If “candidate windows” is far larger than “patches”, the label coverage is thin and the scene may not be worth tiling at this patch size.
Step 3 — Check the split-level class balance
import collections
import pandas as pd
rows = []
for p in manifest:
for cls, n in p["classes"].items():
rows.append({"split": p["split"], "class": int(cls), "pixels": n})
balance = pd.DataFrame(rows).groupby(["split", "class"])["pixels"].sum().unstack(fill_value=0)
print((balance.T / balance.sum(axis=1)).T.round(4))The three splits should have similar class proportions. If the test split has three times the water of the training split, the block assignment happened to isolate a lake — regenerate with a different seed, or stratify block assignment on class presence.
Step 4 — Confirm no patch pair crosses a split
import itertools
by_split = collections.defaultdict(list)
for p in manifest:
by_split[p["split"]].append((p["row"], p["col"]))
for a, b in itertools.combinations(by_split, 2):
for (r1, c1) in by_split[a]:
for (r2, c2) in by_split[b]:
assert abs(r1 - r2) >= spec.patch or abs(c1 - c2) >= spec.patch, (
f"patches from {a} and {b} overlap at ({r1},{c1}) and ({r2},{c2})")Run this once on a small scene rather than every build — it is quadratic — but run it, because it is the only direct proof that the split is clean.
Verification
import numpy as np
def test_no_patch_straddles_a_block():
spec = PatchSpec(patch=256, stride=192, block_px=2048, seed=0)
blocks = assign_blocks(8192, 8192, spec)
for r, c, _ in candidate_windows(8192, 8192, spec, blocks):
assert (r // spec.block_px) == ((r + spec.patch - 1) // spec.block_px)
assert (c // spec.block_px) == ((c + spec.patch - 1) // spec.block_px)
def test_same_seed_gives_the_same_dataset(tmp_path):
a = build_patch_dataset("features.tif", "labels.tif", str(tmp_path / "a"), PatchSpec(seed=7))
b = build_patch_dataset("features.tif", "labels.tif", str(tmp_path / "b"), PatchSpec(seed=7))
assert [(p["row"], p["col"], p["split"]) for p in a] == \
[(p["row"], p["col"], p["split"]) for p in b]
def test_negative_fraction_is_respected(tmp_path):
spec = PatchSpec(negative_fraction=0.2, seed=3)
man = build_patch_dataset("features.tif", "labels.tif", str(tmp_path / "c"), spec)
neg = sum(p["negative"] for p in man)
assert 0.05 <= neg / max(1, len(man)) <= 0.45
def test_sparse_patches_are_dropped(tmp_path):
spec = PatchSpec(min_label_frac=0.5)
man = build_patch_dataset("features.tif", "labels.tif", str(tmp_path / "d"), spec)
assert all(p["labelled_frac"] >= 0.5 for p in man)Beyond the tests, render a handful of patches with their labels overlaid before every training run. Five minutes of looking catches offsets, band-order mistakes and inverted masks that no assertion will.
FAQ
Should training patches overlap?
Within a split, yes — a stride of about 0.75 of the patch adds augmentation and ensures every object appears somewhere other than at an edge. Across splits, never: overlapping patches in different splits are the leak.
How do I stop empty patches from dominating the dataset?
Filter on label density and keep a controlled fraction of negatives — 10–30% is a sensible start. Dropping them all teaches the model the rare class is everywhere; keeping them all starves it of gradient. The same trade-off appears in handling class imbalance in land cover classification.
Should patches be written to disk or cut on the fly?
Written. Reading compressed GeoTIFF windows every epoch usually makes the loader the bottleneck; a materialised store plus a manifest is faster and reproducible.
Related
- Deep Learning Segmentation for Satellite Imagery — the training workflow these patches feed
- Building a U-Net for Land Cover Segmentation in PyTorch — the model and training loop
- Choosing Block Size for Spatial Block Cross-Validation — picking the block scale
- Aligning Rasterized Labels to an Existing Feature Grid — the grid check every patch depends on
Part of: Deep Learning Segmentation for Satellite Imagery Part of: Training Geospatial Predictive Models in Python