COCO vs GeoParquet for Annotation Export
Choose COCO when the immediate consumer is an off-the-shelf detector or segmenter training loop that expects pixel-space bounding boxes and masks with no notion of a coordinate reference system. Choose GeoParquet when the annotations must stay georeferenced — so they can participate in spatial joins, cross-sensor analytics, and large-scale columnar queries — and when the label store has to outlive any single training experiment. The two formats are not competitors in a well-designed pipeline: the durable answer for most geospatial teams is to keep GeoParquet as the source of truth and generate COCO as a derived training artifact, regenerated deterministically whenever the tiling scheme or image set changes.
Why the Export Format Decision Outlasts the Model
The format you export to encodes an assumption about what the annotations are for. COCO was designed for natural-image object detection, where a photograph has no georeference and a box is defined purely in image pixels. That assumption is baked into its schema: bbox is [x, y, width, height] in pixels, segmentation is a polygon or RLE mask in pixels, and there is no field anywhere for a CRS, a datum, or an affine geotransform. Export straight to COCO and you have silently thrown away the one property that made your labels geospatial — the ability to know where on Earth each object sits.
That loss is invisible until the second use case arrives. A model retrains on a new acquisition, someone needs to join detections against a parcel boundary layer, or an auditor asks which annotations fall inside a flood zone. If COCO is your only record, every one of those questions requires reconstructing the geotransform you discarded, and if the imagery was re-tiled in between, the pixel coordinates no longer map to anything. GeoParquet inverts the failure mode: it stores geometries in a real coordinate reference system, so the labels remain meaningful independent of any particular image grid, and the pixel-space COCO file becomes a cheap, reproducible projection of them rather than the primary record.
Dimension-by-Dimension Comparison
The table below contrasts the two formats across the properties that actually decide a geospatial export. Read it as COCO is optimised for direct training ingestion; GeoParquet is optimised for durability, scale, and spatial reasoning.
| Dimension | COCO JSON | GeoParquet |
|---|---|---|
| CRS fidelity | None — pixel coordinates only; CRS must be bolted on via sidecar | Native — CRS stored in file metadata (WKT/PROJJSON) |
| Geometry types | Axis-aligned bbox + polygon/RLE mask, pixel space | Point, LineString, Polygon, Multi* and mixed collections in world coordinates |
| File size | Verbose UTF-8 text; grows fast with dense masks | Binary columnar with compression; typically 3–10× smaller |
| Read speed | Whole document parsed into memory before any subset | Row-group skipping + column pruning; partial reads are cheap |
| Tooling | pycocotools, most detectors/segmenters out of the box | geopandas, pyarrow, DuckDB, QGIS, GDAL, Spark |
| Streaming | Poor — single JSON blob, no random access | Strong — row groups readable independently, predicate pushdown |
| Schema enforcement | Convention only; validated externally with JSON Schema | Arrow schema + typed columns enforced at write time |
Two rows deserve emphasis. Schema enforcement: GeoParquet carries a typed Arrow schema, so a column declared as int64 cannot silently hold a string, whereas COCO’s structure is a convention you must police yourself — which is exactly why teams wrap COCO exports in a validation gate. Streaming: because GeoParquet row groups are independently readable, a spatial query over ten million features can skip everything outside its bounding box, while a COCO file of the same content must be deserialised in full before the first annotation is accessible.
Exporting GeoParquet, Then Deriving COCO
The reference pipeline writes labels once to GeoParquet with an explicit CRS, then projects them into pixel space to build a COCO file for a specific image set. Install the dependencies:
pip install geopandas==0.14.4 pyarrow==16.1.0 shapely==2.0.6 pyproj==3.6.1
Step 1 — Write Annotations to GeoParquet With an Embedded CRS
Build a GeoDataFrame, set its CRS explicitly, and write GeoParquet. The CRS and the typed column schema are persisted in the file, so downstream readers never have to guess. Here the source labels are in WGS84 — the first EPSG code below links to the CRS reference:
from __future__ import annotations
import geopandas as gpd
from shapely.geometry import Polygon
def build_label_store(path: str) -> gpd.GeoDataFrame:
"""Write annotation polygons to GeoParquet with an explicit CRS."""
records: list[dict[str, object]] = [
{"label_id": 1, "category": "building", "image_id": "tile_0007",
"geometry": Polygon([(13.401, 52.520), (13.403, 52.520),
(13.403, 52.519), (13.401, 52.519)])},
{"label_id": 2, "category": "building", "image_id": "tile_0007",
"geometry": Polygon([(13.404, 52.521), (13.406, 52.521),
(13.406, 52.520), (13.404, 52.520)])},
]
gdf = gpd.GeoDataFrame(records, geometry="geometry", crs="EPSG:4326")
gdf.to_parquet(path, index=False) # CRS + schema travel with the file
return gdf
The CRS above is EPSG:4326 (WGS84). Because GeoParquet stores this in file metadata, a later reader that reprojects the geometries can trust the declared source CRS rather than inferring it from coordinate magnitudes.
Step 2 — Define the Image Geotransform
COCO coordinates are pixels, so each image tile needs an affine geotransform mapping world metres to pixel positions, plus the metric CRS the imagery is registered in. Model it explicitly so the mapping is auditable:
from dataclasses import dataclass
from pyproj import Transformer
@dataclass(frozen=True)
class ImageGrid:
"""Georegistration for one raster tile."""
image_id: str
width: int
height: int
epsg: str # metric CRS of the raster, e.g. "EPSG:32633"
# affine: world = (a*col + b*row + c, d*col + e*row + f)
a: float; b: float; c: float
d: float; e: float; f: float
def world_to_pixel(self, x: float, y: float) -> tuple[float, float]:
"""Invert the affine transform to map world coords to (col, row)."""
det = self.a * self.e - self.b * self.d
col = (self.e * (x - self.c) - self.b * (y - self.f)) / det
row = (-self.d * (x - self.c) + self.a * (y - self.f)) / det
return col, row
Step 3 — Project GeoParquet Geometries Into Pixel Space
Read the GeoParquet store, reproject each feature into the image CRS, and apply world_to_pixel to every vertex. Emit a COCO bbox and a flattened segmentation ring per annotation:
def geometry_to_coco(
gdf: gpd.GeoDataFrame,
grid: ImageGrid,
) -> list[dict[str, object]]:
"""Project features registered to `grid` into COCO pixel-space annotations."""
subset = gdf[gdf["image_id"] == grid.image_id].to_crs(grid.epsg)
to_metric = Transformer.from_crs(gdf.crs, grid.epsg, always_xy=True) # noqa
annotations: list[dict[str, object]] = []
for _, row in subset.iterrows():
pixels = [grid.world_to_pixel(x, y)
for x, y in row.geometry.exterior.coords]
cols = [c for c, _ in pixels]
rows = [r for _, r in pixels]
x0, y0 = min(cols), min(rows)
w, h = max(cols) - x0, max(rows) - y0
seg: list[float] = [coord for pt in pixels for coord in pt]
annotations.append({
"id": int(row["label_id"]),
"image_id": grid.image_id,
"category_id": 1,
"bbox": [round(x0, 2), round(y0, 2), round(w, 2), round(h, 2)],
"segmentation": [[round(v, 2) for v in seg]],
"area": round(w * h, 2),
"iscrowd": 0,
})
return annotations
Step 4 — Assemble and Write the Derived COCO JSON
Wrap the per-image annotations in the COCO envelope and serialise. This file is a build output: never hand-edit it, and regenerate it whenever tiling changes.
import json
def write_coco(
grids: list[ImageGrid],
gdf: gpd.GeoDataFrame,
out_path: str,
) -> None:
"""Assemble a COCO document from GeoParquet labels across image grids."""
images: list[dict[str, object]] = []
anns: list[dict[str, object]] = []
for g in grids:
images.append({"id": g.image_id, "width": g.width, "height": g.height})
anns.extend(geometry_to_coco(gdf, g))
doc = {
"images": images,
"annotations": anns,
"categories": [{"id": 1, "name": "building"}],
}
with open(out_path, "w", encoding="utf-8") as fh:
json.dump(doc, fh)
Because the geotransform lives in ImageGrid rather than in COCO, the pixel coordinates are fully reproducible from the georeferenced store. If you also want inference-time detections to be reprojectable, persist that geotransform alongside the COCO file — embedding geotransform metadata in COCO exports covers the sidecar and custom-field conventions for doing so.
Common Errors and Fixes
ValueError: Cannot write GeoDataFrame with no CRS to Parquet
Root cause: the GeoDataFrame was constructed without crs=, so GeoParquet has no CRS to embed.
Fix: set it explicitly with gdf.set_crs("EPSG:4326", inplace=True) before to_parquet, or pass crs= at construction.
COCO boxes land in the wrong place / are mirrored vertically
Root cause: raster geotransforms usually have a negative e term (row index increases as northing decreases), and treating it as positive flips the row axis.
Fix: read the true six affine coefficients from the raster (rasterio dataset.transform) and feed them unchanged into ImageGrid; do not assume a north-up sign.
Pixel coordinates are enormous or negative
Root cause: geometries were projected with the wrong target CRS, so world coordinates and the geotransform are in different units.
Fix: ensure grid.epsg matches the CRS the geotransform was computed in, and call .to_crs(grid.epsg) before world_to_pixel, as in Step 3.
pyarrow.lib.ArrowInvalid: Schema at index N was different
Root cause: appending frames whose columns have inconsistent dtypes (e.g. label_id as int64 in one batch, object in another).
Fix: normalise dtypes before writing — gdf = gdf.astype({"label_id": "int64"}) — so the enforced Arrow schema stays stable across partitions.
COCO file balloons to gigabytes for dense masks
Root cause: storing full-resolution polygon rings as text segmentation for every instance.
Fix: keep GeoParquet authoritative and export COCO per experiment at the needed resolution, or emit RLE masks instead of polygon rings for dense scenes.
Related
- Validating Annotation Export Formats — the parent guide covering schema contracts and geometry checks across COCO, YOLO, and GeoJSON exports
- Enforcing the COCO JSON Schema in CI — gate the derived COCO artifact against a strict schema so cross-references and bbox bounds are validated before training
- Embedding Geotransform Metadata in COCO Exports — attach the affine geotransform and EPSG code to COCO so pixel-space detections reproject to world coordinates
- Coordinate Reference Systems in Annotation Pipelines — the CRS handling that makes GeoParquet a trustworthy georeferenced source of truth
This guide sits within Validating Annotation Export Formats, part of Labeling Workflows & Toolchain Integration for Geospatial AI.