@@ -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
10101048def _ (
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" )
0 commit comments