from __future__ import annotations
from pathlib import Path
from typing import Dict
from PIL import Image
import gradio as gr
from .config import DEFAULT_ANALYZER_SETTINGS, IMAGE_EXTS
from .data_sources import list_all_data_inputs
from .safety import AnalysisRequest, AnalysisSummary, SafetyAnalyzer
from .visualization import compose_view
def _make_request(
footprint_m,
std_thresh,
grad_thresh,
use_water_mask,
use_road_mask,
use_roof_mask,
use_tree_mask,
water_prompt,
road_prompt,
roof_prompt,
tree_prompt,
altitude_m,
fov_deg,
clearance_factor,
process_res_cap,
segmentation_max_side,
segmentation_model_id,
segmentation_score_thresh,
segmentation_mask_thresh,
coverage_strictness,
model_id,
openness_weight,
texture_threshold,
) -> AnalysisRequest:
return AnalysisRequest(
footprint_m=footprint_m,
std_thresh=std_thresh,
grad_thresh=grad_thresh,
use_water_mask=use_water_mask,
use_road_mask=use_road_mask,
use_roof_mask=use_roof_mask,
use_tree_mask=use_tree_mask,
water_prompt=water_prompt,
road_prompt=road_prompt,
roof_prompt=roof_prompt,
tree_prompt=tree_prompt,
altitude_m=altitude_m,
fov_deg=fov_deg,
clearance_factor=clearance_factor,
process_res_cap=process_res_cap,
depth_smoothing_base=0.0,
segmentation_max_side=segmentation_max_side,
segmentation_model_id=segmentation_model_id,
segmentation_score_thresh=segmentation_score_thresh,
segmentation_mask_thresh=segmentation_mask_thresh,
coverage_strictness=coverage_strictness,
model_id=model_id,
openness_weight=openness_weight,
texture_threshold=texture_threshold,
)
def _format_status(summary: AnalysisSummary | None) -> str:
if not summary:
return "**Status**\nAwaiting analysis."
masks_line = " / ".join(
f"{label}:{'on' if enabled else 'off'}"
for label, enabled in (
("Water", summary.water_mask_enabled),
("Road", summary.road_mask_enabled),
("Tree", summary.tree_mask_enabled),
("Roof", summary.roof_mask_enabled),
)
)
warning_text = ""
if summary.warnings:
warning_text = "\nWarnings: " + " | ".join(summary.warnings)
return (
"**Run Status**\n"
f"- Model: `{summary.model_id}`\n"
f"- Process res: {summary.process_resolution}px; Runtime: {summary.runtime_ms:.0f} ms\n"
f"- Footprint: {summary.footprint_m:.1f} m (~{summary.footprint_image_px}px)\n"
f"- Safe: {summary.safe_area_pct:.1f}% | Hazard: {summary.hazard_pct:.1f}%"
f"\n- Masks: {masks_line}"
f"{warning_text}"
)
def _format_metrics(summary: AnalysisSummary | None) -> str:
if not summary:
return "No metrics yet. Run the analyzer to populate this section."
lines = []
if not summary.used_valid_center:
lines.append("Warning: No fully safe footprint; showing lowest-variance patch.")
if summary.warnings:
warn_lines = "
".join(f"⚠️ {msg}" for msg in summary.warnings)
lines.append(warn_lines)
return "
".join(lines)
def build_ui(analyzer: SafetyAnalyzer | None = None) -> gr.Blocks:
analyzer = analyzer or SafetyAnalyzer()
defaults = DEFAULT_ANALYZER_SETTINGS
data_inputs = list_all_data_inputs()
with gr.Blocks(title="Drone Landing Site Safety Analyzer") as demo:
gr.Markdown(
"## Drone Landing Site Safety Analyzer\n"
"Evaluate aerial imagery to spot flat, obstacle-free landing sites.",
elem_classes="tight-title",
)
gr.HTML(
"""
"""
)
images_state = gr.State({})
with gr.Row(equal_height=False):
with gr.Column(scale=1, min_width=280):
input_path = gr.Dropdown(
label="Input file",
choices=data_inputs,
value=data_inputs[0] if data_inputs else None,
allow_custom_value=True,
info="Pick any VISLOC image under data/Image/VISLOC/.",
)
model_id = defaults.model_id
process_res_cap = gr.Slider(
label="Processing max side (px)",
value=defaults.process_res_cap,
minimum=512,
maximum=2048,
step=32,
info="Global resolution cap for depth/analysis steps.",
)
footprint_m = gr.Slider(
label="Landing footprint (meters)",
value=defaults.footprint_m,
minimum=1,
maximum=150,
step=1,
info="Side length required for a clear landing zone.",
)
std_thresh = gr.Slider(
label="Flatness threshold",
value=defaults.std_thresh,
minimum=0.001,
maximum=0.08,
step=0.001,
info="Lower favors flatter regions but may reduce candidates.",
)
grad_thresh = gr.Slider(
label="Gradient threshold",
value=defaults.grad_thresh,
minimum=0.02,
maximum=1.0,
step=0.01,
info="Lower suppresses slopes/edges; higher tolerates tilt.",
)
with gr.Accordion("Hazard segmentation", open=False):
gr.Markdown("Select which hazards to mask using SAM3 segmentation.")
with gr.Row():
use_water_mask = gr.Checkbox(label="Exclude water", value=True, info="Mask water regions.")
use_road_mask = gr.Checkbox(label="Exclude roads", value=True, info="Mask road surfaces.")
with gr.Row():
use_tree_mask = gr.Checkbox(label="Exclude trees", value=True, info="Mask trees/foliage.")
use_roof_mask = gr.Checkbox(label="Exclude rooftops", value=True, info="Mask rooftop areas.")
with gr.Accordion("Segmentation settings", open=False):
gr.Markdown("Control SAM3 and prompts.")
segmentation_model_id = gr.Dropdown(
label="Segmentation model",
value=defaults.segmentation_model_id,
choices=[
("SAM3", "facebook/sam3"),
],
info="Choose segmentation backbone for water/road masks (SAM3 only).",
)
segmentation_max_side = gr.Slider(
label="Segmentation max side (px)",
value=defaults.segmentation_max_side,
minimum=256,
maximum=2048,
step=32,
info="Largest long-side resolution for running the segmentation model.",
)
water_prompt = gr.Textbox(
label="Water prompt",
value=defaults.water_prompt,
placeholder="e.g., water",
)
road_prompt = gr.Textbox(
label="Road prompt",
value=defaults.road_prompt,
placeholder="e.g., road",
)
roof_prompt = gr.Textbox(
label="Roof prompt",
value=defaults.roof_prompt,
placeholder="e.g., roof",
)
tree_prompt = gr.Textbox(
label="Tree prompt",
value=defaults.tree_prompt,
placeholder="e.g., tree",
)
segmentation_score_thresh = gr.Slider(
label="Segmentation score threshold",
value=defaults.segmentation_score_thresh,
minimum=0.1,
maximum=0.9,
step=0.05,
info="Minimum instance confidence from SAM3 to keep a mask.",
)
segmentation_mask_thresh = gr.Slider(
label="Segmentation mask threshold",
value=defaults.segmentation_mask_thresh,
minimum=0.1,
maximum=0.9,
step=0.05,
info="Pixel probability threshold when binarizing SAM3 masks.",
)
with gr.Accordion("Advanced settings", open=False):
gr.Markdown("Adjust detail levels and scoring.")
clearance_factor = gr.Slider(
label="Clearance factor",
value=defaults.clearance_factor,
minimum=0.0,
maximum=2.0,
step=0.05,
info="Dilate unsafe regions relative to footprint size.",
)
coverage_strictness = gr.Slider(
label="Coverage strictness",
value=defaults.coverage_strictness,
minimum=0.8,
maximum=1.0,
step=0.001,
info="Minimum fraction of the footprint that must be safe.",
)
openness_weight = gr.Slider(
label="Open-area preference",
value=defaults.openness_weight,
minimum=0.0,
maximum=1.0,
step=0.05,
info="Higher favors distance from hazards over absolute flatness when picking the landing spot.",
)
texture_threshold = gr.Slider(
label="Clutter tolerance",
value=defaults.texture_threshold,
minimum=0.0,
maximum=1.0,
step=0.05,
info="Lower values avoid visually textured (high-contrast) regions like tracks or debris.",
)
# Plane removal fixed to least squares; toggle removed.
with gr.Accordion("Camera settings", open=False):
gr.Markdown("Configure capture assumptions for footprint sizing.")
altitude_m = gr.Slider(
label="Camera altitude (m)",
value=defaults.altitude_m,
minimum=10,
maximum=1500,
step=5,
)
fov_deg = gr.Slider(
label="Camera FOV (deg)",
value=defaults.fov_deg,
minimum=30,
maximum=150,
step=1,
)
with gr.Column(scale=2, min_width=520, elem_id="preview-wrap", elem_classes="preview-col"):
main_view = gr.Image(
label="Analyzed",
height=485,
elem_id="main-preview",
buttons=["download"],
)
orig_view = gr.Image(
label="Original",
height=485,
buttons=["download"],
)
gr.HTML(
"""
"""
)
with gr.Column(scale=1, min_width=220):
base_view = gr.Dropdown(
label="Base view",
value="RGB",
choices=[
"RGB",
"Depth",
"Flatness map (std)",
"Depth gradient",
"Gradient mask",
"Water mask",
"Road mask",
"Tree mask",
"Safety score",
"Safety heatmap overlay",
],
)
spot_on = gr.Checkbox(label="Show optimal landing spot", value=True, info="Show recommended landing box/marker.")
hazard_on = gr.Checkbox(label="Risk highlight", value=True, info="Show depth-based risk map.")
hazards_on = gr.Checkbox(label="Segmented hazard highlight", value=True, info="Show segmentation + roof hazards.")
grad_on = gr.Checkbox(label="Depth gradient", value=False, info="Gradient magnitude overlay.")
with gr.Row():
run_btn = gr.Button("Run", variant="primary")
stop_btn = gr.Button("Stop", variant="stop")
status_card = gr.Markdown("**Status:** Awaiting analysis.", elem_classes="tight-status")
metrics_card = gr.Markdown("")
def process_any(
input_path,
footprint_m,
std_thresh,
grad_thresh,
use_water_mask,
use_road_mask,
use_roof_mask,
use_tree_mask,
water_prompt,
road_prompt,
roof_prompt,
tree_prompt,
altitude_m,
fov_deg,
clearance_factor,
process_res_cap,
segmentation_max_side,
segmentation_model_id,
segmentation_score_thresh,
segmentation_mask_thresh,
coverage_strictness,
openness_weight,
texture_threshold,
base_view,
hazard_on,
hazards_on,
grad_on,
spot_on,
):
if not input_path:
raise gr.Error("Select an input image first.")
path = Path(input_path)
if not path.exists():
raise gr.Error(f"Input not found: {path}")
if path.suffix.lower() not in IMAGE_EXTS:
raise gr.Error(f"Unsupported input type for path: {path} (images only)")
request = _make_request(
footprint_m,
std_thresh,
grad_thresh,
use_water_mask,
use_road_mask,
use_roof_mask,
use_tree_mask,
water_prompt,
road_prompt,
roof_prompt,
tree_prompt,
altitude_m,
fov_deg,
clearance_factor,
process_res_cap,
segmentation_max_side,
segmentation_model_id,
segmentation_score_thresh,
segmentation_mask_thresh,
coverage_strictness,
model_id,
openness_weight,
texture_threshold,
)
try:
result = analyzer.process_path(path, request)
except ValueError as exc:
raise gr.Error(str(exc)) from exc
imgs = result.images
summary = result.summary
composed = compose_view(
imgs,
base_view,
True,
0.2,
hazard_on,
0.2,
hazards_on,
grad_on,
False,
False,
spot_on=spot_on,
)
orig = imgs.get("RGB")
orig_with_spot = orig
if spot_on and imgs.get("Landing spot overlay") is not None and orig is not None:
orig_with_spot = Image.alpha_composite(orig.convert("RGBA"), imgs["Landing spot overlay"]).convert("RGB")
view = composed
return imgs, view, orig_with_spot or orig, _format_status(summary), _format_metrics(summary)
run_inputs = [
input_path,
footprint_m,
std_thresh,
grad_thresh,
use_water_mask,
use_road_mask,
use_roof_mask,
use_tree_mask,
water_prompt,
road_prompt,
roof_prompt,
tree_prompt,
altitude_m,
fov_deg,
clearance_factor,
process_res_cap,
segmentation_max_side,
segmentation_model_id,
segmentation_score_thresh,
segmentation_mask_thresh,
coverage_strictness,
openness_weight,
texture_threshold,
base_view,
hazard_on,
hazards_on,
grad_on,
spot_on,
]
run_event = run_btn.click(
fn=process_any,
inputs=run_inputs,
outputs=[images_state, main_view, orig_view, status_card, metrics_card],
)
stop_btn.click(fn=None, inputs=None, outputs=None, cancels=[run_event])
overlay_inputs = [
images_state,
base_view,
hazard_on,
hazards_on,
grad_on,
spot_on,
]
def update_overlays_only(
images_state_val,
base_view_val,
hazard_on_val,
hazards_on_val,
grad_on_val,
spot_on_val,
):
if not images_state_val:
return images_state_val, gr.update(), gr.update(), gr.update(), gr.update()
composed = compose_view(
images_state_val,
base_view_val,
True,
0.2,
hazard_on_val,
0.2,
hazards_on_val,
grad_on_val,
False,
False,
spot_on_val,
)
return images_state_val, composed, gr.update(), gr.update(), gr.update()
base_view.change(
fn=update_overlays_only,
inputs=overlay_inputs,
outputs=[images_state, main_view, orig_view, status_card, metrics_card],
)
overlay_toggle_controls = (
hazard_on,
hazards_on,
grad_on,
spot_on,
)
for control in overlay_toggle_controls:
control.change(
fn=update_overlays_only,
inputs=overlay_inputs,
outputs=[images_state, main_view, orig_view, status_card, metrics_card],
)
# Opacity sliders removed; overlays now use fixed alpha.
return demo
__all__ = ["build_ui"]