Rasterizing Labels into Segmentation Masks
To burn polygon labels into a mask that aligns exactly with an image chip, pass the chip’s own transform and shape to rasterio.features.rasterize:
from rasterio.features import rasterize
mask = rasterize(
shapes=[(geom, cls) for geom, cls in zip(gdf.geometry, gdf.class_id)],
out_shape=(256, 256),
transform=chip_transform, # the CHIP transform, not the scene transform
fill=0, # 0 = unlabelled
dtype="uint8",
)
Everything that makes this hard is in the detail: which class wins where polygons overlap, what happens to pixels the boundary crosses, and how to tell the model “I do not know what this pixel is” rather than lying to it. This page sits under building training datasets from satellite imagery in Raster Machine Learning & Model Inference.
Why Burn Order Is a Design Decision
rasterize paints geometries in the order it receives them, so a later geometry overwrites an earlier one wherever they overlap. That is not a quirk to work around — it is the mechanism you use to express priority.
Sorting by descending area works because containment is the common case: ponds inside fields, buildings inside plots, clearings inside forest. When priority is not a function of size — a road crossing several land cover polygons should always win — add an explicit priority column and sort on that instead.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
rasterio |
>=1.3.0 |
features.rasterize and the transform handling |
geopandas |
>=0.14 |
Reading, reprojecting and sorting the label layer |
shapely |
>=2.0 |
Boundary buffers for the ignore region |
numpy |
>=1.23 |
Mask arithmetic and validity checks |
pip install "rasterio>=1.3.0" "geopandas>=0.14" "shapely>=2.0" "numpy>=1.23"
Complete Working Example
This function produces a two-layer target: the class mask, and a boolean array marking pixels the loss should ignore.
import geopandas as gpd
import numpy as np
from rasterio.features import rasterize
IGNORE = 255
def burn_mask(labels: gpd.GeoDataFrame, transform, shape: tuple[int, int],
*, class_field: str = "class_id",
priority_field: str | None = None,
boundary_buffer_m: float = 10.0,
exhaustive: bool = False) -> tuple[np.ndarray, np.ndarray]:
"""Burn a multi-class mask plus an ignore mask for uncertain pixels."""
if labels.empty:
empty = np.full(shape, 0 if exhaustive else IGNORE, dtype="uint8")
return empty, (empty == IGNORE)
# Priority: explicit column if given, else largest first so small wins
if priority_field is not None:
ordered = labels.sort_values(priority_field)
else:
ordered = labels.assign(_a=labels.geometry.area).sort_values("_a", ascending=False)
mask = rasterize(
shapes=[(g, int(v)) for g, v in zip(ordered.geometry, ordered[class_field])],
out_shape=shape,
transform=transform,
fill=0,
all_touched=False,
dtype="uint8",
)
# Boundaries are uncertain: burn a buffered ring as the ignore value
rings = ordered.geometry.boundary.buffer(boundary_buffer_m)
ignore = rasterize(
shapes=[(g, 1) for g in rings if not g.is_empty],
out_shape=shape, transform=transform, fill=0, dtype="uint8",
).astype(bool)
if not exhaustive:
# Unlabelled means unknown, not background
ignore |= (mask == 0)
return mask, ignore
if __name__ == "__main__":
import rasterio
with rasterio.open("s2_stack_36NYF.tif") as src:
win = rasterio.windows.Window(2048, 4096, 256, 256)
chip = src.read(window=win)
tr = src.window_transform(win)
gdf = gpd.read_file("field_labels.gpkg").to_crs(src.crs)
m, ig = burn_mask(gdf.cx[tr.c:tr.c + 2560, tr.f - 2560:tr.f], tr, (256, 256))
print("classes present:", np.unique(m), "ignored:", f"{ig.mean():.1%}")
The exhaustive flag encodes the single most consequential assumption in the whole file. If the label layer covers everything in the chip, an unlabelled pixel is background and the model should learn it. If only fields of interest were digitised, an unlabelled pixel might be anything, and training the model to call it background actively teaches it to miss real targets.
Variant Patterns
1. A boundary-aware three-class target
Some architectures learn better from an explicit boundary class than from an ignore region: interior, boundary, background. The boundary class gives the network something to predict rather than something to skip.
import numpy as np
from rasterio.features import rasterize
def interior_boundary_mask(labels, transform, shape, *, edge_m: float = 15.0):
"""0 = background, 1 = interior, 2 = boundary."""
interior = rasterize([(g, 1) for g in labels.geometry],
out_shape=shape, transform=transform, fill=0, dtype="uint8")
edges = rasterize([(g.boundary.buffer(edge_m), 1) for g in labels.geometry],
out_shape=shape, transform=transform, fill=0, dtype="uint8")
return np.where(edges == 1, 2, interior).astype("uint8")
2. Burning lines and points
Linear features fall between pixel centres and vanish under the default rule. Buffer them to a realistic width instead of reaching for all_touched, which produces a one-pixel line regardless of whether the road is 3 m or 30 m wide.
import geopandas as gpd
from rasterio.features import rasterize
roads = gpd.read_file("roads.gpkg").to_crs(chip_crs)
roads["geometry"] = roads.buffer(roads["width_m"] / 2) # real width, not one pixel
road_mask = rasterize([(g, 1) for g in roads.geometry],
out_shape=(256, 256), transform=chip_transform,
fill=0, dtype="uint8")
3. Masks for a whole scene rather than a chip
The same call scales to a full scene by passing the scene transform and shape. This is how a reference raster for accuracy assessment is made, and the output feeds directly into the comparison in computing confusion matrices from raster pairs.
import rasterio
from rasterio.features import rasterize
with rasterio.open("s2_stack_36NYF.tif") as src:
profile = src.profile | {"count": 1, "dtype": "uint8", "nodata": 0,
"compress": "deflate", "tiled": True}
scene_mask = rasterize(
[(g, int(v)) for g, v in zip(gdf.geometry, gdf.class_id)],
out_shape=(src.height, src.width), transform=src.transform,
fill=0, dtype="uint8",
)
with rasterio.open("reference.tif", "w", **profile) as dst:
dst.write(scene_mask, 1)
Common Errors
The mask is entirely zero
Either no geometry intersects the chip, or the labels are in a different CRS from the transform. rasterize returns the fill value silently in both cases. Reproject first, and assert that a known-overlapping chip produces a non-empty mask.
ValueError: Invalid value for dtype
A class code exceeds the dtype’s range — commonly a class id of 300 in a uint8 mask, or a float class column. Cast the values to int explicitly and keep codes inside 1–254 so 0 and 255 stay available for background and ignore.
Thin features disappear entirely
The default pixel-centre rule skips any polygon that does not cover a pixel centre. Buffer linear features to their real width, or set all_touched=True if a one-pixel-wide representation is genuinely what you want.
Frequently Asked Questions
Q: What happens when two label polygons overlap? The last geometry burned wins, so the iteration order is the priority order. Sort the pairs deliberately — usually smallest area or most specific class last — rather than relying on whatever order the file happened to store.
Q: How do I stop the model being punished for ambiguous boundaries? Burn a thin buffer around every polygon boundary as a dedicated ignore value, then exclude that value from the loss. Field boundaries are rarely digitised to the pixel, and forcing the model to match them exactly wastes capacity on noise.
Q: Should background be a class or an ignore value? It depends on whether unlabelled really means background. If the layer is exhaustive, background is a real class and should be learned. If only some features were digitised, unlabelled pixels are unknown and must be ignored, otherwise the model is trained to call real targets background.
Related
- Building Training Datasets from Satellite Imagery — where the mask meets the image chip.
- Extracting Image Chips Around Labelled Points — the point-label equivalent of this burn.
- Rasterizing Vector Polygons onto a Raster Grid — the general-purpose burn and its geometry rules.
- Balancing Class Frequencies in Chip Datasets — what to do once the masks reveal how skewed the classes are.