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.

GeoParquet source of truth deriving a COCO training artifact A GeoParquet store on the left, holding CRS-aware geometries, branches into two paths. The upper path keeps the data georeferenced for spatial joins and analytics. The lower path passes through a projection step that applies the inverse geotransform to produce a pixel-space COCO file for detector and segmenter training. GeoParquet source of truth CRS-aware · versioned keep georeferenced Spatial joins & analytics derive inverse geotransform COCO pixel-space · training

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:

bash
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:

python
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:

python
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:

python
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.

python
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.

This guide sits within Validating Annotation Export Formats, part of Labeling Workflows & Toolchain Integration for Geospatial AI.