Rasterizing Vector Labels for Segmentation Masks
Semantic segmentation models train on dense per-pixel class arrays, but human annotators draw vector polygons. Converting those polygons into a mask that lines up with the imagery is the job of rasterio.features.rasterize, and the single rule that makes it correct is this: generate the mask with the target image’s affine transform and shape, not a default grid. Pass the function a sequence of (geometry, class_value) pairs, set an explicit fill for background and the right all_touched behaviour, resolve overlapping polygons by burning higher-priority classes last, and write the result as a georeferenced GeoTIFF. Do that and every mask pixel corresponds to exactly the imagery pixel the network will read.
Why Misaligned Masks Silently Teach the Wrong Pixels
A rasterization bug never raises an exception. rasterize happily returns an array of the shape you asked for whether or not the geometries landed where they should, so a mask that is shifted by a few pixels, flipped in the vertical axis, or burned on the wrong grid looks completely normal in a file listing. The damage surfaces only as a stubborn accuracy ceiling: the model is being taught that the roof pixels are sky and the road pixels are grass, and it dutifully learns the offset. Because the error is systematic rather than random, more training data does not wash it out — it reinforces it.
The alignment contract has two halves. The vector coordinates must be expressed in the same coordinate reference system as the raster, and the burn must use the raster’s own affine transform to convert those world coordinates into row and column indices. Skip the reprojection and the polygons land off the tile entirely; skip the transform and rasterize falls back to the identity matrix, treating a UTM easting of 500000 as pixel column 500000. Both failures produce a file, and only a deliberate overlay check tells them apart from a correct mask.
Step-by-Step Implementation
Install the pinned toolchain once. These versions are mutually compatible and match the CRS-handling behaviour the rest of this guide assumes:
pip install rasterio==1.3.10 geopandas==0.14.4 shapely==2.0.6 numpy==1.26.4
Step 1 — Read the Target Raster’s Transform and Shape
The mask must be born on the imagery’s grid, so the first move is to extract that grid. Open the reference GeoTIFF and capture its transform, height, width, and crs. Everything downstream is derived from these four values:
from dataclasses import dataclass
import rasterio
from rasterio.transform import Affine
from rasterio.crs import CRS
@dataclass(frozen=True)
class RasterGrid:
transform: Affine
height: int
width: int
crs: CRS
def read_grid(image_path: str) -> RasterGrid:
"""Capture the exact grid definition of the reference imagery."""
with rasterio.open(image_path) as src:
return RasterGrid(
transform=src.transform,
height=src.height,
width=src.width,
crs=src.crs,
)
Step 2 — Reproject Vector Labels to the Raster CRS
Load the GeoJSON with geopandas and force it into the raster’s CRS. Even when the labels claim to be in the imagery’s projection, an explicit to_crs guards against silent axis-order and datum surprises. This mirrors the schema discipline covered in how to structure GeoJSON for ML training datasets:
import geopandas as gpd
def load_labels(geojson_path: str, grid: RasterGrid,
class_field: str = "class_id") -> gpd.GeoDataFrame:
"""Load vector labels and reproject them onto the raster CRS."""
gdf = gpd.read_file(geojson_path)
if gdf.crs is None:
raise ValueError("Label GeoJSON has no CRS; cannot align to imagery.")
gdf = gdf.to_crs(grid.crs)
gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty]
if class_field not in gdf.columns:
raise KeyError(f"Expected class field '{class_field}' in labels.")
return gdf
Step 3 — Build (geometry, value) Shapes Ordered by Priority
rasterize burns shapes sequentially and later shapes overwrite earlier ones. That behaviour is a feature: sort features so that the highest-priority class is burned last, and overlap resolution becomes deterministic. Here a small priority map decides who wins shared pixels — a building inside a parcel keeps the pixels because building outranks parcel:
from shapely.geometry.base import BaseGeometry
def build_shapes(
gdf: gpd.GeoDataFrame,
class_field: str,
priority: dict[int, int],
) -> list[tuple[BaseGeometry, int]]:
"""
Return (geometry, class_value) pairs sorted so higher-priority
classes burn last and therefore win overlapping cells.
"""
def rank(class_id: int) -> int:
return priority.get(class_id, 0)
ordered = gdf.sort_values(
by=class_field, key=lambda s: s.map(rank)
)
shapes: list[tuple[BaseGeometry, int]] = [
(geom, int(value))
for geom, value in zip(ordered.geometry, ordered[class_field])
]
return shapes
Step 4 — Rasterize With out_shape and transform
This is the core call. Pass the ordered shapes, the raster out_shape as (height, width), the imagery’s transform, a background fill, and an integer dtype. Keep all_touched=False for area classes so only pixel centres inside a polygon are burned:
import numpy as np
from rasterio.features import rasterize
def burn_mask(
shapes: list[tuple[BaseGeometry, int]],
grid: RasterGrid,
fill: int = 0,
all_touched: bool = False,
dtype: str = "uint8",
) -> np.ndarray:
"""Burn vector shapes into a class-indexed mask on the imagery grid."""
mask = rasterize(
shapes=shapes,
out_shape=(grid.height, grid.width),
transform=grid.transform,
fill=fill,
all_touched=all_touched,
dtype=dtype,
)
return mask
Step 5 — Write a Georeferenced Mask GeoTIFF
Persist the mask with the same transform and crs as the imagery so it carries georeferencing and can be reprojected, tiled, or overlaid later without guesswork:
def write_mask(mask: np.ndarray, grid: RasterGrid, out_path: str) -> None:
"""Write the mask as a single-band georeferenced GeoTIFF."""
with rasterio.open(
out_path,
"w",
driver="GTiff",
height=grid.height,
width=grid.width,
count=1,
dtype=mask.dtype,
crs=grid.crs,
transform=grid.transform,
compress="deflate",
nodata=None,
) as dst:
dst.write(mask, 1)
Step 6 — Validate Alignment by Overlay
Rasterization never throws on misalignment, so validate deliberately. Re-read the mask, assert its transform and shape match the imagery exactly, then confirm that burned pixels fall inside the original polygons by sampling a geometry’s representative point back through the inverse transform:
from rasterio.transform import rowcol
def validate_alignment(
mask_path: str,
grid: RasterGrid,
gdf: gpd.GeoDataFrame,
class_field: str,
) -> None:
"""Assert grid parity and that sampled polygon interiors carry a class."""
with rasterio.open(mask_path) as src:
assert src.transform == grid.transform, "Mask transform drifted from imagery."
assert (src.height, src.width) == (grid.height, grid.width), "Shape mismatch."
mask = src.read(1)
for geom, expected in zip(gdf.geometry, gdf[class_field]):
pt = geom.representative_point()
row, col = rowcol(grid.transform, pt.x, pt.y)
if 0 <= row < grid.height and 0 <= col < grid.width:
assert mask[row, col] != 0, "Polygon interior burned as background."
print("Alignment OK: transform, shape, and interiors verified.")
Wire the six steps together and the run is linear — read the grid, load and reproject labels, order the shapes, burn, write, validate:
grid = read_grid("tile_0007.tif")
labels = load_labels("tile_0007_labels.geojson", grid, class_field="class_id")
shapes = build_shapes(labels, "class_id", priority={1: 1, 2: 5})
mask = burn_mask(shapes, grid, fill=0, all_touched=False, dtype="uint8")
write_mask(mask, grid, "tile_0007_mask.tif")
validate_alignment("tile_0007_mask.tif", grid, labels, "class_id")
Key rasterize Parameters
The five parameters below decide whether the mask aligns and how edges and overlaps resolve. Everything else in rasterio.features.rasterize has a sensible default:
| Parameter | Value to pass | Why it matters |
|---|---|---|
transform |
The imagery’s src.transform (Affine) |
Maps world coordinates to row/column. Omitting it falls back to the identity transform and burns nothing on the right grid. |
out_shape |
(height, width) from the imagery |
Fixes mask dimensions to the pixel grid so the array is one-to-one with the image. |
fill |
0 (background index) |
Value written to every unburned cell. Must be a reserved background class, never a real class value. |
all_touched |
False for areas, True for thin features |
False burns only pixel centres inside a polygon; True burns every touched pixel, thickening roads and inflating edge area. |
dtype |
uint8 (≤255 classes) or uint16 |
Integer class-index dtype. Too small a dtype silently wraps class values above its range. |
Common Errors and Fixes
Mask is entirely fill value even though the GeoJSON has polygons
Root cause: CRS mismatch — the vector coordinates and the imagery transform are in different projections, so the geometries fall outside the raster extent and nothing burns.
Fix: call gdf.to_crs(grid.crs) before build_shapes, and confirm gdf.total_bounds intersects the raster bounds. A first EPSG:4326 label set over EPSG:32633 imagery is the classic case — reproject rather than assume the codes match, as detailed on the coordinate reference systems guide.
Mask is shifted by one row or column against the imagery
Root cause: a hand-built transform that assumes pixel-corner versus pixel-centre origin, or a height/width swapped into out_shape as (width, height).
Fix: never reconstruct the transform — reuse src.transform verbatim, and pass out_shape=(grid.height, grid.width) in that exact order.
Overlapping classes resolve inconsistently between tiles
Root cause: shapes were fed to rasterize in raw GeoJSON feature order, which is not stable, so which class wins a shared pixel varies from tile to tile.
Fix: sort (geometry, value) pairs by an explicit priority rank so the highest-priority class is always burned last.
Thin roads or fences disappear from the mask
Root cause: with all_touched=False, a linear feature narrower than a pixel rarely covers a pixel centre, so it drops out; but flipping the flag globally inflates every area class at its edges.
Fix: rasterize narrow classes in a separate pass with all_touched=True and composite them onto the area mask by priority, rather than setting one flag for all classes.
Related
- How to Structure GeoJSON for ML Training Datasets — the upstream vector schema and class-field conventions the rasterizer consumes
- Vector vs Raster Annotation Workflows — where vector polygons and raster masks each fit in an annotation pipeline
- Coordinate Reference Systems in Annotation Pipelines — the reprojection contract that keeps vectors and pixels on one grid
- Calculating IoU Thresholds for Geospatial Object Detection — evaluating the masks and detections this rasterization feeds
This guide is one specialised task within Vector vs Raster Annotation Workflows, which is itself part of Geospatial Annotation Fundamentals & Architecture.