Defining ROI Label Taxonomies for Aerial Imagery

Without a structured ontology, annotation teams produce inconsistent labels, model training suffers from class imbalance, and downstream inference pipelines break under schema drift. A concrete failure mode: a six-class land-cover taxonomy that allows annotators to choose between vegetation and ground_cover based on visual prominence produces Cohen’s Kappa scores below 0.6, making validation metrics meaningless and gradient descent unstable. This guide provides a production-ready workflow for designing, validating, and automating ROI taxonomies tailored to aerial and satellite imagery, aligned with the broader Geospatial Annotation Fundamentals & Architecture framework.

Prerequisites & Toolchain Alignment

Before building a taxonomy, verify your environment against these requirements. Silent data corruption from skipped prerequisites typically surfaces only at model evaluation — after weeks of annotation work.

Python packages (pinned):

code
geopandas==0.14.4
shapely==2.0.4
pydantic==2.7.1
pyproj==3.6.1
rasterio==1.3.10

Install with:

bash
pip install "geopandas==0.14.4" "shapely==2.0.4" "pydantic==2.7.1" \
            "pyproj==3.6.1" "rasterio==1.3.10"

System dependencies: GDAL 3.8+, PROJ 9.3+. On Ubuntu: sudo apt-get install gdal-bin libgdal-dev proj-bin.

Spatial prerequisites:

  • Orthorectified aerial tiles at GSD ≤ 0.5 m with documented acquisition metadata (sensor type, sun elevation, cloud cover).
  • All source imagery and annotation outputs aligned to a single coordinate reference system — projection mismatches silently corrupt spatial joins, area calculations, and IoU metrics. Enforce EPSG:4326 for storage and a local metric CRS (e.g., EPSG:32633 for UTM zone 33N) for all area or distance computations.
  • GeoJSON (RFC 7946), Parquet, or COCO-compatible annotation outputs. GeoJSON is the most interoperable format for vector ROI storage.

Domain prerequisite: a preliminary class hierarchy aligned with project objectives before opening the annotation interface — retrofitting taxonomy structure onto existing annotations is expensive and error-prone.

Taxonomy Architecture: From Ontology to Schema

The diagram below shows how a domain ontology flows through hierarchy design, schema enforcement, and CI validation into a training-ready dataset.

ROI Label Taxonomy Pipeline Domain ontology maps to a class hierarchy, which is encoded in a versioned taxonomy JSON. Pydantic validators and shapely geometry checks run on every annotation commit via CI, producing a validated GeoJSON training dataset. Domain Ontology objectives → classes Hierarchy Design parent → child tree Schema Validation Pydantic + shapely CI Gate (pre-commit) fail on schema error Training Dataset GeoJSON / Parquet schema error → revise hierarchy taxonomy.json (versioned)

Core Workflow

Step 1 — Map Domain Ontology to Discrete ROI Classes

Translate business or research objectives into discrete, mutually exclusive ROI classes. Avoid overlapping definitions that force annotators into subjective decision trees. Separate vegetation_canopy from ground_cover rather than allowing annotators to choose based on visual prominence; document explicit inclusion and exclusion criteria for each class, including edge-case handling for partially occluded structures and seasonal vegetation changes.

A well-scoped ontology reduces inter-annotator variance and simplifies downstream loss function design. When classes overlap conceptually, models learn conflicting gradients, which manifests as unstable validation metrics and poor generalization on unseen tiles.

python
# taxonomy.json — version-controlled alongside annotation data
{
  "version": "1.2.0",
  "classes": [
    {
      "class_id": 1,
      "class_name": "residential",
      "parent": "land_use",
      "geometry_types": ["Polygon"],
      "min_area_m2": 25.0,
      "exclusion_rules": ["Do not label if structure footprint < 25 m²", "Exclude structures with >50% occlusion by vegetation"]
    },
    {
      "class_id": 4,
      "class_name": "vegetation_canopy",
      "parent": "land_cover",
      "geometry_types": ["Polygon"],
      "min_area_m2": 4.0,
      "exclusion_rules": ["Leaf-on imagery only (NDVI > 0.3)", "Exclude bare ground visible through canopy gaps >40%"]
    }
  ]
}

Step 2 — Establish a Hierarchical Label Structure

