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