Skip to content

Sentinel-2 STAC Quicklook Streaming

ESIIL, 2024-06-01

This snippet streams a low-resolution overview of Sentinel-2 imagery using the STAC API and GDAL's /vsicurl interface so that no local GeoTIFF download is required.

We used the following package versions when testing the code:

  • requests==2.31.0
  • rasterio==1.3.9
  • numpy==1.26.4
  • pandas==2.2.2
  • matplotlib==3.8.4
import json, os, io
from typing import List, Dict, Any, Optional, Tuple
import requests, rasterio
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt


def _ensure_ok(r: requests.Response):
    try:
        r.raise_for_status()
    except requests.HTTPError as e:
        msg = ""
        try:
            msg = "\nServer says: " + json.dumps(r.json(), indent=2)[:1000]
        except Exception:
            msg = "\nServer says: " + (r.text[:1000] if r.text else "<no body>")
        raise requests.HTTPError(f"{e}{msg}") from None


def search_stac_first_item_resilient(
    stac_api: str,
    collections: List[str],
    datetime_range: str,
    bbox: Optional[List[float]] = None,
    cloud_lt: Optional[float] = 20,
) -> Dict[str, Any]:
    """
    Robust STAC search: try 'query', then no filter, then CQL2-JSON.
    Returns the first Feature; raises if none.
    """
    url = stac_api.rstrip("/") + "/search"
    base = {"collections": collections, "datetime": datetime_range, "limit": 1}
    if bbox is not None:
        base["bbox"] = bbox

    if cloud_lt is not None:
        payload1 = {**base, "query": {"eo:cloud_cover": {"lt": cloud_lt}}}
        r = requests.post(url, json=payload1, timeout=90, headers={"Content-Type": "application/json"})
        if r.status_code < 400:
            _ensure_ok(r)
            feats = r.json().get("features", [])
            if feats:
                return feats[0]

    r = requests.post(url, json=base, timeout=90, headers={"Content-Type": "application/json"})
    if r.status_code < 400:
        _ensure_ok(r)
        feats = r.json().get("features", [])
        if feats:
            return feats[0]

    if cloud_lt is not None:
        payload3 = {
            **base,
            "filter-lang": "cql2-json",
            "filter": {
                "op": "<",
                "args": [
                    {"property": "eo:cloud_cover"},
                    cloud_lt
                ],
            },
        }
        r = requests.post(url, json=payload3, timeout=90, headers={"Content-Type": "application/json"})
        _ensure_ok(r)
        feats = r.json().get("features", [])
        if feats:
            return feats[0]

    _ensure_ok(r)
    raise ValueError("Search succeeded but returned no features for the given parameters.")


def choose_stac_asset(assets: Dict[str, Dict[str, Any]], asset_key: Optional[str] = None) -> Tuple[str, str]:
    if asset_key and asset_key in assets and assets[asset_key].get("href"):
        return asset_key, assets[asset_key]["href"]

    def score(k, a):
        href = (a.get("href") or "").lower()
        roles = [r.lower() for r in a.get("roles", [])]
        t = (a.get("type") or a.get("media_type") or "").lower()
        s = 0
        if "cog" in json.dumps(a).lower() or href.endswith((".tif", ".tiff")):
            s += 5
        if "data" in roles:
            s += 3
        if "image/tiff" in t or "geotiff" in t:
            s += 2
        if k.lower() in ("data", "analytic", "reflectance", "visual", "b04", "b08"):
            s += 1
        return s

    if not assets:
        raise ValueError("STAC item has no assets.")
    key, a = max(assets.items(), key=lambda kv: score(*kv))
    href = a.get("href")
    if not href:
        raise ValueError("Chosen asset has no href.")
    return key, href


def stream_stac_quicklook(
    stac_api: str,
    collection: str,
    datetime_range: str,
    bbox: Optional[List[float]] = None,
    cloud_lt: Optional[float] = 20,
    asset_key: Optional[str] = None,
    overview_level: int = 4,
) -> Tuple[np.ndarray, Dict[str, Any], plt.Figure]:
    item = search_stac_first_item_resilient(stac_api, [collection], datetime_range, bbox=bbox, cloud_lt=cloud_lt)

    item_self = next((l.get("href") for l in item.get("links", []) if l.get("rel") == "self"), None)
    if item_self is None:
        item_self = f"{stac_api.rstrip('/')}/collections/{item.get('collection')}/items/{item.get('id')}"
    r = requests.get(item_self, timeout=90)
    _ensure_ok(r)
    item_full = r.json()

    key, href = choose_stac_asset(item_full.get("assets", {}), asset_key=asset_key)
    vsi_href = f"/vsicurl/{href}" if href.startswith("http") else href

    gdal_env = {
        "AWS_NO_SIGN_REQUEST": os.environ.get("AWS_NO_SIGN_REQUEST", "YES"),
        "CPL_VSIL_CURL_ALLOWED_EXTENSIONS": os.environ.get("CPL_VSIL_CURL_ALLOWED_EXTENSIONS", "tif,tiff")
    }
    with rasterio.Env(**gdal_env):
        with rasterio.open(vsi_href) as ds:
            if ds.overviews(1):
                olist = ds.overviews(1)
                idx = max(0, min(overview_level - 1, len(olist) - 1))
                out_h = max(1, ds.height // olist[idx])
                out_w = max(1, ds.width // olist[idx])
            else:
                scale = 8 if max(ds.width, ds.height) > 2048 else 2
                out_h = max(1, ds.height // scale)
                out_w = max(1, ds.width // scale)
            arr = ds.read(1, out_shape=(out_h, out_w))
            meta = ds.meta.copy()
            meta.update({"chosen_asset_key": key, "href": href, "vsi_href": vsi_href, "item_id": item.get("id")})

    fig = plt.figure(figsize=(6, 5))
    plt.imshow(arr, interpolation="nearest")
    plt.axis("off")
    plt.title(f"{collection} • {key} • {item.get('id')}")
    plt.tight_layout()
    return arr, meta, fig

To fetch a quicklook over Rocky Mountain National Park:

bbox_rmnp = [-105.9, 40.1, -105.3, 40.6]  # west, south, east, north

arr, meta, fig = stream_stac_quicklook(
    stac_api="https://earth-search.aws.element84.com/v1",
    collection="sentinel-2-l2a",
    datetime_range="2024-08-01T00:00:00Z/2024-08-07T23:59:59Z",
    bbox=bbox_rmnp,
    cloud_lt=20,
    asset_key="visual"  # or None
)
fig

Last update: 2025-09-09