Generating STAC Items from COG Rasters
To generate a STAC Item from a Cloud-Optimized GeoTIFF, open the COG with rasterio to read its native bounds and CRS, reproject those bounds to EPSG:4326 for the Item bbox and geometry, construct a pystac.Item with a stable id and an acquisition datetime, attach the proj extension so the native projection survives, then validate and write the record to JSON.
This is the single-asset case of the broader workflow described in STAC Catalog Metadata Automation, which in turn sits inside the Automated Metadata Generation & Schema Mapping pipeline family. The COG format is the natural input for STAC generation because its internal tiling and overviews make the exact same file both the analysis-ready asset and the source of every spatial property the Item needs — no sidecar, no separate metadata export.
Automated Python Implementation
The script below is self-contained: give it a path to a COG and an output directory, and it produces a validated STAC Item JSON file. It reads bounds, CRS, transform, and shape from the raster; reprojects the footprint to EPSG:4326; resolves an acquisition datetime from embedded tags with a caller override; applies the projection extension; and writes the record with an absolute self href so links resolve after publication.
#!/usr/bin/env python3
"""
cog_to_stac.py — Generate a validated STAC Item from a Cloud-Optimized GeoTIFF.
Usage:
python cog_to_stac.py <cog_path> <item_id> <out_dir> [--asset-href URL] [--datetime ISO8601]
"""
import argparse
import sys
from datetime import datetime, timezone
from pathlib import Path
import pystac
import rasterio
from rasterio.warp import transform_bounds
from pystac.extensions.projection import ProjectionExtension
from shapely.geometry import box, mapping
def resolve_datetime(explicit: str | None, tags: dict) -> datetime:
"""Choose an acquisition instant: caller override, then embedded tag, then error."""
candidates = [explicit, tags.get("TIFFTAG_DATETIME"), tags.get("ACQUISITION_DATE")]
for raw in candidates:
if not raw:
continue
for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y:%m:%d %H:%M:%S", "%Y-%m-%d"):
try:
return datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc)
except ValueError:
continue
raise ValueError(
"No acquisition datetime found; pass --datetime or embed TIFFTAG_DATETIME."
)
def build_cog_item(cog_path: str, item_id: str, asset_href: str,
explicit_dt: str | None = None) -> pystac.Item:
"""Read a COG and return a STAC Item with the projection extension applied."""
with rasterio.open(cog_path) as src:
if src.crs is None:
raise ValueError(f"{cog_path} has no CRS; cannot build a spatial Item.")
native_bounds = src.bounds
epsg = src.crs.to_epsg()
transform = list(src.transform)[:6]
shape = [src.height, src.width]
# Reproject native bounds to EPSG:4326 for the STAC bbox/geometry
wgs84_bounds = transform_bounds(
src.crs, "EPSG:4326", *native_bounds, densify_pts=21
)
acquired = resolve_datetime(explicit_dt, src.tags())
band_count = src.count
footprint = box(*wgs84_bounds)
item = pystac.Item(
id=item_id,
geometry=mapping(footprint),
bbox=list(wgs84_bounds),
datetime=acquired,
properties={},
)
# Primary COG asset
item.add_asset(
"data",
pystac.Asset(
href=asset_href,
media_type=pystac.MediaType.COG,
roles=["data"],
title="Cloud-Optimized GeoTIFF",
extra_fields={"band_count": band_count},
),
)
# Projection extension: keep the native CRS and grid
proj = ProjectionExtension.ext(item, add_if_missing=True)
proj.epsg = epsg
proj.transform = transform
proj.shape = shape
proj.bbox = list(native_bounds)
return item
def main() -> int:
ap = argparse.ArgumentParser(description="Generate a STAC Item from a COG.")
ap.add_argument("cog_path")
ap.add_argument("item_id")
ap.add_argument("out_dir")
ap.add_argument("--asset-href", default=None,
help="Public URL of the COG; defaults to the local path.")
ap.add_argument("--datetime", default=None, help="Override acquisition datetime.")
args = ap.parse_args()
asset_href = args.asset_href or str(Path(args.cog_path).resolve())
item = build_cog_item(args.cog_path, args.item_id, asset_href, args.datetime)
out_path = Path(args.out_dir) / f"{args.item_id}.json"
out_path.parent.mkdir(parents=True, exist_ok=True)
item.set_self_href(str(out_path.resolve()))
try:
item.validate()
except pystac.errors.STACValidationError as exc:
print(f"STAC validation failed: {exc}", file=sys.stderr)
return 1
item.save_object(dest_href=str(out_path))
print(f"Wrote validated Item to {out_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Two design choices are worth calling out. First, transform_bounds is used with densify_pts=21 rather than transforming only the four corners — for rasters far from their projection’s central meridian, a naive four-corner reprojection understates the true geographic extent, and densification adds intermediate points so the EPSG:4326 bbox fully contains the footprint. Second, resolve_datetime deliberately raises rather than silently defaulting to the current time: a metadata record whose datetime is the moment the script happened to run is worse than an explicit failure, because it looks valid while being wrong.
Validation and Pipeline Integration
The script validates in-process via item.validate(), which checks the core schema and the declared proj extension. For a batch or CI context, re-check the written files with the stac-validator CLI so the gate is independent of the generator:
pip install "stac-validator>=3.3"
# Validate one Item file
stac-validator ./out/scene-0001.json
# Validate every Item produced in a run and stop on the first failure
find ./out -name "*.json" -print0 \
| xargs -0 -I{} stac-validator {} --core
Wire the same check into a pull-request gate so no non-conformant Item merges:
# .github/workflows/validate-cog-items.yml
name: Validate STAC Items
on:
pull_request:
paths:
- "out/**"
- "scripts/cog_to_stac.py"
jobs:
validate-items:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install validator
run: pip install "stac-validator>=3.3"
- name: Validate all generated Items
run: |
find ./out -name "*.json" -print0 \
| xargs -0 -I{} stac-validator {} --core
A focused pytest keeps the reprojection and required-field logic honest:
# test_cog_to_stac.py
from cog_to_stac import build_cog_item
def test_bbox_in_wgs84_range(sample_cog_utm):
item = build_cog_item(str(sample_cog_utm), "s1",
"https://data.example.gov/s1.tif",
explicit_dt="2025-04-01T10:15:00Z")
west, south, east, north = item.bbox
assert -180 <= west < east <= 180
assert -90 <= south < north <= 90
def test_proj_extension_applied(sample_cog_utm):
item = build_cog_item(str(sample_cog_utm), "s1",
"https://data.example.gov/s1.tif",
explicit_dt="2025-04-01T10:15:00Z")
d = item.to_dict()
assert any("projection" in ext for ext in d["stac_extensions"])
assert d["properties"]["proj:epsg"] is not None
Long-term compliance best practices
- Derive Item ids deterministically. Base the
idon a stable asset key (scene identifier plus product level), never on a UUID or timestamp, so reprocessing overwrites the record instead of creating a duplicate in downstream search indexes. - Always populate the projection extension. An EPSG:4326
bboxalone loses the native CRS. Recordingproj:epsg,proj:transform, andproj:shapelets consumers reconstruct pixel geolocation without reopening the COG. - Fail loudly on a missing datetime. Treat an absent acquisition time as an error, not a default-to-now, so temporal search never returns records stamped with pipeline run time.
- Reproject with densified bounds. Use
densify_ptswhen transforming extents so the geographicbboxfully encloses skewed footprints far from the projection origin. - Set an absolute self href before serializing. Relative hrefs break the moment a static catalog is moved to object storage; anchor
selfto the published base URL. - Re-validate after any asset change. A new overview, a corrected nodata value, or a re-tiled COG can alter shape or transform; regenerate and re-validate the Item rather than editing the JSON by hand.
Related
- STAC Catalog Metadata Automation — the parent guide covering Collections, the root Catalog, and API publishing
- Automated Metadata Generation & Schema Mapping — the pipeline context this task belongs to
- Generating ISO 19115 Metadata from GeoTIFF Headers — the ISO-side counterpart for the same raster inputs
- Metadata Schema Validation & Linting — validation patterns that gate generated records in CI