Visualizing orbital swath data¶
Visualizing L2 and L3 data products that are generated from sensors that follow orbital tracks around the earth requires some special care to ensure that the underlying data are combined in reasonable ways. The TiTiler-CMR API surfaces several query parameters from the CMR API that make it possible to filter granules down by attributes such as TRACK_NUMBER or other properties that define logical groups of granules.
This notebook contains visualization examples that use granule filters to render coherent views of L2 collections like NISAR Beta Geocoded Polarimetric Covariance (GCOV) and Harmonize Landsat Sentinel (HLS), and L3 collections like OPERA Surface Displacement from Sentinel-1.
import json
from datetime import datetime, timedelta, UTC
import httpx
import shapely
from folium import GeoJson, GeoJsonPopup, GeoJsonTooltip, LayerControl, Map, TileLayer
from shapely.geometry import box, mapping
# titiler_endpoint = "https://staging.openveda.cloud/api/titiler-cmr" # staging endpoint
titiler_endpoint = (
"https://v4jec6i5c0.execute-api.us-west-2.amazonaws.com" # dev endpoint
)
NISAR GCOV in India¶
Shortly before this notebook was written (March 2026) ASF published ~22k granules from the NISAR Beta GCOV collection. It is early days for NISAR but TiTiler-CMR can be used to visualize a mosaic of granules.
The /bbox/.../granules endpoint can be used to retrieve the granule metadata that matches a TiTiler-CMR query as a GeoJSON feature collection. This is useful because it allows you to look directly at the metadata from the granules that TiTiler-CMR will use for a set of CMR search terms.
india_bbox = (66.191, 6.930, 91.297, 34.657)
nisar_assets_req = httpx.get(
f"{titiler_endpoint}/bbox/{','.join(str(b) for b in india_bbox)}/granules",
params={
"collection_concept_id": "C3622214170-ASF",
"temporal": "2026-01-01T00:00:00Z/2026-02-01T00:00:00Z", # January 2026
"f": "geojson", # return granules as a GeoJSON FeatureCollection
"sortkey": "-start_date", # sort granules in descending order by time
"skipcovered": True, # do not return multiple granules with identical geometries
"items_limit": 400, # global granule query limit
},
timeout=None,
)
nisar_assets_req.raise_for_status()
nisar_assets_geojson = nisar_assets_req.json()
print("found", len(nisar_assets_geojson["features"]), "granules")
found 267 granules
for feature in nisar_assets_geojson["features"]:
# move additional_attributes into properties so we can use
# them more easily in maps
feature["properties"].update(
{
attribute["name"]: (
attribute["values"][0]
if len(attribute["values"]) == 1
else attribute["values"]
)
for attribute in feature["properties"]["additional_attributes"]
}
)
# drop additional_attributes after moving values out
feature["properties"].pop("additional_attributes")
feature["properties"]["datetime"] = datetime.fromisoformat(
feature["properties"]["temporal_extent"]["range_date_time"][
"beginning_date_time"
]
).isoformat()
m = Map(
location=((india_bbox[1] + india_bbox[3]) / 2, (india_bbox[0] + india_bbox[2]) / 2),
zoom_start=5,
)
GeoJson(
nisar_assets_geojson,
name="granule footprints",
tooltip=GeoJsonTooltip(fields=["granule_ur"], localize=True),
popup=GeoJsonPopup(fields=["datetime", "ASCENDING_DESCENDING", "TRACK_NUMBER"]),
style_function=lambda x: {
"color": "magenta"
if x["properties"]["ASCENDING_DESCENDING"] == "ASCENDING"
else "blue",
"weight": 1,
"fillOpacity": 0,
},
).add_to(m)
LayerControl().add_to(m)
m