Building a Mosaic from a STAC Search
Search, load onto a shared grid, mask, then reduce along time:
import odc.stac
from pystac_client import Client
items = Client.open("https://earth-search.aws.element84.com/v1").search(
collections=["sentinel-2-l2a"], bbox=bbox, datetime="2026-06-01/2026-08-31",
query={"eo:cloud_cover": {"lt": 40}}).item_collection()
cube = odc.stac.load(items, bands=["red", "green", "blue", "scl"], crs="EPSG:32633",
resolution=10, bbox=bbox, groupby="solar_day", chunks={})
clear = ~cube.scl.isin([3, 8, 9, 10])
mosaic = cube[["red", "green", "blue"]].where(clear).median("time")
A STAC search returns a list of overlapping scenes in different tiles and sometimes different projections; a mosaic needs one grid and one value per pixel. This page belongs to seamless mosaicking and edge blending in Satellite Processing Workflows & Index Pipelines.
From Search Results to One Grid
The groupby="solar_day" argument is the detail that most often gets missed. Sentinel-2 delivers one item per 100 km tile, so a single overpass across a large AOI produces several items with the same date. Without grouping, each becomes its own time slice mostly filled with nodata; with grouping, tiles from the same pass are merged into one slice first, which halves memory use and makes first-valid ordering meaningful.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
pystac-client |
>=0.7 |
Searching the catalogue |
odc-stac |
>=0.3.9 |
Loading items onto a shared grid |
xarray |
>=2023.1 |
Masking and reducing |
dask |
>=2023.1 |
Lazy loading of large stacks |
rioxarray |
>=0.15 |
Writing the COG |
pip install "pystac-client>=0.7" "odc-stac>=0.3.9" "xarray>=2023.1" "dask>=2023.1" "rioxarray>=0.15"
Complete Working Example
import odc.stac
import rioxarray # noqa: F401
import xarray as xr
from pystac_client import Client
SCL_BAD = [0, 1, 3, 8, 9, 10]
def search_items(bbox, dates, max_cloud=50):
items = Client.open("https://earth-search.aws.element84.com/v1").search(
collections=["sentinel-2-l2a"], bbox=bbox, datetime=dates,
query={"eo:cloud_cover": {"lt": max_cloud}}).item_collection()
# least cloudy first, so first-valid prefers clean scenes
return sorted(items, key=lambda it: it.properties["eo:cloud_cover"])
def build_mosaic(bbox, dates, crs, res=10, rule="median", out="mosaic.tif"):
items = search_items(bbox, dates)
cube = odc.stac.load(items, bands=["red", "green", "blue", "nir", "scl"], crs=crs,
resolution=res, bbox=bbox, groupby="solar_day",
chunks={"x": 2048, "y": 2048})
clear = ~cube.scl.isin(SCL_BAD)
bands = cube[["red", "green", "blue", "nir"]].where(clear)
if rule == "median":
mos = bands.median("time", skipna=True)
else: # first-valid in item order
mos = bands.bfill("time").isel(time=0)
count = clear.sum("time").astype("uint8").rename("clear_count")
result = xr.merge([mos, count])
result.to_array("band").rio.to_raster(out, driver="COG", compress="DEFLATE", tiled=True)
return result
The clear-observation count is written alongside the bands because a mosaic that looks clean can rest on one observation in some places and thirty in others; downstream analysis should know which is which. Loading with odc-stac is covered in more depth in loading STAC items into an xarray cube with stackstac, which compares the two loaders.
Ordering Items for First-Valid Mosaics
First-valid produces a mosaic built from real, individual observations, which keeps spectral relationships intact and is the right choice for visual base maps and for anything that needs a single acquisition date per pixel. Median smooths residual cloud and noise but mixes dates. Choosing a pixel-selection rule for composites compares the options in detail.
Keeping Memory in Check
A summer of Sentinel-2 over a 100 km square is several hundred gigabytes at full resolution, so the load must stay lazy until the reduction. Chunking along x and y but not time keeps each chunk’s full time series together, which is what median and first-valid need. For very large areas, running the mosaic tile by tile over a fixed grid and writing each tile separately, then assembling with a VRT, is more robust than one enormous Dask graph.
Choosing the Date Window
The date window is the main lever on mosaic quality, and it pulls in two directions. A wider window gives every pixel more chances at a clear observation, which drives nodata towards zero and lets the median reject more residual cloud. It also mixes phenological states: a window from April to September blends bare spring fields with full summer canopy, and the median of those is a pixel that never existed. For a visual base map this is usually acceptable. For anything that feeds a model or an index, keep the window inside one phenological stage — typically one to three months in temperate regions — and accept a slightly higher nodata fraction, filling the remainder from an adjacent window only where necessary.
A practical pattern is a primary window plus a fallback: build the mosaic from the preferred window, then fill any pixel whose clear count is zero from a wider window, and record in a separate band which window supplied it. That keeps most of the mosaic seasonally coherent while still covering the persistently cloudy corners.
Handling Mixed Projections
Large AOIs straddle UTM zones, and items from different zones arrive in different CRSs. odc-stac reprojects everything onto the requested grid during load, so the mosaic is always consistent, but the choice of target CRS matters. For an AOI mostly inside one zone, use that zone; for a continental mosaic, an equal-area projection avoids distorting pixel areas, which matters for any later area statistics.
Verification
import numpy as np
m = build_mosaic(bbox, "2026-06-01/2026-08-31", "EPSG:32633")
nodata = float(m.red.isnull().mean())
counts = m.clear_count.values
print(f"nodata {nodata:.2%}, median clear obs {np.median(counts):.0f}, "
f"pixels with <=2 obs {np.mean(counts <= 2):.1%}")
assert nodata < 0.01
Common Errors
The mosaic is mostly empty
Items were not grouped by solar day, so each slice is one tile. Add groupby="solar_day".
Memory explodes during load
Chunks are missing or chunk along time. Pass chunks={"x": 2048, "y": 2048}.
Visible seams between tiles
Neighbouring areas come from different dates. Use a median, or feather and match histograms as in histogram matching across scenes.
Values are scaled integers with an offset
Newer processing baselines add an offset to L2A values. Apply the scale and offset before reducing.
Frequently Asked Questions
Q: Why group items by solar day? Because tiled products deliver one item per tile, so a single overpass appears as several items with the same date. Grouping merges them into one time slice, which saves memory and makes first-valid ordering work as intended.
Q: Should I use median or first-valid? Median for analysis where residual cloud and noise matter more than acquisition date; first-valid for visual base maps and anything needing a single real observation per pixel.
Q: How many scenes do I need for a clean mosaic? It depends on cloudiness. In temperate summers five to ten clear-ish scenes are usually enough; in cloudy tropics you may need a whole season. The clear-count layer tells you whether you had enough.
Q: Can I build the mosaic without downloading scenes? Yes. odc-stac reads only the windows needed from cloud-optimised GeoTIFFs over HTTP, so nothing is downloaded in full.
Related
- Seamless Mosaicking and Edge Blending — the parent topic.
- Choosing a Pixel-Selection Rule for Composites — median, max-NDVI, first-valid and more.
- Merging Tiles with rasterio.merge — the simpler path for already-downloaded files.
- Searching Multiple STAC APIs and Merging Results — widening the search before mosaicking.