import sys
sys.path.insert(0, ".") # Add current directory to sys.path for local imports
import shutil
from pathlib import Path
import warnings , os, s3fs
import branca.colormap as bcm
import folium
import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
import numpy as np
import xarray as xr
import zarr
from branca.element import Element
from IPython.display import display
from pyproj import Transformer
from pystac.client import Client
from biophysicalop.angles import (
add_detector_dimension,
select_angle_by_detector_mask,
compute_view_angle_mean,
downsample_detector_masks,
)
from biophysicalop.io import save_zarr, epsg_from_tile_id
from biophysicalop.models import BiophysicalVariables, SatelliteSensors
from biophysicalop.processing import BiophysicalOpProcess, process
from biophysicalop.utils import bilinear_interpolate_xy, parse_sentinel2_product_name
from save_zarr_utils import (
prep_zarr
)Computing Leaf Area Index Using Sentinel-2 Data
By: @dmeaux
Introduction
This notebook computes Leaf Area Index (LAI) πΏ from Sentinel-2 Level-2A satellite data. Input data is read directly from cloud-native Zarr archives published by the Earth Observation Processing Framework (EOPF), so no SAFE archive downloads are required, saving what can take several minutes per Sentinel-2 L2A product. It processes each Sentinel-2 tile in full β consistent with the behaviour of the SNAP BiophysicalOp processor. Each STAC item returned by the query is processed independently and saved as a separate Zarr store under OUTPUT_DIR.
What we will learn
- ποΈ The difference between using EOPF Zarr datasets and SAFE archives for processing Sentinel-2 data.
- π How to integrate EOPF STAC queries and results into a processing pipeline workflow using a neural network inferrence framework.
- π°οΈ How to extract sensor metadata, such as sun and view zenith and azimuth angles, for computations.
- π How to combine extracted metadata with observation data for neural network inference.
- βοΈ How to run stacked tiles through neural netowrk inference.
- π How to save results to the Zarr format.
- πΊοΈ How to visualize results using an interactive map.
- βοΈβοΈ How to batch process a full growing season.
Prerequisites
β οΈ This is a more advanced walkthrough, so you should have experience processing data cubes using Python with Xarray and NumPy.
π Background
Traditional LAI processing with SNAPβs BiophysicalOp begins with downloading an entire Sentinel-2 L2A SAFE archive β typically 600 MB to 1.2 GB per granule β before a single pixel can be processed. On a 100 Mbps connection that equates to roughly 1β2 minutes of idle waiting per tile; on the slower connections common in research institutions or field deployments it can exceed 10 minutes. This notebook eliminates that bottleneck entirely. By opening EOPF products as lazy xarray DataTree objects backed by cloud-hosted Zarr stores, only the compressed chunks are fetched from object storage.
Scaling to a time series or multi-region study exposes an even larger gap. SNAP requires loading each product in the GUI, configuring the BiophysicalOp operator, and exporting results one at a time β a workflow that does not parallelise or automate easily. With the EOPF STAC API, expanding from a single acquisition to a full growing-season time series is a matter of widening the date window and wrapping the processing step in a loop. A European site typically receives 20β30 cloud-free Sentinel-2 passes between April and October; processing all of them in SNAP demands downloading 12β36 GB and chaining 20β30 manual processing runs β a multi-hour exercise even on fast hardware. The same campaign with this notebook streams only the required chunks per scene and processes each tile in approximately 60-90 seconds (with GPU acceleration for running the LAI neural network), compressing the entire time series into an unattended run measured in minutes rather than hours.
Beyond speed, the workflow is designed for reproducibility and frictionless deployment. SNAP carries a roughly 1 GB installation footprint, requires a Java runtime, and exposes its automation interface through the gpt command-line operator β a non-trivial barrier for containerised cloud workers, CI pipelines, or shared compute environments. This notebookβs entire software stack is declared in a single pixi.toml lockfile and installed with one command on any platform. Results are written to CF-compliant Zarr stores β a format that can be streamed directly by Python or a web mapping service without further conversion. Together, these properties make the workflow naturally suited to reproducible research and cloud-native execution at scale.
πΏ What is LAI?
Leaf Area Index (LAI) is a dimensionless biophysical quantity defined as half the total developed area of photosynthetically active elements of the vegetation per unit horizontal ground surface area (mΒ² mβ»Β²). It is one of the Essential Climate Variables (ECVs) recognised by the Global Climate Observing System (GCOS) and underpins a wide range of Earth observation applications:
- Agriculture β crop monitoring, yield forecasting, and irrigation scheduling
- Ecology β forest carbon estimation, phenology studies, and habitat mapping
- Climate science β land surface model parameterisation and validation
- Environmental monitoring β deforestation detection and drought assessment
Typical LAI values range from 0 (bare soil) to roughly 8β10 mΒ² mβ»Β² for dense closed-canopy forests or intensively managed crops at peak growth.
π°οΈ Origins: The SNAP Sentinel-2 Optical Toolbox
The algorithm implemented here is a Python re-implementation of the BiophysicalOp processor from ESAβs open-source SNAP (Sentinel Application Platform), specifically the Optical Toolbox. The underlying science is described in the S2ToolBox Algorithm Theoretical Basis Document (ATBD):
Weiss, M., Baret, F., & Jay, S. (2020). S2ToolBox Level 2 products: LAI, FAPAR, FCOVER β Version 2.1.
The method employs a shallow two-layer feed-forward neural network trained over a large look-up table generated by the PROSAIL radiative-transfer model (PROSPECT for leaf optics + SAIL for canopy architecture). ESA distributes sensor-specific network weight files for Sentinel-2A, -2B, and -2C at both 10 m and 20 m spatial resolutions; this notebook automatically selects the correct weights from the detected satellite ID.
Differences from the Reference Java Implementation
| Aspect | SNAP (Java) | This Notebook |
|---|---|---|
| Input data | Local SAFE files | EOPF Zarr via object storage |
| NN runtime | Java native arrays | Modular MAX engine (compiled graph) |
| Output format | BEAM-DIMAP | CF-compliant Zarr + Cloud-Optimised GeoTIFF |
βοΈ Processing Pipeline
The workflow follows the original SNAP BiophysicalOp processing chain:
- STAC Discovery β query the EOPF catalog for scenes intersecting the AOI and date window
- Angle Interpolation β bilinearly interpolate the sparse 23 Γ 23 angle grids to full image resolution, per detector module
- Input Assembly β stack 8 reflectance bands with 3 trigonometric angle terms (11 features total)
- Input Validation β flag pixels outside the NN training domain (per-band min/max check + convex-hull check)
- NN Inference β two-layer tanh network executed by the Modular MAX engine
- Output Validation β flag and clip outputs to physically plausible bounds
- Export β write LAI and quality flags to Zarr and/or GeoTIFF
The following modules cover each stage of the processing pipeline:
| Module | Role |
|---|---|
pystac.client |
Query the EOPF STAC catalog to discover available Sentinel-2 L2A products by spatial extent, time range, and cloud cover |
xarray |
Lazily open and navigate the hierarchical EOPF Zarr stores as DataTree objects β only the requested array chunks are fetched from object storage |
numpy |
Efficient array operations for angle computation, input stacking, masking, and result post-processing |
biophysical.angles |
Detector-footprint-aware interpolation and aggregation of per-band view zenith/azimuth angle grids |
biophysical.io |
Save LAI values and quality flags to CF-compliant Zarr or Cloud-Optimised GeoTIFF |
biophysical.models |
Enum types defining supported sensors (S2A, S2B, S2C at 10 m / 20 m) and biophysical variables (LAI) |
biophysical.processing |
Full BiophysicalOp pipeline: input validation β MAX-accelerated NN inference β output validation |
biophysical.utils |
Bilinear interpolation helpers and Sentinel-2 product name parser |
Note: This notebook takes advantage of the biophysical package included with it due to the complexity of the above algorithm and its accompanying pre- and post-processing requirements.
π οΈ Setup
Environment Setup with uv
This notebook uses Modularβs MAX Inference Engine for neural network inference because of its ability to run the same code on either the CPU or any of several different GPUs from NVIDIA, AMD, and soon Apple, if one is available. The biophysicalop package provides the LAI retrieval implementation.
There are two ways to add it to your project:
Option A β add as a uv source dependency (recommended and already completed for this project)
Modify your projectβs pyproject.toml to include biophysicalop as a git source and configure access to Modularβs nightly wheel index:
[project]
dependencies = [
"biophysicalop",
# ...
]
[tool.uv.sources]
biophysicalop = { git = "https://github.com/Geomatys/biophysicalop.git" }Then sync your environment:
uv syncOption B β pip install in a notebook cell
For environments where modular version 26.2 is already available, install the package directly from within a notebook cell:
%pip install git+https://github.com/Geomatys/biophysicalop.gitβοΈ Processing Pipeline
We prepare our credentials for S3 access:
warnings.filterwarnings("ignore")
try:
bucket = os.environ["BUCKET_NAME"]
access = os.environ["ACCESS_KEY"]
secret = os.environ["SECRET_KEY"]
bucket_endpoint = os.environ["BUCKET_ENDPOINT"]
except KeyError as e:
raise RuntimeError(
f"Missing required environment variable: {e}\n"
"Please set BUCKET_NAME, ACCESS_KEY, and SECRET_KEY"
)
# S3 filesystem
fs = s3fs.S3FileSystem(
key=access,
secret=secret,
client_kwargs={"endpoint_url": bucket_endpoint},
)Setup the constants to be used in the processing
The parameters below control what data is fetched and at what resolution. The most important choices are:
Time and quality
START_DATE_TIME/END_DATE_TIMEβ ISO-8601 UTC bounds for the temporal search.CLOUD_COVER_MAXβ Maximum acceptable cloud cover as a percentage (i.e. 5.0 = 5.0%)OUTPUT_DIRβ Directory where per-tile Zarr stores are written.
Spatial resolution
SPATIAL_RESOLUTION = 20(recommended) β uses 8 spectral bands (B03βB12, excluding B08) and the 20 m NN weights. Produces a 5 490 Γ 5 490 pixel grid for a standard 100 km Γ 100 km Sentinel-2 tile and matches the reference SNAP BiophysicalOp output.SPATIAL_RESOLUTION = 10β uses only 3 bands (B03, B04, B08) with a separate, lighter 10 m network. Produces a 10 980 Γ 10 980 grid at the cost of reduced spectral diversity.
Band and geometry lookups
SENTINEL2_BAND_GROUPSβ maps each band name to its native resolution group inside the EOPF Zarr hierarchy (r10m,r20m, orr60m).SENTINEL2_GEOMETRY_BAND_INDICESβ maps spectral band names to the integer index used insideconditions/geometry.SENTINEL2_LAI_BANDS_20Mβ the ordered list of 8 reflectance bands that form the first 8 features of the 20 m neural network input vector.
# User-defined parameters
# --------------------------------------------------------------------------------
# [minx, miny, maxx, maxy]
AOI_BBOX = (3.1360, 43.4477, 4.1134, 44.1579) # an inset of tile 31TEJ, covering Montpellier, France
# Temporal range for Sentinel-2 data filtering (in ISO 8601 format)
# These dates are chosen to cover the growing season in the Northern Hemisphere,
# which is typically from April to October.
START_DATE_TIME = "2025-04-01T00:00:00Z"
END_DATE_TIME = "2025-10-31T23:59:59.999999Z"
CLOUD_COVER_MAX = 5.0 # maximum acceptable cloud cover as a percentage (i.e. 5.0 = 5.0%)
OUTPUT_DIR = Path("lai_tiles") # per-tile zarr output directory
# --------------------------------------------------------------------------------
TEMPORAL_RANGE = f"{START_DATE_TIME}/{END_DATE_TIME}"
SPATIAL_RESOLUTION = 20 # in meters, must be one of {10, 20} for Sentinel-2
SENTINEL2_BAND_GROUPS = {
"b01": "r60m",
"b02": "r10m",
"b03": "r10m",
"b04": "r10m",
"b05": "r20m",
"b06": "r20m",
"b07": "r20m",
"b08": "r10m",
"b8a": "r20m",
"b09": "r60m",
"b10": "r60m",
"b11": "r20m",
"b12": "r20m",
}
SENTINEL2_GEOMETRY_BAND_INDICES = {
"b01": "b00",
"b02": "b01",
"b03": "b02",
"b04": "b03",
"b05": "b04",
"b06": "b05",
"b07": "b06",
"b08": "b07",
"b8a": "b08",
"b09": "b09",
"b10": "b10",
"b11": "b11",
"b12": "b12",
}
SENTINEL2_LAI_BANDS_10M = (
"b03",
"b04",
"b08",
)
SENTINEL2_LAI_BANDS_20M = (
"b03",
"b04",
"b05",
"b06",
"b07",
"b8a",
"b11",
"b12",
)
# Constants for EOPF
SAT_COLLECTION = "sentinel-2-l2a"
EOPF_STAC_API_ENDPOINT = "https://stac.core.eopf.eodc.eu/"
ποΈ The Difference Between Using EOPF Zarr Datasets and SAFE Archives
The Earth Observation Processing Framework (EOPF) repackages ESA Copernicus products as Zarr archives on object storage. Zarr is a cloud-native, chunk-based array format: instead of downloading a multi-gigabyte SAFE archive, only the specific array chunks needed are fetched over HTTP, enabling efficient sub-region and sub-band access. As stated in the background, traditional LAI processing with the SNAP Optical Toolbox BiophysicalOp processor begins with downloading an entire Sentinel-2 L2A SAFE archive, usually requiring 600 MB to 1.2 GB to be downloaded per granule.
π Integrating EOPF STAC Queries and Results into a processing Pipeline
Search EOPF STAC for Sentinel-2 Images
Use the user defined time range from the above constants to query for Sentinel-2 Products.
Product discovery follows the open SpatioTemporal Asset Catalog (STAC) specification. Each STAC item represents one Sentinel-2 granule and includes a "product" asset pointing to the root URL of its Zarr store.
The search below returns Sentinel-2 L2A granules that: - Spatially intersect the AOI_BBOX bounding box - Temporally overlap the TEMPORAL_RANGE window - Have less than CLOUD_COVER_MAX% cloud cover (using the ESA-estimated eo:cloud_cover metadata field)
eopf_catalog = Client.open(url=EOPF_STAC_API_ENDPOINT)
s2_results = eopf_catalog.search(
collections=[SAT_COLLECTION],
bbox=AOI_BBOX,
datetime=TEMPORAL_RANGE,
query={"eo:cloud_cover": {"lt": CLOUD_COVER_MAX}} # Filter for low cloud cover
)
items = tuple(s2_results.items())
print(f"Total Sentinel-2 L2A items found: {len(items)}")
for item in items:
print(f"Item ID: {item.id}")
print(f" Geometry: {item.geometry}")
print(f" Bounding Box: {item.bbox}")
print(f" Datetime: {item.datetime}")
print(f" Cloud Cover: {item.properties.get('eo:cloud_cover', 'N/A')}")
product = item.assets["product"]
print(f" Product HREF: {product.href}") # Use product href to get zarr url
Total Sentinel-2 L2A items found: 15
Item ID: S2A_MSIL2A_20251030T104211_N0511_R008_T31TEJ_20251030T144716
Geometry: {'type': 'Polygon', 'coordinates': [[[2.999749474120944, 44.25341758107761], [4.374940058749246, 44.24514241182411], [4.352494032483418, 43.256794294201605], [2.999753565511865, 43.26479037737325], [2.999749474120944, 44.25341758107761]]]}
Bounding Box: [2.999749474120944, 43.256794294201605, 4.374940058749246, 44.25341758107761]
Datetime: 2025-10-30 10:42:11.024000+00:00
Cloud Cover: 0.713588
Product HREF: https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v270/S2A_MSIL2A_20251030T104211_N0511_R008_T31TEJ_20251030T144716.zarr
Item ID: S2B_MSIL2A_20251030T103049_N0511_R108_T31TEJ_20251030T130433
Geometry: {'type': 'Polygon', 'coordinates': [[[4.005694176135717, 43.25884423693857], [4.006666737240778, 43.2618584522954], [4.054214641392845, 43.408714965095214], [4.102001492637269, 43.55556092722918], [4.15009382059985, 43.70238356209406], [4.198619251512779, 43.84908934443938], [4.247012902182092, 43.995914013230525], [4.296322214027034, 44.14255029482407], [4.330147398004033, 44.24541195035435], [4.374940058749246, 44.24514241182411], [4.352494032483418, 43.256794294201605], [4.005694176135717, 43.25884423693857]]]}
Bounding Box: [4.005694176135717, 43.256794294201605, 4.374940058749246, 44.24541195035435]
Datetime: 2025-10-30 10:30:49.024000+00:00
Cloud Cover: 0.082116
Product HREF: https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v270/S2B_MSIL2A_20251030T103049_N0511_R108_T31TEJ_20251030T130433.zarr
Item ID: S2C_MSIL2A_20251028T104151_N0511_R008_T31TEJ_20251028T145122
Geometry: {'type': 'Polygon', 'coordinates': [[[2.999749474120944, 44.25341758107761], [4.374940058749246, 44.24514241182411], [4.352494032483418, 43.256794294201605], [2.999753565511865, 43.26479037737325], [2.999749474120944, 44.25341758107761]]]}
Bounding Box: [2.999749474120944, 43.256794294201605, 4.374940058749246, 44.25341758107761]
Datetime: 2025-10-28 10:41:51.025000+00:00
Cloud Cover: 1.649756
Product HREF: https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v270/S2C_MSIL2A_20251028T104151_N0511_R008_T31TEJ_20251028T145122.zarr
Item ID: S2C_MSIL2A_20251018T104051_N0511_R008_T31TEJ_20251018T205314
Geometry: {'type': 'Polygon', 'coordinates': [[[2.999749735219841, 44.190326696329606], [3.080339941079615, 44.1770058700864], [3.080170636302186, 44.17652324772636], [3.080263323696628, 44.176506816370726], [3.07996368494882, 44.175653051885135], [3.35386759094171, 44.126916725834604], [3.354053308066213, 44.12743560297753], [3.354333588729886, 44.12738583299684], [3.354514055378814, 44.12788843271424], [3.354581669773897, 44.12787644622808], [3.354590676309644, 44.12790162287181], [3.358243193930242, 44.127227341720285], [3.372804642699915, 44.12464592981835], [3.372768770285561, 44.12454581534072], [3.644154551226836, 44.074446038723785], [3.644301827954194, 44.074852523185726], [3.646290132854245, 44.07448576793084], [3.661629478014961, 44.071509882675], [3.661172835930347, 44.070251005517264], [3.931814146994537, 44.017803429006285], [3.931932284678499, 44.01812510406819], [3.931939135312465, 44.01812377567094], [3.932226810709366, 44.01890469249662], [3.948922655063657, 44.01548321726738], [3.948769020412453, 44.01506485326411], [4.237104469035296, 43.95606006746774], [4.237037982837612, 43.95588185462314], [4.238085647405527, 43.955659811266806], [4.237886297413936, 43.95512701199173], [4.238034088381697, 43.9550957154162], [4.237925609151044, 43.95480486726842], [4.367723347430212, 43.92737467141782], [4.352494032483418, 43.256794294201605], [2.999753565511865, 43.26479037737325], [2.999749735219841, 44.190326696329606]]]}
Bounding Box: [2.999749735219841, 43.256794294201605, 4.367723347430212, 44.190326696329606]
Datetime: 2025-10-18 10:40:51.025000+00:00
Cloud Cover: 0.409785
Product HREF: https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v270/S2C_MSIL2A_20251018T104051_N0511_R008_T31TEJ_20251018T205314.zarr
Item ID: S2C_MSIL2A_20251018T104051_N0511_R008_T31TEJ_20251018T200113
Geometry: {'type': 'Polygon', 'coordinates': [[[4.363694039141706, 43.74995532711596], [4.157368162595406, 43.79346341981967], [4.157638319129653, 43.7941901876288], [4.157591188638929, 43.79419986379704], [4.157611894201473, 43.79425557226157], [4.157562147972216, 43.7942657913887], [4.157741992589658, 43.79474893476958], [3.888329331098034, 43.85016310759728], [3.888269674712022, 43.85000068524352], [3.888227552877011, 43.850009342874394], [3.887905751285096, 43.8491322269987], [3.583984502544144, 43.90807962528523], [3.584264495792644, 43.90885361671259], [3.584252941555609, 43.90885575324023], [3.584476459424989, 43.90947277964113], [3.313848366818832, 43.95946280377139], [3.313799872198968, 43.95932740516691], [3.313762211068698, 43.95933436227994], [3.313470629605916, 43.95851939638556], [3.006287053779157, 44.013333526199695], [3.006588778942535, 44.01419031849532], [2.999750459510941, 44.015311915785524], [2.999749474120944, 44.25341758107761], [4.374940058749246, 44.24514241182411], [4.363694039141706, 43.74995532711596]]]}
Bounding Box: [2.999749474120944, 43.74995532711596, 4.374940058749246, 44.25341758107761]
Datetime: 2025-10-18 10:40:51.025000+00:00
Cloud Cover: 0.65563
Product HREF: https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v270/S2C_MSIL2A_20251018T104051_N0511_R008_T31TEJ_20251018T200113.zarr
Item ID: S2A_MSIL2A_20251017T103041_N0511_R108_T31TEJ_20251017T155021
Geometry: {'type': 'Polygon', 'coordinates': [[[4.009598665597077, 43.258821157402444], [4.015407696185386, 43.2767208010392], [4.06316280730122, 43.42358105442678], [4.110894246531354, 43.57038983482697], [4.158693425647685, 43.71716151753356], [4.206996096092864, 43.863816709106246], [4.25510711981884, 44.01056802547626], [4.304059103877955, 44.15719324523995], [4.33312976289036, 44.24539400406059], [4.374940058749246, 44.24514241182411], [4.352494032483418, 43.256794294201605], [4.009598665597077, 43.258821157402444]]]}
Bounding Box: [4.009598665597077, 43.256794294201605, 4.374940058749246, 44.24539400406059]
Datetime: 2025-10-17 10:30:41.024000+00:00
Cloud Cover: 0.079225
Product HREF: https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v270/S2A_MSIL2A_20251017T103041_N0511_R108_T31TEJ_20251017T155021.zarr
Item ID: S2C_MSIL2A_20251015T103031_N0511_R108_T31TEJ_20251015T161117
Geometry: {'type': 'Polygon', 'coordinates': [[[4.009840633040255, 43.25881972712676], [4.048973646286205, 43.38025370013829], [4.096295595835079, 43.527178549418565], [4.14381103027854, 43.674062901396816], [4.170348465596649, 43.75558371079492], [4.192183484413271, 43.82252533122775], [4.240110737137591, 43.969365249374015], [4.260182216799283, 44.02997724998347], [4.288269822020025, 44.1144251058632], [4.3313063232805, 44.245404976555285], [4.374940058749246, 44.24514241182411], [4.352494032483418, 43.256794294201605], [4.009840633040255, 43.25881972712676]]]}
Bounding Box: [4.009840633040255, 43.256794294201605, 4.374940058749246, 44.245404976555285]
Datetime: 2025-10-15 10:30:31.025000+00:00
Cloud Cover: 0.027531
Product HREF: https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v270/S2C_MSIL2A_20251015T103031_N0511_R108_T31TEJ_20251015T161117.zarr
Item ID: S2A_MSIL2A_20251010T104001_N0511_R008_T31TEJ_20251010T172518
Geometry: {'type': 'Polygon', 'coordinates': [[[2.999751647531111, 43.72824351260538], [3.196169918404005, 43.693363313713085], [3.196219759900159, 43.69350318669725], [3.199216067303486, 43.6929703361596], [3.199403482706296, 43.69349686389792], [3.199622798471584, 43.69345793364484], [3.199877147377955, 43.694171491838055], [3.487026245631773, 43.64098954616449], [3.487166955732334, 43.64137999270429], [3.490161997825271, 43.6408255847484], [3.501472739139136, 43.63862606856076], [3.501065580989618, 43.637496909841886], [3.501109162888151, 43.63748843936892], [3.500891007117723, 43.63688283808694], [3.770586220690224, 43.584471752404816], [3.770744522107497, 43.58490579503936], [3.773369769440987, 43.58439513744098], [3.773831933369448, 43.5856623909434], [3.776305013657012, 43.585181469315465], [3.789823927300506, 43.58240661765728], [3.789700888334701, 43.58206987103524], [4.074260310509901, 43.52381547570125], [4.074225034692251, 43.52372015894707], [4.074455899276074, 43.52367105089948], [4.074437013109758, 43.523620117506105], [4.074518786667455, 43.523602737473446], [4.074005415212866, 43.52221593164724], [4.344670655368001, 43.46506504133068], [4.344710603564738, 43.46517165126475], [4.344959885842306, 43.46511905967128], [4.344990861999915, 43.46520182125857], [4.3473700608977, 43.464699065346885], [4.347705994578857, 43.46559640436082], [4.357187636230289, 43.46346403490077], [4.352494032483418, 43.256794294201605], [2.999753565511865, 43.26479037737325], [2.999751647531111, 43.72824351260538]]]}
Bounding Box: [2.999751647531111, 43.256794294201605, 4.357187636230289, 43.72824351260538]
Datetime: 2025-10-10 10:40:01.024000+00:00
Cloud Cover: 0.00881
Product HREF: https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v270/S2A_MSIL2A_20251010T104001_N0511_R008_T31TEJ_20251010T172518.zarr
Item ID: S2C_MSIL2A_20251005T102921_N0511_R108_T31TEJ_20251005T155605
Geometry: {'type': 'Polygon', 'coordinates': [[[3.996902592802162, 43.25889620421039], [4.002550239623694, 43.2763128635939], [4.050358167958506, 43.42322155121074], [4.098264130761962, 43.57007655526528], [4.146294506040694, 43.71688208047622], [4.194763315665021, 43.86358218775699], [4.243058650581385, 44.010342845215725], [4.292051709437927, 44.15699201427013], [4.321205161369018, 44.24546576000262], [4.374940058749246, 44.24514241182411], [4.352494032483418, 43.256794294201605], [3.996902592802162, 43.25889620421039]]]}
Bounding Box: [3.996902592802162, 43.256794294201605, 4.374940058749246, 44.24546576000262]
Datetime: 2025-10-05 10:29:21.025000+00:00
Cloud Cover: 0.620101
Product HREF: https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v270/S2C_MSIL2A_20251005T102921_N0511_R108_T31TEJ_20251005T155605.zarr
Item ID: S2A_MSIL2A_20250907T103031_N0511_R108_T31TEJ_20250907T142815
Geometry: {'type': 'Polygon', 'coordinates': [[[4.007933578555628, 43.25883099977413], [4.053914061479624, 43.400130355868015], [4.10200129449421, 43.5470258320004], [4.150286246823151, 43.693872715999696], [4.198977069700992, 43.84056658443097], [4.246877625970667, 43.98740530584433], [4.295831549412578, 44.13392803962985], [4.332295072100586, 44.245399026788085], [4.374940058749246, 44.24514241182411], [4.352494032483418, 43.256794294201605], [4.007933578555628, 43.25883099977413]]]}
Bounding Box: [4.007933578555628, 43.256794294201605, 4.374940058749246, 44.245399026788085]
Datetime: 2025-09-07 10:30:31.024000+00:00
Cloud Cover: 4.397467
Product HREF: https://data.eodc.eu/collections/EOPF_ZARR/products/cpm_v270/S02MSIL2A/2025/09/07/S2A_MSIL2A_20250907T103031_N0511_R108_T31TEJ_20250907T142815.zarr
Item ID: S2A_MSIL2A_20250530T103041_N0511_R108_T31TEJ_20250530T142711
Geometry: {'type': 'Polygon', 'coordinates': [[[3.996151030886651, 43.258900646711915], [4.023793990467271, 43.34402002455392], [4.071660137619537, 43.490925621177965], [4.11966554524329, 43.63778121797476], [4.168007854238619, 43.7844957106434], [4.216194567973007, 43.93121158394251], [4.26500308831756, 44.07775724585984], [4.313670484861823, 44.22443496225287], [4.320641676579815, 44.24546915075591], [4.374940058749246, 44.24514241182411], [4.352494032483418, 43.256794294201605], [3.996151030886651, 43.258900646711915]]]}
Bounding Box: [3.996151030886651, 43.256794294201605, 4.374940058749246, 44.24546915075591]
Datetime: 2025-05-30 10:30:41.024000+00:00
Cloud Cover: 0.023985
Product HREF: https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v270/S2A_MSIL2A_20250530T103041_N0511_R108_T31TEJ_20250530T142711.zarr
Item ID: S2A_MSIL2A_20250430T102701_N0511_R108_T31TEJ_20250430T190517
Geometry: {'type': 'Polygon', 'coordinates': [[[3.999719577844396, 43.258879552940435], [4.01066893050314, 43.292618330840085], [4.058667839661785, 43.43963555941233], [4.106914943696001, 43.586620584359615], [4.15531245648374, 43.73349867183368], [4.203877089687448, 43.88029686606191], [4.25234288801703, 44.02709602620672], [4.301478140176486, 44.17367392313218], [4.325129722814647, 44.24544214406853], [4.374940058749246, 44.24514241182411], [4.352494032483418, 43.256794294201605], [3.999719577844396, 43.258879552940435]]]}
Bounding Box: [3.999719577844396, 43.256794294201605, 4.374940058749246, 44.24544214406853]
Datetime: 2025-04-30 10:27:01.024000+00:00
Cloud Cover: 0.047449
Product HREF: https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v270/S2A_MSIL2A_20250430T102701_N0511_R108_T31TEJ_20250430T190517.zarr
Item ID: S2A_MSIL2A_20250420T103041_N0511_R108_T31TEJ_20250420T202912
Geometry: {'type': 'Polygon', 'coordinates': [[[3.999054267090983, 43.258883485609154], [4.046727311476494, 43.40550124694311], [4.094877054390124, 43.55241547881358], [4.143268956901982, 43.69933392159373], [4.192035284878529, 43.846133169476616], [4.216811041370139, 43.92123219977511], [4.240519415716781, 43.99294472589098], [4.249841276796993, 44.02070565347856], [4.289983445930977, 44.139607974567234], [4.324815706219906, 44.24544403365424], [4.374940058749246, 44.24514241182411], [4.352494032483418, 43.256794294201605], [3.999054267090983, 43.258883485609154]]]}
Bounding Box: [3.999054267090983, 43.256794294201605, 4.374940058749246, 44.24544403365424]
Datetime: 2025-04-20 10:30:41.024000+00:00
Cloud Cover: 1.786733
Product HREF: https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v270/S2A_MSIL2A_20250420T103041_N0511_R108_T31TEJ_20250420T202912.zarr
Item ID: S2C_MSIL2A_20250418T103041_N0511_R108_T31TEJ_20250418T160655
Geometry: {'type': 'Polygon', 'coordinates': [[[3.996524135750957, 43.258898441279605], [4.029442036161715, 43.36057738888665], [4.077147593719051, 43.50739796354541], [4.125125045129605, 43.6542448039122], [4.173779370552519, 43.80100107173931], [4.221976447715368, 43.94791608964147], [4.270987202437039, 44.09461300238891], [4.31946199620316, 44.24146793252264], [4.320796341402683, 44.24546822006485], [4.374940058749246, 44.24514241182411], [4.352494032483418, 43.256794294201605], [3.996524135750957, 43.258898441279605]]]}
Bounding Box: [3.996524135750957, 43.256794294201605, 4.374940058749246, 44.24546822006485]
Datetime: 2025-04-18 10:30:41.025000+00:00
Cloud Cover: 0.165078
Product HREF: https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v270/S2C_MSIL2A_20250418T103041_N0511_R108_T31TEJ_20250418T160655.zarr
Item ID: S2B_MSIL2A_20250406T103629_N0511_R008_T31TEJ_20250406T133008
Geometry: {'type': 'Polygon', 'coordinates': [[[2.999749474120944, 44.25341758107761], [4.374940058749246, 44.24514241182411], [4.352494032483418, 43.256794294201605], [2.999753565511865, 43.26479037737325], [2.999749474120944, 44.25341758107761]]]}
Bounding Box: [2.999749474120944, 43.256794294201605, 4.374940058749246, 44.25341758107761]
Datetime: 2025-04-06 10:36:29.024000+00:00
Cloud Cover: 0.004834
Product HREF: https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v270/S2B_MSIL2A_20250406T103629_N0511_R008_T31TEJ_20250406T133008.zarr
π§ Walkthrough
The cells below walk through each processing step in detail for the first scene returned by the STAC query. This single-scene walkthrough illustrates the full pipeline β angle interpolation, input assembly, NN inference, and output saving β before the batch processing section repeats the same logic efficiently for every scene.
# get the url to the product of the first item in the search results
product = items[0].assets["product"]
print(f" Product HREF: {product.href}")
# lazily open the product zarr with xarray
dt = xr.open_datatree(product.href, engine="zarr")
dt Product HREF: https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v270/S2A_MSIL2A_20251030T104211_N0511_R008_T31TEJ_20251030T144716.zarr
<xarray.DataTree>
Group: /
β Attributes:
β other_metadata: {'aot_retrieval_model': 'SEN2COR_DDV', 'band_descrip...
β processing_history: {'Level-0 Product': [{'facility': 'ESA', 'inputs': '...
β stac_discovery: {'assets': {'aod1240': {'href': '/conditions/meteoro...
βββ Group: /conditions
β βββ Group: /conditions/geometry
β β Dimensions: (angle: 2, band: 13, y: 23, x: 23,
β β detector: 7)
β β Coordinates:
β β * angle (angle) <U7 56B 'zenith' 'azimuth'
β β * band (band) <U3 156B 'b01' 'b02' ... 'b11' 'b12'
β β * y (y) float32 92B 4.9e+06 ... 4.79e+06
β β * x (x) float32 92B 5e+05 5.05e+05 ... 6.1e+05
β β * detector (detector) object 56B 'd04' 'd05' ... 'd10'
β β Data variables:
β β mean_sun_angles (angle) float64 16B ...
β β mean_viewing_incidence_angles (band, angle) float64 208B ...
β β sun_angles (angle, y, x) float64 8kB ...
β β viewing_incidence_angles (band, detector, angle, y, x) float64 770kB ...
β βββ Group: /conditions/mask
β β βββ Group: /conditions/mask/detector_footprint
β β β βββ Group: /conditions/mask/detector_footprint/r10m
β β β β Dimensions: (y: 10980, x: 10980)
β β β β Coordinates:
β β β β * y (y) float32 44kB 4.9e+06 4.9e+06 4.9e+06 ... 4.79e+06 4.79e+06
β β β β * x (x) float32 44kB 5e+05 5e+05 5e+05 ... 6.098e+05 6.098e+05
β β β β Data variables:
β β β β b02 (y, x) uint8 121MB ...
β β β β b03 (y, x) uint8 121MB ...
β β β β b04 (y, x) uint8 121MB ...
β β β β b08 (y, x) uint8 121MB ...
β β β βββ Group: /conditions/mask/detector_footprint/r20m
β β β β Dimensions: (y: 5490, x: 5490)
β β β β Coordinates:
β β β β * y (y) float32 22kB 4.9e+06 4.9e+06 4.9e+06 ... 4.79e+06 4.79e+06
β β β β * x (x) float32 22kB 5e+05 5e+05 5e+05 ... 6.098e+05 6.098e+05
β β β β Data variables:
β β β β b05 (y, x) uint8 30MB ...
β β β β b06 (y, x) uint8 30MB ...
β β β β b07 (y, x) uint8 30MB ...
β β β β b11 (y, x) uint8 30MB ...
β β β β b12 (y, x) uint8 30MB ...
β β β β b8a (y, x) uint8 30MB ...
β β β βββ Group: /conditions/mask/detector_footprint/r60m
β β β Dimensions: (y: 1830, x: 1830)
β β β Coordinates:
β β β * y (y) float32 7kB 4.9e+06 4.9e+06 4.9e+06 ... 4.79e+06 4.79e+06
β β β * x (x) float32 7kB 5e+05 5.001e+05 5.001e+05 ... 6.097e+05 6.098e+05
β β β Data variables:
β β β b01 (y, x) uint8 3MB ...
β β β b09 (y, x) uint8 3MB ...
β β β b10 (y, x) uint8 3MB ...
β β βββ Group: /conditions/mask/l1c_classification
β β β βββ Group: /conditions/mask/l1c_classification/r60m
β β β Dimensions: (y: 1830, x: 1830)
β β β Coordinates:
β β β * y (y) float32 7kB 4.9e+06 4.9e+06 4.9e+06 ... 4.79e+06 4.79e+06
β β β * x (x) float32 7kB 5e+05 5.001e+05 5.001e+05 ... 6.097e+05 6.098e+05
β β β Data variables:
β β β b00 (y, x) uint8 3MB ...
β β βββ Group: /conditions/mask/l2a_classification
β β βββ Group: /conditions/mask/l2a_classification/r20m
β β β Dimensions: (y: 5490, x: 5490)
β β β Coordinates:
β β β * y (y) float32 22kB 4.9e+06 4.9e+06 4.9e+06 ... 4.79e+06 4.79e+06
β β β * x (x) float32 22kB 5e+05 5e+05 5e+05 ... 6.098e+05 6.098e+05
β β β Data variables:
β β β scl (y, x) float64 241MB ...
β β βββ Group: /conditions/mask/l2a_classification/r60m
β β Dimensions: (y: 1830, x: 1830)
β β Coordinates:
β β * y (y) float32 7kB 4.9e+06 4.9e+06 4.9e+06 ... 4.79e+06 4.79e+06
β β * x (x) float32 7kB 5e+05 5.001e+05 5.001e+05 ... 6.097e+05 6.098e+05
β β Data variables:
β β scl (y, x) float64 27MB ...
β βββ Group: /conditions/meteorology
β βββ Group: /conditions/meteorology/cams
β β Dimensions: (latitude: 9, longitude: 9)
β β Coordinates:
β β * latitude (latitude) float32 36B 44.25 44.13 44.0 ... 43.5 43.38 43.26
β β * longitude (longitude) float32 36B 3.0 3.169 3.338 ... 4.015 4.184 4.353
β β Data variables:
β β aod1240 (latitude, longitude) float32 324B ...
β β aod469 (latitude, longitude) float32 324B ...
β β aod550 (latitude, longitude) float32 324B ...
β β aod670 (latitude, longitude) float32 324B ...
β β aod865 (latitude, longitude) float32 324B ...
β β bcaod550 (latitude, longitude) float32 324B ...
β β duaod550 (latitude, longitude) float32 324B ...
β β omaod550 (latitude, longitude) float32 324B ...
β β ssaod550 (latitude, longitude) float32 324B ...
β β suaod550 (latitude, longitude) float32 324B ...
β β z (latitude, longitude) float32 324B ...
β βββ Group: /conditions/meteorology/ecmwf
β Dimensions: (latitude: 9, longitude: 9)
β Coordinates:
β * latitude (latitude) float32 36B 44.25 44.13 44.0 ... 43.5 43.38 43.26
β * longitude (longitude) float32 36B 3.0 3.169 3.338 ... 4.015 4.184 4.353
β Data variables:
β msl (latitude, longitude) float32 324B ...
β r (latitude, longitude) float32 324B ...
β tco3 (latitude, longitude) float32 324B ...
β tcwv (latitude, longitude) float32 324B ...
β u10 (latitude, longitude) float32 324B ...
β v10 (latitude, longitude) float32 324B ...
βββ Group: /measurements
β βββ Group: /measurements/reflectance
β βββ Group: /measurements/reflectance/r10m
β β Dimensions: (y: 10980, x: 10980)
β β Coordinates:
β β * y (y) float32 44kB 4.9e+06 4.9e+06 4.9e+06 ... 4.79e+06 4.79e+06
β β * x (x) float32 44kB 5e+05 5e+05 5e+05 ... 6.098e+05 6.098e+05
β β Data variables:
β β b02 (y, x) float64 964MB ...
β β b03 (y, x) float64 964MB ...
β β b04 (y, x) float64 964MB ...
β β b08 (y, x) float64 964MB ...
β βββ Group: /measurements/reflectance/r20m
β β Dimensions: (y: 5490, x: 5490)
β β Coordinates:
β β * y (y) float32 22kB 4.9e+06 4.9e+06 4.9e+06 ... 4.79e+06 4.79e+06
β β * x (x) float32 22kB 5e+05 5e+05 5e+05 ... 6.098e+05 6.098e+05
β β Data variables:
β β b01 (y, x) float64 241MB ...
β β b02 (y, x) float64 241MB ...
β β b03 (y, x) float64 241MB ...
β β b04 (y, x) float64 241MB ...
β β b05 (y, x) float64 241MB ...
β β b06 (y, x) float64 241MB ...
β β b07 (y, x) float64 241MB ...
β β b11 (y, x) float64 241MB ...
β β b12 (y, x) float64 241MB ...
β β b8a (y, x) float64 241MB ...
β βββ Group: /measurements/reflectance/r60m
β Dimensions: (y: 1830, x: 1830)
β Coordinates:
β * y (y) float32 7kB 4.9e+06 4.9e+06 4.9e+06 ... 4.79e+06 4.79e+06
β * x (x) float32 7kB 5e+05 5.001e+05 5.001e+05 ... 6.097e+05 6.098e+05
β Data variables:
β b01 (y, x) float64 27MB ...
β b02 (y, x) float64 27MB ...
β b03 (y, x) float64 27MB ...
β b04 (y, x) float64 27MB ...
β b05 (y, x) float64 27MB ...
β b06 (y, x) float64 27MB ...
β b07 (y, x) float64 27MB ...
β b09 (y, x) float64 27MB ...
β b11 (y, x) float64 27MB ...
β b12 (y, x) float64 27MB ...
β b8a (y, x) float64 27MB ...
βββ Group: /quality
βββ Group: /quality/atmosphere
β βββ Group: /quality/atmosphere/r10m
β β Dimensions: (y: 10980, x: 10980)
β β Coordinates:
β β * y (y) float32 44kB 4.9e+06 4.9e+06 4.9e+06 ... 4.79e+06 4.79e+06
β β * x (x) float32 44kB 5e+05 5e+05 5e+05 ... 6.098e+05 6.098e+05
β β Data variables:
β β aot (y, x) float64 964MB ...
β β wvp (y, x) float64 964MB ...
β βββ Group: /quality/atmosphere/r20m
β β Dimensions: (y: 5490, x: 5490)
β β Coordinates:
β β * y (y) float32 22kB 4.9e+06 4.9e+06 4.9e+06 ... 4.79e+06 4.79e+06
β β * x (x) float32 22kB 5e+05 5e+05 5e+05 ... 6.098e+05 6.098e+05
β β Data variables:
β β aot (y, x) float64 241MB ...
β β wvp (y, x) float64 241MB ...
β βββ Group: /quality/atmosphere/r60m
β Dimensions: (y: 1830, x: 1830)
β Coordinates:
β * y (y) float32 7kB 4.9e+06 4.9e+06 4.9e+06 ... 4.79e+06 4.79e+06
β * x (x) float32 7kB 5e+05 5.001e+05 5.001e+05 ... 6.097e+05 6.098e+05
β Data variables:
β aot (y, x) float64 27MB ...
β wvp (y, x) float64 27MB ...
βββ Group: /quality/mask
β βββ Group: /quality/mask/r10m
β β Dimensions: (y: 10980, x: 10980)
β β Coordinates:
β β * y (y) float32 44kB 4.9e+06 4.9e+06 4.9e+06 ... 4.79e+06 4.79e+06
β β * x (x) float32 44kB 5e+05 5e+05 5e+05 ... 6.098e+05 6.098e+05
β β Data variables:
β β b02 (y, x) uint8 121MB ...
β β b03 (y, x) uint8 121MB ...
β β b04 (y, x) uint8 121MB ...
β β b08 (y, x) uint8 121MB ...
β βββ Group: /quality/mask/r20m
β β Dimensions: (y: 5490, x: 5490)
β β Coordinates:
β β * y (y) float32 22kB 4.9e+06 4.9e+06 4.9e+06 ... 4.79e+06 4.79e+06
β β * x (x) float32 22kB 5e+05 5e+05 5e+05 ... 6.098e+05 6.098e+05
β β Data variables:
β β b05 (y, x) uint8 30MB ...
β β b06 (y, x) uint8 30MB ...
β β b07 (y, x) uint8 30MB ...
β β b11 (y, x) uint8 30MB ...
β β b12 (y, x) uint8 30MB ...
β β b8a (y, x) uint8 30MB ...
β βββ Group: /quality/mask/r60m
β Dimensions: (y: 1830, x: 1830)
β Coordinates:
β * y (y) float32 7kB 4.9e+06 4.9e+06 4.9e+06 ... 4.79e+06 4.79e+06
β * x (x) float32 7kB 5e+05 5.001e+05 5.001e+05 ... 6.097e+05 6.098e+05
β Data variables:
β b01 (y, x) uint8 3MB ...
β b09 (y, x) uint8 3MB ...
β b10 (y, x) uint8 3MB ...
βββ Group: /quality/probability
βββ Group: /quality/probability/r20m
Dimensions: (y: 5490, x: 5490)
Coordinates:
* y (y) float32 22kB 4.9e+06 4.9e+06 4.9e+06 ... 4.79e+06 4.79e+06
* x (x) float32 22kB 5e+05 5e+05 5e+05 ... 6.098e+05 6.098e+05
Data variables:
cld (y, x) float32 121MB ...
snw (y, x) float32 121MB ...The EOPF Zarr store is opened lazily as an xarray.DataTree β a hierarchical tree of Dataset nodes that mirrors the internal Zarr group structure. No pixel data is downloaded at this point; arrays are only fetched when explicitly accessed or computed.
The main sub-trees used in this notebook are:
| Group path | Contents |
|---|---|
measurements/reflectance/r10m |
Bottom-of-Atmosphere (BOA) reflectance at 10 m β bands B02, B03, B04, B08 |
measurements/reflectance/r20m |
BOA reflectance at 20 m β bands B05, B06, B07, B8A, B11, B12 |
conditions/geometry |
Sun and per-band, per-detector view angle grids (23 Γ 23 points at ~5 km spacing) |
conditions/mask/detector_footprint/r10m and r20m |
Per-detector binary footprint masks indicating which detector module observed each pixel |
quality/l2a_quicklook/r60m |
True-Colour Image (TCI) at 60 m for quick visual inspection |
Reflectance scaling β bands are stored as scaled
uint16integers. The physical BOA reflectance is recovered as:reflectance = (raw_uint16 β 1000) / 10 000(BOA_ADD_OFFSET = β1000, QUANTIFICATION_VALUE = 10 000, per the Sentinel-2 Product Specification.)
Build a True-Color Composite
The true-colour composite (B04 red, B03 green, B02 blue) at 60 m resolution serves as a quick visual sanity check to confirm:
- The correct granule was selected (scene footprint covers the AOI)
- Cloud cover is within acceptable limits for the LAI retrieval
- The image is correctly georeferenced when overlaid on the CartoDB basemap
The composite is built directly from the measurements/reflectance/r60m group and displayed with a 2β98 % percentile linear stretch for natural-looking contrast. The LAI product is computed at 20 m.
Note: If you see the message Make this Notebook Trusted to load map: File -> Trust Notebook instead of a map after running the next cell, run the code in folium_test.ipynb and then click back on this notebookβs tab. The map should then be displayed.
# ---- Build a true-colour RGB composite from 60 m reflectance bands ----
# R = B04 (red), G = B03 (green), B = B02 (blue)
r60m = dt["measurements/reflectance/r60m"]
band_r = r60m["b04"].values # red β BOA reflectance, float32
band_g = r60m["b03"].values # green
band_b = r60m["b02"].values # blue
rgb = np.stack([band_r, band_g, band_b], axis=-1) # (y, x, 3)
del band_r, band_g, band_b
# 2β98 % percentile linear stretch across all three bands for display contrast
p2, p98 = np.nanpercentile(rgb, 2), np.nanpercentile(rgb, 98)
tci_rgb = np.clip((rgb - p2) / (p98 - p2) * 255, 0, 255).astype(np.uint8)
del rgb
# ---- Compute WGS-84 bounding box from the 60 m coordinate arrays ----
ref_da = r60m["b02"]
x_coords = ref_da.coords["x"].values
y_coords = ref_da.coords["y"].values
half_px_x = abs(float(x_coords[1] - x_coords[0])) / 2
half_px_y = abs(float(y_coords[0] - y_coords[1])) / 2
x_min = float(x_coords.min()) - half_px_x
x_max = float(x_coords.max()) + half_px_x
y_min = float(y_coords.min()) - half_px_y
y_max = float(y_coords.max()) + half_px_y
tci_product_id = parse_sentinel2_product_name(Path(product.href))
tci_epsg = epsg_from_tile_id(tci_product_id.tile_id)
tci_transformer = Transformer.from_crs(f"EPSG:{tci_epsg}", "EPSG:4326", always_xy=True)
tci_corners = [
tci_transformer.transform(x, y)
for x, y in [(x_min, y_min), (x_min, y_max), (x_max, y_min), (x_max, y_max)]
]
tci_lons, tci_lats = zip(*tci_corners)
tci_bounds = [[min(tci_lats), min(tci_lons)], [max(tci_lats), max(tci_lons)]]
tci_center = [(min(tci_lats) + max(tci_lats)) / 2, (min(tci_lons) + max(tci_lons)) / 2]
# ---- Folium map ----
tci_map = folium.Map(location=tci_center, zoom_start=9, tiles="CartoDB positron")
folium.raster_layers.ImageOverlay(
image=tci_rgb,
bounds=tci_bounds,
opacity=1.0,
name="True Colour Composite (B4/B3/B2)",
).add_to(tci_map)
folium.LayerControl().add_to(tci_map)
tci_map