Vector vs Raster Annotation Workflows for Geospatial ML
The choice between vector and raster annotation formats is an architectural decision that propagates through every stage of a geospatial ML pipeline: storage costs, preprocessing complexity, model architecture compatibility, and inference latency all depend on it. A poorly chosen format discovered mid-project means weeks of reprocessing. The failure scenario is concrete: a segmentation team annotates 50,000 tiles in raw PNG masks at full resolution, only to learn their object-detection model expects polygon-level instance IDs — every annotation must be re-exported, coordinate reference system realigned, and rasterized from scratch.
This page gives you the step-by-step implementation for both workflows, a decision matrix, and the spatial-specific gotchas that cause silent pipeline failures. For the foundational concepts that underpin both approaches, see Geospatial Annotation Fundamentals & Architecture.
Prerequisites & Toolchain Alignment
Before choosing a format, confirm the following are locked in. Misaligned prerequisites are the primary source of downstream crashes and degraded model convergence.
Python packages (pin these versions):
geopandas>=0.14.0— spatial dataframes, CRS management, GeoJSON I/Oshapely>=2.0.0— geometry validation,make_valid, topology operationsrasterio>=1.3.0— raster I/O, affine transforms,rasterizepyproj>=3.6.0— CRS authority lookups, datum transformationsfiona>=1.9.0— low-level vector I/O, schema inspectionnumpy>=1.26.0— mask array operations, dtype enforcement
System dependencies: GDAL 3.6+ and PROJ 9.2+ must be installed and on PATH. Mismatches between the GDAL C library version and the rasterio/fiona wheels cause cryptic import errors.
Spatial knowledge prerequisites:
- Source imagery metadata: Verify GeoTIFF bit depth, band order, compression (LZW vs DEFLATE), and tile boundaries before annotation begins. Inconsistent tiling schemes produce boundary artifacts during rasterization and break spatial joins between adjacent tiles.
- Label taxonomy: Establish a controlled class vocabulary with mutually exclusive categories and explicit boundary rules before any annotation starts. A taxonomy revised mid-project requires back-annotating all existing labels.
- CRS contract: All annotation outputs must share the exact CRS as the source imagery. Sub-pixel misalignment from mixing
EPSG:4326geographic coordinates with a projected CRS such asEPSG:32618(UTM zone 18N) distorts IoU calculations and breaks spatial indexing.
Pipeline Architecture
The diagram below shows how vector annotations and raster masks fit into the same pipeline — the recommended hybrid stores vector annotations on disk and rasterizes lazily at the data-loader stage.
Step 1 — Ingest Source Imagery and Verify the Affine Transform
Before any annotation begins, extract and record the affine transform, shape, and CRS from every source tile. Store these in a sidecar JSON so that downstream rasterization can always reconstruct the exact pixel grid.
import rasterio
import json
from pathlib import Path
def extract_tile_metadata(tif_path: str) -> dict:
"""Return the spatial metadata needed to pin annotation CRS and rasterize correctly."""
with rasterio.open(tif_path) as src:
return {
"path": tif_path,
"crs_wkt": src.crs.to_wkt(),
"epsg": src.crs.to_epsg(),
"transform": list(src.transform), # 6-element affine tuple
"width": src.width,
"height": src.height,
"dtype": src.dtypes[0],
"count": src.count,
}
# Usage
meta = extract_tile_metadata("/data/tiles/tile_0042.tif")
Path("/data/tiles/tile_0042_meta.json").write_text(json.dumps(meta, indent=2))
Fail fast if epsg is None — that means the CRS is not registered in the PROJ authority and will silently break downstream transforms.
Step 2 — Vector Annotation: Boundary Delineation and Topology Enforcement
Vector workflows produce discrete geometric primitives (points, lines, polygons) with attached attribute tables. They are ideal for object detection, instance segmentation, and feature extraction tasks where boundary precision, topological relationships, and attribute querying matter.
Annotators trace features in QGIS or a web-based polygon editor. Topology rules — no overlaps, closed rings, minimum vertex spacing, snap-to-grid alignment — must be enforced at the UI level or via post-processing. Invalid geometries (self-intersections, duplicate vertices, unclosed rings) break spatial indexing and cause silent failures during training data generation.
import geopandas as gpd
from shapely.validation import make_valid
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
def validate_vector_annotations(
input_path: str,
output_path: str,
target_epsg: int = 32618,
) -> gpd.GeoDataFrame:
"""
Load, reproject, repair, and export a vector annotation file.
Args:
input_path: Path to the raw GeoJSON or GeoPackage from the annotation tool.
output_path: Destination GeoJSON path for validated output.
target_epsg: EPSG code matching the source imagery CRS (default UTM 18N).
Returns:
Validated GeoDataFrame ready for rasterization or ML export.
"""
gdf = gpd.read_file(input_path)
logging.info("Loaded %d features. Source CRS: %s", len(gdf), gdf.crs)
# --- CRS enforcement ---
target_crs = f"EPSG:{target_epsg}"
if gdf.crs is None:
raise ValueError("Input file has no CRS. Declare it explicitly before export.")
if gdf.crs.to_epsg() != target_epsg:
logging.warning("Reprojecting from %s to %s", gdf.crs, target_crs)
gdf = gdf.to_crs(target_crs)
# --- Topology repair ---
invalid_mask = ~gdf.geometry.is_valid
n_invalid = int(invalid_mask.sum())
if n_invalid:
logging.warning("Repairing %d invalid geometries via make_valid().", n_invalid)
gdf.loc[invalid_mask, "geometry"] = (
gdf.loc[invalid_mask, "geometry"].apply(make_valid)
)
# --- Drop nulls and empty rings post-repair ---
before = len(gdf)
gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty].copy()
logging.info("Dropped %d null/empty geometries.", before - len(gdf))
# --- Required attribute schema ---
for col in ("class_id", "class_name", "annotator_id"):
if col not in gdf.columns:
raise ValueError(f"Missing required column: {col}")
gdf.to_file(output_path, driver="GeoJSON")
logging.info("Exported %d valid features → %s", len(gdf), output_path)
return gdf
For production-grade attribute schema and property naming conventions, follow the GeoJSON structuring guide for ML training datasets, which covers numeric typing, explicit CRS declarations, and embedding confidence scores per feature.
Step 3 — Raster Annotation: Pixel-Level Mask Generation
Raster workflows convert labeled regions into pixel-aligned masks where each pixel value encodes a class ID. This format is consumed directly by CNN and Vision Transformer architectures for semantic segmentation and change detection.
Encoding rules:
- Single-band
uint8, values0(background) through254(active classes);255is reserved for “unlabeled / ignore”. - Nearest-neighbour fill — never bilinear or cubic, which blur class boundaries.
- Lossless compression:
compress="lzw"orcompress="deflate"inrasterio.open.
import rasterio
from rasterio.features import rasterize
from rasterio.transform import Affine
import numpy as np
import geopandas as gpd
import logging
def generate_raster_mask(
vector_path: str,
template_tif: str,
output_mask: str,
class_mapping: dict[str, int],
) -> np.ndarray:
"""
Rasterize validated vector annotations against a source imagery tile.
Args:
vector_path: Path to validated GeoJSON.
template_tif: Source GeoTIFF whose grid pins the output mask.
output_mask: Output GeoTIFF mask path.
class_mapping: Dict mapping class_name strings to uint8 IDs.
Returns:
The rasterized mask array (H, W) for downstream assertion.
"""
with rasterio.open(template_tif) as src:
transform: Affine = src.transform
out_shape: tuple[int, int] = (src.height, src.width)
crs = src.crs
logging.info("Template: shape=%s CRS=%s", out_shape, crs)
gdf = gpd.read_file(vector_path)
if gdf.crs.to_epsg() != crs.to_epsg():
logging.warning("Reprojecting annotations to match tile CRS.")
gdf = gdf.to_crs(crs)
gdf["class_id"] = (
gdf["class_name"].map(class_mapping).fillna(0).astype(np.uint8)
)
# Sort descending by class_id so higher-priority classes overwrite lower ones
gdf = gdf.sort_values("class_id", ascending=False)
shapes = (
(geom, int(val))
for geom, val in zip(gdf.geometry, gdf["class_id"])
if geom is not None and not geom.is_empty
)
mask = rasterize(
shapes,
out_shape=out_shape,
transform=transform,
fill=0,
dtype=np.uint8,
merge_alg=rasterio.enums.MergeAlg.replace,
)
with rasterio.open(
output_mask, "w",
driver="GTiff",
height=out_shape[0],
width=out_shape[1],
count=1,
dtype=np.uint8,
crs=crs,
transform=transform,
compress="lzw",
) as dst:
dst.write(mask, 1)
logging.info("Mask → %s unique classes: %s", output_mask, np.unique(mask).tolist())
return mask
Always version-control the class_mapping dictionary alongside the dataset — a renamed class breaks existing masks silently. Use SHA-256 hashing of the lookup table to detect mapping drift across dataset versions.
Step 4 — Lazy Rasterization in the PyTorch Data Loader
The recommended hybrid stores annotations as vector on disk and rasterizes at load time. This avoids storing large mask files while still feeding CNNs the pixel arrays they need.
import torch
from torch.utils.data import Dataset
import rasterio
from rasterio.features import rasterize
import geopandas as gpd
import numpy as np
from pathlib import Path
class GeoAnnotationDataset(Dataset):
"""Lazy-rasterize vector annotations to uint8 masks per tile."""
def __init__(
self,
tile_dir: str,
annotation_dir: str,
class_mapping: dict[str, int],
transform=None,
):
self.tiles = sorted(Path(tile_dir).glob("*.tif"))
self.annotation_dir = Path(annotation_dir)
self.class_mapping = class_mapping
self.transform = transform
def __len__(self) -> int:
return len(self.tiles)
def __getitem__(self, idx: int) -> dict:
tif_path = self.tiles[idx]
geojson_path = self.annotation_dir / tif_path.with_suffix(".geojson").name
with rasterio.open(tif_path) as src:
image = src.read().astype(np.float32) # (C, H, W)
t, shape, crs = src.transform, src.shape, src.crs
gdf = gpd.read_file(geojson_path)
if gdf.crs.to_epsg() != crs.to_epsg():
gdf = gdf.to_crs(crs)
gdf["class_id"] = gdf["class_name"].map(self.class_mapping).fillna(0).astype(np.uint8)
shapes = ((g, int(v)) for g, v in zip(gdf.geometry, gdf["class_id"]) if g and not g.is_empty)
mask = rasterize(shapes, out_shape=shape, transform=t, fill=0, dtype=np.uint8)
sample = {"image": torch.from_numpy(image), "mask": torch.from_numpy(mask.astype(np.int64))}
if self.transform:
sample = self.transform(sample)
return sample
Spatial Parameters & Configuration Reference
| Parameter | Type | Valid Range / Values | Spatial Implication |
|---|---|---|---|
target_epsg |
int |
Any PROJ-registered EPSG (e.g. 32618) |
Must match source tile; mixing with EPSG:4326 shifts annotations sub-pixel |
| Mask dtype | numpy.dtype |
uint8 (0–255) |
uint16 wastes storage; float masks corrupt class boundaries |
| Rasterize fill | int |
0 |
Background class; 255 is the standard “ignore” sentinel |
| Compression | str |
"lzw" or "deflate" |
Lossless only; JPEG introduces interpolation artifacts |
| Min vertex spacing | float (metres) |
≥ 0.5 × GSD | Below GSD produces sub-pixel slivers invisible in training |
| Confidence threshold | float |
0.0–1.0 | Labels below 0.6 should be flagged for human review before training |
| Tile overlap | int (pixels) |
32–256 px | Required for seamless predictions; must match inference stride |
Edge Cases & Spatial Gotchas
Silent CRS mismatch between annotation and tile. geopandas will happily join two GeoDataFrames with different CRS without raising an error unless check_crs=True is set. The spatial join produces plausible-looking results offset by hundreds of metres. Always assert gdf.crs.to_epsg() == tile_epsg before rasterization.
Self-intersecting polygons from undo/redo in annotation tools. QGIS’s undo stack occasionally produces bowtie polygons. shapely.is_valid returns False; make_valid() splits them into a MultiPolygon. Your rasterization loop must handle MultiPolygon geometry types or the extra parts are silently dropped.
Tile boundary clipping corrupts polygons. Features that span tile edges are clipped to the tile extent during export. The clipped edge becomes a straight line at the tile boundary, introducing artificial edges into training data. Use gdf.clip(tile_bbox) and flag boundary-touching features for potential exclusion from loss computation.
Band order differs between tool and model. rasterio reads tiles as (C, H, W) in band-file order, which may be BGR for imagery exported from certain platforms. Models pre-trained on RGB ImageNet weights expect (R, G, B). Assert band order by reading the ColorInterp metadata and reorder with np.flip(image, axis=0) if needed.
IoU collapse from unprojected coordinates. Computing IoU thresholds on geometries still in EPSG:4326 produces degree-based areas rather than metric areas. A polygon over a mid-latitude tile will have its area underestimated by ~25–40%. Always reproject to a local metric CRS before any area or IoU calculation.
Rasterization order determines class priority. When two polygons overlap, the last one written wins in rasterize. Sort your GeoDataFrame by class_id descending (or by annotation priority) so that high-priority classes overwrite low-priority background.
Integration & Automation Hooks
Label Studio export hook
After annotation sessions, a webhook or post-export script should immediately run topology validation before committing:
# label_studio_export_hook.py
import subprocess
import sys
def on_export_complete(export_path: str, target_epsg: int = 32618) -> None:
"""Called by Label Studio export webhook — validates before committing to dataset."""
result = subprocess.run(
["python", "validate_annotations.py", export_path, str(target_epsg)],
capture_output=True, text=True,
)
if result.returncode != 0:
raise RuntimeError(f"Annotation validation failed:\n{result.stderr}")
print(f"Validation passed: {export_path}")
For deeper Label Studio integration patterns — including project configuration, ML backend wiring, and export format mapping — see the dedicated integration guide.
DVC pipeline stage
Register vector validation and rasterization as explicit DVC pipeline stages so dataset provenance is tracked end-to-end:
# dvc.yaml
stages:
validate_vectors:
cmd: python validate_annotations.py data/raw_annotations data/validated_annotations
deps:
- data/raw_annotations
- validate_annotations.py
outs:
- data/validated_annotations
rasterize_masks:
cmd: python rasterize_pipeline.py data/validated_annotations data/tiles data/masks
deps:
- data/validated_annotations
- data/tiles
- rasterize_pipeline.py
outs:
- data/masks
This makes every transformation reproducible and enables rollback strategies when a corrupted batch is detected downstream.
Validation & Testing
Run these checks on every export before annotations enter version control:
import geopandas as gpd
import rasterio
import numpy as np
from pathlib import Path
def assert_annotation_integrity(
geojson_path: str,
mask_path: str,
template_tif: str,
class_mapping: dict[str, int],
) -> None:
"""Raise AssertionError on any integrity violation."""
# 1. All vector geometries valid
gdf = gpd.read_file(geojson_path)
assert gdf.geometry.is_valid.all(), "Invalid geometries detected — run make_valid()"
# 2. CRS roundtrip: vector CRS matches tile CRS
with rasterio.open(template_tif) as src:
tile_epsg = src.crs.to_epsg()
tile_transform = src.transform
tile_shape = src.shape
assert gdf.crs.to_epsg() == tile_epsg, (
f"CRS mismatch: annotations={gdf.crs.to_epsg()} tile={tile_epsg}"
)
# 3. Mask shape matches tile shape
with rasterio.open(mask_path) as msrc:
mask = msrc.read(1)
assert msrc.crs.to_epsg() == tile_epsg, "Mask CRS mismatch"
assert msrc.transform == tile_transform, "Mask affine transform mismatch"
assert mask.shape == tile_shape, f"Mask shape {mask.shape} ≠ tile shape {tile_shape}"
# 4. Mask contains only known class IDs
valid_ids = {0, 255} | set(class_mapping.values())
observed_ids = set(np.unique(mask).tolist())
unknown = observed_ids - valid_ids
assert not unknown, f"Mask contains unknown class IDs: {unknown}"
# 5. Non-empty annotation coverage
coverage = float((mask > 0).sum()) / mask.size
assert coverage > 0.001, f"Mask is nearly empty (coverage={coverage:.4f})"
print(f"All integrity checks passed. Coverage: {coverage:.2%}")
Wire this as a pytest fixture or a pre-commit hook:
# .pre-commit-config.yaml
- repo: local
hooks:
- id: annotation-integrity
name: Annotation integrity check
entry: python scripts/assert_annotation_integrity.py
language: python
files: \.geojson$
Decision Matrix: Format Trade-offs
| Criterion | Vector | Raster | Hybrid (recommended) |
|---|---|---|---|
| Storage per tile | Low (KB–MB) | High (MB–GB) | Low (vector on disk) |
| Deep learning compatibility | Needs rasterization | Direct CNN input | Lazy rasterize at load |
| Boundary precision | Sub-pixel | Pixel-grid constrained | Sub-pixel (stored) |
| Editability | High (per-feature) | Low (full regeneration) | High |
| Multi-class support | Attribute table | Integer LUT | Both |
| Preprocessing at training | ~15 ms/tile lazy | None | ~15 ms/tile lazy |
| Versioning & rollback | GeoJSON diffs via DVC | Binary diff (poor) | GeoJSON diffs via DVC |
The hybrid approach — vector annotation stored in version control, lazy rasterization at the data-loader stage — preserves full editability while satisfying model input requirements without duplicate storage.
Related
- How to Structure GeoJSON for ML Training Datasets — attribute schema, property typing, and CRS declaration conventions
- Coordinate Reference Systems in Annotation Pipelines — CRS contract, datum transforms, and automated validation routines
- Calculating IoU Thresholds for Geospatial Object Detection — metric-CRS IoU, GSD-based threshold selection
- Defining ROI Label Taxonomies for Aerial Imagery — class hierarchy design before annotation begins
- Rollback Strategies for Corrupted Spatial Datasets — recovering from bad rasterization batches
This workflow is one component of the broader Geospatial Annotation Fundamentals & Architecture section, which covers CRS contracts, label taxonomies, confidence scoring, and the full annotation stack for spatial ML pipelines.