Indexing Archived Imagery Footprints for Fast Discovery
Discovery is the part of an archive that has to be fast even when the data behind it is not. A user searching a decade of imagery expects results in under a second, and delivering that against several hundred thousand scenes means an index built for the query shape — bounded in space, bounded in time, filtered on a handful of properties. This walkthrough is for the engineer building that index, and it covers the choice most archives get wrong: indexing bounding boxes when the footprints are not rectangles.
Bounding Boxes Lie About Imagery
A satellite scene is a rotated quadrilateral; an aerial run is a long thin strip; a mosaic tile with nodata edges covers less than its extent suggests. Indexing the bounding box alone returns scenes whose actual coverage misses the query entirely.
Step-by-Step Procedure
Step 1 — Store the real footprint, not just the extent
The STAC item’s geometry member is the footprint and its bbox is the extent. Populate both: the bbox drives coarse filtering, the geometry decides the answer.
from osgeo import gdal
from shapely.geometry import Polygon, mapping
import numpy as np
def footprint(path: str, simplify_m: float = 5.0) -> dict:
"""Real coverage polygon from the valid-data mask, not the raster extent."""
ds = gdal.Open(path)
mask_ds = gdal.Translate("/vsimem/mask.tif", ds, bandList=[1],
outputType=gdal.GDT_Byte, scaleParams=[[0, 1, 255, 255]])
poly_ds = gdal.Polygonize(mask_ds.GetRasterBand(1).GetMaskBand(), None,
*_ogr_layer("/vsimem/fp.shp"), 0)
geom = _largest_ring("/vsimem/fp.shp").simplify(simplify_m)
return mapping(geom)
Simplify the footprint before storing it. An unsimplified valid-data mask can hold tens of thousands of vertices per scene, which inflates the index without changing any answer at query resolution.
Step 2 — Build a two-stage index
Coarse filtering on a spatial key is fast and cheap; exact intersection is correct and slower. Running them in that order gives both.
-- Stage 1: an H3 cell list per scene, for index-only pruning
CREATE TABLE scene_cells AS
SELECT item_id, h3_cell
FROM scenes, UNNEST(h3_polygon_to_cells(footprint, 6)) AS t(h3_cell);
CREATE INDEX ON scene_cells (h3_cell);
CREATE INDEX ON scenes USING GIST (footprint); -- stage 2: exact geometry
-- The query uses both: cells prune, geometry decides
SELECT s.item_id, s.datetime, s.gsd, s.storage_tier
FROM scenes s
WHERE s.item_id IN (SELECT item_id FROM scene_cells
WHERE h3_cell = ANY (h3_polygon_to_cells(:query_geom, 6)))
AND ST_Intersects(s.footprint, :query_geom)
AND s.datetime BETWEEN :t0 AND :t1
ORDER BY s.datetime DESC
LIMIT 200;
Step 3 — Keep the index rebuildable from the objects
The index is derived data and should be reconstructible in an afternoon from the archive itself, because that is what makes it safe to lose — and what makes a recovery in another region possible.
python -m archive.index rebuild \
--source-bucket spatial-archive --prefix archive/imagery/ \
--h3-resolution 6 --simplify-m 5 --workers 64
# 412,037 scenes indexed in 38 min (footprints read from item documents, not from rasters)
Validation & Verification
python -m archive.index verify --sample 500
# sampled queries 500
# results matching brute force 500
# false positives 0
# false negatives 0
# p95 latency 62 ms
Expected output is exact agreement with a brute-force scan on the sample. False negatives are the serious failure — a scene that exists and is not returned — and they usually mean the cell coverage was computed at a coarser resolution than the query uses.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| Results include scenes with no overlap | Only the bbox was tested | Add the exact geometry test as stage two |
| Missing scenes near cell edges | Footprint cells computed with containment, not intersection | Use polygon-to-cells with intersection semantics |
| Index build very slow | Footprints recomputed from rasters each time | Read footprints from the item documents; compute once at ingest |
| Index far larger than expected | Unsimplified footprints with thousands of vertices | Simplify to the query resolution before storing |
| Queries fast but stale | Index not rebuilt after new ingest | Append on write; rebuild fully only after a schema change |
Operational Execution Checklist
Keeping the Index Honest as the Archive Grows
A footprint index degrades in two ways as an archive grows: it falls behind new ingest, and its simplification tolerance becomes wrong as resolutions change. Both are detectable from the index itself, so neither needs to be discovered by a user.
The tolerance row is the one that appears years in, when a collection of high-resolution frames arrives into an index simplified for satellite scenes. Measuring the area error per collection rather than for the index as a whole is what makes it visible.
Frequently Asked Questions
Should the index live in a database or in files?
Both, with different roles. A database gives interactive latency and is the right home for the serving index; a set of static footprint files in the archive is the durable record from which the database can be rebuilt. Archives that keep only the database eventually discover that its schema, its host and its backups are all outside the preservation boundary.
What H3 resolution suits a footprint index?
One where a typical scene covers a handful of cells rather than one or hundreds — resolution 6 for satellite scenes and mosaic tiles at the scales most archives hold, resolution 7 or 8 for high-resolution aerial frames. The test is the cell count per scene: single digits to low tens prunes well without inflating the index.
How are time-bounded queries handled?
As an ordinary index on the datetime column, applied after the spatial prune. Spatial selectivity is usually much higher than temporal selectivity in an archive — a query covers a small area and a wide time range — so pruning spatially first and filtering on time second is the ordering that does the least work.
Should the index store footprints in geographic or projected coordinates?
In one reference system for the whole index, almost always geographic, with per-scene projected details left in the item. Mixing systems inside the index means every query has to be transformed per candidate, which defeats the purpose. The conversion cost is paid once at index time rather than repeatedly at query time.
How are very large footprints handled?
Coarse-resolution cells and a size threshold. A continental mosaic covers thousands of cells at the index resolution, which bloats the cell table for one scene. Storing such scenes at a coarser cell resolution, with a flag, keeps the table small and costs a slightly less selective prune for the few scenes that are inherently unselective anyway.
Does the index need to support time-travel queries?
If the archive publishes generations, yes — and it is cheap. Adding a validity interval to each index row lets a query ask what the archive held as of a past date, which is exactly the question an audit asks. Without it, an index describes only the present and cannot support a reproducibility claim about an earlier analysis.
Should the index include collections that are not yet published?
Include them and mark them, rather than excluding them. An index that silently omits unpublished material makes it impossible to answer questions about the archive’s total holding, and the marking costs a boolean. Filtering at query time is the right place to enforce visibility, not at index time.
What is the cost of keeping the index continuously up to date?
An append per ingested scene, which is microseconds, plus the footprint computation that already happened during validation. The expensive version is the nightly rebuild, which many archives adopt by default and rarely need — appending on write is both cheaper and fresher, and the full rebuild becomes a recovery mechanism rather than a routine.
How should the index handle scenes with no valid data at all?
Index them with an empty footprint and a flag rather than omitting them. A scene that is entirely nodata is a fact about the archive worth being able to find, and silently excluding such scenes makes the index and the catalogue disagree on the holding’s size.
Related
- Metadata Cataloging & Discovery — the parent topic covering the catalogue this index serves.
- Keeping STAC Catalogs in Sync with Lifecycle Transitions — keeping the storage-tier field in the search results honest.
- Choosing H3 vs S2 vs Quadkey for Archive Partitioning — the grid comparison behind the cell index.
- Rebuilding a Spatial Archive After Regional Bucket Loss — why the index has to be rebuildable from the objects.
Up one level: Metadata Cataloging & Discovery.