Cropping a STAC Item to an AOI Without Full Download
To clip a STAC asset to a study area without transferring the whole scene, transform the area’s bounds into the asset’s CRS and read only that window:
import rasterio
from rasterio.warp import transform_bounds
from rasterio.windows import from_bounds
href = item.assets["B04"].href
with rasterio.Env(GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR"):
with rasterio.open(href) as src:
left, bottom, right, top = transform_bounds("EPSG:4326", src.crs, *aoi_bounds_wgs84,
densify_pts=21)
window = from_bounds(left, bottom, right, top, transform=src.transform)
arr = src.read(1, window=window, masked=True)
clip_transform = src.window_transform(window)
This is the network-native version of the clipping workflow in Automated Image Clipping and Cropping.
Why This Arises in Remote Sensing Workflows
Study areas are small and scenes are large. A catchment, a district, a farm block or a park is typically a few kilometres across; a Sentinel-2 tile is 110 km on a side and a Landsat scene 185 km. Downloading the scene to extract a hundredth of it wastes almost all of the transfer, and at time-series scale that waste is the entire cost of the pipeline.
Cloud-optimized assets remove the need. Because the file is tiled and its offsets are indexed in the header, a windowed read touches only the tiles that intersect the window — the mechanism described in Reading a COG over S3 Without Downloading. The work in this page is therefore not about reading efficiently but about getting from a STAC item and a polygon to a correct window: transforming the bounds, rounding them safely, handling partial overlap, and keeping the output georeferenced.
Environment & Setup
| Package | Version | Why |
|---|---|---|
pystac-client |
≥0.7 | Searching and resolving item assets |
rasterio |
≥1.3.0 | Windowed remote reads, transform_bounds |
shapely |
≥2.0 | AOI geometry handling |
numpy |
≥1.23 | Array output |
pip install "pystac-client>=0.7" "rasterio>=1.3.0" "shapely>=2.0"
Complete Working Example
This function takes a STAC item, a list of asset keys and an AOI in WGS84, and returns one clipped array per asset with a shared transform — without transferring anything outside the window.
import numpy as np
import rasterio
from rasterio.warp import transform_bounds
from rasterio.windows import Window, from_bounds
GDAL_OPTS = {
"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
"GDAL_INGESTED_BYTES_AT_OPEN": "65536",
"GDAL_HTTP_MULTIRANGE": "YES",
"GDAL_HTTP_MERGE_CONSECUTIVE_RANGES": "YES",
"VSI_CACHE": "TRUE",
}
def clip_item(
item,
asset_keys: list[str],
aoi_bounds_wgs84: tuple[float, float, float, float],
*,
sign=None,
) -> dict:
"""Clip several assets of one STAC item to an AOI, reading only the window."""
out: dict[str, object] = {}
with rasterio.Env(**GDAL_OPTS):
for key in asset_keys:
href = item.assets[key].href
if sign is not None:
href = sign(href) # resolve tokens at read time
with rasterio.open(href) as src:
# Densified transformation: a projected box is not the transform of two corners
left, bottom, right, top = transform_bounds(
"EPSG:4326", src.crs, *aoi_bounds_wgs84, densify_pts=21)
window = from_bounds(left, bottom, right, top, transform=src.transform)
window = window.round_offsets(op="floor").round_lengths(op="ceil")
window = window.intersection(Window(0, 0, src.width, src.height))
if window.width < 1 or window.height < 1:
out[key] = None # no overlap: report, do not read
continue
out[key] = {
"array": src.read(1, window=window, masked=True),
"transform": src.window_transform(window),
"crs": src.crs,
"window": window,
"nodata": src.nodata,
}
return out
def write_clip(clip: dict, path: str) -> None:
"""Write one clipped array with the window's own transform."""
arr = clip["array"]
profile = {
"driver": "COG", "height": arr.shape[0], "width": arr.shape[1], "count": 1,
"dtype": arr.dtype, "crs": clip["crs"], "transform": clip["transform"],
"nodata": clip["nodata"], "compress": "DEFLATE", "blocksize": 512,
}
with rasterio.open(path, "w", **profile) as dst:
dst.write(arr.filled(clip["nodata"] if clip["nodata"] is not None else 0), 1)
if __name__ == "__main__":
clips = clip_item(item, ["B04", "B08", "SCL"], (36.70, -1.45, 36.82, -1.33))
for key, clip in clips.items():
if clip is None:
print(f"{key}: no overlap")
continue
print(key, clip["array"].shape, "valid:", int(clip["array"].count()))
write_clip(clip, f"clip_{key}.tif")
Two lines do the safety work. round_offsets(floor) with round_lengths(ceil) guarantees the window covers the whole AOI rather than shaving a boundary row — the rounding argument from Clipping a Raster to a Bounding Box with Windowed Reads. And the intersection with the dataset window turns a partially-overlapping AOI into a smaller valid read instead of a negative offset error.
Variant Patterns
1. Search and clip in one pass
from shapely.geometry import shape, box
aoi = box(36.70, -1.45, 36.82, -1.33)
for item in search.items():
if not shape(item.geometry).intersects(aoi):
continue # bbox said maybe; the real footprint says no
clips = clip_item(item, ["B04", "B08"], aoi.bounds)
2. Clipping to a polygon rather than a box
Reading the window is the expensive part; masking to the exact polygon afterwards is free by comparison.
from rasterio.features import geometry_mask
clip = clips["B04"]
inside = geometry_mask([aoi_geom_in_scene_crs],
out_shape=clip["array"].shape,
transform=clip["transform"],
invert=True)
polygon_clip = np.ma.masked_array(clip["array"], mask=clip["array"].mask | ~inside)
Always window first and mask second, for the reason set out in How to Clip Rasters to Irregular Polygon Boundaries.
3. An AOI spanning several tiles
from rasterio.merge import merge
pieces = []
for item in intersecting_items:
clip = clip_item(item, ["B04"], aoi.bounds)["B04"]
if clip is not None:
pieces.append(clip)
# Merge the small clips, not the full tiles
mosaic, transform = merge([p["array"] for p in pieces], transform=[p["transform"] for p in pieces])
Clipping before merging keeps every transfer proportional to the AOI; merging first would assemble two full tiles to discard 99 percent of them, and the blending questions that follow are covered in Seamless Mosaicking and Edge Blending.
Verifying the Clip
Three assertions cover the failure modes that matter, and all are cheap:
import numpy as np
from rasterio.transform import array_bounds
clip = clips["B04"]
arr = clip["array"]
# 1. The clip covers the AOI: its bounds must contain the transformed AOI bounds
bounds = array_bounds(arr.shape[0], arr.shape[1], clip["transform"])
assert bounds[0] <= left and bounds[3] >= top, "window does not cover the AOI"
# 2. There is data: a fully masked clip means the AOI is outside the swath
assert arr.count() > 0, "clip is entirely nodata — check the item footprint"
# 3. The output georeferences correctly: pixel (0,0) maps to the window origin
assert clip["transform"].c == bounds[0] and clip["transform"].f == bounds[3]
The second is the one that catches partial-tile items: a scene whose bounding box overlaps the AOI but whose valid swath does not, which is common at the edge of an orbit.
Common Errors
WindowError: Bounds and transform are inconsistent
The AOI bounds were passed in degrees while the transform is in metres. Transform the bounds into the asset’s CRS first, with densify_pts set.
The clip is one row or column short
The window was rounded inward. Use round_offsets(op="floor") and round_lengths(op="ceil").
The output GeoTIFF is in the wrong place
The scene’s transform was written instead of the window’s. Use src.window_transform(window) for the clipped output.
Frequently Asked Questions
Q: Do I need to download the scene first? No. If the asset is a Cloud-Optimized GeoTIFF, GDAL fetches only the tiles intersecting the window. A 10 km study area inside a Sentinel-2 tile typically transfers a few megabytes instead of a few hundred.
Q: Should I use the item’s bbox to decide whether to read it? Yes, as a first filter — it costs nothing and rejects most non-intersecting items. But the bbox is a rectangle around a possibly irregular footprint, so a positive result still needs the actual read to confirm coverage.
Q: What if the AOI spans two tiles? Clip from each intersecting item and merge the results. Clipping first keeps every read small; merging first would mean assembling the full tiles before discarding most of them.
Related
- Automated Image Clipping and Cropping — the parent topic, including scale patterns for many polygons.
- Clipping a Raster to a Bounding Box with Windowed Reads — window derivation and rounding in detail.
- Reading a COG over S3 Without Downloading — the read mechanism this depends on.
- Querying STAC Catalogs Programmatically — producing the items being clipped.