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.

06 — ML patches + augmentations

ML patches — augmentations + tiled inference

The companion to Patching: grid → process → stitch. Same patcher / Field / gz.patch_ops machinery, but now the per-chip work is the ML inference path: load → augment (training-time only) → model → stitch into a per-pixel classification map.

We demonstrate three slices of the package on a real Sentinel-2 scene:

StageModuleOps
Training-chip extraction (irregular sampling)geopatcherSpatialJitteredStride
Per-chip augmentationgz.augmentCompose([RandomFlip, RandomRotate90, BrightnessJitter, GaussianNoise])
Inference + carrier wrapgz.ModelOpModelOp(model, method="predict")
Vote-stitching of classification labelsgeopatcherSpatialHardVote(n_classes=3)

The “model” is a rule-based three-class classifier driven by NDVI + NDWI thresholds — water (0), bare/scrub (1), vegetation (2). It plays the role of any real sklearn / PyTorch estimator: anything with a predict method (or __call__) plugs into ModelOp interchangeably.

1. Load one Sentinel-2 scene

Lake Tahoe, same scene as the companion patching notebook. BGRN (4 bands) — we want green + NIR for NDWI and red + NIR for NDVI.

using S2B_MSIL2A_20240614T183919_R070_T10SGJ_20240615T003207
reflectance carrier: shape=(4, 3935, 1599)  dtype=float64

2. Training-chip sampling — SpatialJitteredStride

For a training dataset we don’t want a regular grid (every chip lines up the same way and overfits to position); we want jittered anchors so the model sees translated variants of similar terrain. SpatialJitteredStride(step, jitter) perturbs each regular-grid anchor by a uniform random offset bounded by jitter pixels.

Compare to SpatialPoissonDisk — Poisson-disk sampling places anchors as far apart as possible (minimum-distance constraint), giving the most spatially-diverse training chips. Use it when training samples are expensive (annotated labels) and you want maximum coverage per chip.

sampled 160 training chips of size 128×128
<Figure size 1400x700 with 8 Axes>

3. Augment one chip with gz.augment.Compose

A typical RS-safe augmentation pipeline: random flips, 90° rotations, brightness jitter (multiplicative gain), and Gaussian sensor noise. Each gz.augment.* op:

  • preserves CRS and updates the affine transform (so flips and rotations still map to the right world coordinates),
  • is reproducible — pass seed=... for a deterministic draw or omit for entropy from the OS,
  • composes via gz.augment.Compose(ops, p=...) so the whole chain has a single overall-probability gate.
Compose(augmentations=[{'class': 'RandomFlip', 'config': {'p_horizontal': 0.5, 'p_vertical': 0.5, 'seed': None}}, {'class': 'RandomRotate90', 'config': {'p': 0.75, 'seed': None}}, {'class': 'BrightnessJitter', 'config': {'factor': (0.85, 1.15), 'per_band': True, 'seed': None}}, {'class': 'GaussianNoise', 'config': {'sigma': 0.01, 'per_band': True, 'seed': None}}], p=1.0, seed=None)
<Figure size 1700x400 with 5 Axes>

Notice each draw is different (flips, rotations, brightness shifts all sample independently per seed) but always preserves the CRS + spatial extent semantics. That’s the reproducible-RS-augmentation contract gz.augment enforces.

4. A three-class rule-based “model”

To keep this notebook self-contained we use a small NDVI / NDWI threshold rule as our classifier. Any callable with a predict method (sklearn) or __call__ (PyTorch / JAX) drops in via gz.ModelOp the same way — ModelOp is the substrate adapter.

Classes:

idnamerule
0WaterNDWI > 0.1
1Bare / scrubNDVI < 0.25 and not water
2VegetationNDVI ≥ 0.25
ModelOp(model_type='NDVIClassifier', method='predict', batch_size=None)
single-chip output: shape=(128, 128)  dtype=int64
class counts:      [    0   926 15458]

5. Tiled inference — Sequential([GridSampler, ApplyToChips, Stitch])

Now the full pipeline. The per-chip op is ModelOp(...) instead of the NDVI from the previous notebook; the aggregation is SpatialHardVote(n_classes=3) instead of SpatialOverlapAdd because we are stitching integer class labels, not continuous values.

For inference we also use a denser, non-jittered sampler (SpatialRegularStride) — jitter is a training-time trick, not an inference-time one.

Sequential([GridSampler(patcher={'geometry': {'class': 'SpatialRectangular', 'config': {'size': [256, 256]}}, 'sampler': {'class': 'SpatialRegularStride', 'config': {'step': [256, 256]}}, 'window': {'class': 'SpatialBoxcar', 'config': {}}, 'aggregation': {'class': 'SpatialHardVote', 'config': {'n_classes': 3}}}), ApplyToChips(operator={'class': 'ModelOp', 'config': {'model_type': 'NDVIClassifier', 'method': 'predict', 'batch_size': None}}), Stitch(aggregation={'class': 'SpatialHardVote', 'config': {'n_classes': 3}})])
classification map: shape=(3935, 1599)  dtype=int64
class counts:      [ 546953 4559300 1185812]

6. Compare against full-scene inference

Run the same model on the whole scene without patching as a reference. The patched output should match across the interior.

per-pixel agreement: 93.76%
<Figure size 1800x800 with 5 Axes>

7. Recap

The same three-op patching pattern from 05 — Patching: grid → process → stitch hosts the ML inference path with zero structural change — only the parts that need to vary do:

WhatPatching demoML demo
SamplerSpatialRegularStride (every pixel covered)SpatialJitteredStride (training) or SpatialRegularStride (inference)
WindowSpatialHann (smooth blending)SpatialBoxcar (votes don’t need blending)
Per-chip opNDVIgz.ModelOp(model, method="predict")
AggregationSpatialOverlapAdd (continuous map)SpatialHardVote(n_classes=K) (class labels)

Other building blocks worth pairing in:

  • gz.augment — Compose([RandomFlip, RandomRotate90, BrightnessJitter, GaussianNoise, AtmosphericHaze, SimulatedClouds, CutMix, ...]) for training-chip augmentation. Each op preserves CRS / transform so the augmented carrier is still geographically valid.
  • SklearnOp — gz.learn.SklearnOp(estimator, mode="pixel") if you want a fitted sklearn estimator (RandomForestClassifier, GBM, kNN, …) instead of a hand-coded rule.
  • SpatialSoftVote — like HardVote but stitches per-class probabilities (chip outputs shape (K, H, W)), letting you threshold or compute class margins at the field level.
  • Profile + ShapeTrace from the observability section of operators_lake_tahoe — wrap ModelOp to time per-chip inference cost across the run.

For the cross-package walk-through (STAC → catalog → patcher → operators), see geocatalog/docs/notebooks/end_to_end_lake_tahoe.ipynb.