Image Preprocessing and Tiling
Failure modes here are silent, not exceptions: a -9999 nodata sentinel shifts a channel mean by
orders of magnitude, a mask gets bilinear-interpolated into non-integer class ids, augmentation
randomness is duplicated across eight workers, inference normalization differs from training by a
factor of 255. The model still trains. It just does not work.
1. Tiling: choosing tile size and stride
| Decision |
Rule |
| Tile size |
Largest power-of-two that fits batch size >= 8 in VRAM. 512 for most segmentation UNets at bs 8-16 on 24GB; 256 if you need bs 32+; 1024 only if context genuinely matters (e.g. large fields, tumor architecture). |
| Training stride |
Irrelevant if you sample random crops. If you pre-cut a fixed tile grid, use stride == tile (no overlap) and rely on random offsets per epoch instead. |
| Inference stride |
tile // 2 is the default worth defending. It is the minimum overlap at which a Hann window sums to a constant (COLA), and it doubles compute in each axis (4x total). tile * 3 // 4 (25% overlap) is the cheap compromise. |
| Receptive field check |
Overlap should be >= the model's effective receptive field on each side, otherwise pixels near a tile edge are predicted from truncated context. For a UNet with 4 downsamples, that is roughly 100-200 px. Overlap of 128 px at tile 512 satisfies this; overlap of 16 px does not. |
Why overlap at inference: a pixel 3 px from a tile border saw almost no context on one side, so
its prediction is systematically worse. With stride == tile you keep exactly those bad predictions
along every seam - that is the visible grid pattern. Overlap lets you replace edge predictions with
center predictions from the neighbour. Naive overwrite is not enough: writing one tile over another
just moves the discontinuity to the write boundary. You need a smooth weight.
2. Reassembly with feathered (Hann) blending
import numpy as np
def hann2d(th, tw, eps=1e-6):
# np.hanning(N) is EXACTLY 0 at both endpoints. If a border pixel is covered
# by only one tile, its accumulated weight would be 0 -> 0/0 -> NaN.
# Sampling the interior of a length-(N+2) window avoids the zeros entirely.
wy = np.hanning(th + 2)[1:-1]
wx = np.hanning(tw + 2)[1:-1]
return np.maximum(np.outer(wy, wx), eps).astype(np.float32)
def tile_starts(size, tile, stride):
"""Start indices covering [0, size). Final tile is SHIFTED to end exactly at
`size` instead of padding - so no tile ever contains synthetic pixels."""
if size <= tile:
return [0]
starts = list(range(0, size - tile + 1, stride))
if starts[-1] != size - tile:
starts.append(size - tile)
return starts
def predict_scene(image, model_fn, tile=512, stride=256, n_classes=1):
"""image: (C, H, W) float32, already normalized. model_fn: (C,t,t)->(K,t,t)."""
C, H, W = image.shape
if H < tile or W < tile: # scene smaller than one tile:
pad = ((0, 0), (0, max(0, tile - H)), (0, max(0, tile - W)))
big = np.pad(image, pad, mode="symmetric") # pad up, predict, crop back
return predict_scene(big, model_fn, tile, stride, n_classes)[:, :H, :W]
acc = np.zeros((n_classes, H, W), np.float32) # weighted sum of logits/probs
wacc = np.zeros((1, H, W), np.float32) # accumulated weight
win = hann2d(tile, tile)[None] # (1,t,t) broadcasts over K
for y in tile_starts(H, tile, stride):
for x in tile_starts(W, tile, stride):
patch = image[:, y:y+tile, x:x+tile]
pred = model_fn(patch) # (K, t, t)
acc[:, y:y+tile, x:x+tile] += pred * win
wacc[:, y:y+tile, x:x+tile] += win
return acc / wacc
Non-obvious points in that code:
- Divide by accumulated weight, not by a count. The shifted final tile overlaps its neighbour by
an irregular amount; some pixels get 2 tiles, some 4, some 3. Only
acc / wacc is correct for all
of them, and it makes the irregular last-row/last-column stride a non-issue.
- Blend probabilities or logits, not argmax labels. Averaging class indices is meaningless.
Prefer averaging softmax probabilities (bounded, well-behaved); averaging logits is also fine and
slightly sharper, but do not mix the two across tiles.
acc must be float32 minimum. float16 accumulation over 4 overlapping tiles of a 20k x 20k
scene loses precision visibly in low-probability regions.
- Memory.
acc for a 20000x20000 scene with 10 classes in float32 is 16 GB. Use
np.lib.format.open_memmap, or process the scene in row-blocks.
- The torch equivalent is
F.unfold/F.fold: F.fold sums overlaps, so fold pred * win and
separately fold win broadcast to the same shape, then divide. But unfold materializes every
tile at once and will OOM on a large scene.
- 3D volumes: do not hand-roll this.
monai.inferers.sliding_window_inference(inputs, roi_size, sw_batch_size, predictor, overlap=0.5, mode="gaussian") is the same algorithm with a Gaussian
instead of a Hann window, batched patches, and device=/sw_device= so the accumulator can live
on CPU while the model runs on GPU. mode="constant" is the seam-producing default trap.
- Round-trip assertion (section 10): with
model_fn = lambda p: p, the reassembled output must
equal the input to ~1e-5. If it does not, your seams are a tiling bug, not a model bug.
3. Edges and remainders
Two valid strategies for H % tile != 0:
| Strategy |
Use when |
Cost |
Shifted final tile (tile_starts above) |
Inference, always, when H >= tile |
Extra overlap in the last row/col; free because weighted blending handles it. |
| Pad then crop |
H < tile; or fully-convolutional models needing a fixed grid; or training where you want the true border seen |
Introduces synthetic pixels the model must learn to ignore. |
If you pad, pad by reflection, not zeros:
pad_h, pad_w = (-H) % tile, (-W) % tile
padded = np.pad(image, ((0,0), (0,pad_h), (0,pad_w)), mode="reflect")
# then crop back: out = out[:, :H, :W]
- Zero padding creates a hard step from real radiance to exactly 0. Convolutions near the border see
an edge that exists in every padded sample at the same place, so the network learns a
border-specific response - a bright or dark 5-20 px rim on predictions and worse metrics near
scene edges. 0 is also a legitimate reflectance value and a common nodata sentinel.
np.pad(mode="reflect") tolerates pad_width >= dim (it reflects repeatedly), but the same
request raises in torch.nn.functional.pad(mode="reflect") and in cv2.copyMakeBorder, both of
which require pad < dim (pad < dim - 1 for BORDER_REFLECT_101). Padding a 3-px strip by 100
therefore works offline in numpy and blows up inside the model or an albumentations transform.
In numpy, mode="symmetric" repeats the edge sample under no length constraint - the robust
default. Either way, padding a strip by more than its own width fabricates most of the tile; drop
such slivers instead of padding them.
- In OpenCV/albumentations the equivalent is
cv2.BORDER_REFLECT_101 (= numpy reflect) vs
cv2.BORDER_REFLECT (= numpy symmetric). BORDER_CONSTANT is the zero-padding trap.
- Pad the mask with an ignore label, not with class 0. Reflect-padding the image but zero-padding
the mask teaches the model that reflected content is background. Use
fill_mask=255 (or your
ignore_index) and set ignore_index=255 in the loss.
4. Normalization
The four rules
- Statistics come from the training split only. Train+val+test is leakage - small, but exactly
what reviewers ask about, and not small at all with a distribution-shifted test set.
- Per channel. A 12-band Sentinel-2 stack has band means spanning an order of magnitude; one
scalar mean/std flattens the informative bands into noise.
- ImageNet statistics are only valid for 8-bit RGB natural photographs. For multispectral, SAR
(dB, can be negative), thermal, DEM (metres), or 16-bit medical data they are meaningless. Even
for 3-band RGB satellite imagery they are wrong - the radiometry is different.
- Save the statistics next to the checkpoint and reload them at inference. Recomputing them
from the inference scene is the most common source of train/inference skew, and it presents
exactly as a model that "doesn't generalize".
Computing stats without poisoning them
import json, numpy as np, rasterio
C = 12
n = np.zeros(C, np.float64); s = np.zeros(C, np.float64); ss = np.zeros(C, np.float64)
SHIFT = None # provisional mean, prevents catastrophic cancellation for large values
for path in TRAIN_PATHS: # TRAIN SPLIT ONLY
with rasterio.open(path) as src:
a = src.read(masked=True) # masked=True honours the nodata tag
a = a.astype(np.float64)
valid = ~np.ma.getmaskarray(a)
# Also drop known sentinels the file failed to declare:
valid &= np.isfinite(a) & (a != -9999) & (a != 0) # tune per dataset!
if SHIFT is None:
SHIFT = np.array([a[c][valid[c]].mean() if valid[c].any() else 0.0
for c in range(C)])
d = np.where(valid, a - SHIFT[:, None, None], 0.0)
n += valid.sum(axis=(1, 2), dtype=np.float64)
s += d.sum(axis=(1, 2), dtype=np.float64)
ss += (d * d).sum(axis=(1, 2), dtype=np.float64)
assert (n > 0).all(), n # a channel with 0 valid pixels -> NaN stats, silently
mean = SHIFT + s / n
std = np.sqrt(np.maximum(ss / n - (s / n) ** 2, 0.0))
json.dump({"mean": mean.tolist(), "std": std.tolist(), "n": n.tolist()},
open("norm_stats.json", "w"))
Gotchas encoded above:
src.read() returns nodata pixels as their raw sentinel value. masked=True gives a
MaskedArray using the file's declared nodata - but many GeoTIFFs declare nothing, or declare 0
while also using -9999 in some tiles. Print src.nodata, then histogram one scene and look for
a spike at 0 / -9999 / 65535 before trusting it. A -9999 sentinel in 1% of pixels drags a
band with mean 1500 / std 300 to mean 1385 and std 1182 - the mean moves 8%, the std nearly 4x.
Every downstream z-score is then compressed toward zero.
- Always pass
dtype=np.float64 to .sum(). Integer reductions accumulate in the platform's
default integer, which is 32-bit on Windows under numpy < 2.0 - uint16 sums overflow silently
past 4.3e9 (a single 20k x 20k band of mid-range reflectance). NumPy >= 2.0 made the Windows
default 64-bit, so this bug appears and disappears with the numpy version; pin the accumulator.
ss/n - mean^2 is numerically fragile for uint16 reflectance (values ~1e4, N ~1e10). The
provisional-mean shift above costs nothing and removes the problem; np.maximum(..., 0) guards
the residual negative variance.
- Do not compute stats over the tiles your foreground-biased sampler emits - that biases the
statistics toward the rare class. Compute over the full training scenes.
Percentile clipping
For imagery with heavy tails (specular water glint, clouds, sensor spikes, saturated pathology
whites), z-scoring alone leaves 99.9% of pixels squeezed into a narrow band.
# Sample ~1e7 valid pixels per channel from the TRAIN split, then:
p1, p99 = np.percentile(sampled, [1, 99], axis=0) # shape (C,)
x = np.clip(x, p1[:, None, None], p99[:, None, None])
x = (x - p1[:, None, None]) / (p99 - p1)[:, None, None] # -> [0, 1]
Order matters: clip first, then scale. Compute the percentiles globally over the training set,
never per-scene at inference - per-scene percentiles make the same physical radiance map to
different network inputs depending on what else is in the scene, destroying cross-scene
comparability. (Per-scene is acceptable only for visualization, or when sensor gain genuinely varies
per acquisition and you have decided to normalize it away.) The right percentile is data dependent:
1/99 is fine on cloud-free agricultural scenes; 2/98 is often needed with water glint or cloud.
Domain notes
- CT is calibrated in Hounsfield units. Window first (
soft tissue ~ center 40 / width 400;
lung ~ -600 / 1500; bone ~ 400 / 1800), then scale to [0,1]. Global stats are meaningful.
- MRI intensities are in arbitrary units that vary by scanner, coil and sequence. Global
mean/std across a dataset is wrong. Normalize per volume (z-score over the brain mask, or
scale by the 99th percentile inside the mask), and do it after skull-stripping / bias-field
correction if you are doing those.
- SAR in linear power is log-normal - convert to dB (
10*log10) before z-scoring, and handle
zeros before the log.
- Voxel spacing before patching (CT/MRI). A 96x96x96 patch is a different physical volume in
every scan until you resample to a fixed spacing (e.g. 1x1x1 mm, or the dataset median as nnU-Net
does). CT series are routinely 0.7x0.7x5 mm - patching that anisotropic grid directly means the
z-axis receptive field covers 10x the anatomy the in-plane one does. Resample the image with
linear/spline interpolation and the label map with nearest neighbour, and re-check the label
set afterwards. Also apply the DICOM
RescaleSlope/RescaleIntercept before treating values as
HU; pydicom does not apply them for you (apply_modality_lut does).
- Adapting a pretrained RGB encoder to N bands. Do not feed 12 bands through an ImageNet-stat
normalization. Compute your own N-channel stats, then inflate the stem weights:
w_new = w.mean(1, keepdim=True).repeat(1, N, 1, 1) * (3 / N) - the 3/N keeps the stem's output
magnitude where the pretrained downstream layers expect it. Without the rescale, a 12-band stem
emits ~4x the activation scale and the first epochs are spent undoing it.
5. Augmentation with albumentations
import cv2, numpy as np, albumentations as A
from albumentations.pytorch import ToTensorV2
MEAN, STD = stats["mean"], stats["std"] # loaded from norm_stats.json
train_tf = A.Compose([
# --- geometric: applied identically to image AND mask ---
A.PadIfNeeded(min_height=512, min_width=512,
border_mode=cv2.BORDER_REFLECT_101),
A.RandomCrop(height=512, width=512),
A.HorizontalFlip(p=0.5),
A.VerticalFlip(p=0.5), # overhead imagery ONLY - see section 6
A.RandomRotate90(p=0.5), # overhead imagery ONLY
A.Affine(scale=(0.9, 1.1), translate_percent=(-0.05, 0.05),
rotate=(-15, 15), p=0.5),
# --- photometric: image only, albumentations handles this automatically ---
A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.3),
# --- normalization + tensor ---
A.Normalize(mean=MEAN, std=STD, max_pixel_value=1.0),
ToTensorV2(),
])
out = train_tf(image=img_hwc, mask=mask_hw) # img (H,W,C), mask (H,W) int
x, y = out["image"], out["mask"] # (C,H,W) float32, (H,W) int64-able
The traps, in order of how often they bite:
A.Normalize(max_pixel_value=255.0) is the default. If your array is already float in [0,1],
or is 16-bit reflectance you scaled yourself, the default divides by 255 again and your inputs
land near 0.004. The model trains, slowly, to a mediocre score. Pass max_pixel_value=1.0
whenever the input is not raw uint8.
ToTensorV2 does NOT divide by 255 (unlike torchvision.transforms.ToTensor); it only
transposes HWC->CHW. Porting a torchvision pipeline without adding an explicit scale is a silent
255x error.
ToTensorV2 does not transpose the mask unless you pass transpose_mask=True. A (H,W,K)
one-hot mask stays (H,W,K) and your loss silently broadcasts wrong.
Masks are interpolated with nearest neighbour by albumentations' geometric transforms, so
class ids stay integral - but only if you pass the array as mask=. If you sneak the mask through
as a 4th image channel to "save a call", it gets bilinear-interpolated and you get class id 3.7.
Assert set(np.unique(y)) <= set(range(K)) | {ignore_index} after augmentation.
additional_targets is how you get a second mask, a second image, or a change-detection pair
transformed with the same random parameters:
tf = A.Compose([...], additional_targets={"image_t2": "image", "mask_prev": "mask"})
out = tf(image=a, image_t2=b, mask=m, mask_prev=mp)
Without it, calling the transform twice draws two independent random states and image and mask are
no longer aligned - which presents as a model that cannot learn, not as a bug.
Photometric augs must not touch the mask. Albumentations enforces this for its built-ins
(apply_to_mask is identity for brightness/contrast/blur/noise). For a custom transform, subclass
A.ImageOnlyTransform, not A.DualTransform.
Version rename (albumentations 2.0): the border/fill kwargs were inconsistent before 2.0 and
were unified in it. Pre-2.0: most geometric transforms (PadIfNeeded, Rotate, ShiftScaleRotate)
took border_mode= / value= / mask_value=, while A.Affine took mode= / cval= /
cval_mask=. From 2.0 everything is border_mode= / fill= / fill_mask=. Print A.__version__
and inspect.signature(A.Affine.__init__) rather than guessing; an unknown kwarg is silently
ignored in some versions and raises in others, so a wrong name can mean you were zero-padding all
along.
>3 channels: geometric transforms and Normalize handle arbitrary channel counts. Colour-space
transforms (HueSaturationValue, RGBShift, CLAHE, ToGray, most RandomFog-style ones)
assume 3 or 1 channels and will raise or silently mangle a 12-band stack. Verify on one sample.
Leave is_check_shapes=True (default) on - it catches image/mask shape mismatch.
6. Augmentations that are wrong for your domain
| Augmentation |
Wrong when |
Why |
HorizontalFlip |
Text, digits, characters, any chirality-bearing content; medical images where left/right is diagnostic (situs inversus, side-specific lesions, laterality labels) |
Mirrored "3" is not a 3. A flipped chest X-ray moves the heart to the right side - the model learns that heart-on-right is normal. |
VerticalFlip / RandomRotate90 |
Ground-level natural photos, portraits, documents, most microscopy with a defined stage orientation |
Gravity gives natural scenes a canonical up. Upside-down cars are off-manifold and waste capacity. |
VerticalFlip / RandomRotate90 |
Fine and recommended for overhead/satellite/aerial imagery, whole-slide pathology, and most 2D microscopy |
There is no privileged orientation - the full dihedral group D4 is a free 8x data multiplier. |
Arbitrary rotate |
Medical volumes with laterality or standardized acquisition planes; anything where you later report an anatomical measurement |
Rotation past a few degrees breaks the plane convention the labels assume. Keep to +/-10-15 deg. |
ColorJitter / RandomBrightnessContrast / HueSaturationValue |
Multispectral, SAR, thermal, CT (HU), any calibrated intensity |
The pixel value is the physical measurement. Jittering it teaches invariance to the exact signal you are trying to measure - NDVI-like band ratios are destroyed. |
ColorJitter |
Fine and important for H&E pathology (stain variation) and RGB drone/street imagery |
Stain and illumination genuinely vary between scanners/sites. Prefer stain-specific augmentation (HED colour-space jitter) over naive RGB jitter for pathology. |
Normalize before geometric transforms |
Always |
Rotation/scale interpolate; doing it on normalized data is fine numerically but any constant-fill border becomes a "0 = mean" pixel rather than a flaggable value. Keep Normalize + ToTensorV2 last. |
RandomResizedCrop with aggressive scale |
Remote sensing with a fixed GSD; anything where object size is a physical quantity |
If 1 px = 10 m always, scale jitter breaks the relationship between object size and class. Mild scale (0.9-1.1) only. |
7. Class imbalance in dense prediction
Uniformly sampling tile origins from a scene where the target covers 0.5% of pixels gives you tiles
that are ~85-95% completely empty. The gradient is dominated by background, the loss drops fast, and
the model converges to predicting all-background - which scores 99.5% pixel accuracy and 0 IoU.
Precompute a tile index with foreground fraction, then sample it.
# Offline, once - tile_starts() is from section 2:
index = []
for scene_id, mask in enumerate(train_masks): # each (H, W) uint8
H, W = mask.shape
for y in tile_starts(H, TILE, TILE):
for x in tile_starts(W, TILE, TILE):
fg = float((mask[y:y+TILE, x:x+TILE] > 0).mean())
index.append({"scene": scene_id, "y": y, "x": x, "fg": fg})
# In the Dataset: 50/50 mix
fg_pool = [i for i, r in enumerate(index) if r["fg"] > 0.01]
bg_pool = [i for i, r in enumerate(index) if r["fg"] <= 0.01]
def __getitem__(self, i):
pool = fg_pool if (i % 2 == 0) else bg_pool
rec = index[random.choice(pool)] # global RNG: PyTorch reseeds it per worker.
... # An RNG built in __init__ would not be - section 9.
A.CropNonEmptyMaskIfExists(height, width, p=1.0) crops around a non-zero mask region when one
exists and falls back to a random crop otherwise - a fast baseline, but it centres crops on
foreground, biasing object position toward the tile centre. Mix it at p=0.5 with plain
RandomCrop to avoid that.
- Oversampling foreground changes the class prior, so predicted probabilities are miscalibrated
relative to the real scene. Tune any decision threshold on a validation set sampled the way
deployment samples (full scenes), not on the oversampled tiles.
- Do not stack mechanisms: aggressive oversampling plus class-weighted loss plus focal loss
overshoots into over-prediction. Pick one primary (usually sampling) and one mild secondary (Dice).
- Keep genuinely-empty tiles in the mix. A model that never sees pure background produces false
positives everywhere on the real scene.
8. DataLoader performance
Decoding is almost always the bottleneck - not the GPU, not the disk. A 512x512 JPEG decode is
~2-5 ms; a compressed-tile GeoTIFF window read 10-50 ms; an OpenSlide read_region at level 0
50-200 ms. 8 workers x 3 ms is ~2600 img/s (more than most models consume); 8 workers x 50 ms is
160 img/s and your GPU sits idle.
loader = DataLoader(
ds, batch_size=16, shuffle=True,
num_workers=8, # start at physical cores; measure, do not guess
pin_memory=True, # only helps if you then use .to(dev, non_blocking=True)
persistent_workers=True, # essential on Windows: avoids respawning every epoch
prefetch_factor=4, # batches queued per worker; raise if timings are spiky
drop_last=True,
)
Storage format decision table
| Format |
Wins when |
Cost |
| Loose JPEG/PNG |
Small datasets, RGB, prototyping |
Slow on network/spinning storage; millions of small files kill NTFS |
np.memmap / np.lib.format.open_memmap |
Fixed-size tiles, uncompressed, fits on local disk. Fastest possible random access; OS page cache does the work |
No compression - a 100k x (12,512,512) uint16 store is huge |
| WebDataset (tar shards) |
Large datasets, network/cloud storage, multi-node. Sequential reads, near-linear scaling |
Only approximate shuffling (shuffle buffer + shard shuffle); awkward for foreground-biased sampling |
| LMDB |
Millions of small samples, need true random access with compression |
Single-writer; DB file size must be preallocated (map_size); one more dependency |
| zarr (+ Dask) |
Huge N-dimensional arrays (time series of scenes, 3D volumes) where you slice arbitrary windows; chunked + compressed |
Chunk shape must match your access pattern or you decompress 10x more than you read |
| Native GeoTIFF via rasterio windows |
You cannot afford to duplicate a multi-TB archive |
Ensure the file is tiled (not striped) and internally overviewed, else a 512x512 window read decodes whole 20000-px strips |
Rule of thumb: convert to memmap or WebDataset when a profiling run shows GPU utilization below
~70% and worker count is already at core count.
Whole-slide images (OpenSlide)
Windows install. Python 3.8+ ignores PATH when resolving extension DLLs, so import openslide fails with a DLL load error until you point at the unpacked binaries first:
import os
with os.add_dll_directory(r"C:\openslide\bin"): # before the import, every process
import openslide
Because workers spawn, this must run at module import, not once in main().
read_region((x, y), level, (w, h)) takes (x, y) in the level-0 frame no matter which
level you pass, while (w, h) is in that level's pixels. Scaling the origin by
slide.level_downsamples[level] "to be consistent" is the classic bug: you read a region
downsample^2 away from where you meant, and every tile is misaligned with its annotation.
It returns RGBA. .convert("RGB") explicitly - the alpha channel is 0 outside the scanned
area and naive np.array(region)[..., :3] leaves those regions black, which your tissue filter
then happily accepts as "dark = tissue".
Filter glass before tiling. 70-90% of a slide is background. Otsu-threshold the saturation
channel of a low-resolution level (slide.get_thumbnail(...) or level_count - 1), then keep only
tiles whose tissue fraction exceeds ~0.1. Doing this offline into a tile index (section 7) is the
difference between a 3-hour and a 30-hour epoch.
Normalize magnification, not pixels. slide.properties[openslide.PROPERTY_NAME_MPP_X] is
0.25 um/px at 40x and 0.5 at 20x, and it is missing on some NDPI/scanner exports. A fixed 512-px
tile therefore covers 2x different tissue across a multi-site cohort - resample to a target MPP
and fail loudly when the property is absent rather than assuming 40x.
Windows specifics
Windows uses spawn, not fork. num_workers > 0 requires your training entry point to be
guarded, or each worker re-executes the script and you get an infinite spawn storm (usually
presenting as a RuntimeError about the current process finishing bootstrapping, or as the
machine simply freezing):
if __name__ == "__main__":
main()
Everything crossing the process boundary must be picklable: no lambdas in worker_init_fn,
no local closures in collate_fn, no partial over a nested function. Module-level functions only.
Spawn re-imports your module in every worker, so module-level heavy work (loading a big index,
importing torch, opening a DB) is paid num_workers times per spawn. Worker startup is ~1-3 s
each on Windows vs ~50 ms on Linux, so num_workers=16 can cost 30 s per epoch in pure startup;
4-8 is the sweet spot even on a 16-core machine, and persistent_workers=True (pay once per run,
not once per epoch) is often the single biggest wall-clock win.
Open file handles lazily, inside the worker. h5py.File, rasterio.open, lmdb.open and
OpenSlide handles created in Dataset.__init__ are either unpicklable (spawn crashes with a
confusing pickling error) or shared unsafely. Standard pattern:
def __getitem__(self, i):
if self._h5 is None: # set to None in __init__
self._h5 = h5py.File(self.path, "r")
...
Add cv2.setNumThreads(0) at module level (and OMP_NUM_THREADS=1). OpenCV's thread pool times
8 worker processes oversubscribes the CPU and can make the loader slower than num_workers=0.
9. Determinism in the data path
import random, numpy as np, torch
def seed_worker(worker_id): # must be module-level (Windows pickling)
s = torch.initial_seed() % 2**32 # per-worker, derived from base_seed
np.random.seed(s)
random.seed(s)
tf = torch.utils.data.get_worker_info().dataset.transform
if hasattr(tf, "set_random_seed"): # albumentations >= 2.0, see below
tf.set_random_seed(s)
g = torch.Generator()
g.manual_seed(1337) # controls the shuffle order
loader = DataLoader(ds, ..., worker_init_fn=seed_worker, generator=g)
- The
generator= argument seeds the sampler (which indices, in what order). worker_init_fn
seeds the augmentation RNGs. You need both; they are independent.
- Modern PyTorch does seed
random and numpy's global RNG per worker, but a np.random.RandomState
or random.Random instance you construct in Dataset.__init__ is copied to every worker
identically - so all 8 workers draw the same augmentation sequence. Construct per-worker RNGs
lazily inside __getitem__/worker_init_fn, or use the global RNG.
- Albumentations changed RNG model in 2.0 and
seed_worker no longer covers it. Pre-2.0
transforms drew from the global random / numpy RNGs, so seeding the globals per worker was
enough. From 2.0 (and late 1.4.x) every transform owns a per-instance RNG fixed at construction
time, plus A.Compose(..., seed=N). A Compose built in the parent process and shipped to
workers therefore carries the same RNG state into all of them - eight workers, one
augmentation sequence - and nothing in worker_init_fn that touches globals will change that.
Either reseed the pipeline per worker (the set_random_seed lines above) or construct the
Compose lazily inside the worker.
Symptom if you miss it: the k-th sample produced by every worker gets identical augmentation
parameters, so each batch contains num_workers copies of the same flip/rotate/brightness draw
applied to different tiles. Verify by transforming one fixed array in each worker and comparing
hashes - they must differ.
- To reproduce one exact sample, use
A.ReplayCompose and store the returned replay dict.
- Reproducing a run needs the sampler seed, the worker seed scheme,
num_workers, the dataset
ordering, and library versions - log all of them. Changing num_workers changes which sample gets
which seed, so a run is not reproducible across a worker-count change even with identical seeds.
torch.use_deterministic_algorithms(True) plus CUBLAS_WORKSPACE_CONFIG=:4096:8 handles the model
side; it does nothing for the data path.
10. Debugging: how to actually verify the pipeline
Do these before training anything. Each one has caught a real, silent, model-killing bug.
1. Dump a batch and look at it with your eyes.
import torchvision
x, y = next(iter(train_loader))
vis = x[:, :3] # pick RGB bands for multispectral
vis = (vis - vis.amin()) / (vis.amax() - vis.amin() + 1e-8)
torchvision.utils.save_image(torchvision.utils.make_grid(vis, nrow=4), "batch.png")
torchvision.utils.save_image(
torchvision.utils.make_grid((y.float() / max(1, y.max())).unsqueeze(1), nrow=4),
"batch_mask.png")
Then overlay (vis[:, 0] = torch.where(y > 0, 1.0, vis[:, 0]), save again). A side-by-side will
not reveal a 1-pixel misalignment or a transposed mask; an overlay will.
2. Assert dtype and range after every stage. Put these in the Dataset temporarily:
assert img.dtype == np.float32, img.dtype
assert np.isfinite(img).all()
assert -6 < img.mean() < 6 and 0.2 < img.std() < 5, (img.mean(), img.std())
assert mask.dtype in (np.uint8, np.int64), mask.dtype
assert set(np.unique(mask)).issubset(ALLOWED_IDS), np.unique(mask)
The mask-class assertion catches: bilinear-interpolated masks, an unremapped label file where
classes are 0/38/75/113 instead of 0/1/2/3, and a padding fill of 0 colliding with a real class.
3. Round-trip the tiler with an identity model.
img = np.random.rand(3, 1731, 2049).astype(np.float32) # deliberately not divisible
rec = predict_scene(img, model_fn=lambda p: p, tile=512, stride=256, n_classes=3)
assert np.allclose(rec, img, atol=1e-5), np.abs(rec - img).max()
Use non-square, indivisible dimensions - a 2048x2048 test image passes with almost any broken
implementation.
4. Verify inference normalization equals training normalization. Store the stats inside the
checkpoint and have the inference script read them from there, so they cannot drift; if they live in
a sidecar file, assert equality of the loaded dicts at inference startup.
5. Overfit 4 samples to ~0 loss. With augmentation off. If the model cannot memorize 4 tiles,
the bug is in the data path (misaligned mask, wrong loss target dtype, ignore_index eating
everything), not in the architecture or LR.
6. Count label pixels across the whole training set, print the per-class fraction. A class at
0.0000 is absent - either genuinely, or because your remap dropped it.
7. Flag constant tiles. img.std() < 1e-6 means an all-nodata tile reached training; those go
NaN under per-tile normalization and poison the whole batch.
1---2name: image-preprocessing-and-tiling3description: Build and debug image preprocessing pipelines for deep learning when images are large, multi-channel, or scientifically calibrated - satellite/aerial GeoTIFF, Sentinel/Landsat multispectral stacks, whole-slide pathology (SVS/NDPI/OpenSlide), CT/MRI volumes, or any raster too big for the GPU. Use when the task mentions tiling, patching, sliding window, chip extraction, stride/overlap, seams when stitching predictions, reassembling tiles into a full-scene mask, normalization statistics, per-channel mean/std, percentile clipping, nodata poisoning stats, albumentations, image+mask augmentation, additional_targets, class imbalance in segmentation, empty/background tiles, slow DataLoader num_workers on Windows, worker seeding and reproducible augmentation, or converting a dataset to memmap numpy / WebDataset / LMDB / zarr. Also use when a segmentation or dense-prediction model trains fine but produces grid-shaped seams, all-background outputs, or scores far worse at inference than in validation.4---56# Image Preprocessing and Tiling78Failure modes here are silent, not exceptions: a `-9999` nodata sentinel shifts a channel mean by9orders of magnitude, a mask gets bilinear-interpolated into non-integer class ids, augmentation10randomness is duplicated across eight workers, inference normalization differs from training by a11factor of 255. The model still trains. It just does not work.1213---1415## 1. Tiling: choosing tile size and stride1617| Decision | Rule |18|---|---|19| Tile size | Largest power-of-two that fits batch size >= 8 in VRAM. 512 for most segmentation UNets at bs 8-16 on 24GB; 256 if you need bs 32+; 1024 only if context genuinely matters (e.g. large fields, tumor architecture). |20| Training stride | Irrelevant if you sample random crops. If you pre-cut a fixed tile grid, use stride == tile (no overlap) and rely on random offsets per epoch instead. |21| Inference stride | `tile // 2` is the default worth defending. It is the minimum overlap at which a Hann window sums to a constant (COLA), and it doubles compute in each axis (4x total). `tile * 3 // 4` (25% overlap) is the cheap compromise. |22| Receptive field check | Overlap should be >= the model's effective receptive field on each side, otherwise pixels near a tile edge are predicted from truncated context. For a UNet with 4 downsamples, that is roughly 100-200 px. Overlap of 128 px at tile 512 satisfies this; overlap of 16 px does not. |2324**Why overlap at inference:** a pixel 3 px from a tile border saw almost no context on one side, so25its prediction is systematically worse. With stride == tile you keep exactly those bad predictions26along every seam - that is the visible grid pattern. Overlap lets you replace edge predictions with27center predictions from the neighbour. Naive overwrite is not enough: writing one tile over another28just moves the discontinuity to the write boundary. You need a smooth weight.2930---3132## 2. Reassembly with feathered (Hann) blending3334```python35import numpy as np3637def hann2d(th, tw, eps=1e-6):38 # np.hanning(N) is EXACTLY 0 at both endpoints. If a border pixel is covered39 # by only one tile, its accumulated weight would be 0 -> 0/0 -> NaN.40 # Sampling the interior of a length-(N+2) window avoids the zeros entirely.41 wy = np.hanning(th + 2)[1:-1]42 wx = np.hanning(tw + 2)[1:-1]43 return np.maximum(np.outer(wy, wx), eps).astype(np.float32)4445def tile_starts(size, tile, stride):46 """Start indices covering [0, size). Final tile is SHIFTED to end exactly at47 `size` instead of padding - so no tile ever contains synthetic pixels."""48 if size <= tile:49 return [0]50 starts = list(range(0, size - tile + 1, stride))51 if starts[-1] != size - tile:52 starts.append(size - tile)53 return starts5455def predict_scene(image, model_fn, tile=512, stride=256, n_classes=1):56 """image: (C, H, W) float32, already normalized. model_fn: (C,t,t)->(K,t,t)."""57 C, H, W = image.shape58 if H < tile or W < tile: # scene smaller than one tile:59 pad = ((0, 0), (0, max(0, tile - H)), (0, max(0, tile - W)))60 big = np.pad(image, pad, mode="symmetric") # pad up, predict, crop back61 return predict_scene(big, model_fn, tile, stride, n_classes)[:, :H, :W]62 acc = np.zeros((n_classes, H, W), np.float32) # weighted sum of logits/probs63 wacc = np.zeros((1, H, W), np.float32) # accumulated weight64 win = hann2d(tile, tile)[None] # (1,t,t) broadcasts over K6566 for y in tile_starts(H, tile, stride):67 for x in tile_starts(W, tile, stride):68 patch = image[:, y:y+tile, x:x+tile]69 pred = model_fn(patch) # (K, t, t)70 acc[:, y:y+tile, x:x+tile] += pred * win71 wacc[:, y:y+tile, x:x+tile] += win72 return acc / wacc73```7475Non-obvious points in that code:7677- **Divide by accumulated weight, not by a count.** The shifted final tile overlaps its neighbour by78 an irregular amount; some pixels get 2 tiles, some 4, some 3. Only `acc / wacc` is correct for all79 of them, and it makes the irregular last-row/last-column stride a non-issue.80- **Blend probabilities or logits, not argmax labels.** Averaging class indices is meaningless.81 Prefer averaging softmax probabilities (bounded, well-behaved); averaging logits is also fine and82 slightly sharper, but do not mix the two across tiles.83- **`acc` must be float32 minimum.** float16 accumulation over 4 overlapping tiles of a 20k x 20k84 scene loses precision visibly in low-probability regions.85- **Memory.** `acc` for a 20000x20000 scene with 10 classes in float32 is 16 GB. Use86 `np.lib.format.open_memmap`, or process the scene in row-blocks.87- The torch equivalent is `F.unfold`/`F.fold`: `F.fold` sums overlaps, so fold `pred * win` and88 separately fold `win` broadcast to the same shape, then divide. But `unfold` materializes every89 tile at once and will OOM on a large scene.90- **3D volumes:** do not hand-roll this. `monai.inferers.sliding_window_inference(inputs, roi_size,91 sw_batch_size, predictor, overlap=0.5, mode="gaussian")` is the same algorithm with a Gaussian92 instead of a Hann window, batched patches, and `device=`/`sw_device=` so the accumulator can live93 on CPU while the model runs on GPU. `mode="constant"` is the seam-producing default trap.94- **Round-trip assertion** (section 10): with `model_fn = lambda p: p`, the reassembled output must95 equal the input to ~1e-5. If it does not, your seams are a tiling bug, not a model bug.9697---9899## 3. Edges and remainders100101Two valid strategies for `H % tile != 0`:102103| Strategy | Use when | Cost |104|---|---|---|105| **Shifted final tile** (`tile_starts` above) | Inference, always, when `H >= tile` | Extra overlap in the last row/col; free because weighted blending handles it. |106| **Pad then crop** | `H < tile`; or fully-convolutional models needing a fixed grid; or training where you want the true border seen | Introduces synthetic pixels the model must learn to ignore. |107108If you pad, **pad by reflection, not zeros**:109110```python111pad_h, pad_w = (-H) % tile, (-W) % tile112padded = np.pad(image, ((0,0), (0,pad_h), (0,pad_w)), mode="reflect")113# then crop back: out = out[:, :H, :W]114```115116- Zero padding creates a hard step from real radiance to exactly 0. Convolutions near the border see117 an edge that exists in *every* padded sample at the same place, so the network learns a118 border-specific response - a bright or dark 5-20 px rim on predictions and worse metrics near119 scene edges. 0 is also a legitimate reflectance value and a common nodata sentinel.120- `np.pad(mode="reflect")` tolerates `pad_width >= dim` (it reflects repeatedly), but the same121 request raises in `torch.nn.functional.pad(mode="reflect")` and in `cv2.copyMakeBorder`, both of122 which require `pad < dim` (`pad < dim - 1` for `BORDER_REFLECT_101`). Padding a 3-px strip by 100123 therefore works offline in numpy and blows up inside the model or an albumentations transform.124 In numpy, `mode="symmetric"` repeats the edge sample under no length constraint - the robust125 default. Either way, padding a strip by more than its own width fabricates most of the tile; drop126 such slivers instead of padding them.127- In OpenCV/albumentations the equivalent is `cv2.BORDER_REFLECT_101` (= numpy `reflect`) vs128 `cv2.BORDER_REFLECT` (= numpy `symmetric`). `BORDER_CONSTANT` is the zero-padding trap.129- **Pad the mask with an ignore label, not with class 0.** Reflect-padding the image but zero-padding130 the mask teaches the model that reflected content is background. Use `fill_mask=255` (or your131 ignore_index) and set `ignore_index=255` in the loss.132133---134135## 4. Normalization136137### The four rules1381391. **Statistics come from the training split only.** Train+val+test is leakage - small, but exactly140 what reviewers ask about, and not small at all with a distribution-shifted test set.1412. **Per channel.** A 12-band Sentinel-2 stack has band means spanning an order of magnitude; one142 scalar mean/std flattens the informative bands into noise.1433. **ImageNet statistics are only valid for 8-bit RGB natural photographs.** For multispectral, SAR144 (dB, can be negative), thermal, DEM (metres), or 16-bit medical data they are meaningless. Even145 for 3-band RGB *satellite* imagery they are wrong - the radiometry is different.1464. **Save the statistics next to the checkpoint and reload them at inference.** Recomputing them147 from the inference scene is the most common source of train/inference skew, and it presents148 exactly as a model that "doesn't generalize".149150### Computing stats without poisoning them151152```python153import json, numpy as np, rasterio154155C = 12156n = np.zeros(C, np.float64); s = np.zeros(C, np.float64); ss = np.zeros(C, np.float64)157SHIFT = None # provisional mean, prevents catastrophic cancellation for large values158159for path in TRAIN_PATHS: # TRAIN SPLIT ONLY160 with rasterio.open(path) as src:161 a = src.read(masked=True) # masked=True honours the nodata tag162 a = a.astype(np.float64)163 valid = ~np.ma.getmaskarray(a)164 # Also drop known sentinels the file failed to declare:165 valid &= np.isfinite(a) & (a != -9999) & (a != 0) # tune per dataset!166 if SHIFT is None:167 SHIFT = np.array([a[c][valid[c]].mean() if valid[c].any() else 0.0168 for c in range(C)])169 d = np.where(valid, a - SHIFT[:, None, None], 0.0)170 n += valid.sum(axis=(1, 2), dtype=np.float64)171 s += d.sum(axis=(1, 2), dtype=np.float64)172 ss += (d * d).sum(axis=(1, 2), dtype=np.float64)173174assert (n > 0).all(), n # a channel with 0 valid pixels -> NaN stats, silently175mean = SHIFT + s / n176std = np.sqrt(np.maximum(ss / n - (s / n) ** 2, 0.0))177json.dump({"mean": mean.tolist(), "std": std.tolist(), "n": n.tolist()},178 open("norm_stats.json", "w"))179```180181Gotchas encoded above:182183- **`src.read()` returns nodata pixels as their raw sentinel value.** `masked=True` gives a184 `MaskedArray` using the file's *declared* nodata - but many GeoTIFFs declare nothing, or declare 0185 while also using `-9999` in some tiles. Print `src.nodata`, then histogram one scene and look for186 a spike at 0 / -9999 / 65535 before trusting it. A `-9999` sentinel in **1%** of pixels drags a187 band with mean 1500 / std 300 to mean 1385 and std 1182 - the mean moves 8%, the std nearly **4x**.188 Every downstream z-score is then compressed toward zero.189- **Always pass `dtype=np.float64` to `.sum()`.** Integer reductions accumulate in the platform's190 default integer, which is 32-bit on Windows under numpy < 2.0 - `uint16` sums overflow silently191 past 4.3e9 (a single 20k x 20k band of mid-range reflectance). NumPy >= 2.0 made the Windows192 default 64-bit, so this bug appears and disappears with the numpy version; pin the accumulator.193- **`ss/n - mean^2` is numerically fragile** for uint16 reflectance (values ~1e4, N ~1e10). The194 provisional-mean shift above costs nothing and removes the problem; `np.maximum(..., 0)` guards195 the residual negative variance.196- Do **not** compute stats over the tiles your foreground-biased sampler emits - that biases the197 statistics toward the rare class. Compute over the full training scenes.198199### Percentile clipping200201For imagery with heavy tails (specular water glint, clouds, sensor spikes, saturated pathology202whites), z-scoring alone leaves 99.9% of pixels squeezed into a narrow band.203204```python205# Sample ~1e7 valid pixels per channel from the TRAIN split, then:206p1, p99 = np.percentile(sampled, [1, 99], axis=0) # shape (C,)207x = np.clip(x, p1[:, None, None], p99[:, None, None])208x = (x - p1[:, None, None]) / (p99 - p1)[:, None, None] # -> [0, 1]209```210211Order matters: **clip first, then scale**. Compute the percentiles globally over the training set,212never per-scene at inference - per-scene percentiles make the same physical radiance map to213different network inputs depending on what else is in the scene, destroying cross-scene214comparability. (Per-scene is acceptable only for visualization, or when sensor gain genuinely varies215per acquisition and you have decided to normalize it away.) The right percentile is data dependent:2161/99 is fine on cloud-free agricultural scenes; 2/98 is often needed with water glint or cloud.217218### Domain notes219220- **CT** is calibrated in Hounsfield units. Window first (`soft tissue` ~ center 40 / width 400;221 `lung` ~ -600 / 1500; `bone` ~ 400 / 1800), then scale to [0,1]. Global stats are meaningful.222- **MRI** intensities are in arbitrary units that vary by scanner, coil and sequence. Global223 mean/std across a dataset is wrong. Normalize **per volume** (z-score over the brain mask, or224 scale by the 99th percentile inside the mask), and do it after skull-stripping / bias-field225 correction if you are doing those.226- **SAR** in linear power is log-normal - convert to dB (`10*log10`) before z-scoring, and handle227 zeros before the log.228- **Voxel spacing before patching (CT/MRI).** A 96x96x96 patch is a different physical volume in229 every scan until you resample to a fixed spacing (e.g. 1x1x1 mm, or the dataset median as nnU-Net230 does). CT series are routinely 0.7x0.7x5 mm - patching that anisotropic grid directly means the231 z-axis receptive field covers 10x the anatomy the in-plane one does. Resample the image with232 linear/spline interpolation and the label map with **nearest neighbour**, and re-check the label233 set afterwards. Also apply the DICOM `RescaleSlope`/`RescaleIntercept` before treating values as234 HU; `pydicom` does not apply them for you (`apply_modality_lut` does).235- **Adapting a pretrained RGB encoder to N bands.** Do not feed 12 bands through an ImageNet-stat236 normalization. Compute your own N-channel stats, then inflate the stem weights:237 `w_new = w.mean(1, keepdim=True).repeat(1, N, 1, 1) * (3 / N)` - the `3/N` keeps the stem's output238 magnitude where the pretrained downstream layers expect it. Without the rescale, a 12-band stem239 emits ~4x the activation scale and the first epochs are spent undoing it.240241---242243## 5. Augmentation with albumentations244245```python246import cv2, numpy as np, albumentations as A247from albumentations.pytorch import ToTensorV2248249MEAN, STD = stats["mean"], stats["std"] # loaded from norm_stats.json250251train_tf = A.Compose([252 # --- geometric: applied identically to image AND mask ---253 A.PadIfNeeded(min_height=512, min_width=512,254 border_mode=cv2.BORDER_REFLECT_101),255 A.RandomCrop(height=512, width=512),256 A.HorizontalFlip(p=0.5),257 A.VerticalFlip(p=0.5), # overhead imagery ONLY - see section 6258 A.RandomRotate90(p=0.5), # overhead imagery ONLY259 A.Affine(scale=(0.9, 1.1), translate_percent=(-0.05, 0.05),260 rotate=(-15, 15), p=0.5),261 # --- photometric: image only, albumentations handles this automatically ---262 A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.3),263 # --- normalization + tensor ---264 A.Normalize(mean=MEAN, std=STD, max_pixel_value=1.0),265 ToTensorV2(),266])267268out = train_tf(image=img_hwc, mask=mask_hw) # img (H,W,C), mask (H,W) int269x, y = out["image"], out["mask"] # (C,H,W) float32, (H,W) int64-able270```271272The traps, in order of how often they bite:273274- **`A.Normalize(max_pixel_value=255.0)` is the default.** If your array is already float in [0,1],275 or is 16-bit reflectance you scaled yourself, the default divides by 255 *again* and your inputs276 land near 0.004. The model trains, slowly, to a mediocre score. Pass `max_pixel_value=1.0`277 whenever the input is not raw uint8.278- **`ToTensorV2` does NOT divide by 255** (unlike `torchvision.transforms.ToTensor`); it only279 transposes HWC->CHW. Porting a torchvision pipeline without adding an explicit scale is a silent280 255x error.281- **`ToTensorV2` does not transpose the mask** unless you pass `transpose_mask=True`. A `(H,W,K)`282 one-hot mask stays `(H,W,K)` and your loss silently broadcasts wrong.283- **Masks are interpolated with nearest neighbour** by albumentations' geometric transforms, so284 class ids stay integral - but only if you pass the array as `mask=`. If you sneak the mask through285 as a 4th image channel to "save a call", it gets bilinear-interpolated and you get class id 3.7.286 Assert `set(np.unique(y)) <= set(range(K)) | {ignore_index}` after augmentation.287- **`additional_targets`** is how you get a second mask, a second image, or a change-detection pair288 transformed with the *same* random parameters:289290 ```python291 tf = A.Compose([...], additional_targets={"image_t2": "image", "mask_prev": "mask"})292 out = tf(image=a, image_t2=b, mask=m, mask_prev=mp)293 ```294295 Without it, calling the transform twice draws two independent random states and image and mask are296 no longer aligned - which presents as a model that cannot learn, not as a bug.297- **Photometric augs must not touch the mask.** Albumentations enforces this for its built-ins298 (`apply_to_mask` is identity for brightness/contrast/blur/noise). For a custom transform, subclass299 `A.ImageOnlyTransform`, not `A.DualTransform`.300- **Version rename (albumentations 2.0):** the border/fill kwargs were inconsistent before 2.0 and301 were unified in it. Pre-2.0: most geometric transforms (`PadIfNeeded`, `Rotate`, `ShiftScaleRotate`)302 took `border_mode=` / `value=` / `mask_value=`, while `A.Affine` took `mode=` / `cval=` /303 `cval_mask=`. From 2.0 everything is `border_mode=` / `fill=` / `fill_mask=`. Print `A.__version__`304 and `inspect.signature(A.Affine.__init__)` rather than guessing; an unknown kwarg is silently305 ignored in some versions and raises in others, so a wrong name can mean you were zero-padding all306 along.307- **>3 channels:** geometric transforms and `Normalize` handle arbitrary channel counts. Colour-space308 transforms (`HueSaturationValue`, `RGBShift`, `CLAHE`, `ToGray`, most `RandomFog`-style ones)309 assume 3 or 1 channels and will raise or silently mangle a 12-band stack. Verify on one sample.310 Leave `is_check_shapes=True` (default) on - it catches image/mask shape mismatch.311312---313314## 6. Augmentations that are wrong for your domain315316| Augmentation | Wrong when | Why |317|---|---|---|318| `HorizontalFlip` | Text, digits, characters, any chirality-bearing content; medical images where left/right is diagnostic (situs inversus, side-specific lesions, laterality labels) | Mirrored "3" is not a 3. A flipped chest X-ray moves the heart to the right side - the model learns that heart-on-right is normal. |319| `VerticalFlip` / `RandomRotate90` | Ground-level natural photos, portraits, documents, most microscopy with a defined stage orientation | Gravity gives natural scenes a canonical up. Upside-down cars are off-manifold and waste capacity. |320| `VerticalFlip` / `RandomRotate90` | **Fine and recommended** for overhead/satellite/aerial imagery, whole-slide pathology, and most 2D microscopy | There is no privileged orientation - the full dihedral group D4 is a free 8x data multiplier. |321| Arbitrary `rotate` | Medical volumes with laterality or standardized acquisition planes; anything where you later report an anatomical measurement | Rotation past a few degrees breaks the plane convention the labels assume. Keep to +/-10-15 deg. |322| `ColorJitter` / `RandomBrightnessContrast` / `HueSaturationValue` | Multispectral, SAR, thermal, CT (HU), any calibrated intensity | The pixel value *is* the physical measurement. Jittering it teaches invariance to the exact signal you are trying to measure - NDVI-like band ratios are destroyed. |323| `ColorJitter` | **Fine and important** for H&E pathology (stain variation) and RGB drone/street imagery | Stain and illumination genuinely vary between scanners/sites. Prefer stain-specific augmentation (HED colour-space jitter) over naive RGB jitter for pathology. |324| `Normalize` before geometric transforms | Always | Rotation/scale interpolate; doing it on normalized data is fine numerically but any constant-fill border becomes a "0 = mean" pixel rather than a flaggable value. Keep `Normalize` + `ToTensorV2` last. |325| `RandomResizedCrop` with aggressive scale | Remote sensing with a fixed GSD; anything where object size is a physical quantity | If 1 px = 10 m always, scale jitter breaks the relationship between object size and class. Mild scale (0.9-1.1) only. |326327---328329## 7. Class imbalance in dense prediction330331Uniformly sampling tile origins from a scene where the target covers 0.5% of pixels gives you tiles332that are ~85-95% completely empty. The gradient is dominated by background, the loss drops fast, and333the model converges to predicting all-background - which scores 99.5% pixel accuracy and 0 IoU.334335**Precompute a tile index with foreground fraction, then sample it.**336337```python338# Offline, once - tile_starts() is from section 2:339index = []340for scene_id, mask in enumerate(train_masks): # each (H, W) uint8341 H, W = mask.shape342 for y in tile_starts(H, TILE, TILE):343 for x in tile_starts(W, TILE, TILE):344 fg = float((mask[y:y+TILE, x:x+TILE] > 0).mean())345 index.append({"scene": scene_id, "y": y, "x": x, "fg": fg})346347# In the Dataset: 50/50 mix348fg_pool = [i for i, r in enumerate(index) if r["fg"] > 0.01]349bg_pool = [i for i, r in enumerate(index) if r["fg"] <= 0.01]350351def __getitem__(self, i):352 pool = fg_pool if (i % 2 == 0) else bg_pool353 rec = index[random.choice(pool)] # global RNG: PyTorch reseeds it per worker.354 ... # An RNG built in __init__ would not be - section 9.355```356357`A.CropNonEmptyMaskIfExists(height, width, p=1.0)` crops around a non-zero mask region when one358exists and falls back to a random crop otherwise - a fast baseline, but it centres crops on359foreground, biasing object position toward the tile centre. Mix it at `p=0.5` with plain360`RandomCrop` to avoid that.361362- **Oversampling foreground changes the class prior**, so predicted probabilities are miscalibrated363 relative to the real scene. Tune any decision threshold on a validation set sampled the way364 deployment samples (full scenes), not on the oversampled tiles.365- Do not stack mechanisms: aggressive oversampling **plus** class-weighted loss **plus** focal loss366 overshoots into over-prediction. Pick one primary (usually sampling) and one mild secondary (Dice).367- Keep genuinely-empty tiles in the mix. A model that never sees pure background produces false368 positives everywhere on the real scene.369370---371372## 8. DataLoader performance373374Decoding is almost always the bottleneck - not the GPU, not the disk. A 512x512 JPEG decode is375~2-5 ms; a compressed-tile GeoTIFF window read 10-50 ms; an OpenSlide `read_region` at level 037650-200 ms. 8 workers x 3 ms is ~2600 img/s (more than most models consume); 8 workers x 50 ms is377160 img/s and your GPU sits idle.378379```python380loader = DataLoader(381 ds, batch_size=16, shuffle=True,382 num_workers=8, # start at physical cores; measure, do not guess383 pin_memory=True, # only helps if you then use .to(dev, non_blocking=True)384 persistent_workers=True, # essential on Windows: avoids respawning every epoch385 prefetch_factor=4, # batches queued per worker; raise if timings are spiky386 drop_last=True,387)388```389390### Storage format decision table391392| Format | Wins when | Cost |393|---|---|---|394| Loose JPEG/PNG | Small datasets, RGB, prototyping | Slow on network/spinning storage; millions of small files kill NTFS |395| **`np.memmap` / `np.lib.format.open_memmap`** | Fixed-size tiles, uncompressed, fits on local disk. Fastest possible random access; OS page cache does the work | No compression - a 100k x (12,512,512) uint16 store is huge |396| **WebDataset (tar shards)** | Large datasets, network/cloud storage, multi-node. Sequential reads, near-linear scaling | Only *approximate* shuffling (shuffle buffer + shard shuffle); awkward for foreground-biased sampling |397| **LMDB** | Millions of small samples, need true random access with compression | Single-writer; DB file size must be preallocated (`map_size`); one more dependency |398| **zarr** (+ Dask) | Huge N-dimensional arrays (time series of scenes, 3D volumes) where you slice arbitrary windows; chunked + compressed | Chunk shape must match your access pattern or you decompress 10x more than you read |399| Native GeoTIFF via rasterio windows | You cannot afford to duplicate a multi-TB archive | Ensure the file is *tiled* (not striped) and internally overviewed, else a 512x512 window read decodes whole 20000-px strips |400401Rule of thumb: convert to memmap or WebDataset when a profiling run shows GPU utilization below402~70% and worker count is already at core count.403404### Whole-slide images (OpenSlide)405406- **Windows install.** Python 3.8+ ignores `PATH` when resolving extension DLLs, so `import407 openslide` fails with a DLL load error until you point at the unpacked binaries first:408409 ```python410 import os411 with os.add_dll_directory(r"C:\openslide\bin"): # before the import, every process412 import openslide413 ```414415 Because workers spawn, this must run at module import, not once in `main()`.416- **`read_region((x, y), level, (w, h))` takes `(x, y)` in the level-0 frame** no matter which417 `level` you pass, while `(w, h)` is in that level's pixels. Scaling the origin by418 `slide.level_downsamples[level]` "to be consistent" is the classic bug: you read a region419 `downsample^2` away from where you meant, and every tile is misaligned with its annotation.420- **It returns RGBA.** `.convert("RGB")` explicitly - the alpha channel is 0 outside the scanned421 area and naive `np.array(region)[..., :3]` leaves those regions black, which your tissue filter422 then happily accepts as "dark = tissue".423- **Filter glass before tiling.** 70-90% of a slide is background. Otsu-threshold the saturation424 channel of a low-resolution level (`slide.get_thumbnail(...)` or `level_count - 1`), then keep only425 tiles whose tissue fraction exceeds ~0.1. Doing this offline into a tile index (section 7) is the426 difference between a 3-hour and a 30-hour epoch.427- **Normalize magnification, not pixels.** `slide.properties[openslide.PROPERTY_NAME_MPP_X]` is428 0.25 um/px at 40x and 0.5 at 20x, and it is missing on some NDPI/scanner exports. A fixed 512-px429 tile therefore covers 2x different tissue across a multi-site cohort - resample to a target MPP430 and fail loudly when the property is absent rather than assuming 40x.431432### Windows specifics433434- Windows uses `spawn`, not `fork`. **`num_workers > 0` requires your training entry point to be435 guarded**, or each worker re-executes the script and you get an infinite spawn storm (usually436 presenting as a `RuntimeError` about the current process finishing bootstrapping, or as the437 machine simply freezing):438439 ```python440 if __name__ == "__main__":441 main()442 ```443- Everything crossing the process boundary must be **picklable**: no lambdas in `worker_init_fn`,444 no local closures in `collate_fn`, no `partial` over a nested function. Module-level functions only.445- Spawn re-imports your module in every worker, so module-level heavy work (loading a big index,446 importing torch, opening a DB) is paid `num_workers` times per spawn. Worker startup is ~1-3 s447 each on Windows vs ~50 ms on Linux, so `num_workers=16` can cost 30 s per epoch in pure startup;448 4-8 is the sweet spot even on a 16-core machine, and `persistent_workers=True` (pay once per run,449 not once per epoch) is often the single biggest wall-clock win.450- **Open file handles lazily, inside the worker.** `h5py.File`, `rasterio.open`, `lmdb.open` and451 OpenSlide handles created in `Dataset.__init__` are either unpicklable (spawn crashes with a452 confusing pickling error) or shared unsafely. Standard pattern:453454 ```python455 def __getitem__(self, i):456 if self._h5 is None: # set to None in __init__457 self._h5 = h5py.File(self.path, "r")458 ...459 ```460- Add `cv2.setNumThreads(0)` at module level (and `OMP_NUM_THREADS=1`). OpenCV's thread pool times461 8 worker processes oversubscribes the CPU and can make the loader *slower* than `num_workers=0`.462463---464465## 9. Determinism in the data path466467```python468import random, numpy as np, torch469470def seed_worker(worker_id): # must be module-level (Windows pickling)471 s = torch.initial_seed() % 2**32 # per-worker, derived from base_seed472 np.random.seed(s)473 random.seed(s)474 tf = torch.utils.data.get_worker_info().dataset.transform475 if hasattr(tf, "set_random_seed"): # albumentations >= 2.0, see below476 tf.set_random_seed(s)477478g = torch.Generator()479g.manual_seed(1337) # controls the shuffle order480loader = DataLoader(ds, ..., worker_init_fn=seed_worker, generator=g)481```482483- The `generator=` argument seeds the **sampler** (which indices, in what order). `worker_init_fn`484 seeds the **augmentation** RNGs. You need both; they are independent.485- Modern PyTorch does seed `random` and numpy's global RNG per worker, but a `np.random.RandomState`486 or `random.Random` instance you construct in `Dataset.__init__` is copied to every worker487 identically - so all 8 workers draw the *same* augmentation sequence. Construct per-worker RNGs488 lazily inside `__getitem__`/`worker_init_fn`, or use the global RNG.489- **Albumentations changed RNG model in 2.0 and `seed_worker` no longer covers it.** Pre-2.0490 transforms drew from the global `random` / `numpy` RNGs, so seeding the globals per worker was491 enough. From 2.0 (and late 1.4.x) every transform owns a per-instance RNG fixed at *construction*492 time, plus `A.Compose(..., seed=N)`. A `Compose` built in the parent process and shipped to493 workers therefore carries the **same** RNG state into all of them - eight workers, one494 augmentation sequence - and nothing in `worker_init_fn` that touches globals will change that.495 Either reseed the pipeline per worker (the `set_random_seed` lines above) or construct the496 `Compose` lazily inside the worker.497 Symptom if you miss it: the k-th sample produced by *every* worker gets identical augmentation498 parameters, so each batch contains `num_workers` copies of the same flip/rotate/brightness draw499 applied to different tiles. Verify by transforming one fixed array in each worker and comparing500 hashes - they must differ.501- To reproduce one exact sample, use `A.ReplayCompose` and store the returned `replay` dict.502- Reproducing a run needs the sampler seed, the worker seed scheme, `num_workers`, the dataset503 ordering, and library versions - log all of them. Changing `num_workers` changes which sample gets504 which seed, so a run is not reproducible across a worker-count change even with identical seeds.505- `torch.use_deterministic_algorithms(True)` plus `CUBLAS_WORKSPACE_CONFIG=:4096:8` handles the model506 side; it does nothing for the data path.507508---509510## 10. Debugging: how to actually verify the pipeline511512Do these before training anything. Each one has caught a real, silent, model-killing bug.513514**1. Dump a batch and look at it with your eyes.**515516```python517import torchvision518x, y = next(iter(train_loader))519vis = x[:, :3] # pick RGB bands for multispectral520vis = (vis - vis.amin()) / (vis.amax() - vis.amin() + 1e-8)521torchvision.utils.save_image(torchvision.utils.make_grid(vis, nrow=4), "batch.png")522torchvision.utils.save_image(523 torchvision.utils.make_grid((y.float() / max(1, y.max())).unsqueeze(1), nrow=4),524 "batch_mask.png")525```526527Then **overlay** (`vis[:, 0] = torch.where(y > 0, 1.0, vis[:, 0])`, save again). A side-by-side will528not reveal a 1-pixel misalignment or a transposed mask; an overlay will.529530**2. Assert dtype and range after every stage.** Put these in the Dataset temporarily:531532```python533assert img.dtype == np.float32, img.dtype534assert np.isfinite(img).all()535assert -6 < img.mean() < 6 and 0.2 < img.std() < 5, (img.mean(), img.std())536assert mask.dtype in (np.uint8, np.int64), mask.dtype537assert set(np.unique(mask)).issubset(ALLOWED_IDS), np.unique(mask)538```539540The mask-class assertion catches: bilinear-interpolated masks, an unremapped label file where541classes are 0/38/75/113 instead of 0/1/2/3, and a padding fill of 0 colliding with a real class.542543**3. Round-trip the tiler with an identity model.**544545```python546img = np.random.rand(3, 1731, 2049).astype(np.float32) # deliberately not divisible547rec = predict_scene(img, model_fn=lambda p: p, tile=512, stride=256, n_classes=3)548assert np.allclose(rec, img, atol=1e-5), np.abs(rec - img).max()549```550551Use non-square, indivisible dimensions - a 2048x2048 test image passes with almost any broken552implementation.553554**4. Verify inference normalization equals training normalization.** Store the stats *inside* the555checkpoint and have the inference script read them from there, so they cannot drift; if they live in556a sidecar file, assert equality of the loaded dicts at inference startup.557558**5. Overfit 4 samples to ~0 loss.** With augmentation off. If the model cannot memorize 4 tiles,559the bug is in the data path (misaligned mask, wrong loss target dtype, ignore_index eating560everything), not in the architecture or LR.561562**6. Count label pixels across the whole training set**, print the per-class fraction. A class at5630.0000 is absent - either genuinely, or because your remap dropped it.564565**7. Flag constant tiles.** `img.std() < 1e-6` means an all-nodata tile reached training; those go566NaN under per-tile normalization and poison the whole batch.