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 id on 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 bbox alone loses the native CRS. Recording proj:epsg, proj:transform, and proj:shape lets 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_pts when transforming extents so the geographic bbox fully 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 self to 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.