Writing a STAC Item for an Orthomosaic

A STAC item that validates is not necessarily a STAC item that helps. The specification requires very little — a geometry, a bounding box, a datetime, an id — and an item carrying only those is a catalogue entry that answers “does something exist here” and nothing else.

This page covers the fields that make an item genuinely useful for a survey orthomosaic, where each one comes from, and how to check the result before it is published. It belongs to publishing rasters to STAC and object storage.

Derive, never type

Every field on the list below except the datetime and the survey identifier can be read out of the raster. Typing them instead is how a catalogue ends up describing data that does not exist: a transposed EPSG code, a ground sample distance from the plan rather than the product, a bounding box from the previous flight.

Where each STAC item field comes from Item fields are grouped by source. From the raster itself come the geometry and bounding box after transformation to geographic coordinates, the projection EPSG code, the affine transform and shape, the ground sample distance, and the per-band data type, nodata value and statistics. From the processing run come the capture datetime, the survey identifier, the platform and sensor, and the processing software versions. From the upload comes the asset href. A note states that only the second group requires external knowledge, and that typing anything from the first group is how a catalogue comes to describe data that does not exist. read from the raster geometry and bbox, transformed to WGS84 proj:epsg, proj:transform, proj:shape gsd, from the transform's pixel size per-band data type and nodata value per-band statistics for a sensible stretch no external knowledge required from the processing run datetime of capture survey identifier platform and sensor processing software versions asset href, from the upload the run already knows all of these Typing anything from the left group is how a catalogue comes to describe data that does not exist.

Figure 1 — Two sources, and neither of them is a person retyping.

Minimal reproducible solution

from datetime import datetime, timezone
import numpy as np
import pystac
import rasterio
from rasterio.warp import transform_bounds
from shapely.geometry import box, mapping

PROJ_EXT = "https://stac-extensions.github.io/projection/v1.1.0/schema.json"
RASTER_EXT = "https://stac-extensions.github.io/raster/v1.1.0/schema.json"


def orthomosaic_item(cog_path: str, *, item_id: str, href: str,
                     captured: datetime, survey_id: str,
                     platform: str | None = None) -> pystac.Item:
    """A STAC item for one orthomosaic, with every geometric field derived.

    The projection extension holds the raster's own CRS and transform while the
    item geometry stays in WGS84. Both are needed: the first tells a client
    whether the raster will line up with its data, the second is what makes the
    item findable at all.
    """
    with rasterio.open(cog_path) as src:
        west, south, east, north = transform_bounds(
            src.crs, "EPSG:4326", *src.bounds, densify_pts=21)
        epsg = src.crs.to_epsg()
        transform = list(src.transform)[:6] + [0.0, 0.0, 1.0]
        shape = [src.height, src.width]
        gsd = abs(src.transform.a)
        bands = band_metadata(src)

    item = pystac.Item(
        id=item_id,
        geometry=mapping(box(west, south, east, north)),
        bbox=[west, south, east, north],
        datetime=captured.astimezone(timezone.utc),
        properties={
            "gsd": float(gsd),
            "survey:id": survey_id,
            "proj:epsg": epsg,
            "proj:transform": transform,
            "proj:shape": shape,
        },
        stac_extensions=[PROJ_EXT, RASTER_EXT],
    )
    if platform:
        item.properties["platform"] = platform

    item.add_asset("data", pystac.Asset(
        href=href,
        media_type=pystac.MediaType.COG,
        roles=["data", "visual"],
        title="Orthomosaic",
        extra_fields={"raster:bands": bands},
    ))
    return item

Band metadata that earns its place

The raster extension’s raster:bands array is the field most often omitted and most often missed. It lets a viewer choose a stretch, decide whether a band is elevation or reflectance, and mask nodata — all before requesting a single tile.