A flat taxonomy rarely scales across complex aerial scenes. Implement a parent-child hierarchy that supports both coarse-grained scene classification and fine-grained object detection. The diagram below illustrates a two-domain hierarchy covering land use and land cover — the two root categories most aerial annotation projects require.

ROI Label Taxonomy Hierarchy Two root domains: land_use (residential → single_family, multi_unit; commercial; industrial → manufacturing, logistics_hub) and land_cover (vegetation_canopy → deciduous, coniferous; impervious_surface; water → standing, flowing). land_use residential commercial industrial single_family multi_unit manufacturing logistics_hub land_cover vegetation _canopy impervious _surface water Land Use Domain Land Cover Domain

This structure enables multi-task learning architectures and allows models to predict at varying confidence thresholds. During inference, aggregate child-class probabilities to validate parent-class consistency — a useful automated sanity check for label quality.

When deciding which geometry type to attach to each hierarchy level, consult best practices for polygon vs bounding box annotation to align annotation effort with model architecture requirements. Fine-grained subclasses typically require polygon outlines; coarse parent classes can use bounding boxes for faster annotation throughput.

Step 3 — Standardize Attribute and Metadata Payloads

ROIs require more than geometry and a class name. Attach standardized attributes to support filtering, versioning, and confidence scoring downstream:

python
# Canonical attribute schema for every ROI feature
{
  "annotation_id": "550e8400-e29b-41d4-a716-446655440000",  # UUIDv4
  "class_id": 1,                          # Integer mapping to taxonomy
  "class_name": "residential",            # Immutable string from taxonomy.json
  "confidence": 0.92,                     # Float 0.0–1.0 (annotator certainty)
  "annotator_id": "ann_007",              # Traceable to individual annotator
  "created_at": "2026-06-24T09:15:00Z",  # ISO 8601
  "updated_at": "2026-06-24T11:32:00Z",
  "validation_status": "reviewed",        # Enum: draft | reviewed | approved | rejected
  "taxonomy_version": "1.2.0",           # Locked at annotation time
  "acquisition_date": "2026-04-10",       # Source imagery date for temporal filtering
  "gsd_m": 0.30                          # Ground sampling distance of source tile
}

When deciding between vector vs raster annotation workflows for storing these attributes, evaluate your pipeline’s spatial query patterns — vector GeoJSON supports attribute filtering directly, while raster masks require a separate sidecar metadata file.

Step 4 — Implement Programmatic Schema Validation

Manual review cannot scale to millions of ROIs. Enforce taxonomy compliance programmatically before annotations enter the training dataset. The validator below cross-checks class IDs, geometry validity, minimum area thresholds, and taxonomy version locks:

python
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Literal, Optional
from datetime import datetime
from shapely.geometry import shape
from shapely.validation import explain_validity
import uuid
import json

# Load versioned taxonomy at import time
with open("taxonomy.json") as f:
    _raw = json.load(f)
    TAXONOMY = {c["class_name"]: c for c in _raw["classes"]}
    VALID_CLASS_IDS = {c["class_id"]: c["class_name"] for c in _raw["classes"]}

class ROIProperties(BaseModel):
    annotation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    class_id: int
    class_name: str
    confidence: float = Field(ge=0.0, le=1.0)
    validation_status: Literal["draft", "reviewed", "approved", "rejected"] = "draft"
    taxonomy_version: str
    created_at: datetime = Field(default_factory=datetime.utcnow)
    gsd_m: float = Field(gt=0.0, le=2.0, description="Ground sampling distance in metres")

    @field_validator("class_name")
    @classmethod
    def validate_class_in_taxonomy(cls, v: str) -> str:
        if v not in TAXONOMY:
            raise ValueError(f"'{v}' is not a registered class in taxonomy.json")
        return v

    @model_validator(mode="after")
    def validate_class_id_name_consistency(self) -> "ROIProperties":
        expected_id = TAXONOMY[self.class_name]["class_id"]
        if self.class_id != expected_id:
            raise ValueError(
                f"class_id {self.class_id} does not match '{self.class_name}' "
                f"(expected {expected_id})"
            )
        return self

