Spaces:
Runtime error
Runtime error
Update UI layout and overlay visuals
Browse files- README.md +1 -1
- app/safety.py +20 -1
- app/ui.py +124 -154
- 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
"
|
| 83 |
-
f"
|
| 84 |
-
f"Footprint: {summary.footprint_m:.1f} m ({summary.footprint_image_px}px
|
| 85 |
-
f"
|
| 86 |
-
|
| 87 |
-
|
|
|
|
| 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 (
|
| 114 |
gr.Markdown(
|
| 115 |
-
"## Landing Site Safety Analyzer\n"
|
| 116 |
-
"Evaluate
|
|
|
|
| 117 |
)
|
| 118 |
gr.HTML(
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 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 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
images_state = gr.State({})
|
| 143 |
|
| 144 |
with gr.Row(equal_height=False):
|
| 145 |
-
with gr.Column(scale=1, min_width=
|
| 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 =
|
| 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 |
-
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
| 220 |
with gr.Accordion("Segmentation settings", open=False):
|
| 221 |
-
gr.Markdown("Control SAM3
|
| 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="
|
| 335 |
-
height=
|
| 336 |
elem_id="main-preview",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
show_fullscreen_button=False,
|
| 338 |
)
|
| 339 |
gr.HTML(
|
| 340 |
"""
|
| 341 |
-
<
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
</div>
|
| 347 |
"""
|
| 348 |
)
|
| 349 |
-
|
| 350 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 426 |
0.2,
|
| 427 |
hazard_on,
|
| 428 |
0.2,
|
| 429 |
-
|
| 430 |
-
road_on,
|
| 431 |
-
tree_on,
|
| 432 |
grad_on,
|
| 433 |
False,
|
| 434 |
False,
|
| 435 |
spot_on=spot_on,
|
| 436 |
)
|
| 437 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 509 |
0.2,
|
| 510 |
hazard_on_val,
|
| 511 |
0.2,
|
| 512 |
-
|
| 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 |
-
|
| 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 |
-
|
| 27 |
-
|
| 28 |
-
safe_overlay[
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
if mask is None:
|
| 107 |
return Image.new("RGBA", image.size, (0, 0, 0, 0))
|
| 108 |
-
m =
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = (
|
| 161 |
-
outline = (
|
| 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 =
|
| 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=
|
| 174 |
-
box_draw.line((cx_draw, by0, cx_draw, by1), fill=outline, width=
|
| 175 |
-
box_draw.line((bx0, cy_draw, bx1, cy_draw), fill=outline, width=
|
| 176 |
-
radius =
|
| 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 |
-
|
| 206 |
-
|
| 207 |
-
|
| 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
|
| 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,
|
| 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
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 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"]
|