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.

From a COG on storage to an Item in a CollectionThe generator reads the COG header, reprojects the footprint to EPSG:4326, builds the Item with band metadata and attaches it to a Collection.Read COG headertransform, CRS, bandsreprojectFootprint in 4326geometry and bbox agreebuildSTAC Itemproperties, eo bandsattachCollection memberlinks resolve both ways
The reprojection step is the one that is easy to skip and always wrong to skip.

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:

Where a COG-derived datetime should come fromThree tests to pick the correct datetime for a STAC Item generated from a COG.Is acquisition time in the header?TIFFTAG_DATETIME or a domain keyUse itthe correct answeryesnoIs there a sidecar or manifest?Use the manifest valuerecord the sourceyesnoIs this a mosaic or composite?Use a datetime rangestart and end propertiesyesnoFile modification timeflagged as a fallback in properties
The file's modification time is the answer of last resort and should always be marked as such.
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.
What a COG Item should carry beyond the required fieldsThree groups of optional STAC properties that make a generated Item useful: provenance, licensing and raster description.Licensingoften omittedlicence identifierprovidersnotice textProvenancederived_from linkprocessing levelsoftware versionRaster descriptioneo bandsnodata valueground resolution
Required fields make the Item valid. These three groups make it usable by somebody who did not generate it.

Relative and Absolute Hrefs

The single decision that determines whether a generated catalogue survives its first move is whether asset and link hrefs are written relative or absolute, and generators get it wrong in both directions.

Absolute hrefs break on relocation. A catalogue generated with absolute URLs pointing at a staging bucket keeps pointing at the staging bucket after promotion to production. Every Item is individually correct and the catalogue as a whole is wrong. The failure surfaces when the staging bucket is cleaned up, typically some weeks later, and by then the catalogue has been harvested.

Relative hrefs break on partial harvest. A consumer that fetches one Item document without its parent Catalog cannot resolve a relative asset path, because it has no base to resolve against. Item-level consumers are extremely common — a search index returns Items, not catalogues.

The convention that works is to use relative links for the structural relationships that are always traversed together — root, parent, child, item — and absolute hrefs for assets and for the Item’s own self link. Structural links then survive a move of the whole tree, while an Item lifted out of its catalogue still names its data unambiguously and can say where it came from.

This makes the self link load-bearing rather than decorative, so it should be written on every Item at generation time rather than added later by a serving layer. A static catalogue whose Items lack self links is one that cannot be partially consumed, which defeats most of the reason for generating it.