class ROIAnnotation(BaseModel):
    type: Literal["Feature"] = "Feature"
    properties: ROIProperties
    geometry: dict

    @model_validator(mode="before")
    @classmethod
    def validate_geojson_structure(cls, data: dict) -> dict:
        if data.get("type") != "Feature":
            raise ValueError("Top-level GeoJSON type must be 'Feature'")
        if "geometry" not in data or "properties" not in data:
            raise ValueError("Missing required 'geometry' or 'properties' fields")
        return data

    @model_validator(mode="after")
    def validate_geometry_integrity(self) -> "ROIAnnotation":
        geom = shape(self.geometry)
        if not geom.is_valid:
            reason = explain_validity(geom)
            raise ValueError(f"Invalid geometry: {reason}")
        # Enforce minimum area from taxonomy definition
        class_def = TAXONOMY.get(self.properties.class_name, {})
        min_area = class_def.get("min_area_m2", 0.0)
        # Note: geom.area is in CRS units; caller must reproject to metric CRS first
        if hasattr(geom, "area") and geom.area < min_area / 1e6:  # rough degree-to-m² guard
            raise ValueError(
                f"Geometry area below minimum {min_area} m² for class "
                f"'{self.properties.class_name}'"
            )
        return self


def validate_annotation_file(path: str) -> list[str]:
    """Validate all features in a GeoJSON file. Returns list of error strings."""
    errors: list[str] = []
    with open(path) as f:
        fc = json.load(f)
    for i, feature in enumerate(fc.get("features", [])):
        try:
            ROIAnnotation.model_validate(feature)
        except Exception as e:
            errors.append(f"Feature {i}: {e}")
    return errors

Step 5 — Automate Taxonomy Deployment via CI/CD

Schema validation must run automatically on every annotation commit. A pre-commit hook prevents invalid annotations from reaching the main branch:

bash
#!/bin/bash
# .git/hooks/pre-commit — make executable with chmod +x
set -e

CHANGED_GEOJSON=$(git diff --cached --name-only --diff-filter=ACM | grep '\.geojson$' || true)

if [ -z "$CHANGED_GEOJSON" ]; then
  exit 0
fi

echo "Running ROI taxonomy validation..."
ERRORS=0
for file in $CHANGED_GEOJSON; do
    python - <<EOF
import sys
from validate_roi import validate_annotation_file
errs = validate_annotation_file("$file")
if errs:
    for e in errs:
        print(f"  ERROR in $file: {e}", file=sys.stderr)
    sys.exit(1)
EOF
    if [ $? -ne 0 ]; then
        ERRORS=$((ERRORS + 1))
    fi
done

if [ $ERRORS -gt 0 ]; then
    echo "Taxonomy validation failed: $ERRORS file(s) rejected. Fix schema errors and re-stage."
    exit 1
fi
echo "All annotation files passed taxonomy validation."

For GitHub Actions, add a workflow that runs the full validation suite on pull requests:

yaml
# .github/workflows/annotation-qa.yml
name: Annotation Schema QA
on:
  pull_request:
    paths:
      - "annotations/**/*.geojson"
      - "taxonomy.json"

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install "pydantic==2.7.1" "shapely==2.0.4" "geopandas==0.14.4"
      - name: Validate all annotation files
        run: |
          python -c "
          import glob, sys
          from validate_roi import validate_annotation_file
          files = glob.glob('annotations/**/*.geojson', recursive=True)
          all_errors = []
          for f in files:
              errs = validate_annotation_file(f)
              all_errors.extend(errs)
          if all_errors:
              for e in all_errors:
                  print(e, file=sys.stderr)
              sys.exit(1)
          print(f'Validated {len(files)} annotation files — all passed.')
          "

When the taxonomy evolves — adding a subclass, deprecating a parent — bump taxonomy.json version, run a migration script to backfill taxonomy_version in existing annotations, and add a backward-compatibility shim in your training data loader. Pair this with DVC pipelines to snapshot annotation state at each taxonomy version, enabling clean rollbacks if a schema change corrupts downstream training runs.

Spatial Parameters & Configuration Reference

