Skip to content

Commit 993dfda

Browse files
committed
draft of predefined visualisation recipe
1 parent e35704d commit 993dfda

6 files changed

Lines changed: 1070 additions & 86 deletions

File tree

copernicus_marimo.py

Lines changed: 183 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -835,7 +835,37 @@ def _(
835835

836836

837837
@app.cell
838-
def _(cached_images, file_metadata, mo, time_slider):
838+
def _(cached_images, mo, satellite_type):
839+
"""Create band recipe selector (independent of time slider to maintain state)."""
840+
band_recipe_selector = None
841+
842+
if cached_images and len(cached_images) > 0:
843+
from src.data.copernicus.band_recipes import get_available_recipes
844+
845+
_recipes = get_available_recipes()
846+
847+
# Filter recipes based on satellite type
848+
if satellite_type.value == "S2":
849+
# S2: Show optical recipes only
850+
_recipe_names = [r.name for rid, r in _recipes.items() if not rid.startswith("sar_")]
851+
elif satellite_type.value == "S1":
852+
# S1: Show SAR recipes only
853+
_recipe_names = [r.name for rid, r in _recipes.items() if rid.startswith("sar_")]
854+
else: # S1+S2
855+
# S1+S2: Show optical recipes (visualizing S2)
856+
_recipe_names = [r.name for rid, r in _recipes.items() if not rid.startswith("sar_")]
857+
858+
if _recipe_names:
859+
band_recipe_selector = mo.ui.dropdown(
860+
options=_recipe_names,
861+
label="Band Recipe",
862+
)
863+
864+
return (band_recipe_selector,)
865+
866+
867+
@app.cell
868+
def _(band_recipe_selector, cached_images, file_metadata, mo, time_slider):
839869
"""Display time slider and adjustment sliders in a compact side-by-side layout."""
840870
slider_display = None
841871
contrast_slider = None
@@ -877,6 +907,18 @@ def _(cached_images, file_metadata, mo, time_slider):
877907
_current_idx = time_slider.value
878908
_current_meta = file_metadata[_current_idx]
879909

910+
# Build adjustment controls
911+
_adjustment_controls = [
912+
mo.md("**Fine-tune image appearance**"),
913+
contrast_slider,
914+
brightness_slider,
915+
gamma_slider,
916+
]
917+
918+
# Add band recipe selector if available
919+
if band_recipe_selector is not None:
920+
_adjustment_controls.insert(1, band_recipe_selector)
921+
880922
# Create two-column layout: time slider on left, adjustments on right
881923
slider_display = mo.vstack(
882924
[
@@ -903,22 +945,25 @@ def _(cached_images, file_metadata, mo, time_slider):
903945
],
904946
align="start",
905947
),
906-
# Right column: Adjustment sliders
907-
mo.vstack(
908-
[
909-
mo.md("**Fine-tune image appearance**"),
910-
contrast_slider,
911-
brightness_slider,
912-
gamma_slider,
913-
],
914-
align="start",
915-
),
948+
# Right column: Adjustment sliders and band recipe
949+
mo.vstack(_adjustment_controls, align="start"),
916950
],
917951
justify="start",
918952
),
919953
]
920954
)
921955
elif file_metadata and len(file_metadata) == 1:
956+
# Build adjustment controls
957+
_adjustment_controls = [
958+
contrast_slider,
959+
brightness_slider,
960+
gamma_slider,
961+
]
962+
963+
# Add band recipe selector if available
964+
if band_recipe_selector is not None:
965+
_adjustment_controls.insert(0, band_recipe_selector)
966+
922967
# Single image - just show adjustments compactly
923968
slider_display = mo.vstack(
924969
[
@@ -930,14 +975,7 @@ def _(cached_images, file_metadata, mo, time_slider):
930975
**Date**: {file_metadata[0]['date_str']} | **File**: `{file_metadata[0]['filename'][:50]}...`
931976
"""
932977
),
933-
mo.hstack(
934-
[
935-
contrast_slider,
936-
brightness_slider,
937-
gamma_slider,
938-
],
939-
justify="start",
940-
),
978+
mo.hstack(_adjustment_controls, justify="start"),
941979
]
942980
)
943981

@@ -1009,9 +1047,11 @@ def apply_image_adjustments(image_array, contrast, brightness, gamma):
10091047
@app.cell
10101048
def _(
10111049
apply_image_adjustments,
1050+
band_recipe_selector,
10121051
brightness_slider,
10131052
cached_images,
10141053
contrast_slider,
1054+
crop_to_bbox,
10151055
file_metadata,
10161056
gamma_slider,
10171057
max_lat,
@@ -1023,13 +1063,13 @@ def _(
10231063
time_slider,
10241064
traceback,
10251065
):
1026-
"""Visualize the selected satellite image using cached data.
1066+
"""Visualize the selected satellite image using cached data with band recipe support.
10271067
10281068
This cell displays pre-processed images from cache, making slider
10291069
interactions nearly instant (no disk I/O or processing needed).
10301070
10311071
Visualization details:
1032-
- S2: RGB composite (natural color) with target bbox overlay
1072+
- S2: Supports multiple band recipes (True Color, False Color, Agriculture, NDVI, NDWI)
10331073
- S1: VV polarization (grayscale) with adaptive contrast
10341074
- Both: Already cropped to target bbox during pre-processing
10351075
- Image adjustments applied in real-time based on slider values
@@ -1063,12 +1103,46 @@ def _(
10631103
_brightness = brightness_slider.value if brightness_slider is not None else 50
10641104
_gamma = gamma_slider.value if gamma_slider is not None else 50
10651105

1106+
# Get selected band recipe
1107+
_selected_recipe = None
1108+
if band_recipe_selector is not None:
1109+
_selected_recipe = band_recipe_selector.value
1110+
1111+
# Apply band recipe if needed
1112+
_display_data = _image_data
1113+
_needs_recipe_processing = False
1114+
1115+
# Determine if we need to reprocess with recipe
1116+
if _selected_recipe is not None:
1117+
if satellite_type.value in ["S2", "S1+S2"]:
1118+
# S2: Reprocess if not True Color
1119+
_needs_recipe_processing = _selected_recipe != "True Color (RGB)"
1120+
elif satellite_type.value == "S1":
1121+
# S1: Reprocess if not default SAR VV
1122+
_needs_recipe_processing = _selected_recipe != "SAR VV (Surface)"
1123+
1124+
if _needs_recipe_processing:
1125+
# Need to reprocess with the selected recipe
1126+
from src.data.copernicus.band_recipes import apply_band_recipe
1127+
1128+
# Only pass bbox if crop_to_bbox is enabled
1129+
_recipe_bbox = _viz_bbox if crop_to_bbox.value else None
1130+
1131+
_recipe_result = apply_band_recipe(
1132+
_metadata["path"], _selected_recipe, bbox=_recipe_bbox
1133+
)
1134+
if _recipe_result is not None:
1135+
_display_data = _recipe_result
1136+
else:
1137+
# Fall back to cached data if recipe fails
1138+
print(f"Failed to apply recipe '{_selected_recipe}', using cached data")
1139+
10661140
# Create figure
10671141
fig, ax = plt.subplots(1, 1, figsize=(10, 8))
10681142

1069-
# Display the cached image
1070-
if _image_data is not None and _image_data.get("bounds_wgs84") is not None:
1071-
_bounds = _image_data["bounds_wgs84"]
1143+
# Display the image
1144+
if _display_data is not None and _display_data.get("bounds_wgs84") is not None:
1145+
_bounds = _display_data["bounds_wgs84"]
10721146
_extent = (
10731147
_bounds[0],
10741148
_bounds[2],
@@ -1078,7 +1152,7 @@ def _(
10781152

10791153
# PERFORMANCE FIX: If image is not cropped, crop it now for visualization
10801154
# This prevents matplotlib from rendering millions of pixels that won't be visible
1081-
_display_data = None
1155+
_display_array = None
10821156
_display_extent = _extent
10831157

10841158
# Calculate zoom area with padding
@@ -1100,18 +1174,22 @@ def _(
11001174
# Crop the image to the zoom area for faster rendering
11011175
from src.data.copernicus.image_processing import crop_to_bbox as _crop_fn
11021176

1103-
# Check if we have RGB or SAR data
1104-
if "rgb_array" in _image_data:
1105-
_display_data = _crop_fn(_image_data["rgb_array"], _bounds, _zoom_bbox)
1106-
elif "sar_array" in _image_data:
1107-
_sar_data = _image_data["sar_array"]
1177+
# Check if we have RGB, index, or SAR data
1178+
if "rgb_array" in _display_data:
1179+
_display_array = _crop_fn(_display_data["rgb_array"], _bounds, _zoom_bbox)
1180+
elif "index_array" in _display_data:
1181+
_display_array = _crop_fn(
1182+
_display_data["index_array"], _bounds, _zoom_bbox
1183+
)
1184+
elif "sar_array" in _display_data:
1185+
_sar_data = _display_data["sar_array"]
11081186
if _sar_data.ndim == 3:
11091187
_sar_data = _sar_data[:, :, 0] # Extract first polarization
1110-
_display_data = _crop_fn(_sar_data, _bounds, _zoom_bbox)
1188+
_display_array = _crop_fn(_sar_data, _bounds, _zoom_bbox)
11111189
else:
1112-
_display_data = None
1190+
_display_array = None
11131191

1114-
if _display_data is not None:
1192+
if _display_array is not None:
11151193
# Update extent to match cropped area
11161194
_display_extent = (
11171195
_zoom_bbox[0],
@@ -1124,28 +1202,85 @@ def _(
11241202
_needs_crop = False
11251203

11261204
# Display the image array (already normalized and ready)
1127-
# Check what type of data we have (RGB or SAR)
1128-
if "rgb_array" in _image_data:
1205+
# Check what type of data we have (RGB, index, or SAR)
1206+
if "rgb_array" in _display_data:
11291207
# RGB image (H, W, 3) - for S2 or S1+S2 mode
1130-
if _needs_crop and _display_data is not None:
1208+
if _needs_crop and _display_array is not None:
11311209
# Apply image adjustments
11321210
_adjusted_data = apply_image_adjustments(
1133-
_display_data, _contrast, _brightness, _gamma
1211+
_display_array, _contrast, _brightness, _gamma
11341212
)
11351213
ax.imshow(_adjusted_data, extent=_display_extent, aspect="auto")
11361214
else:
11371215
# Apply image adjustments
11381216
_adjusted_data = apply_image_adjustments(
1139-
_image_data["rgb_array"], _contrast, _brightness, _gamma
1217+
_display_data["rgb_array"], _contrast, _brightness, _gamma
11401218
)
11411219
ax.imshow(_adjusted_data, extent=_extent, aspect="auto")
1142-
elif "sar_array" in _image_data:
1220+
elif "index_array" in _display_data:
1221+
# Spectral index or SAR data (H, W) - for NDVI, NDWI, SAR VV/VH, etc.
1222+
_index_array = (
1223+
_display_array
1224+
if _needs_crop and _display_array is not None
1225+
else _display_data["index_array"]
1226+
)
1227+
1228+
# Check if this is SAR data (needs different normalization)
1229+
_is_sar = _display_data.get("metadata", {}).get("type") == "sar"
1230+
1231+
if _is_sar:
1232+
# SAR data: Use percentile-based normalization (dB values vary)
1233+
_vmin, _vmax = np.percentile(_index_array, [2, 98])
1234+
_normalized = np.clip(
1235+
(_index_array - _vmin) / (_vmax - _vmin + 1e-10), 0, 1
1236+
)
1237+
else:
1238+
# Spectral indices: Use fixed value range
1239+
_vmin, _vmax = _display_data.get("value_range", (-1, 1))
1240+
_normalized = np.clip(
1241+
(_index_array - _vmin) / (_vmax - _vmin + 1e-10), 0, 1
1242+
)
1243+
1244+
# Apply image adjustments
1245+
_adjusted_data = apply_image_adjustments(
1246+
_normalized, _contrast, _brightness, _gamma
1247+
)
1248+
1249+
# Display with appropriate colormap
1250+
_cmap = _display_data.get("colormap", "RdYlGn")
1251+
_extent_to_use = _display_extent if _needs_crop else _extent
1252+
im = ax.imshow(
1253+
_adjusted_data,
1254+
extent=_extent_to_use,
1255+
aspect="auto",
1256+
cmap=_cmap,
1257+
vmin=0,
1258+
vmax=1,
1259+
)
1260+
# Add colorbar for index visualization
1261+
cbar = plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
1262+
1263+
# Label colorbar appropriately
1264+
if _is_sar:
1265+
_pol_name = _display_data.get("metadata", {}).get("polarization", "SAR")
1266+
cbar.set_label(
1267+
f"{_pol_name} Backscatter (dB)",
1268+
rotation=270,
1269+
labelpad=15,
1270+
)
1271+
else:
1272+
cbar.set_label(
1273+
_display_data.get("metadata", {}).get("index", "Index Value"),
1274+
rotation=270,
1275+
labelpad=15,
1276+
)
1277+
elif "sar_array" in _display_data:
11431278
# SAR grayscale image (H, W, 1) - squeeze to (H, W) - for S1 mode
1144-
if _needs_crop and _display_data is not None:
1279+
if _needs_crop and _display_array is not None:
11451280
# Normalize to 0-1 range for adjustments
1146-
_vmin, _vmax = np.percentile(_display_data, [2, 98])
1281+
_vmin, _vmax = np.percentile(_display_array, [2, 98])
11471282
_normalized = np.clip(
1148-
(_display_data - _vmin) / (_vmax - _vmin + 1e-10), 0, 1
1283+
(_display_array - _vmin) / (_vmax - _vmin + 1e-10), 0, 1
11491284
)
11501285

11511286
# Apply image adjustments
@@ -1162,7 +1297,7 @@ def _(
11621297
vmax=1,
11631298
)
11641299
else:
1165-
_sar_data = _image_data["sar_array"]
1300+
_sar_data = _display_data["sar_array"]
11661301
if _sar_data.ndim == 3:
11671302
_sar_data = _sar_data[:, :, 0] # Extract first polarization
11681303

@@ -1217,7 +1352,11 @@ def _(
12171352
ax.set_xlabel("Longitude (°E)", fontsize=12)
12181353
ax.set_ylabel("Latitude (°N)", fontsize=12)
12191354

1220-
_title = f"{satellite_type.value} Image - {_metadata['date_str']}\n{_metadata['filename'][:50]}..."
1355+
# Update title to show recipe name
1356+
_recipe_name = _selected_recipe if _selected_recipe else satellite_type.value
1357+
_title = (
1358+
f"{_recipe_name} - {_metadata['date_str']}\n{_metadata['filename'][:50]}..."
1359+
)
12211360
ax.set_title(_title, fontsize=11, fontweight="bold")
12221361

12231362
ax.grid(True, alpha=0.3, color="white")

src/data/copernicus/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
"""Copernicus Data Space Ecosystem client for fetching Sentinel-1 and Sentinel-2 data."""
22

3+
from .band_recipes import (
4+
apply_band_recipe,
5+
get_available_recipes,
6+
get_recipe_by_name,
7+
get_recipe_names,
8+
)
39
from .client import CopernicusClient
410
from .enums import S1AcquisitionMode, S1Polarization, S1ProductType, S2Band
511
from .image_processing import (
@@ -38,6 +44,11 @@
3844
"S1ProductType",
3945
"S1Polarization",
4046
"S1AcquisitionMode",
47+
# Band recipes
48+
"get_available_recipes",
49+
"get_recipe_names",
50+
"get_recipe_by_name",
51+
"apply_band_recipe",
4152
# Image processing
4253
"extract_rgb_composite",
4354
"extract_sar_composite",

0 commit comments

Comments
 (0)