The architecture is an encoder that halves resolution four times, a bottleneck, and a decoder that doubles it back, with skip connections carrying full-resolution detail across. What makes it work on satellite imagery rather than photographs is the surrounding detail: an input layer sized to the number of spectral bands, a loss that ignores unlabelled pixels, class weights that stop the majority class from swallowing the gradient, and a validation set that is geographically separate.
This page is the implementation. For the design decisions around it — splits, patch strategy, inference — see Deep Learning Segmentation for Satellite Imagery.
Why This Fails in Geospatial ML Pipelines
The commonest failure is a loss that treats unlabelled pixels as a class. Label rasters carry an ignore value for ground nobody digitised, and unless it is passed to the loss as ignore_index, the network is trained to predict “class 255” — or, if the ignore value happens to equal the background code, to paint background over every undigitised region. Since undigitised ground is usually the majority of a scene, the model converges beautifully and produces a map that is uniformly one class.
The second is nodata reaching the tensors. Satellite products encode gaps with sentinels like -9999. After normalisation that becomes a number several hundred standard deviations from the mean, which saturates the batch norms and drives the loss to nan within a few hundred steps. The traceback points at the loss; the cause is three files upstream in the reader.
The third is a validation set that is not really held out. If patches were split randomly rather than by block, validation IoU rises smoothly, early stopping fires late, and the resulting model is tuned on data it memorised. The remedy is upstream, in tiling large rasters into training patches, but its symptom shows up here as a validation curve that looks too good.
Core Principles
- Size the first convolution from the band count, never assume three channels.
- Pass
ignore_indexto the loss and confirm it with a test. - Cap class weights. Inverse frequency unbounded makes a 0.1% class destabilise training.
- Track macro IoU, not accuracy. Stop on the metric you actually care about.
- Use mixed precision. It buys a larger patch, which buys more context per prediction.
- Checkpoint on the metric, not the loss. They diverge, and IoU is the one that matters.
Production-Ready Code
from __future__ import annotations
import logging
from dataclasses import dataclass
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, Dataset
logger = logging.getLogger(__name__)
IGNORE_INDEX = 255
def conv_block(cin: int, cout: int) -> nn.Sequential:
return nn.Sequential(
nn.Conv2d(cin, cout, 3, padding=1, bias=False),
nn.BatchNorm2d(cout), nn.ReLU(inplace=True),
nn.Conv2d(cout, cout, 3, padding=1, bias=False),
nn.BatchNorm2d(cout), nn.ReLU(inplace=True),
)
class UNet(nn.Module):
"""U-Net whose input width is set by the number of spectral bands."""
def __init__(self, in_bands: int, n_classes: int, width: int = 32):
super().__init__()
w = width
self.enc = nn.ModuleList([
conv_block(in_bands, w), conv_block(w, w * 2),
conv_block(w * 2, w * 4), conv_block(w * 4, w * 8),
])
self.pool = nn.MaxPool2d(2)
self.bottleneck = conv_block(w * 8, w * 16)
self.ups = nn.ModuleList([
nn.ConvTranspose2d(w * 16, w * 8, 2, stride=2),
nn.ConvTranspose2d(w * 8, w * 4, 2, stride=2),
nn.ConvTranspose2d(w * 4, w * 2, 2, stride=2),
nn.ConvTranspose2d(w * 2, w, 2, stride=2),
])
self.dec = nn.ModuleList([
conv_block(w * 16, w * 8), conv_block(w * 8, w * 4),
conv_block(w * 4, w * 2), conv_block(w * 2, w),
])
self.head = nn.Conv2d(w, n_classes, 1)
def forward(self, x: torch.Tensor) -> torch.Tensor:
skips = []
for enc in self.enc:
x = enc(x)
skips.append(x)
x = self.pool(x)
x = self.bottleneck(x)
for up, dec, skip in zip(self.ups, self.dec, reversed(skips)):
x = dec(torch.cat([up(x), skip], dim=1))
return self.head(x)
class PatchDataset(Dataset):
"""Memory-mapped patch store, normalised with statistics from the artifact."""
def __init__(self, x_path: str, y_path: str, indices: list[int],
mean: np.ndarray, std: np.ndarray, augment: bool = False):
self.x = np.load(x_path, mmap_mode="r")
self.y = np.load(y_path, mmap_mode="r")
self.idx = indices
self.mean = mean[:, None, None].astype("float32")
self.std = std[:, None, None].astype("float32")
self.augment = augment
def __len__(self) -> int:
return len(self.idx)
def __getitem__(self, i: int):
j = self.idx[i]
x = np.asarray(self.x[j], dtype="float32")
y = np.asarray(self.y[j], dtype="int64")
if not np.isfinite(x).all():
x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
x = (x - self.mean) / self.std
if self.augment: # only flips/rotations — never colour jitter
k = np.random.randint(4)
x, y = np.rot90(x, k, (1, 2)).copy(), np.rot90(y, k, (0, 1)).copy()
if np.random.rand() < 0.5:
x, y = x[:, :, ::-1].copy(), y[:, ::-1].copy()
return torch.from_numpy(x), torch.from_numpy(y)
def capped_class_weights(counts: np.ndarray, cap: float = 8.0) -> torch.Tensor:
"""Inverse-frequency weights, clipped so a very rare class cannot dominate."""
w = counts.sum() / (len(counts) * np.maximum(counts, 1))
return torch.tensor(np.clip(w, 0.2, cap), dtype=torch.float32)
def iou_from_confusion(cm: np.ndarray) -> np.ndarray:
tp = np.diag(cm).astype("float64")
denom = cm.sum(axis=1) + cm.sum(axis=0) - tp
return np.where(denom > 0, tp / np.maximum(denom, 1), np.nan)Step-by-Step Walkthrough
Step 1 — Wire the loaders to the manifest splits
import json
blob = json.load(open("data/patches_manifest.json"))
splits = {s: [p["index"] for p in blob["patches"] if p["split"] == s]
for s in ("train", "val", "test")}
stats = np.load("artifacts/norm_stats.npz")
train_ds = PatchDataset("data/patches_x.npy", "data/patches_y.npy",
splits["train"], stats["mean"], stats["std"], augment=True)
val_ds = PatchDataset("data/patches_x.npy", "data/patches_y.npy",
splits["val"], stats["mean"], stats["std"])
train_dl = DataLoader(train_ds, batch_size=8, shuffle=True, num_workers=4,
pin_memory=True, drop_last=True)
val_dl = DataLoader(val_ds, batch_size=8, num_workers=2, pin_memory=True)
print(f"train {len(train_ds)} patches, val {len(val_ds)} patches")Loading the normalisation statistics from a file rather than computing them here is the contract described in scaling features consistently between training and inference.
Step 2 — Build the model and the loss
N_CLASSES = 5
counts = np.array([4_100_000, 820_000, 96_000, 31_000, 210_000], dtype="float64")
device = "cuda" if torch.cuda.is_available() else "cpu"
model = UNet(in_bands=stats["mean"].shape[0], n_classes=N_CLASSES).to(device)
criterion = nn.CrossEntropyLoss(weight=capped_class_weights(counts).to(device),
ignore_index=IGNORE_INDEX)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=40)
scaler = torch.cuda.amp.GradScaler(enabled=device == "cuda")Step 3 — The training loop
best_iou = -1.0
for epoch in range(40):
model.train()
running = 0.0
for x, y in train_dl:
x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True)
optimizer.zero_grad(set_to_none=True)
with torch.autocast(device_type=device, enabled=device == "cuda"):
loss = criterion(model(x), y)
scaler.scale(loss).backward()
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(optimizer)
scaler.update()
running += float(loss)
model.eval()
cm = np.zeros((N_CLASSES, N_CLASSES), dtype="int64")
with torch.no_grad():
for x, y in val_dl:
pred = model(x.to(device)).argmax(1).cpu().numpy()
t = y.numpy()
keep = t != IGNORE_INDEX
k = t[keep] * N_CLASSES + pred[keep]
cm += np.bincount(k, minlength=N_CLASSES ** 2).reshape(N_CLASSES, N_CLASSES)
ious = iou_from_confusion(cm)
macro = float(np.nanmean(ious))
scheduler.step()
logger.info("epoch %2d loss %.4f macro IoU %.4f per-class %s",
epoch, running / len(train_dl), macro, np.round(ious, 3).tolist())
if macro > best_iou:
best_iou = macro
torch.save({"state_dict": model.state_dict(), "macro_iou": macro,
"bands": int(stats["mean"].shape[0]), "n_classes": N_CLASSES},
"artifacts/unet_best.pt")Step 4 — Read the per-class curve, not the loss
print({i: round(float(v), 3) for i, v in enumerate(ious)})
assert not np.isnan(ious).all(), "no class was ever predicted — check ignore_index"A class stuck at zero IoU across many epochs means the sampler never shows it, not that its weight is too low. Fix it in the patch sampler rather than by raising the weight past the cap.
Verification
import numpy as np
import torch
import torch.nn as nn
def test_output_shape_matches_input():
model = UNet(in_bands=6, n_classes=5)
out = model(torch.zeros(2, 6, 256, 256))
assert out.shape == (2, 5, 256, 256)
def test_ignore_pixels_contribute_no_gradient():
"""A fully unlabelled batch must produce a zero or nan loss, never a real one."""
logits = torch.randn(1, 5, 8, 8, requires_grad=True)
target = torch.full((1, 8, 8), IGNORE_INDEX, dtype=torch.long)
loss = nn.CrossEntropyLoss(ignore_index=IGNORE_INDEX)(logits, target)
assert torch.isnan(loss) or float(loss) == 0.0
def test_model_overfits_a_single_patch():
"""The strongest smoke test there is: if it cannot memorise one patch, it is broken."""
torch.manual_seed(0)
model = UNet(in_bands=3, n_classes=2, width=8)
x = torch.randn(1, 3, 64, 64)
y = (torch.rand(1, 64, 64) > 0.5).long()
opt = torch.optim.Adam(model.parameters(), lr=1e-2)
crit = nn.CrossEntropyLoss()
for _ in range(120):
opt.zero_grad()
loss = crit(model(x), y)
loss.backward()
opt.step()
assert float(loss) < 0.05, f"could not overfit one patch (loss {float(loss):.3f})"
def test_augmentation_keeps_x_and_y_aligned():
ds = PatchDataset("data/patches_x.npy", "data/patches_y.npy", [0],
mean=np.zeros(6, "float32"), std=np.ones(6, "float32"), augment=True)
x, y = ds[0]
assert x.shape[-2:] == y.shape[-2:]The overfit test is worth more than every other check combined. A model that cannot drive the loss to near zero on a single patch has a wiring bug — a detached skip connection, a wrong channel count, a target that is not aligned with its input — and no amount of training will rescue it.
FAQ
Can I use an ImageNet-pretrained encoder with six spectral bands?
Yes — replace the first convolution and initialise the new channels from the mean of the pretrained RGB weights. Early filters learn edges and textures that transfer, though the benefit is smaller than on natural images because the spectral statistics differ so much.
Why does my loss go to nan in the first epoch?
Almost always an unmasked nodata sentinel: -9999 survives normalisation as a huge negative value and saturates the batch norms. Convert nodata to NaN at read time and handle it before the loader, as the dataset class above does.
How many epochs does a segmentation model need?
Fewer than you would expect, because each patch supplies tens of thousands of labelled pixels — 30–60 epochs over a few thousand patches is typical. Stop on macro IoU measured against a spatially separate validation set, per spatial cross-validation strategies.
Related
- Deep Learning Segmentation for Satellite Imagery — the surrounding workflow
- Tiling Large Rasters into Training Patches — where the patches come from
- Building a CNN for Satellite Imagery Classification — the patch-classification counterpart
- ONNX Export for Geospatial Model Inference — shipping the trained network
Part of: Deep Learning Segmentation for Satellite Imagery Part of: Training Geospatial Predictive Models in Python