Parameter Type Valid Range Spatial Implication
gsd_m float 0.05–2.0 Controls minimum resolvable object size; GSD > 0.5 m limits fine-grained subclass annotation
min_area_m2 float 1.0–10 000 Prevents sub-pixel polygon fragments; set per class based on GSD
confidence float 0.0–1.0 Values below 0.7 should flag annotations for human review before training inclusion
taxonomy_version semver string e.g. "1.2.0" Must match the taxonomy.json version at commit time; mismatches block ingest
geometry_types array of strings ["Polygon", "MultiPolygon"] Restrict per class; mixed geometry types in a class break COCO export serialization
crs_storage EPSG code EPSG:4326 GeoJSON spec requires geographic CRS for storage; reproject to metric CRS for area validation
crs_compute EPSG code UTM zone for AOI Use for IoU, area, and buffer computations to avoid angular distortion
iou_threshold_coarse float 0.40–0.55 Appropriate for large land-cover classes (field, water body)
iou_threshold_fine float 0.65–0.75 Required for structure-level objects (building, road segment); see IoU threshold reference

Edge Cases & Spatial Gotchas

Self-intersecting polygons from manual digitizing. Annotators drawing freehand polygons in web tools frequently produce bowtie or figure-eight geometries. shapely.is_valid returns False for these; explain_validity() identifies the crossing point. Fix with shapely.make_valid() and re-validate, but always log auto-corrections for annotator feedback:

python
from shapely.geometry import shape
from shapely.validation import make_valid

def fix_and_log(geom_dict: dict, annotation_id: str) -> dict:
    geom = shape(geom_dict)
    if not geom.is_valid:
        fixed = make_valid(geom)
        print(f"Auto-corrected geometry for {annotation_id}: {geom.geom_type}{fixed.geom_type}")
        return fixed.__geo_interface__
    return geom_dict

Seasonal class drift in multi-temporal datasets. A deciduous polygon valid in summer imagery becomes ambiguous in a winter tile. Add acquisition_season to the annotation schema and define class-specific seasonal validity rules in taxonomy.json. Validate acquisition_date against the class definition during ingest.

Annotator disagreement on boundary features. Road edges, shorelines, and building overhangs generate systematic boundary disagreement. Rather than forcing a single boundary, record all annotator boundaries and compute the consensus polygon using shapely.ops.unary_union on the intersection region. Pixels that fall outside the consensus zone are excluded from the training mask.

Class ID collisions after taxonomy merge. When merging annotations from two teams using different taxonomy versions, ID collisions silently reassign labels. Always remap class IDs through the canonical taxonomy before merging: load both taxonomy files, build a name-based mapping, and reject any annotation whose class name does not appear in the target taxonomy.

CRS mismatch in area-based minimum filtering. Computing geometry area in EPSG:4326 (degrees) produces nonsensical values for area-threshold checks. Always reproject to a local metric CRS before computing geom.area:

python
import geopandas as gpd

def validate_min_area(
    gdf: gpd.GeoDataFrame,
    min_area_m2: float,
    metric_crs: str = "EPSG:32633"
) -> gpd.GeoDataFrame:
    projected = gdf.to_crs(metric_crs)
    too_small = projected[projected.geometry.area < min_area_m2]
    if not too_small.empty:
        raise ValueError(f"{len(too_small)} features below {min_area_m2} m² minimum area threshold")
    return gdf

Integration & Automation Hooks

Label Studio integration. Export taxonomy classes as a Label Studio XML config block. Use Label Studio’s pre-annotation API to push taxonomy-validated predictions back into the review queue, reducing cold-annotation throughput:

python
import label_studio_sdk

client = label_studio_sdk.Client(url="http://localhost:8080", api_key="<your-key>")
project = client.get_project(project_id=1)

# Push a validated prediction for human review
project.create_prediction(
    task_id=task_id,
    result=[{
        "from_name": "label",
        "to_name": "image",
        "type": "polygonlabels",
        "value": {"points": polygon_points, "polygonlabels": [class_name]},
        "score": confidence
    }]
)

QGIS plugin hook. Store taxonomy.json on a shared network path and load it into the QGIS annotation form at session start. Use a QGIS Python plugin to enforce dropdown class selection — preventing free-text entry that bypasses schema validation.

DVC pipeline snapshot. Lock annotation state at each taxonomy version with a DVC stage. Any taxonomy version bump triggers a full re-validation run:

yaml
# dvc.yaml
stages:
  validate_annotations:
    cmd: python validate_roi.py --dir annotations/ --taxonomy taxonomy.json
    deps:
      - annotations/
      - taxonomy.json
      - validate_roi.py
    metrics:
      - validation_report.json:
          cache: false

