yakvrz commited on
Commit
af0c994
·
1 Parent(s): 142f0ca

Update UI layout and overlay visuals

Browse files
Files changed (4) hide show
  1. README.md +1 -1
  2. app/safety.py +20 -1
  3. app/ui.py +124 -154
  4. app/visualization.py +103 -56
README.md CHANGED
@@ -9,7 +9,7 @@ Analyze aerial RGB imagery to detect safe drone landing sites. Combines monocula
9
  </p>
10
 
11
  ## What’s inside
12
- - **Main app (`landing_app.py`)** — runs full inference with adjustable thresholds, overlays, and camera assumptions; requires >8GB VRAM (assuming default 1024px processing resolution); runtime is ~1000ms per image.
13
  - **Curated gallery (`demo/curated_app.py`)** — precomputed PNG/JPG/JSON artifacts for fast, zero-GPU browsing.
14
 
15
  ## Prereqs
 
9
  </p>
10
 
11
  ## What’s inside
12
+ - **Main app (`landing_app.py`)** — runs full inference with adjustable thresholds, overlays, and camera assumptions; requires >8GB VRAM (assuming default 1024 px processing resolution); runtime is ~1000ms per image.
13
  - **Curated gallery (`demo/curated_app.py`)** — precomputed PNG/JPG/JSON artifacts for fast, zero-GPU browsing.
14
 
15
  ## Prereqs
