Automating Batch Pre-Labeling with SAM and QGIS
A headless Python pipeline runs Meta’s Segment Anything Model (SAM) on georeferenced raster tiles, transforms binary segmentation masks back to map coordinates using each tile’s affine transform, and exports spatially aligned GeoJSON that QGIS loads directly for human review. The pipeline removes manual digitizing as the rate-limiting step: a GPU pass over one hundred 1024×1024 tiles completes in minutes, producing polygon annotations that annotators correct rather than draw from scratch.
Why Unprojected Inference Breaks the Pipeline
When the source raster is in a geographic coordinate reference system such as EPSG:4326, one pixel does not represent a consistent ground distance across the image. SAM’s receptive field spans different physical areas in the north versus south of a scene, causing fragmented or oversized masks at scale. The second failure is quieter: converting pixel-space contours back to geographic coordinates using a geographic CRS affine transform produces geometries that look correct in a GIS viewer but carry distorted area and perimeter values, which collapses IoU threshold calculations at model evaluation time. Both failures are silent — the pipeline finishes, the GeoJSON loads, but the annotations do not match the ground truth.
Step-by-Step Implementation
Step 1 — Tile the Raster and Normalize the CRS
Use rasterio and rio-cogeo to reproject to a local UTM zone and split into overlapping tiles. Reprojection must happen before tiling so every tile inherits a consistent metric CRS.
# requirements: rasterio==1.3.10, rio-cogeo==3.6.0, numpy==1.26.4
import math
import rasterio
from rasterio.warp import calculate_default_transform, reproject, Resampling
from rasterio.windows import Window
import os
TARGET_CRS = "EPSG:32634" # UTM zone 34N — adjust to your AOI
TILE_SIZE = 1024 # pixels
OVERLAP = int(TILE_SIZE * 0.175) # ~18% overlap
def reproject_to_metric(src_path: str, dst_path: str) -> None:
"""Reproject an arbitrary raster to a metric CRS before tiling."""
with rasterio.open(src_path) as src:
transform, width, height = calculate_default_transform(
src.crs, TARGET_CRS, src.width, src.height, *src.bounds
)
profile = src.profile.copy()
profile.update(crs=TARGET_CRS, transform=transform,
width=width, height=height)
with rasterio.open(dst_path, "w", **profile) as dst:
for i in range(1, src.count + 1):
reproject(
source=rasterio.band(src, i),
destination=rasterio.band(dst, i),
src_transform=src.transform,
src_crs=src.crs,
dst_transform=transform,
dst_crs=TARGET_CRS,
resampling=Resampling.bilinear,
)
def tile_raster(src_path: str, out_dir: str) -> list[str]:
"""Slice a metric raster into overlapping tiles; return tile paths."""
os.makedirs(out_dir, exist_ok=True)
tile_paths: list[str] = []
step = TILE_SIZE - OVERLAP
with rasterio.open(src_path) as src:
cols = math.ceil((src.width - OVERLAP) / step)
rows = math.ceil((src.height - OVERLAP) / step)
for r in range(rows):
for c in range(cols):
col_off = c * step
row_off = r * step
w = min(TILE_SIZE, src.width - col_off)
h = min(TILE_SIZE, src.height - row_off)
window = Window(col_off, row_off, w, h)
transform = src.window_transform(window)
profile = src.profile.copy()
profile.update(width=w, height=h, transform=transform)
tile_path = os.path.join(out_dir, f"tile_r{r:04d}_c{c:04d}.tif")
with rasterio.open(tile_path, "w", **profile) as dst:
dst.write(src.read(window=window))
tile_paths.append(tile_path)
return tile_paths
Step 2 — Batch SAM Inference
Pre-load the checkpoint once and iterate tiles in a torch.no_grad() context. Mixed-precision cuts VRAM usage by roughly 40% without affecting mask quality.
# requirements: segment-anything==1.0, torch==2.3.0, opencv-python==4.9.0.80
import torch
import numpy as np
import rasterio
from segment_anything import sam_model_registry, SamAutomaticMaskGenerator
CHECKPOINT = "checkpoints/sam_vit_h_4b8939.pth"
MODEL_TYPE = "vit_h"
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
sam = sam_model_registry[MODEL_TYPE](checkpoint=CHECKPOINT)
sam.to(device=DEVICE)
mask_generator = SamAutomaticMaskGenerator(
model=sam,
points_per_side=32,
pred_iou_thresh=0.85,
stability_score_thresh=0.92,
crop_n_layers=1,
min_mask_region_area=500, # pixels; filters noise fragments
)
def run_sam_on_tiles(tile_paths: list[str]) -> list[tuple[str, list[dict]]]:
"""Return (tile_path, masks) pairs for every input tile."""
results: list[tuple[str, list[dict]]] = []
with torch.no_grad():
with torch.autocast(device_type=DEVICE, dtype=torch.float16):
for tile_path in tile_paths:
with rasterio.open(tile_path) as src:
img = src.read().transpose(1, 2, 0) # (H, W, C)
# SAM expects uint8 RGB; handle multi-band imagery
if img.shape[2] > 3:
img = img[:, :, :3]
img = img.astype(np.uint8)
masks = mask_generator.generate(img)
results.append((tile_path, masks))
return results
Step 3 — Convert Pixel Masks to Georeferenced Polygons
Apply each tile’s affine transform to map pixel contour coordinates to real-world map coordinates. Assign per-polygon confidence scores so annotators can triage masks by certainty in QGIS.
# requirements: shapely==2.0.4, geopandas==0.14.4, opencv-python==4.9.0.80
import cv2
from shapely.geometry import Polygon, mapping
from shapely.ops import transform as shapely_transform
import geopandas as gpd
from rasterio.transform import Affine
MIN_AREA_PX = 500
def mask_to_georef_polygons(
mask_binary: np.ndarray,
affine_tfm: Affine,
predicted_iou: float,
source_tile: str,
) -> list[dict]:
"""Convert one SAM binary mask to a list of GeoJSON-ready feature dicts."""
contours, _ = cv2.findContours(
mask_binary.astype(np.uint8),
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE,
)
features: list[dict] = []
for cnt in contours:
if cv2.contourArea(cnt) < MIN_AREA_PX:
continue
pts = cnt.squeeze()
if pts.ndim < 2 or len(pts) < 3:
continue
# Build pixel-space polygon, then apply raster affine transform
pixel_ring = [tuple(pt) for pt in pts]
pixel_poly = Polygon(pixel_ring)
if not pixel_poly.is_valid:
pixel_poly = pixel_poly.buffer(0)
def _affine(xs, ys):
geo_xs, geo_ys = zip(*(affine_tfm * (col, row) for col, row in zip(xs, ys)))
return list(geo_xs), list(geo_ys)
geo_poly = shapely_transform(_affine, pixel_poly)
geo_poly = geo_poly.simplify(0.5, preserve_topology=True)
features.append({
"geometry": mapping(geo_poly),
"properties": {
"source_tile": source_tile,
"confidence": round(float(predicted_iou), 4),
"area_m2": round(geo_poly.area, 2),
},
})
return features
def build_geodataframe(
sam_results: list[tuple[str, list[dict]]],
output_crs: str = TARGET_CRS,
) -> gpd.GeoDataFrame:
"""Aggregate all tile results into a single GeoDataFrame."""
all_features: list[dict] = []
for tile_path, masks in sam_results:
with rasterio.open(tile_path) as src:
tfm = src.transform
for m in masks:
all_features.extend(
mask_to_georef_polygons(
m["segmentation"], tfm, m["predicted_iou"], tile_path
)
)
gdf = gpd.GeoDataFrame.from_features(all_features, crs=output_crs)
# Remove duplicates from overlapping tiles: keep the highest-confidence copy
gdf = gdf.sort_values("confidence", ascending=False)
gdf = gdf.drop_duplicates(subset=["geometry"])
return gdf.reset_index(drop=True)
Step 4 — Export GeoJSON and Load into QGIS
Write the GeoDataFrame to a GeoJSON file and configure QGIS for annotation review.
OUTPUT_GEOJSON = "prelabels/sam_batch_output.geojson"
def export_prelabels(gdf: gpd.GeoDataFrame) -> None:
gdf.to_file(OUTPUT_GEOJSON, driver="GeoJSON")
print(f"Exported {len(gdf)} pre-label polygons → {OUTPUT_GEOJSON}")
high_conf = (gdf["confidence"] >= 0.90).sum()
print(f" High-confidence (≥0.90): {high_conf} ({100*high_conf/len(gdf):.1f}%)")
In QGIS, open Layer > Add Layer > Add Vector Layer and select the GeoJSON. Apply a Graduated renderer on the confidence field — green for ≥ 0.90, amber for 0.80–0.89, red for < 0.80. Enable Snapping (Settings > Snapping Options, tolerance 5–10 map units) so editors can snap pre-label edges to existing survey boundaries. This review cycle integrates with the broader QGIS plugin ecosystem for annotation teams.
Because every polygon already carries a metric geotransform, the reviewed layer exports cleanly to COCO/YOLO formats with the geotransform preserved as metadata — no second reprojection round-trip, and pixel-space bounding boxes recovered by inverting the same affine used in Step 3.
Key Parameters Reference
| Parameter | Recommended value | Effect |
|---|---|---|
points_per_side |
32 | Grid density for automatic prompt points; raise to 64 for dense urban scenes |
pred_iou_thresh |
0.85 | Minimum predicted IoU to keep a mask; lower increases recall, raises noise |
stability_score_thresh |
0.92 | Mask consistency across threshold perturbations; lower if SAM misses soft boundaries |
min_mask_region_area |
500 px | Pixel noise filter; scale with GSD (higher GSD → larger threshold) |
| Tile overlap | 15–20% | Prevents edge-cut artifacts; overlap > 25% creates excessive duplicates |
simplify tolerance |
0.5 m | Vertex reduction; keep < 1 m to preserve parcel or building edges |
MIN_AREA_M2 post-filter |
25–100 m² | Remove sub-parcel noise after metric CRS conversion |
Common Errors and Fixes
TypeError: image must be uint8
SAM requires three-channel uint8 RGB input, but multispectral rasters read via rasterio are often uint16 and carry more than three bands. The mask generator raises this on the first tile. Fix: img = img[:, :, :3].astype(np.uint8) — and apply a per-band stretch first if the source is true uint16 radiance rather than a display-ready composite.
Masks look correct but coordinates land far off the basemap
The raster’s CRS was geographic (EPSG:4326) at inference time, so each tile’s affine transform maps pixel columns and rows to decimal-degree deltas instead of metres. Polygons render with the right shape but the wrong scale and position. Fix: run reproject_to_metric() before tiling so every tile inherits a uniform metric transform.
Topology Checker flags hundreds of overlapping polygons
Tile overlap produces the same object in two adjacent tiles without deduplication. The drop_duplicates(subset=["geometry"]) step removes exact duplicates, but near-duplicates with slightly different vertex counts survive. Fix: dissolve near-duplicates with gpd.overlay(gdf, gdf, how="union") followed by a groupby("source_tile").geometry.union_all() pass, or snap to a shared grid before deduplicating.
CUDA out of memory with vit_h
The vit_h checkpoint needs roughly 7 GB of VRAM per batch, which overflows a 16 GB GPU once the automatic mask generator allocates its crop pyramid. Fix: switch to vit_l (~4 GB), enable torch.autocast as shown in Step 2, or drop crop_n_layers to 0 and process tiles strictly sequentially.
Related
- QGIS Plugin Ecosystem for Annotation Teams — parent page covering plugin selection, scripting hooks, and team review workflows
- Automating Pre-Labeling with Foundation Models — broader treatment of foundation-model-assisted labeling including Label Studio integration
- Coordinate Reference Systems in Annotation Pipelines — foundational CRS concepts underlying the reprojection steps above
- Confidence Scoring for Geospatial Labels — how to use SAM’s
predicted_ioufield to drive active learning queues
This page is part of the QGIS Plugin Ecosystem for Annotation Teams section within Labeling Workflows & Toolchain Integration.