Validation & Testing

Verify your taxonomy stack end-to-end before opening the annotation interface to your team:

python
import pytest
from shapely.geometry import mapping, Polygon
from validate_roi import ROIAnnotation

class TestROITaxonomyValidation:

    def test_valid_annotation_passes(self) -> None:
        polygon = Polygon([(0, 0), (0, 0.001), (0.001, 0.001), (0.001, 0), (0, 0)])
        feature = {
            "type": "Feature",
            "geometry": mapping(polygon),
            "properties": {
                "class_id": 1,
                "class_name": "residential",
                "confidence": 0.88,
                "taxonomy_version": "1.2.0",
                "gsd_m": 0.30
            }
        }
        result = ROIAnnotation.model_validate(feature)
        assert result.properties.class_name == "residential"

    def test_class_id_mismatch_raises(self) -> None:
        polygon = Polygon([(0, 0), (0, 0.001), (0.001, 0.001), (0.001, 0), (0, 0)])
        feature = {
            "type": "Feature",
            "geometry": mapping(polygon),
            "properties": {
                "class_id": 99,  # Wrong ID for "residential"
                "class_name": "residential",
                "confidence": 0.88,
                "taxonomy_version": "1.2.0",
                "gsd_m": 0.30
            }
        }
        with pytest.raises(Exception, match="class_id"):
            ROIAnnotation.model_validate(feature)

    def test_out_of_range_confidence_raises(self) -> None:
        polygon = Polygon([(0, 0), (0, 0.001), (0.001, 0.001), (0.001, 0), (0, 0)])
        feature = {
            "type": "Feature",
            "geometry": mapping(polygon),
            "properties": {
                "class_id": 1,
                "class_name": "residential",
                "confidence": 1.5,  # Invalid
                "taxonomy_version": "1.2.0",
                "gsd_m": 0.30
            }
        }
        with pytest.raises(Exception):
            ROIAnnotation.model_validate(feature)

Run with: pytest test_taxonomy_validation.py -v

Statistical Quality Gates

Beyond structural validation, implement statistical gates to monitor annotation consistency across batches. These transform taxonomy management from a static documentation exercise into a dynamic feedback loop:

Inter-Annotator Agreement (IAA). Calculate Cohen’s Kappa for overlapping ROI assignments using sklearn.metrics.cohen_kappa_score. Values below 0.75 indicate ambiguous class definitions requiring ontology refinement. Trigger an alert and open an ontology review when IAA drops below this threshold across any two annotators.

Class distribution monitoring. Track ROI counts per class across batches. Sudden spikes or drops signal annotator confusion or imagery bias. Alert when any class falls below 5% or exceeds 40% of the total ROI count in a batch, as these imbalances propagate directly to model precision/recall on underrepresented classes. Pair this with the SHA-based annotation change tracking to audit which specific commits shifted the distribution.

Spatial consistency checks. Validate that adjacent ROIs do not overlap unless explicitly permitted by the taxonomy (e.g., multi-layer vegetation_canopy over impervious_surface). Use geopandas.overlay() to detect illegal intersections:

python
import geopandas as gpd

def check_illegal_overlaps(
    gdf: gpd.GeoDataFrame,
    permitted_pairs: list[tuple[str, str]]
) -> gpd.GeoDataFrame:
    """Returns GeoDataFrame of overlapping features that violate taxonomy rules."""
    overlaps = gpd.overlay(gdf, gdf, how="intersection")
    # Exclude self-intersections and permitted class pairs
    illegal = overlaps[
        (overlaps["annotation_id_1"] != overlaps["annotation_id_2"]) &
        ~overlaps.apply(
            lambda r: (r["class_name_1"], r["class_name_2"]) in permitted_pairs, axis=1
        )
    ]
    return illegal

Confidence calibration audit. Ensure annotator confidence scores correlate with IAA metrics. If high-confidence annotations consistently disagree with consensus, the confidence scale is miscalibrated — provide annotators with recalibration examples showing the real distribution of ambiguous boundary cases.

This workflow is one component of the broader Geospatial Annotation Fundamentals & Architecture framework, which covers the full pipeline from CRS contracts through label schema design to training data export.