app/safety.py CHANGED
@@ -113,6 +113,15 @@ class SafetyAnalyzer:
113
  area_thresh = max(footprint_px * footprint_px // 4, 64)
114
  num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(roof_mask, connectivity=8)
115
  refined = np.zeros_like(roof_mask, dtype=bool)
 
 
 
 
 
 
 
 
 
116
  max_area = max_area_frac * depth_mask.size if max_area_frac > 0 else None
117
  for i in range(1, num_labels):
118
  area = stats[i, cv2.CC_STAT_AREA]
@@ -121,7 +130,17 @@ class SafetyAnalyzer:
121
  if max_area is not None and area > max_area:
122
  # Skip overly large blobs (e.g., entire fields) to avoid over-masking
123
  continue
124
- refined |= labels == i
 
 
 
 
 
 
 
 
 
 
125
  return refined if refined.any() else None
126
 
127
  def analyze_image(self, image: Image.Image, request: AnalysisRequest) -> AnalysisResult:
 
113
  area_thresh = max(footprint_px * footprint_px // 4, 64)
114
  num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(roof_mask, connectivity=8)
115
  refined = np.zeros_like(roof_mask, dtype=bool)
116
+ depth_norm = (depth - depth.min()) / (np.ptp(depth) + 1e-6)
117
+ global_mad = np.median(np.abs(depth_norm - np.median(depth_norm))) + 1e-6
118
+ ring_kernel = cv2.getStructuringElement(
119
+ cv2.MORPH_ELLIPSE,
120
+ (
121
+ max(3, int(round(footprint_px * 0.6)) | 1),
122
+ max(3, int(round(footprint_px * 0.6)) | 1),
123
+ ),
124
+ )
125
  max_area = max_area_frac * depth_mask.size if max_area_frac > 0 else None
126
  for i in range(1, num_labels):
127
  area = stats[i, cv2.CC_STAT_AREA]
 
130
  if max_area is not None and area > max_area:
131
  # Skip overly large blobs (e.g., entire fields) to avoid over-masking
132
  continue
133
+ comp_mask = labels == i
134
+ ring = cv2.dilate(comp_mask.astype(np.uint8), ring_kernel, iterations=1).astype(bool) & (~comp_mask)
135
+ if not ring.any():
136
+ continue
137
+ comp_mean = float(depth_norm[comp_mask].mean())
138
+ ring_mean = float(depth_norm[ring].mean())
139
+ prominence = ring_mean - comp_mean
140
+ min_prominence = max(0.02, 0.5 * global_mad)
141
+ if prominence < min_prominence or ring_mean <= comp_mean:
142
+ continue
143
+ refined |= comp_mask
144
  return refined if refined.any() else None
145
 
146
  def analyze_image(self, image: Image.Image, request: AnalysisRequest) -> AnalysisResult:
app/ui.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
 
3
  from pathlib import Path
4
  from typing import Dict
 
5
 
6
  import gradio as gr
7
 
@@ -65,38 +66,33 @@ def _make_request(
65
 
66
  def _format_status(summary: AnalysisSummary | None) -> str:
67
  if not summary:
68
- return "**Status:** Awaiting analysis."
69
- mask_bits = []
70
- for label, enabled, pct in (
71
- ("Water", summary.water_mask_enabled, summary.water_mask_pct),
72
- ("Road", summary.road_mask_enabled, summary.road_mask_pct),
73
- ("Roof", summary.roof_mask_enabled, summary.roof_mask_pct),
74
- ):
75
- if enabled:
76
- pct_text = "n/a" if pct is None else f"{pct:.1f}%"
77
- mask_bits.append(f"{label} {pct_text}")
78
- else:
79
- mask_bits.append(f"{label} off")
80
- masks_line = " • ".join(mask_bits)
81
- lines = [
82
- "**Status**",
83
- f"Model: `{summary.model_id}` — Process res: {summary.process_resolution}px Runtime: {summary.runtime_ms:.0f} ms",
84
- f"Footprint: {summary.footprint_m:.1f} m ({summary.footprint_image_px}px image scale)",
85
- f"Masks: {masks_line}",
86
- ]
87
- return "<br/>".join(lines)
 
88
 
89
 
90
  def _format_metrics(summary: AnalysisSummary | None) -> str:
91
  if not summary:
92
  return "No metrics yet. Run the analyzer to populate this section."
93
- lines = [
94
- f"**Safe coverage:** {summary.safe_area_pct:.1f}% of frame",
95
- f"**Hazard coverage:** {summary.hazard_pct:.1f}%",
96
- f"**Landing center (px):** {summary.landing_center_image[0]}, {summary.landing_center_image[1]}",
97
- f"**Footprint size:** {summary.footprint_m:.1f} m ≈ {summary.footprint_image_px}px",
98
- f"**Effective thresholds:** std ≤ {summary.std_thresh_applied:.4f}, grad ≤ {summary.grad_thresh_applied:.3f}",
99
- ]
100
  if not summary.used_valid_center:
101
  lines.append("Warning: No fully safe footprint; showing lowest-variance patch.")
102
  if summary.warnings:
@@ -110,20 +106,21 @@ def build_ui(analyzer: SafetyAnalyzer | None = None) -> gr.Blocks:
110
  defaults = DEFAULT_ANALYZER_SETTINGS
111
  data_inputs = list_all_data_inputs()
112
 
113
- with gr.Blocks(title="Landing Site Safety Analyzer (VISLOC)") as demo:
114
  gr.Markdown(
115
- "## Landing Site Safety Analyzer\n"
116
- "Evaluate VISLOC imagery with DepthAnything3 to spot flat, obstacle-free landing sites."
 
117
  )
118
  gr.HTML(
119
- """
120
- <style>
121
- #preview-wrap { position: relative; }
122
- #preview-wrap .hover-legend {
123
- position: absolute;
124
- right: 12px;
125
- bottom: 12px;
126
- background: rgba(0, 0, 0, 0.65);
127
  color: #fff;
128
  padding: 8px 10px;
129
  border-radius: 10px;
@@ -134,31 +131,22 @@ def build_ui(analyzer: SafetyAnalyzer | None = None) -> gr.Blocks:
134
  pointer-events: none;
135
  }
136
  #preview-wrap:hover .hover-legend { opacity: 1; }
137
- #preview-wrap .hover-legend .row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
138
- #preview-wrap .hover-legend .swatch { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
139
- </style>
140
- """
141
- )
142
  images_state = gr.State({})
143
 
144
  with gr.Row(equal_height=False):
145
- with gr.Column(scale=1, min_width=360):
146
  input_path = gr.Dropdown(
147
  label="Input file",
148
  choices=data_inputs,
149
  value=data_inputs[0] if data_inputs else "",
150
  info="Pick any VISLOC image under data/Image/VISLOC/.",
151
  )
152
- model_id = gr.Dropdown(
153
- label="DepthAnything3 model",
154
- value=defaults.model_id,
155
- choices=[
156
- "depth-anything/DA3MONO-LARGE",
157
- "depth-anything/DA3METRIC-LARGE",
158
- "depth-anything/DA3NESTED-GIANT-LARGE",
159
- ],
160
- info="Select a pretrained checkpoint.",
161
- )
162
  process_res_cap = gr.Slider(
163
  label="Processing max side (px)",
164
  value=defaults.process_res_cap,
@@ -191,34 +179,14 @@ def build_ui(analyzer: SafetyAnalyzer | None = None) -> gr.Blocks:
191
  step=0.01,
192
  info="Lower suppresses slopes/edges; higher tolerates tilt.",
193
  )
194
- use_water_mask = gr.Checkbox(
195
- label="Exclude water (segmentation)",
196
- value=True,
197
- info="Runs SAM3 segmentation with water prompts.",
198
- )
199
- water_prompt = gr.Textbox(
200
- label="Water prompt",
201
- value=defaults.water_prompt,
202
- placeholder="e.g., water",
203
- )
204
- use_road_mask = gr.Checkbox(label="Exclude roads (segmentation)", value=True)
205
- road_prompt = gr.Textbox(
206
- label="Road prompt",
207
- value=defaults.road_prompt,
208
- placeholder="e.g., road",
209
- )
210
- use_tree_mask = gr.Checkbox(label="Exclude trees (segmentation)", value=True)
211
- tree_prompt = gr.Textbox(
212
- label="Tree prompt",
213
- value=defaults.tree_prompt,
214
- placeholder="e.g., tree",
215
- )
216
- use_roof_mask = gr.Checkbox(label="Exclude rooftops (depth-based)", value=True)
217
  with gr.Row():
218
- run_btn = gr.Button("Run", variant="primary")
219
- stop_btn = gr.Button("Stop", variant="stop")
 
 
 
220
  with gr.Accordion("Segmentation settings", open=False):
221
- gr.Markdown("Control SAM3/Mask2Former and thresholds.")
222
  segmentation_model_id = gr.Dropdown(
223
  label="Segmentation model",
224
  value=defaults.segmentation_model_id,
@@ -235,6 +203,21 @@ def build_ui(analyzer: SafetyAnalyzer | None = None) -> gr.Blocks:
235
  step=32,
236
  info="Largest long-side resolution for running the segmentation model.",
237
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  segmentation_score_thresh = gr.Slider(
239
  label="Segmentation score threshold",
240
  value=defaults.segmentation_score_thresh,
@@ -286,33 +269,6 @@ def build_ui(analyzer: SafetyAnalyzer | None = None) -> gr.Blocks:
286
  info="Lower values avoid visually textured (high-contrast) regions like tracks or debris.",
287
  )
288
  # Plane removal fixed to least squares; toggle removed.
289
- with gr.Accordion("Overlay settings", open=False):
290
- gr.Markdown("Toggle visualization layers.")
291
- base_view = gr.Dropdown(
292
- label="Base view",
293
- value="RGB",
294
- choices=[
295
- "RGB",
296
- "Depth",
297
- "Flatness map (std)",
298
- "Depth gradient",
299
- "Gradient mask",
300
- "Water mask",
301
- "Road mask",
302
- "Tree mask",
303
- "Safety score",
304
- "Safety heatmap overlay",
305
- ],
306
- )
307
- with gr.Row():
308
- heat_on = gr.Checkbox(label="Safety highlight", value=True, info="Show safe landing mask (green).")
309
- hazard_on = gr.Checkbox(label="Hazard highlight", value=False, info="Show hazard mask (red).")
310
- with gr.Row():
311
- water_on = gr.Checkbox(label="Water overlay", value=True, info="Color water masks blue.")
312
- road_on = gr.Checkbox(label="Road overlay", value=True, info="Color road masks orange.")
313
- tree_on = gr.Checkbox(label="Tree overlay", value=True, info="Color tree masks green.")
314
- grad_on = gr.Checkbox(label="Depth gradient", value=False, info="Gradient magnitude overlay.")
315
- spot_on = gr.Checkbox(label="Show landing spot", value=True)
316
  with gr.Accordion("Camera settings", open=False):
317
  gr.Markdown("Configure capture assumptions for footprint sizing.")
318
  altitude_m = gr.Slider(
@@ -329,25 +285,55 @@ def build_ui(analyzer: SafetyAnalyzer | None = None) -> gr.Blocks:
329
  maximum=150,
330
  step=1,
331
  )
332
- with gr.Column(scale=2, min_width=520, elem_id="preview-wrap"):
333
  main_view = gr.Image(
334
- label="Preview",
335
- height=720,
336
  elem_id="main-preview",
 
 
 
 
 
 
 
337
  show_fullscreen_button=False,
338
  )
339
  gr.HTML(
340
  """
341
- <div class="hover-legend">
342
- <div class="row"><span class="swatch" style="background: linear-gradient(90deg, #1e0a3c 0%, #fcfecf 100%);"></span><span>Continuous safety gradient</span></div>
343
- <div class="row"><span class="swatch" style="background: #00ff00;"></span><span>Safe outline</span></div>
344
- <div class="row"><span class="swatch" style="background: #007aff;"></span><span>Water hazards</span></div>
345
- <div class="row"><span class="swatch" style="background: #ff6200;"></span><span>Road hazards</span></div>
346
- </div>
347
  """
348
  )
349
- status_card = gr.Markdown("**Status:** Awaiting analysis.")
350
- metrics_card = gr.Markdown("No metrics yet. Run the analyzer to get results.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
 
352
  def process_any(
353
  input_path,
@@ -370,15 +356,11 @@ def build_ui(analyzer: SafetyAnalyzer | None = None) -> gr.Blocks:
370
  segmentation_score_thresh,
371
  segmentation_mask_thresh,
372
  coverage_strictness,
373
- model_id,
374
  openness_weight,
375
  texture_threshold,
376
  base_view,
377
- heat_on,
378
  hazard_on,
379
- water_on,
380
- road_on,
381
- tree_on,
382
  grad_on,
383
  spot_on,
384
  ):
@@ -422,19 +404,22 @@ def build_ui(analyzer: SafetyAnalyzer | None = None) -> gr.Blocks:
422
  composed = compose_view(
423
  imgs,
424
  base_view,
425
- heat_on,
426
  0.2,
427
  hazard_on,
428
  0.2,
429
- water_on,
430
- road_on,
431
- tree_on,
432
  grad_on,
433
  False,
434
  False,
435
  spot_on=spot_on,
436
  )
437
- return imgs, composed, _format_status(summary), _format_metrics(summary)
 
 
 
 
 
438
 
439
  run_inputs = [
440
  input_path,
@@ -457,15 +442,11 @@ def build_ui(analyzer: SafetyAnalyzer | None = None) -> gr.Blocks:
457
  segmentation_score_thresh,
458
  segmentation_mask_thresh,
459
  coverage_strictness,
460
- model_id,
461
  openness_weight,
462
  texture_threshold,
463
  base_view,
464
- heat_on,
465
  hazard_on,
466
- water_on,
467
- road_on,
468
- tree_on,
469
  grad_on,
470
  spot_on,
471
  ]
@@ -473,18 +454,15 @@ def build_ui(analyzer: SafetyAnalyzer | None = None) -> gr.Blocks:
473
  run_event = run_btn.click(
474
  fn=process_any,
475
  inputs=run_inputs,
476
- outputs=[images_state, main_view, status_card, metrics_card],
477
  )
478
  stop_btn.click(fn=None, inputs=None, outputs=None, cancels=[run_event])
479
 
480
  overlay_inputs = [
481
  images_state,
482
  base_view,
483
- heat_on,
484
  hazard_on,
485
- water_on,
486
- road_on,
487
- tree_on,
488
  grad_on,
489
  spot_on,
490
  ]
@@ -492,44 +470,36 @@ def build_ui(analyzer: SafetyAnalyzer | None = None) -> gr.Blocks:
492
  def update_overlays_only(
493
  images_state_val,
494
  base_view_val,
495
- heat_on_val,
496
  hazard_on_val,
497
- water_on_val,
498
- road_on_val,
499
- tree_on_val,
500
  grad_on_val,
501
  spot_on_val,
502
  ):
503
  if not images_state_val:
504
- return images_state_val, gr.update(), gr.update(), gr.update()
505
  composed = compose_view(
506
  images_state_val,
507
  base_view_val,
508
- heat_on_val,
509
  0.2,
510
  hazard_on_val,
511
  0.2,
512
- water_on_val,
513
- road_on_val,
514
- tree_on_val,
515
  grad_on_val,
516
  False,
517
  False,
518
  spot_on_val,
519
  )
520
- return images_state_val, composed, gr.update(), gr.update()
521
 
522
  base_view.change(
523
  fn=update_overlays_only,
524
  inputs=overlay_inputs,
525
- outputs=[images_state, main_view, status_card, metrics_card],
526
  )
527
  overlay_toggle_controls = (
528
- heat_on,
529
  hazard_on,
530
- water_on,
531
- road_on,
532
- tree_on,
533
  grad_on,
534
  spot_on,
535
  )
@@ -537,7 +507,7 @@ def build_ui(analyzer: SafetyAnalyzer | None = None) -> gr.Blocks:
537
  control.change(
538
  fn=update_overlays_only,
539
  inputs=overlay_inputs,
540
- outputs=[images_state, main_view, status_card, metrics_card],
541
  )
542
  # Opacity sliders removed; overlays now use fixed alpha.
543
 
 
2
 
3
  from pathlib import Path
4
  from typing import Dict
5
+ from PIL import Image
6
 
7
  import gradio as gr
8
 
 
66
 
67
  def _format_status(summary: AnalysisSummary | None) -> str:
68
  if not summary:
69
+ return "**Status**\nAwaiting analysis."
70
+ masks_line = " / ".join(
71
+ f"{label}:{'off' if not enabled else ('n/a' if pct is None else f'{pct:.1f}%')}"
72
+ for label, enabled, pct in (
73
+ ("Water", summary.water_mask_enabled, summary.water_mask_pct),
74
+ ("Road", summary.road_mask_enabled, summary.road_mask_pct),
75
+ ("Roof", summary.roof_mask_enabled, summary.roof_mask_pct),
76
+ )
77
+ )
78
+ warning_text = ""
79
+ if summary.warnings:
80
+ warning_text = "\nWarnings: " + " | ".join(summary.warnings)
81
+ return (
82
+ "**Run Status**\n"
83
+ f"- Model: `{summary.model_id}`\n"
84
+ f"- Process res: {summary.process_resolution}px; Runtime: {summary.runtime_ms:.0f} ms\n"
85
+ f"- Footprint: {summary.footprint_m:.1f} m (~{summary.footprint_image_px}px)\n"
86
+ f"- Safe: {summary.safe_area_pct:.1f}% | Hazard: {summary.hazard_pct:.1f}%\n"
87
+ f"- Masks: {masks_line}"
88
+ f"{warning_text}"
89
+ )
90
 
91
 
92
  def _format_metrics(summary: AnalysisSummary | None) -> str:
93
  if not summary:
94
  return "No metrics yet. Run the analyzer to populate this section."
95
+ lines = []
 
 
 
 
 
 
96
  if not summary.used_valid_center:
97
  lines.append("Warning: No fully safe footprint; showing lowest-variance patch.")
98
  if summary.warnings:
 
106
  defaults = DEFAULT_ANALYZER_SETTINGS
107
  data_inputs = list_all_data_inputs()
108
 
109
+ with gr.Blocks(title="Drone Landing Site Safety Analyzer", theme=gr.themes.Base()) as demo:
110
  gr.Markdown(
111
+ "## Drone Landing Site Safety Analyzer\n"
112
+ "Evaluate aerial imagery to spot flat, obstacle-free landing sites.",
113
+ elem_classes="tight-title",
114
  )
115
  gr.HTML(
116
+ """
117
+ <style>
118
+ #preview-wrap { position: relative; }
119
+ #preview-wrap .hover-legend {
120
+ position: absolute;
121
+ right: 12px;
122
+ bottom: 12px;
123
+ background: rgba(0, 0, 0, 0.65);
124
  color: #fff;
125
  padding: 8px 10px;
126
  border-radius: 10px;
 
131
  pointer-events: none;
132
  }
133
  #preview-wrap:hover .hover-legend { opacity: 1; }
134
+ #preview-wrap .hover-legend .row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
135
+ #preview-wrap .hover-legend .swatch { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
136
+ </style>
137
+ """
138
+ )
139
  images_state = gr.State({})
140
 
141
  with gr.Row(equal_height=False):
142
+ with gr.Column(scale=1, min_width=280):
143
  input_path = gr.Dropdown(
144
  label="Input file",
145
  choices=data_inputs,
146
  value=data_inputs[0] if data_inputs else "",
147
  info="Pick any VISLOC image under data/Image/VISLOC/.",
148
  )
149
+ model_id = defaults.model_id
 
 
 
 
 
 
 
 
 
150
  process_res_cap = gr.Slider(
151
  label="Processing max side (px)",
152
  value=defaults.process_res_cap,
 
179
  step=0.01,
180
  info="Lower suppresses slopes/edges; higher tolerates tilt.",
181
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  with gr.Row():
183
+ use_water_mask = gr.Checkbox(label="Exclude water", value=True, info="Mask water regions.")
184
+ use_road_mask = gr.Checkbox(label="Exclude roads", value=True, info="Mask road surfaces.")
185
+ with gr.Row():
186
+ use_tree_mask = gr.Checkbox(label="Exclude trees", value=True, info="Mask trees/foliage.")
187
+ use_roof_mask = gr.Checkbox(label="Exclude rooftops", value=True, info="Mask rooftop areas.")
188
  with gr.Accordion("Segmentation settings", open=False):
189
+ gr.Markdown("Control SAM3 and prompts.")
190
  segmentation_model_id = gr.Dropdown(
191
  label="Segmentation model",
192
  value=defaults.segmentation_model_id,
 
203
  step=32,
204
  info="Largest long-side resolution for running the segmentation model.",
205
  )
206
+ water_prompt = gr.Textbox(
207
+ label="Water prompt",
208
+ value=defaults.water_prompt,
209
+ placeholder="e.g., water",
210
+ )
211
+ road_prompt = gr.Textbox(
212
+ label="Road prompt",
213
+ value=defaults.road_prompt,
214
+ placeholder="e.g., road",
215
+ )
216
+ tree_prompt = gr.Textbox(
217
+ label="Tree prompt",
218
+ value=defaults.tree_prompt,
219
+ placeholder="e.g., tree",
220
+ )
221
  segmentation_score_thresh = gr.Slider(
222
  label="Segmentation score threshold",
223
  value=defaults.segmentation_score_thresh,
 
269
  info="Lower values avoid visually textured (high-contrast) regions like tracks or debris.",
270
  )
271
  # Plane removal fixed to least squares; toggle removed.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  with gr.Accordion("Camera settings", open=False):
273
  gr.Markdown("Configure capture assumptions for footprint sizing.")
274
  altitude_m = gr.Slider(
 
285
  maximum=150,
286
  step=1,
287
  )
288
+ with gr.Column(scale=2, min_width=520, elem_id="preview-wrap", elem_classes="preview-col"):
289
  main_view = gr.Image(
290
+ label="Analyzed",
291
+ height=441,
292
  elem_id="main-preview",
293
+ show_download_button=True,
294
+ show_fullscreen_button=False,
295
+ )
296
+ orig_view = gr.Image(
297
+ label="Original",
298
+ height=441,
299
+ show_download_button=True,
300
  show_fullscreen_button=False,
301
  )
302
  gr.HTML(
303
  """
304
+ <style>
305
+ .preview-col { gap: 0 !important; }
306
+ .tight-status { margin-top: 0 !important; padding-top: 0 !important; }
307
+ .tight-title { margin-bottom: 0 !important; }
308
+ </style>
 
309
  """
310
  )
311
+ with gr.Column(scale=1, min_width=220):
312
+ base_view = gr.Dropdown(
313
+ label="Base view",
314
+ value="RGB",
315
+ choices=[
316
+ "RGB",
317
+ "Depth",
318
+ "Flatness map (std)",
319
+ "Depth gradient",
320
+ "Gradient mask",
321
+ "Water mask",
322
+ "Road mask",
323
+ "Tree mask",
324
+ "Safety score",
325
+ "Safety heatmap overlay",
326
+ ],
327
+ )
328
+ spot_on = gr.Checkbox(label="Show optimal landing spot", value=True, info="Show recommended landing box/marker.")
329
+ hazard_on = gr.Checkbox(label="Risk highlight", value=True, info="Show depth-based risk map.")
330
+ hazards_on = gr.Checkbox(label="Segmented hazard highlight", value=True, info="Show segmentation + roof hazards.")
331
+ grad_on = gr.Checkbox(label="Depth gradient", value=False, info="Gradient magnitude overlay.")
332
+ with gr.Row():
333
+ run_btn = gr.Button("Run", variant="primary")
334
+ stop_btn = gr.Button("Stop", variant="stop")
335
+ status_card = gr.Markdown("**Status:** Awaiting analysis.", elem_classes="tight-status")
336
+ metrics_card = gr.Markdown("")
337
 
338
  def process_any(
339
  input_path,
 
356
  segmentation_score_thresh,
357
  segmentation_mask_thresh,
358
  coverage_strictness,
 
359
  openness_weight,
360
  texture_threshold,
361
  base_view,
 
362
  hazard_on,
363
+ hazards_on,
 
 
364
  grad_on,
365
  spot_on,
366
  ):
 
404
  composed = compose_view(
405
  imgs,
406
  base_view,
407
+ True,
408
  0.2,
409
  hazard_on,
410
  0.2,
411
+ hazards_on,
 
 
412
  grad_on,
413
  False,
414
  False,
415
  spot_on=spot_on,
416
  )
417
+ orig = imgs.get("RGB")
418
+ orig_with_spot = orig
419
+ if spot_on and imgs.get("Landing spot overlay") is not None and orig is not None:
420
+ orig_with_spot = Image.alpha_composite(orig.convert("RGBA"), imgs["Landing spot overlay"]).convert("RGB")
421
+ view = composed
422
+ return imgs, view, orig_with_spot or orig, _format_status(summary), _format_metrics(summary)
423
 
424
  run_inputs = [
425
  input_path,
 
442
  segmentation_score_thresh,
443
  segmentation_mask_thresh,
444
  coverage_strictness,
 
445
  openness_weight,
446
  texture_threshold,
447
  base_view,
 
448
  hazard_on,
449
+ hazards_on,
 
 
450
  grad_on,
451
  spot_on,
452
  ]
 
454
  run_event = run_btn.click(
455
  fn=process_any,
456
  inputs=run_inputs,
457
+ outputs=[images_state, main_view, orig_view, status_card, metrics_card],
458
  )
459
  stop_btn.click(fn=None, inputs=None, outputs=None, cancels=[run_event])
460
 
461
  overlay_inputs = [
462
  images_state,
463
  base_view,
 
464
  hazard_on,
465
+ hazards_on,
 
 
466
  grad_on,
467
  spot_on,
468
  ]
 
470
  def update_overlays_only(
471
  images_state_val,
472
  base_view_val,
 
473
  hazard_on_val,
474
+ hazards_on_val,
 
 
475
  grad_on_val,
476
  spot_on_val,
477
  ):
478
  if not images_state_val:
479
+ return images_state_val, gr.update(), gr.update(), gr.update(), gr.update()
480
  composed = compose_view(
481
  images_state_val,
482
  base_view_val,
483
+ True,
484
  0.2,
485
  hazard_on_val,
486
  0.2,
487
+ hazards_on_val,
 
 
488
  grad_on_val,
489
  False,
490
  False,
491
  spot_on_val,
492
  )
493
+ return images_state_val, composed, gr.update(), gr.update(), gr.update()
494
 
495
  base_view.change(
496
  fn=update_overlays_only,
497
  inputs=overlay_inputs,
498
+ outputs=[images_state, main_view, orig_view, status_card, metrics_card],
499
  )
500
  overlay_toggle_controls = (
 
501
  hazard_on,
502
+ hazards_on,
 
 
503
  grad_on,
504
  spot_on,
505
  )
 
507
  control.change(
508
  fn=update_overlays_only,
509
  inputs=overlay_inputs,
510
+ outputs=[images_state, main_view, orig_view, status_card, metrics_card],
511
  )
512
  # Opacity sliders removed; overlays now use fixed alpha.
513
 
app/visualization.py CHANGED
@@ -10,6 +10,23 @@ from .depth_pipeline import visualize_depth
10
  GRAD_ALPHA = 0.35
11
  FLAT_ALPHA = 0.25
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  def make_safety_heatmap(
14
  rgb: Image.Image,
15
  safe_mask: np.ndarray,
@@ -23,17 +40,23 @@ def make_safety_heatmap(
23
 
24
  h, w = safe.shape
25
  safe_overlay = np.zeros((h, w, 4), dtype=np.uint8)
26
- safe_pixels = safe > 0.0
27
- safe_overlay[safe_pixels, 1] = 255
28
- safe_overlay[safe_pixels, 3] = 255
 
 
 
29
 
30
  risk_focus = np.zeros_like(risk)
31
  risk_focus[risk > risk_threshold] = risk[risk > risk_threshold]
32
  hazard_intensity = np.where(hazard, np.maximum(risk_focus, 1.0), risk_focus)
33
  hazard_alpha = (np.clip(hazard_intensity, 0.0, 1.0) * 255).astype(np.uint8)
34
  hazard_overlay = np.zeros((h, w, 4), dtype=np.uint8)
35
- hazard_overlay[..., 0] = 255
36
  hazard_overlay[..., 3] = hazard_alpha
 
 
 
37
 
38
  safe_img = Image.fromarray(safe_overlay, mode="RGBA").resize(rgb.size, resample=Image.NEAREST)
39
  hazard_img = Image.fromarray(hazard_overlay, mode="RGBA").resize(rgb.size, resample=Image.NEAREST)
@@ -102,16 +125,53 @@ def build_result_layers(
102
  roof_mask_img = _mask_to_image(roof_mask)
103
  tree_mask_img = _mask_to_image(tree_mask)
104
 
105
- def _color_overlay(mask: np.ndarray | None, color: tuple[int, int, int]) -> Image.Image:
 
 
 
 
 
 
106
  if mask is None:
107
  return Image.new("RGBA", image.size, (0, 0, 0, 0))
108
- m = Image.fromarray((mask.astype(np.uint8) * 255)).resize(image.size, resample=Image.NEAREST)
109
- rgba = Image.new("RGBA", image.size, color + (255,))
110
- return Image.composite(rgba, Image.new("RGBA", image.size, (0, 0, 0, 0)), m)
111
-
112
- water_hazard_overlay = _color_overlay(water_mask, (0, 122, 255)) # blue
113
- road_hazard_overlay = _color_overlay(road_mask, (255, 98, 0)) # orange/red
114
- tree_hazard_overlay = _color_overlay(tree_mask, (34, 139, 34)) # forest green
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
  safe_overlay, hazard_overlay, heat_gray = make_safety_heatmap(image, safe_mask, hazard_mask, risk_map)
117
  flat_heat_overlay = make_flatness_heatmap(std_map_vis, image.size)
@@ -157,23 +217,23 @@ def build_result_layers(
157
  cy_draw = int(round(min(max(cy_img, by0), by1)))
158
  overlay_box = Image.new("RGBA", image.size, (0, 0, 0, 0))
159
  box_draw = ImageDraw.Draw(overlay_box)
160
- fill = (0, 102, 255, 60)
161
- outline = (0, 102, 255, 255)
162
 
163
- # Crosshair sized 3x the landing box for clearer focus.
164
  cross_half = int(round(side_img * 1.5))
165
  hx0 = max(0, cx_draw - cross_half)
166
  hx1 = min(image.width - 1, cx_draw + cross_half)
167
  hy0 = max(0, cy_draw - cross_half)
168
  hy1 = min(image.height - 1, cy_draw + cross_half)
169
- cross_width = 4
170
  draw.line((hx0, cy_draw, hx1, cy_draw), fill=outline, width=cross_width)
171
  draw.line((cx_draw, hy0, cx_draw, hy1), fill=outline, width=cross_width)
172
 
173
- box_draw.rectangle((bx0, by0, bx1, by1), fill=fill, outline=outline, width=4)
174
- box_draw.line((cx_draw, by0, cx_draw, by1), fill=outline, width=2)
175
- box_draw.line((bx0, cy_draw, bx1, cy_draw), fill=outline, width=2)
176
- radius = 8
177
  box_draw.ellipse((cx_draw - radius, cy_draw - radius, cx_draw + radius, cy_draw + radius), fill=outline)
178
 
179
  return {
@@ -191,6 +251,7 @@ def build_result_layers(
191
  "Water hazard overlay": water_hazard_overlay,
192
  "Road hazard overlay": road_hazard_overlay,
193
  "Tree hazard overlay": tree_hazard_overlay,
 
194
  "Flatness heatmap overlay": flat_heat_overlay,
195
  "Safety score": heat_gray,
196
  "Landing spot overlay": Image.alpha_composite(spot_overlay, overlay_box),
@@ -202,11 +263,9 @@ def compose_view(
202
  base_view: str,
203
  heat_on: bool,
204
  heat_alpha: float,
205
- hazard_on: bool,
206
- hazard_alpha: float,
207
- water_on: bool,
208
- road_on: bool,
209
- tree_on: bool,
210
  grad_on: bool,
211
  flat_on: bool,
212
  flat_heat_on: bool,
@@ -234,45 +293,33 @@ def compose_view(
234
  safe_rgba.putalpha(Image.fromarray(alpha_channel, mode="L"))
235
  out = Image.alpha_composite(out, safe_rgba)
236
 
237
- if hazard_on and "Hazard overlay" in images_dict:
238
  hazard = images_dict.get("Hazard overlay")
239
  if hazard is not None:
240
  hazard_rgba = hazard.convert("RGBA")
241
- alpha_factor = max(0.0, min(1.0, hazard_alpha))
242
  alpha_channel = np.array(hazard_rgba.getchannel("A"), dtype=np.uint8)
243
  alpha_channel = (alpha_channel.astype(np.float32) * alpha_factor).astype(np.uint8)
244
  hazard_rgba.putalpha(Image.fromarray(alpha_channel, mode="L"))
245
  out = Image.alpha_composite(out, hazard_rgba)
246
 
247
- if water_on and "Water hazard overlay" in images_dict:
248
- water = images_dict.get("Water hazard overlay")
249
- if water is not None:
250
- water_rgba = water.convert("RGBA")
251
- alpha_factor = max(0.0, min(1.0, hazard_alpha))
252
- alpha_channel = np.array(water_rgba.getchannel("A"), dtype=np.uint8)
253
- alpha_channel = (alpha_channel.astype(np.float32) * alpha_factor).astype(np.uint8)
254
- water_rgba.putalpha(Image.fromarray(alpha_channel, mode="L"))
255
- out = Image.alpha_composite(out, water_rgba)
256
-
257
- if road_on and "Road hazard overlay" in images_dict:
258
- road = images_dict.get("Road hazard overlay")
259
- if road is not None:
260
- road_rgba = road.convert("RGBA")
261
- alpha_factor = max(0.0, min(1.0, hazard_alpha))
262
- alpha_channel = np.array(road_rgba.getchannel("A"), dtype=np.uint8)
263
- alpha_channel = (alpha_channel.astype(np.float32) * alpha_factor).astype(np.uint8)
264
- road_rgba.putalpha(Image.fromarray(alpha_channel, mode="L"))
265
- out = Image.alpha_composite(out, road_rgba)
266
-
267
- if tree_on and "Tree hazard overlay" in images_dict:
268
- tree = images_dict.get("Tree hazard overlay")
269
- if tree is not None:
270
- tree_rgba = tree.convert("RGBA")
271
- alpha_factor = max(0.0, min(1.0, hazard_alpha))
272
- alpha_channel = np.array(tree_rgba.getchannel("A"), dtype=np.uint8)
273
- alpha_channel = (alpha_channel.astype(np.float32) * alpha_factor).astype(np.uint8)
274
- tree_rgba.putalpha(Image.fromarray(alpha_channel, mode="L"))
275
- out = Image.alpha_composite(out, tree_rgba)
276
 
277
  if grad_on and "Depth gradient" in images_dict:
278
  grad_img = images_dict["Depth gradient"]
 
10
  GRAD_ALPHA = 0.35
11
  FLAT_ALPHA = 0.25
12
 
13
+
14
+ def _outline_mask(mask: np.ndarray | None) -> np.ndarray:
15
+ """Compute a 1px outline around a boolean mask without external deps."""
16
+ if mask is None:
17
+ return np.zeros((1, 1), dtype=bool)
18
+ mask_bool = mask.astype(bool)
19
+ h, w = mask_bool.shape
20
+ padded = np.pad(mask_bool, 1, mode="edge")
21
+ dilated = np.zeros_like(mask_bool, dtype=bool)
22
+ for dy in (-1, 0, 1):
23
+ for dx in (-1, 0, 1):
24
+ if dx == 0 and dy == 0:
25
+ continue
26
+ dilated |= padded[1 + dy : 1 + dy + h, 1 + dx : 1 + dx + w]
27
+ border = dilated & (~mask_bool)
28
+ return border
29
+
30
  def make_safety_heatmap(
31
  rgb: Image.Image,
32
  safe_mask: np.ndarray,
 
40
 
41
  h, w = safe.shape
42
  safe_overlay = np.zeros((h, w, 4), dtype=np.uint8)
43
+ safe_mask_bool = safe > 0.0
44
+ # Semi-transparent fill plus thicker outline for visibility (#00BF00) at ~80% alpha
45
+ safe_overlay[safe_mask_bool, :] = (0, 191, 0, 204)
46
+ safe_border = _outline_mask(safe_mask_bool)
47
+ safe_border |= _outline_mask(safe_border) # thicken outline to ~2px
48
+ safe_overlay[safe_border, :] = (0, 191, 0, 255)
49
 
50
  risk_focus = np.zeros_like(risk)
51
  risk_focus[risk > risk_threshold] = risk[risk > risk_threshold]
52
  hazard_intensity = np.where(hazard, np.maximum(risk_focus, 1.0), risk_focus)
53
  hazard_alpha = (np.clip(hazard_intensity, 0.0, 1.0) * 255).astype(np.uint8)
54
  hazard_overlay = np.zeros((h, w, 4), dtype=np.uint8)
55
+ hazard_overlay[..., 0] = 255 # pure red for depth-based hazards
56
  hazard_overlay[..., 3] = hazard_alpha
57
+ hazard_mask_bool = hazard
58
+ hazard_border = _outline_mask(hazard_mask_bool)
59
+ hazard_overlay[hazard_border, :] = (255, 0, 0, 255)
60
 
61
  safe_img = Image.fromarray(safe_overlay, mode="RGBA").resize(rgb.size, resample=Image.NEAREST)
62
  hazard_img = Image.fromarray(hazard_overlay, mode="RGBA").resize(rgb.size, resample=Image.NEAREST)
 
125
  roof_mask_img = _mask_to_image(roof_mask)
126
  tree_mask_img = _mask_to_image(tree_mask)
127
 
128
+ def _hatched_overlay(
129
+ mask: np.ndarray | None,
130
+ color: tuple[int, int, int],
131
+ alpha: int = 180,
132
+ hatch_step: int = 8,
133
+ hatch_thickness: int = 3,
134
+ ) -> Image.Image:
135
  if mask is None:
136
  return Image.new("RGBA", image.size, (0, 0, 0, 0))
137
+ m = np.array(
138
+ Image.fromarray((mask.astype(np.uint8) * 255)).resize(image.size, resample=Image.NEAREST)
139
+ ).astype(bool)
140
+ overlay = np.zeros((image.height, image.width, 4), dtype=np.uint8)
141
+ overlay[..., :3] = np.array(color, dtype=np.uint8)
142
+ overlay[..., 3] = np.where(m, alpha, 0).astype(np.uint8)
143
+ if hatch_step > 0 and hatch_thickness > 0:
144
+ xs = np.arange(image.width, dtype=np.int32)
145
+ ys = np.arange(image.height, dtype=np.int32)
146
+ grid_x, grid_y = np.meshgrid(xs, ys)
147
+ stripe = ((grid_x + grid_y) % hatch_step) < hatch_thickness
148
+ stripe_alpha = (alpha // 3)
149
+ overlay[..., 3] = np.where(m & stripe, stripe_alpha, overlay[..., 3])
150
+ return Image.fromarray(overlay, mode="RGBA")
151
+
152
+ # Segmentation hazards: black with hatch to separate from depth-risk red.
153
+ hazard_color = (0, 0, 0)
154
+ overlays = []
155
+ for mask, alpha in (
156
+ (water_mask, 130),
157
+ (road_mask, 130),
158
+ (tree_mask, 130),
159
+ (roof_mask, 150),
160
+ ):
161
+ ov = _hatched_overlay(mask, hazard_color, alpha=alpha, hatch_step=24, hatch_thickness=2)
162
+ overlays.append(ov)
163
+ water_hazard_overlay, road_hazard_overlay, tree_hazard_overlay, roof_hazard_overlay = overlays
164
+ for overlay in overlays:
165
+ mask = np.array(overlay.getchannel("A")) > 0
166
+ if not mask.any():
167
+ continue
168
+ border = _outline_mask(mask)
169
+ if not border.any():
170
+ continue
171
+ arr = np.array(overlay)
172
+ arr[border, :] = (0, 0, 0, 255)
173
+ arr[np.logical_and(border, arr[..., 3] > 0), 3] = 255
174
+ overlay.paste(Image.fromarray(arr, mode="RGBA"))
175
 
176
  safe_overlay, hazard_overlay, heat_gray = make_safety_heatmap(image, safe_mask, hazard_mask, risk_map)
177
  flat_heat_overlay = make_flatness_heatmap(std_map_vis, image.size)
 
217
  cy_draw = int(round(min(max(cy_img, by0), by1)))
218
  overlay_box = Image.new("RGBA", image.size, (0, 0, 0, 0))
219
  box_draw = ImageDraw.Draw(overlay_box)
220
+ fill = (255, 140, 0, 90) # translucent orange
221
+ outline = (255, 140, 0, 255)
222
 
223
+ # Crosshair sized 3x the landing box for clearer focus, thicker lines.
224
  cross_half = int(round(side_img * 1.5))
225
  hx0 = max(0, cx_draw - cross_half)
226
  hx1 = min(image.width - 1, cx_draw + cross_half)
227
  hy0 = max(0, cy_draw - cross_half)
228
  hy1 = min(image.height - 1, cy_draw + cross_half)
229
+ cross_width = 6
230
  draw.line((hx0, cy_draw, hx1, cy_draw), fill=outline, width=cross_width)
231
  draw.line((cx_draw, hy0, cx_draw, hy1), fill=outline, width=cross_width)
232
 
233
+ box_draw.rectangle((bx0, by0, bx1, by1), fill=fill, outline=outline, width=6)
234
+ box_draw.line((cx_draw, by0, cx_draw, by1), fill=outline, width=3)
235
+ box_draw.line((bx0, cy_draw, bx1, cy_draw), fill=outline, width=3)
236
+ radius = 10
237
  box_draw.ellipse((cx_draw - radius, cy_draw - radius, cx_draw + radius, cy_draw + radius), fill=outline)
238
 
239
  return {
 
251
  "Water hazard overlay": water_hazard_overlay,
252
  "Road hazard overlay": road_hazard_overlay,
253
  "Tree hazard overlay": tree_hazard_overlay,
254
+ "Roof hazard overlay": roof_hazard_overlay,
255
  "Flatness heatmap overlay": flat_heat_overlay,
256
  "Safety score": heat_gray,
257
  "Landing spot overlay": Image.alpha_composite(spot_overlay, overlay_box),
 
263
  base_view: str,
264
  heat_on: bool,
265
  heat_alpha: float,
266
+ risk_on: bool,
267
+ risk_alpha: float,
268
+ hazards_on: bool,
 
 
269
  grad_on: bool,
270
  flat_on: bool,
271
  flat_heat_on: bool,
 
293
  safe_rgba.putalpha(Image.fromarray(alpha_channel, mode="L"))
294
  out = Image.alpha_composite(out, safe_rgba)
295
 
296
+ if risk_on and "Hazard overlay" in images_dict:
297
  hazard = images_dict.get("Hazard overlay")
298
  if hazard is not None:
299
  hazard_rgba = hazard.convert("RGBA")
300
+ alpha_factor = max(0.0, min(1.0, risk_alpha))
301
  alpha_channel = np.array(hazard_rgba.getchannel("A"), dtype=np.uint8)
302
  alpha_channel = (alpha_channel.astype(np.float32) * alpha_factor).astype(np.uint8)
303
  hazard_rgba.putalpha(Image.fromarray(alpha_channel, mode="L"))
304
  out = Image.alpha_composite(out, hazard_rgba)
305
 
306
+ if hazards_on:
307
+ if "Water hazard overlay" in images_dict:
308
+ water = images_dict.get("Water hazard overlay")
309
+ if water is not None:
310
+ out = Image.alpha_composite(out, water.convert("RGBA"))
311
+ if "Road hazard overlay" in images_dict:
312
+ road = images_dict.get("Road hazard overlay")
313
+ if road is not None:
314
+ out = Image.alpha_composite(out, road.convert("RGBA"))
315
+ if "Tree hazard overlay" in images_dict:
316
+ tree = images_dict.get("Tree hazard overlay")
317
+ if tree is not None:
318
+ out = Image.alpha_composite(out, tree.convert("RGBA"))
319
+ if "Roof hazard overlay" in images_dict:
320
+ roof = images_dict.get("Roof hazard overlay")
321
+ if roof is not None:
322
+ out = Image.alpha_composite(out, roof.convert("RGBA"))
 
 
 
 
 
 
 
 
 
 
 
 
323
 
324
  if grad_on and "Depth gradient" in images_dict:
325
  grad_img = images_dict["Depth gradient"]