Bootstrapping Labels with a Zero-Shot Model

A zero-shot segmentation model will happily outline every roof, shadow, field boundary and parked car in a tile without having seen a single label from your project. That is genuinely useful for a first batch — tracing is the expensive part of annotation and the model does it for free — provided two rules hold. The proposals must be filtered hard enough that correcting them beats drawing from scratch, and they must arrive without classes, because the model’s vocabulary is not your taxonomy and a confident wrong label costs more than no label. This guide covers the run, the vectorisation, the filters, and the measurement that says whether the assistance is paying for itself.

Why This Matters at Cold Start

In the cold-start phase there is no project model, so there is no uncertainty to sample on and the only lever is making the first batches cheaper. Zero-shot geometry is that lever. It is also the phase where a bad pre-labelling setup does the most damage: the first batch defines the annotation guide, sets the team’s habits, and becomes the seed everything else is measured against. A pre-label that quietly biases it toward what a general-purpose model finds salient is expensive to unwind.

The model draws; the human decides what it drew A zero-shot segmentation model returns accurate outlines with no semantics: it separates a roof from its surroundings without knowing whether the project calls it building, warehouse or industrial. The annotator's remaining work is assigning a class from the project taxonomy and correcting the minority of outlines that are wrong, which is far cheaper than tracing every object by hand. what the model supplies outlines, in world coordinates, with no class what the annotator supplies the class, from your taxonomy the judgement about what counts corrections to the minority that are wrong the objects the model missed entirely none of which a general-purpose model can guess splitting the work this way is what keeps the assistance from becoming a source of silent bias

Step-by-Step Implementation

Step 1 — Run on Source Pixels, Tile by Tile

bash
pip install torch==2.3.1 rasterio==1.3.10 shapely==2.0.6 geopandas==0.14.4 numpy==1.26.4

Read a window from the source COG rather than a rendered map tile. The model then sees native values, and the georeferencing is a single affine rather than a chain of inversions through a tiler’s rendering.

python
import rasterio
from rasterio.windows import Window

def tile_windows(path: str, size: int = 1024, overlap: int = 64):
    """Yield (window, transform) pairs covering the scene with a fixed overlap."""
    with rasterio.open(path) as src:
        step = size - overlap
        for row in range(0, src.height, step):
            for col in range(0, src.width, step):
                w = Window(col, row,
                           min(size, src.width - col),
                           min(size, src.height - row))
                if w.width < size // 2 or w.height < size // 2:
                    continue                       # skip slivers at the far edges
                yield w, rasterio.windows.transform(w, src.transform), src.crs

The overlap matters for the same reason it does in automating pre-labeling with foundation models: an object crossing a window edge is proposed twice, in truncated form, and the duplicates must be merged after georeferencing rather than in pixel space.

Step 2 — Vectorise, Simplify, Georeference

python
from rasterio.features import shapes
from shapely.geometry import shape
from shapely.geometry.base import BaseGeometry

def masks_to_polygons(masks, transform, simplify_m: float = 0.3) -> list[BaseGeometry]:
    """Trace each boolean mask to a simplified polygon in the source CRS."""
    out: list[BaseGeometry] = []
    for mask in masks:
        for geom, value in shapes(mask.astype("uint8"), mask=mask, transform=transform):
            if value != 1:
                continue
            poly = shape(geom).simplify(simplify_m).buffer(0)
            if not poly.is_empty and poly.geom_type in ("Polygon", "MultiPolygon"):
                out.append(poly)
    return out

simplify_m should be roughly the ground size of one pixel: enough to remove the staircase that pixel tracing produces, not enough to round off real corners. The trade-off is the one covered in best practices for polygon vs bounding box annotation.

Step 3 — Filter Hard

Most of what a zero-shot model returns is not an object your project cares about. Two cheap geometric filters remove the bulk of it.

What survives each filter on one tile Automatic mode returns 412 masks on one tile. Dropping everything below twenty square metres leaves 96. Removing shapes more than twelve times longer than they are wide — shadows, hedgerows, road margins — leaves 61. Merging duplicates across the window overlap leaves 54, which is the number an annotator actually sees. raw model output 412 area ≥ 20 m² 96 elongation ≤ 12 61 overlap merged 54 87% of what the model returned never reaches a person — and that is the filters working, not the model failing: it was asked for everything and it gave everything
python
import math

def keep_proposal(poly: BaseGeometry, min_area_m2: float = 20.0,
                  max_elongation: float = 12.0) -> bool:
    """Area and shape filters, both in projected metres."""
    if poly.area < min_area_m2:
        return False                       # correcting it costs more than drawing it
    box = poly.minimum_rotated_rectangle
    xs, ys = box.exterior.coords.xy
    edges = [math.dist((xs[i], ys[i]), (xs[i + 1], ys[i + 1])) for i in range(4)]
    long_, short = max(edges), min(edges)
    if short == 0 or (long_ / short) > max_elongation:
        return False                       # a shadow strip or a field margin, not an object
    return True

