Compare src.transform for the feature raster and the label raster term by term. If the c (x origin) or f (y origin) terms differ by anything other than an exact multiple of the pixel size, the two grids are offset and every boundary pixel in your training set is mislabelled. The fix is not to shift the array — it is to re-burn the labels from the original vector geometries onto the feature raster’s transform.
This page covers detection and repair. The burn itself is in burning polygon labels into a training raster, and the surrounding design decisions are in Rasterizing Vector Layers for Model Inputs.
Why This Fails in Geospatial ML Pipelines
Offsets are born in small conveniences. Someone computes the label grid from gdf.total_bounds instead of reading it from the features, and the origin lands wherever the northernmost vertex happens to be. Someone windows a raster with rasterio.windows.Window and reuses the parent transform instead of rasterio.windows.transform(window, src.transform). Someone reprojects the labels separately and lets GDAL pick the output grid. All three produce files with the right CRS, the right resolution and the right approximate extent — which is exactly what a quick visual check confirms.
The damage is concentrated at class boundaries. Interior pixels of a large field are still labelled correctly, so overall accuracy hardly moves. What degrades is the model’s ability to place edges: it is trained on boundary pixels whose spectra belong half to one class and half to another, so it learns a smeared decision surface. In segmentation this shows up as rounded, bloated shapes; in per-pixel classification it shows up as a fringe of misclassification around every polygon that no amount of hyperparameter tuning removes. If you have ever tuned a model for a week and never beaten a boundary artefact, this is the first thing to check.
Sub-pixel offsets are also invisible to most tooling. gdalinfo reports the transform but nobody compares two of them digit by digit; QGIS overlays the layers and a half-pixel shift is a fraction of a screen pixel at any sensible zoom. Only an explicit numerical assertion catches it, which is why that assertion belongs in the pipeline rather than in a checklist.
Core Principles
- Grids are equal or they are not. Compare all six affine terms, plus width, height and CRS. “Close enough” is not a category.
- Re-burn, do not shift. Rasterizing again from the source geometries onto the reference transform is exact; translating an array is a guess.
- Resample categorical data only with nearest neighbour. Bilinear on class codes invents classes.
- Derive windowed transforms with
windows.transform. Never reuse a parent transform for a subset. - Assert alignment at the training-data boundary. The check costs microseconds and prevents a week of chasing edge artefacts.
- Record the reference grid identity. A hash of the transform plus shape, written into both files’ tags, makes mismatches obvious in a listing.
Production-Ready Code
from __future__ import annotations
import hashlib
import logging
import numpy as np
import rasterio
from rasterio.affine import Affine
from rasterio.enums import Resampling
from rasterio.warp import reproject
logger = logging.getLogger(__name__)
def grid_fingerprint(path: str) -> str:
"""Stable short hash of (crs, transform, width, height) — two files match or they don't."""
with rasterio.open(path) as src:
payload = "|".join([
src.crs.to_string() if src.crs else "NOCRS",
",".join(f"{v:.9g}" for v in src.transform[:6]),
f"{src.width}x{src.height}",
])
return hashlib.sha256(payload.encode()).hexdigest()[:12]
def describe_misalignment(feature_path: str, label_path: str) -> dict:
"""Quantify how two grids differ. Returns a report; never raises."""
with rasterio.open(feature_path) as f, rasterio.open(label_path) as l:
ft, lt = f.transform, l.transform
report = {
"crs_match": f.crs == l.crs,
"shape_match": (f.width, f.height) == (l.width, l.height),
"pixel_size_match": (np.isclose(ft.a, lt.a) and np.isclose(ft.e, lt.e)),
"dx_m": lt.c - ft.c,
"dy_m": lt.f - ft.f,
"feature_shape": (f.height, f.width),
"label_shape": (l.height, l.width),
}
px, py = abs(ft.a), abs(ft.e)
report["dx_px"] = report["dx_m"] / px if px else float("nan")
report["dy_px"] = report["dy_m"] / py if py else float("nan")
report["aligned"] = (
report["crs_match"] and report["shape_match"] and report["pixel_size_match"]
and abs(report["dx_px"] - round(report["dx_px"])) < 1e-6
and abs(report["dy_px"] - round(report["dy_px"])) < 1e-6
)
return report
def assert_aligned(feature_path: str, label_path: str) -> None:
"""Fail loudly, with numbers, when the two grids are not identical."""
r = describe_misalignment(feature_path, label_path)
if r["aligned"] and r["dx_px"] == 0 and r["dy_px"] == 0:
return
raise ValueError(
"label grid does not match the feature grid: "
f"crs_match={r['crs_match']} shape_match={r['shape_match']} "
f"pixel_size_match={r['pixel_size_match']} "
f"offset=({r['dx_px']:.3f}, {r['dy_px']:.3f}) px "
f"shapes {r['feature_shape']} vs {r['label_shape']}"
)
def snap_categorical(label_path: str, feature_path: str, out_path: str) -> None:
"""Last-resort repair when the source vectors are gone: nearest-neighbour snap.
Prefer re-burning from geometry. This preserves class codes exactly but cannot
recover boundary detail that the original burn placed on the wrong grid.
"""
with rasterio.open(feature_path) as ref, rasterio.open(label_path) as src:
profile = src.profile.copy()
profile.update(transform=ref.transform, width=ref.width, height=ref.height,
crs=ref.crs)
dst_arr = np.full((ref.height, ref.width), src.nodata or 255, dtype=src.dtypes[0])
reproject(
source=rasterio.band(src, 1),
destination=dst_arr,
src_transform=src.transform, src_crs=src.crs,
dst_transform=ref.transform, dst_crs=ref.crs,
resampling=Resampling.nearest, # never anything else for classes
src_nodata=src.nodata, dst_nodata=src.nodata or 255,
)
with rasterio.open(out_path, "w", **profile) as dst:
dst.write(dst_arr, 1)
logger.info("snapped %s onto the grid of %s", label_path, feature_path)Step-by-Step Walkthrough
Step 1 — Fingerprint both files
print("features:", grid_fingerprint("features.tif"))
print("labels: ", grid_fingerprint("labels.tif"))Identical fingerprints mean you are done. Different ones tell you nothing about how they differ, which is what the next step is for.
Step 2 — Quantify the difference
r = describe_misalignment("features.tif", "labels.tif")
for k, v in r.items():
print(f"{k:>18}: {v}")A typical bad report reads dx_px: 0.5, dy_px: -0.5 with everything else True — the classic half-pixel case, usually from a grid built out of geometry bounds. A report with pixel_size_match: False is a different problem: the label raster was written at a different resolution and needs rebuilding, not snapping.
Step 3 — Re-burn from the source geometries
labels = burn_labels("field_labels.gpkg", "features.tif", "labels_aligned.tif")
assert_aligned("features.tif", "labels_aligned.tif")This is the correct repair whenever the vector source still exists, which it usually does. It is exact: the geometries are evaluated against the reference transform directly, so boundary pixels land where they belong.
Step 4 — If the vectors are gone, snap and record the compromise
snap_categorical("labels.tif", "features.tif", "labels_snapped.tif")
assert_aligned("features.tif", "labels_snapped.tif")
with rasterio.open("labels_snapped.tif", "r+") as dst:
dst.update_tags(alignment="nearest-neighbour snap from labels.tif",
caveat="boundary pixels inherited from a misaligned burn")Nearest-neighbour snapping moves the labels onto the right grid but cannot recover the boundary detail lost in the original burn. Recording that in the file tags stops a future reader from assuming the labels are pristine.
Step 5 — Wire the assertion into the data loader
class LabelledScene:
def __init__(self, feature_path: str, label_path: str):
assert_aligned(feature_path, label_path) # cheap, and it never lies
self.feature_path, self.label_path = feature_path, label_pathVerification
import numpy as np
import rasterio
from rasterio.transform import from_origin
def test_detects_half_pixel_offset(tmp_path):
"""A 5 m shift on a 10 m grid must be reported as 0.5 px, not as aligned."""
ref = tmp_path / "features.tif"
off = tmp_path / "labels.tif"
base = {"driver": "GTiff", "count": 1, "dtype": "uint8", "width": 10, "height": 10,
"crs": "EPSG:32633"}
with rasterio.open(ref, "w", transform=from_origin(0, 100, 10, 10), **base) as d:
d.write(np.zeros((10, 10), "uint8"), 1)
with rasterio.open(off, "w", transform=from_origin(5, 95, 10, 10), **base) as d:
d.write(np.zeros((10, 10), "uint8"), 1)
r = describe_misalignment(str(ref), str(off))
assert np.isclose(r["dx_px"], 0.5) and np.isclose(r["dy_px"], -0.5)
assert not r["aligned"]
def test_whole_pixel_shift_is_not_a_subpixel_offset(tmp_path):
"""A clean 1-pixel shift is a windowing bug, not a sampling bug — report it as such."""
r = describe_misalignment(str(ref_1px_shifted), str(labels))
assert r["aligned"] is True # grids are commensurate
assert abs(r["dx_px"]) == 1.0 # but the extents differ
def test_reburn_reproduces_the_reference_fingerprint(tmp_path):
out = tmp_path / "labels_aligned.tif"
burn_labels("field_labels.gpkg", "features.tif", str(out))
assert grid_fingerprint("features.tif") == grid_fingerprint(str(out))A useful field check after repair: compute the class-boundary agreement between the old and new label rasters. If interior pixels agree above 99% while boundary pixels agree around 50%, you have just confirmed the offset was real and that the re-burn fixed exactly the pixels it should have.
FAQ
How do I tell whether my labels are offset from my features?
Compare the affine transforms numerically. If the c or f terms differ by anything that is not an exact multiple of the pixel size, the grids are offset. A visual overlay will not show a half-pixel shift at any usable zoom, which is precisely the magnitude that hurts training most.
Can I fix an offset by resampling the label raster?
Only with nearest neighbour, and only as a fallback. Any interpolating resampler invents class codes that never existed. Re-burning from the source geometry onto the reference transform is exact and is almost always available.
Does a half-pixel offset really matter?
At class boundaries, badly. Every edge pixel is trained against its neighbour’s spectra, so the model learns a blurred decision surface — bloated shapes in segmentation, a persistent misclassification fringe in per-pixel models. Interior pixels are unaffected, which is why overall accuracy barely moves and the problem survives review.
Related
- Burning Polygon Labels into a Training Raster — the exact re-burn this page recommends
- Rasterizing Vector Layers for Model Inputs — burn rules and priority design
- Reprojecting a Raster with rasterio.warp.reproject — controlling the output grid during reprojection
- Deep Learning Segmentation for Satellite Imagery — the models most damaged by an offset
Part of: Rasterizing Vector Layers for Model Inputs Part of: Spatial Feature Engineering for Machine Learning