Step-by-Step CVAT Setup for Drone Imagery Annotation
Deploy CVAT v2.14+ via Docker Compose, tile GeoTIFF orthomosaics into 2048 × 2048 JPEG frames using gdal_retile.py, automate task ingestion with cvat-sdk, then reconstruct projected coordinate reference system coordinates from exported pixel offsets using a sidecar affine manifest. The key constraint: CVAT’s browser canvas treats every upload as a plain raster — GeoTIFF projection tags are silently discarded at ingest, so spatial context must be preserved externally from the start.
Why Naïve GeoTIFF Uploads Break Drone Annotation Pipelines
Drone survey orthomosaics arrive as large GeoTIFF files (5–40 GB) referenced to a UTM zone such as EPSG:32632 or a local equivalent. Uploading a raw GeoTIFF directly to CVAT produces three failure modes that compound each other: CRS loss (the geotransform is stripped, leaving pixel-only coordinates with no route back to the geodetic frame), browser OOM (Chrome’s ~4 GB canvas cap causes frames above roughly 4096 × 4096 pixels to render blank or crash the tab silently), and annotation drift across tile boundaries that makes post-export vector vs raster annotation validation fragile. The four-step workflow below eliminates all three before annotation starts.
Prerequisites
| Component | Minimum version | Notes |
|---|---|---|
| Docker Engine | 24.0 | Compose V2 (docker compose) required |
| CVAT | v2.14+ | Pin with git checkout v2.14.3 |
| Python | 3.10+ | Required for cvat-sdk and GDAL bindings |
| GDAL | 3.6+ | conda install -c conda-forge gdal==3.8.4 |
| rasterio | 1.3.9+ | CRS manifest generation |
| RAM | 16 GB (32 GB recommended) | Orthomosaics >5 GB need chunked processing |
Step 1 — Deploy CVAT with Persistent NVMe Storage
Map volumes to a fast NVMe drive before the first docker compose up; retrofitting storage paths after data is written requires a full database export and re-import.
git clone https://github.com/cvat-ai/cvat.git
cd cvat
git checkout v2.14.3
cp docker-compose.override.yml.example docker-compose.override.yml
Edit docker-compose.override.yml to bind data to the NVMe mount:
services:
cvat_server:
volumes:
- /mnt/nvme/cvat_data:/home/cvat/data
environment:
CVAT_NUM_WORKERS: "4"
CVAT_MAX_REQUEST_SIZE: "2147483648" # 2 GB — covers large tile batches
cvat_db:
volumes:
- /mnt/nvme/cvat_db:/var/lib/postgresql/data
Start and verify health, then create a superuser:
docker compose up -d
docker compose ps --format "table {{.Name}}\t{{.Status}}"
docker compose exec cvat_server python manage.py createsuperuser
All containers must report healthy or running before proceeding.
Step 2 — Tile GeoTIFFs and Build a CRS Manifest
CVAT’s canvas handles images up to ~4096 × 4096 px reliably. Use gdal_retile.py to produce 2048 × 2048 JPEG tiles with 200-pixel overlap, then record the pixel-to-world geotransform for every tile in a manifest CSV.
gdal_retile.py \
-ps 2048 2048 \
-overlap 200 \
-levels 1 \
-targetDir ./tiles \
-r bilinear \
-of JPEG \
input_orthomosaic.tif
Generate the CRS manifest with rasterio so each tile’s origin and pixel size are recorded in the source projection:
# build_manifest.py — requires rasterio>=1.3.9
import csv
from pathlib import Path
import rasterio
TILE_DIR = Path("./tiles")
MANIFEST = Path("./crs_manifest.csv")
with MANIFEST.open("w", newline="") as fh:
writer = csv.DictWriter(
fh,
fieldnames=["tile_id", "origin_x", "origin_y", "pixel_size_x",
"pixel_size_y", "epsg", "width", "height"],
)
writer.writeheader()
for tile_path in sorted(TILE_DIR.glob("*.jpg")):
with rasterio.open(tile_path) as src:
t = src.transform
writer.writerow({
"tile_id": tile_path.name,
"origin_x": t.c,
"origin_y": t.f,
"pixel_size_x": t.a,
"pixel_size_y": t.e, # negative for north-up rasters
"epsg": src.crs.to_epsg() if src.crs else "UNKNOWN",
"width": src.width,
"height": src.height,
})
print(f"Manifest written: {MANIFEST}")
Store crs_manifest.csv alongside the tile directory and track it in your DVC-versioned dataset repository so spatial context is never decoupled from the annotation asset.
Step 3 — Automate Project and Task Creation with cvat-sdk
Manual upload breaks above a few hundred frames. The SDK handles chunked uploads and background task initialization:
pip install "cvat-sdk>=2.14.0"
# ingest_tasks.py — Python 3.10+, cvat-sdk>=2.14.0
from cvat_sdk import make_client
from cvat_sdk.models import ProjectWriteRequest, TaskWriteRequest, DataRequest
from pathlib import Path
HOST = "http://localhost:8080"
CREDENTIALS = ("admin", "your_password")
TILE_DIR = Path("./tiles")
BATCH_SIZE = 200 # frames per CVAT task — keeps UI responsive
def build_label_schema() -> list[dict]:
return [
{"name": "building", "color": "#e6194b", "type": "polygon"},
{"name": "road_surface", "color": "#3cb44b", "type": "polygon"},
{"name": "vegetation", "color": "#4363d8", "type": "polygon"},
{"name": "vehicle", "color": "#f58231", "type": "rectangle"},
{"name": "water_body", "color": "#911eb4", "type": "polygon"},
]
def ingest(project_name: str, tile_dir: Path, batch_size: int = BATCH_SIZE) -> None:
tiles = sorted(tile_dir.glob("*.jpg"))
if not tiles:
raise FileNotFoundError(f"No .jpg tiles found in {tile_dir}")
with make_client(host=HOST, credentials=CREDENTIALS) as client:
project = client.projects.create(
ProjectWriteRequest(name=project_name, labels=build_label_schema())
)
print(f"Project '{project_name}' created id={project.id}")
for batch_idx, start in enumerate(range(0, len(tiles), batch_size)):
batch = tiles[start : start + batch_size]
task = client.tasks.create_from_data(
spec=TaskWriteRequest(
name=f"batch_{batch_idx:04d}",
project_id=project.id,
),
resource_type="local",
resources=[str(p) for p in batch],
data_params=DataRequest(
chunk_size=1, # 1 frame per chunk: precise frame-seek
sorting_method="natural",
image_quality=95,
),
)
print(f" Task batch_{batch_idx:04d} id={task.id} frames={len(batch)}")
if __name__ == "__main__":
ingest("Drone_Survey_Q3_2026", TILE_DIR)
For datasets exceeding 10 GB, upload tiles to S3 first and pass presigned URLs as resources with resource_type="share".
Step 4 — Export Annotations and Reconstruct Geospatial Coordinates
CVAT COCO exports contain pixel coordinates relative to each tile. Reconstruction joins them with the manifest and applies the stored affine transform, converting pixel offsets back to the survey’s source CRS. Assign per-annotation confidence scores at this stage to make the output compatible with active-learning review queues.
# reconstruct_geo.py — requires geopandas>=0.14.3, pandas>=2.2.2
import json
import csv
from pathlib import Path
import pandas as pd
import geopandas as gpd
EXPORT_JSON = Path("./cvat_export_coco.json")
MANIFEST = Path("./crs_manifest.csv")
OUTPUT = Path("./annotations_geo.geojson")
def pixel_to_world(px: float, py: float, row: pd.Series) -> tuple[float, float]:
return (
row.origin_x + px * row.pixel_size_x,
row.origin_y + py * row.pixel_size_y, # pixel_size_y is negative
)
def reconstruct() -> None:
manifest = pd.read_csv(MANIFEST, index_col="tile_id")
with EXPORT_JSON.open() as fh:
coco = json.load(fh)
images_by_id = {img["id"]: img["file_name"] for img in coco["images"]}
features: list[dict] = []
for ann in coco["annotations"]:
tile_name = Path(images_by_id[ann["image_id"]]).name
if tile_name not in manifest.index:
raise KeyError(f"Tile '{tile_name}' missing from CRS manifest")
row = manifest.loc[tile_name]
if "segmentation" not in ann or not ann["segmentation"]:
continue
ring = ann["segmentation"][0]
coords = [
pixel_to_world(ring[i], ring[i + 1], row)
for i in range(0, len(ring), 2)
]
coords.append(coords[0]) # close the ring
features.append({
"type": "Feature",
"geometry": {"type": "Polygon", "coordinates": [coords]},
"properties": {
"category_id": ann["category_id"],
"tile_id": tile_name,
"annotation_id": ann["id"],
"epsg": int(row.epsg),
},
})
gdf = gpd.GeoDataFrame.from_features(
features, crs=f"EPSG:{int(manifest.epsg.iloc[0])}"
)
invalid = gdf[~gdf.is_valid]
if not invalid.empty:
print(f"Warning: {len(invalid)} invalid geometries — applying buffer(0) repair")
gdf.geometry = gdf.geometry.buffer(0)
gdf.to_file(OUTPUT, driver="GeoJSON")
print(f"Exported {len(gdf)} features to {OUTPUT}")
if __name__ == "__main__":
reconstruct()
Spot-check a sample of reconstructed polygons in QGIS overlaid on the original orthomosaic before committing annotations to your training dataset.
Verifying the Reconstruction Before Training
Visual QA in QGIS catches gross misalignment, but a programmatic roundtrip check catches the subtle errors — a flipped pixel_size_y sign, a tile indexed against the wrong manifest row, or an overlap offset applied twice. The assertion below picks a tile, walks a known pixel corner forward to world coordinates and back, and confirms the residual stays inside one ground-sampling-distance unit:
# verify_roundtrip.py — requires rasterio>=1.3.9, numpy>=1.26
import numpy as np
import pandas as pd
manifest = pd.read_csv("./crs_manifest.csv", index_col="tile_id")
row = manifest.iloc[0]
# forward: pixel (col, line) -> world (x, y)
col, line = 1024.0, 1024.0 # tile centre for a 2048px tile
world_x = row.origin_x + col * row.pixel_size_x
world_y = row.origin_y + line * row.pixel_size_y
# inverse: world -> pixel
back_col = (world_x - row.origin_x) / row.pixel_size_x
back_line = (world_y - row.origin_y) / row.pixel_size_y
residual = np.hypot(back_col - col, back_line - line)
assert residual < 1e-6, f"Affine roundtrip failed: {residual:.3e} px"
print(f"Roundtrip OK — residual {residual:.2e} px, GSD {abs(row.pixel_size_x):.4f} m/px")
Wire this into your annotation export step so a corrupted manifest fails the pipeline before geometries reach the DVC-versioned dataset repository rather than after a model has already trained on drifted labels.
Key Thresholds Reference
| Parameter | Recommended value | Spatial implication |
|---|---|---|
| Tile size | 2048 × 2048 px | Stays inside Chrome canvas memory budget |
| Tile overlap | 200 px (~10%) | Resolves boundary features without seam artefacts |
| JPEG quality | 95 | Preserves fine structural edges for building footprint annotation |
chunk_size |
1 frame | Enables precise single-frame seek in the CVAT UI |
CVAT_MAX_REQUEST_SIZE |
2147483648 (2 GB) |
Prevents 413 errors on large tile batches |
| Source CRS | EPSG:32632–EPSG:32636 (UTM) |
Metric units; required for IoU computation in metres |
| Export format | COCO JSON | Best cvat-sdk support; carries per-annotation segmentation masks |
Common Errors & Fixes
413 Request Entity Too Large on tile upload
: CVAT’s nginx proxy defaults to a 1 MB body limit. Set CVAT_MAX_REQUEST_SIZE=2147483648 under cvat_server.environment in docker-compose.override.yml, then docker compose up -d cvat_server.
Blank white frames in the annotation canvas
: The tile exceeds the browser canvas memory budget. Reduce tile size to 1536 × 1536 px and re-tile: gdal_retile.py -ps 1536 1536 .... Also disable browser extensions — ad-blockers frequently intercept canvas blob URLs and cause silent decode failures.
KeyError: 'EPSG:UNKNOWN' in reconstruct_geo.py
: gdal_retile.py writes JPEG tiles that strip the CRS because JPEG has no geotag standard. Read the source GeoTIFF CRS with rasterio.open before tiling and write it directly into the manifest; if to_epsg() returns None, record src.crs.to_wkt() instead.
Polygon ring not closed after COCO export
: COCO segmentation rings do not require the first vertex to be repeated. The reconstruct_geo.py script appends coords[0] explicitly — include the same closure in any custom parser or Shapely raises TopologicalError.
Database bloat slows task listing after several months
: Run VACUUM FULL on the Postgres container monthly: docker compose exec cvat_db psql -U postgres -c "VACUUM FULL ANALYZE;".
This workflow is one component of the broader Integrating Label Studio with Geospatial Workflows guide, which covers platform comparison, Label Studio ML backend setup, and export pipeline design.
Related
- Integrating Label Studio with Geospatial Workflows — parent page: platform comparison and export pipeline design
- Coordinate Reference Systems in Annotation Pipelines — CRS contracts, datum transforms, and projection-safe IoU computation
- Implementing DVC for Geospatial Training Data — version-control the tile manifest and annotation exports alongside imagery
- Converting Label Studio Exports to YOLOv8 Format — adapt the spatial reconstruction output for YOLO-based object detection training
- Confidence Scoring for Geospatial Labels — assign per-annotation scores at reconstruction time to feed active-learning queues