The elongation filter earns its place on aerial imagery specifically: shadows, hedgerows and road margins are the commonest false proposals and they are all long and thin, while the built objects most projects care about are not.

Step 4 — Ship Them Class-Less

python
from dataclasses import dataclass

@dataclass(frozen=True)
class Proposal:
    tile_id: str
    geometry_wkt: str
    crs: str
    source: str = "zero-shot"
    class_name: str | None = None          # deliberately empty — the human decides

Every annotation platform will happily accept a class on a prediction, and it is worth resisting. The failure it prevents is not the model being wrong occasionally; it is the model being plausibly wrong systematically, so that a whole batch inherits its idea of where warehouse stops and industrial starts.

Step 5 — Measure Whether It Helped

Pre-labelling is an intervention and deserves a control. Run a slice of the first batch without proposals and compare.

python
import pandas as pd

def assistance_report(events: pd.DataFrame) -> pd.DataFrame:
    """Accept/adjust/delete rates and median tile time, assisted against control."""
    g = events.groupby("arm")
    return pd.DataFrame({
        "tiles": g["tile_id"].nunique(),
        "median_seconds_per_tile": g["seconds"].median().round(1),
        "accepted": g["action"].apply(lambda s: (s == "accept").mean().round(3)),
        "adjusted": g["action"].apply(lambda s: (s == "adjust").mean().round(3)),
        "deleted": g["action"].apply(lambda s: (s == "delete").mean().round(3)),
    })

Three readings and what each means:

  • Median time per tile is not lower than the control. The proposals are not helping; the filters are too loose or the model is a poor fit for this imagery.
  • Acceptance above ~90%. Almost certainly nobody is checking. Confirm with an adjudication sample rather than a survey, using the agreement machinery in annotation quality metrics and agreement.
  • Deletion above ~40%. The filters are letting through objects the project does not care about. Raise min_area_m2 before touching the model.
The control arm is what makes the saving a fact The assisted arm completes a tile in a median of 96 seconds against 168 for the unassisted control, a 43 percent saving. Of its proposals, 62 percent are accepted as drawn, 24 percent adjusted and 14 percent deleted — a distribution that indicates annotators are genuinely checking rather than clicking through. median seconds per tile control 168 s assisted 96 s a 43% saving — and the control arm is the only thing that makes that a measurement what happened to the proposals accepted 62% adjusted 24% del 14% acceptance in the 55–85% band: high enough to save time, low enough that people are still looking above 90% would be a warning about attention, not a report of quality

Parameters and Thresholds Reference

Parameter Typical Scales with
min_area_m2 20 m² at 30 cm GSD Resolution — about 1 m² at 5 cm
max_elongation 12 The shapes your project cares about
simplify_m ≈ 1 pixel on the ground GSD
Window / overlap 1024 px / 64 px Model input size
Healthy acceptance 0.55 – 0.85
Control arm 5 – 10% of the batch Enough to compare medians

Common Errors and Fixes

Proposals land tens of metres from the objects Root cause: the model ran on rendered map tiles and the coordinates were inverted through the tiler’s Web Mercator rendering. Fix: run on source windows, as in Step 1, and transform with the window’s own affine.

Thousands of proposals per tile Root cause: automatic mode with no filtering — the model has segmented every roof facet and shadow separately. Fix: apply Step 3’s filters before anything reaches a platform, and consider prompting with a detector’s boxes rather than running in automatic mode.

Annotators say the proposals slow them down Root cause: deletion rate is high, so the assistance is net negative. Fix: measure it with the control arm rather than debating it; raise min_area_m2 and re-measure.

Duplicate proposals along window edges Root cause: overlapping windows, deduplicated in pixel space or not at all. Fix: merge after georeferencing, on world-coordinate IoU — the two windows have different pixel origins.

Frequently Asked Questions

# Can I fine-tune the zero-shot model on the first batch?

Yes, and that is usually the point at which the project stops being cold-started. Once a fine-tuned model exists, its uncertainty starts to mean something and the handover to uncertainty sampling becomes the next question.

# Does this work for line features like roads?

Less well. Segmentation models return regions, and converting a region to a centreline is a skeletonisation step that introduces its own errors. For linear networks, prompting with an existing vector source and having annotators correct it usually beats zero-shot proposals.

# How do I keep proposals from biasing the taxonomy?

Keep them class-less, and write the annotation guide from adjudicated examples rather than from what the model proposed. The bias to watch for is the guide silently adopting the model’s implicit object boundaries — the roof versus the parcel, for instance — because those were the shapes people spent their time correcting.

# Should proposals be versioned with the dataset?

Record which model and which parameters produced them, in the batch manifest, and version the accepted result rather than the proposals themselves. What matters six months later is knowing that this batch was pre-labelled at all, since it is a plausible explanation for a systematic difference between it and an unassisted batch.

Bootstrapping is the first move within Cold-Start Strategies for New Annotation Projects, part of Active Learning & Model Feedback Loops.