def band_metadata(src, *, sample_size: int = 2048) -> list[dict]:
    """Per-band descriptors, with statistics from a decimated read.

    Reading a decimated overview rather than the full resolution keeps this
    cheap on a multi-gigabyte mosaic. The statistics are for choosing a display
    stretch, not for analysis, so an approximation from a reduced read is
    exactly the right accuracy for the cost.
    """
    out = []
    for index in range(1, src.count + 1):
        scale = max(1, max(src.height, src.width) // sample_size)
        data = src.read(index, masked=True,
                        out_shape=(src.height // scale, src.width // scale))
        valid = data.compressed()
        entry = {
            "data_type": src.dtypes[index - 1],
            "nodata": src.nodatavals[index - 1],
        }
        if valid.size:
            entry["statistics"] = {
                "minimum": float(valid.min()),
                "maximum": float(valid.max()),
                "mean": float(valid.mean()),
                "stddev": float(valid.std()),
                # Percentiles are what a viewer should stretch to; min and max
                # are dominated by a handful of outlying pixels on any real
                # survey product.
                "valid_percent": float(100 * valid.size / data.size),
            }
            entry["p2"] = float(np.percentile(valid, 2))
            entry["p98"] = float(np.percentile(valid, 98))
        out.append(entry)
    return out

Choosing an item id

The id has to be unique within the collection and stable across everything except reprocessing. A convention that works: <site>-<capture-date> for the first processing of a survey, with -r2, -r3 appended for reprocessed versions.

What to avoid is more instructive. Do not put a random identifier in it — the id is what appears in every link and every error message, and an unreadable one makes a catalogue hostile to debug. Do not put the processing date in it, because reprocessing then produces an id that sorts away from the survey it belongs to. And do not include a status word like final, because the next version will also be final.

Item identifier conventions that work and that do not Two columns compare identifier conventions. The working column shows site and capture date, with a revision suffix for reprocessed versions, and notes that it sorts chronologically, reads clearly in links and error messages, and keeps reprocessed versions adjacent to their original. The failing column shows three patterns: a random identifier, which makes a catalogue hostile to debug; a processing date, which sorts reprocessed versions away from the survey they belong to; and a status word such as final, which the next version will also be. works hawkridge-2026-09-17 hawkridge-2026-09-17-r2 sorts chronologically reads clearly in links and errors revisions stay beside the original fails a random identifier — hostile to debug the processing date — revisions sort away from the survey a status word such as "final" — the next version is also final The id appears in every link and every error message. Make it readable.

Figure 2 — Identifier conventions, and what each costs later.

Footprints that follow the flight

The default footprint — the raster’s bounding rectangle — is fine for a compact site and badly wrong for anything else. A linear survey along six kilometres of railway produces a bounding box covering tens of square kilometres of countryside the aircraft never overflew, and every spatial search over that region returns the item as a match.

The fix is to derive the footprint from the valid data mask rather than from the raster’s extent. The mask is already there — it is what distinguishes real pixels from the nodata border — and reducing it to a polygon is a standard operation.

import rasterio.features
from shapely.geometry import shape
from shapely.ops import unary_union


def valid_data_footprint(cog_path: str, *, simplify_m: float = 5.0,
                         overview_level: int = 2) -> dict:
    """Polygonise the valid data region and return it in WGS84.

    Working from an overview keeps this fast on a large mosaic, and the
    resulting polygon is simplified anyway — a footprint is for spatial search,
    not for measurement, so a five-metre tolerance loses nothing that matters
    and avoids a geometry with fifty thousand vertices in every catalogue read.
    """
    with rasterio.open(cog_path) as src:
        mask = src.read_masks(1, out_shape=(
            src.height >> overview_level, src.width >> overview_level))
        transform = src.transform * src.transform.scale(
            src.width / mask.shape[1], src.height / mask.shape[0])
        shapes = rasterio.features.shapes(
            (mask > 0).astype("uint8"), mask=mask > 0, transform=transform)
        polygons = [shape(geom) for geom, value in shapes if value == 1]
        merged = unary_union(polygons).simplify(simplify_m)
        source_crs = src.crs

    from rasterio.warp import transform_geom
    return transform_geom(source_crs, "EPSG:4326", mapping(merged),
                          precision=7)

Keep the bounding box as the rectangle around that polygon, because STAC requires a bbox and a client uses it as a cheap first filter. The polygon is the second filter, and on corridor and coastal surveys it is what stops an item from matching half the county.

An item that validates compared with one that is usable Two columns. The validates column lists a geometry, a bounding box, a datetime and an identifier, which is everything the specification requires and answers only whether something exists in this area. The usable column lists those plus the projection EPSG so a client knows whether the raster aligns with its data, the ground sample distance so it knows whether the detail suffices, per-band statistics so a viewer can choose a stretch, a published asset href rather than a local path, and a footprint following the valid data rather than the raster's rectangle. validates a geometry a bounding box a datetime an identifier is usable proj:epsg — does it align with my data gsd — is it detailed enough raster:bands — what stretch should I use a published href, and a real footprint The left column answers only whether something exists here. Nobody searches for that alone.

Figure 3 — Validity is the floor, not the goal.

Edge-case matrix

Situation Effect Handling
Geometry in the native CRS Item unsearchable Transform to WGS84
Bbox from four corners Search misses the item Densify the transform
No proj:epsg Client cannot tell if it aligns Read it from the raster
No raster:bands Viewer guesses the stretch Derive from a decimated read
Naive datetime Ambiguous by hours Always attach UTC
Statistics from full read Slow on large mosaics Read an overview
Extension used, not declared Fails validation List it in stac_extensions
Id contains a status word Next version contradicts it Site plus date plus revision

Verification snippet

def check_item(item: pystac.Item) -> dict:
    """Validate against the schema, then against usefulness.

    Schema validity is necessary and nowhere near sufficient: an item with a
    geometry, a bbox and a datetime passes validation and answers almost no
    question a consumer will actually ask.
    """
    problems = []
    try:
        item.validate()
    except Exception as exc:                      # noqa: BLE001
        problems.append(f"schema: {exc}")

    for field in ("gsd", "proj:epsg", "proj:shape"):
        if field not in item.properties:
            problems.append(f"missing {field}")

    if item.datetime is None or item.datetime.tzinfo is None:
        problems.append("datetime must be present and timezone-aware")

    data = item.assets.get("data")
    if data is None:
        problems.append("no asset named data")
    else:
        if "raster:bands" not in data.extra_fields:
            problems.append("asset has no raster:bands")
        if not data.href.startswith(("http://", "https://", "s3://")):
            problems.append(f"asset href is not a published URL: {data.href}")

    west, south, east, north = item.bbox
    if not (-180 <= west < east <= 180 and -90 <= south < north <= 90):
        problems.append("bbox is not a sane WGS84 extent")

    return {"ok": not problems, "problems": problems}

The href check catches the single most common publication mistake: an item written with a local filesystem path still in it, which validates perfectly and is useless to everybody except the machine that produced it.

When to escalate

  • The raster has no CRS. Do not invent one. An orthomosaic without a coordinate reference system is not publishable, and guessing is worse than blocking.
  • Validation passes but consumers cannot use it. Check the href and the datetime timezone first; those two account for most of these reports.
  • The footprint is a rectangle covering large nodata areas. For an irregular survey boundary, derive the geometry from the valid data mask rather than the raster bounds — a bounding rectangle over a corridor survey claims enormous areas the flight never covered.

Publishing Rasters to STAC and Object Storage