Files
Ayzen b88d704126 Port the SAR3D background gate, CLEAN and object boxes from Multi_depthnorm_clean
- BG_GATE_ENABLE: frame peak over its median background (sar3d_background); the
  GUI scales the frame with bg_gate_gain and its cloud threshold.
- SAR_CLEAN_ENABLE: CLEAN of a one-frame aperture on a 4x10x4 cm search grid over
  the whole scene; objects in their own scale (sar3d_objects).
- SAR_MARK_ENABLE: boxes by class around object groups (sar3d_marks), with the
  legend under the views.
- SAR_VOXEL_M as three steps X/Y/Z with the notebook defaults 0.025/0.02/0.02.
- GPU backend: image/download/load/project API, per-plane block reduction for the
  maximum, depth gate without FMA; sar3d host code built with -ffp-contract=off.
- References regenerated from notebook cells 1 and 5, with new CLEAN scenes and a
  synthetic box-rule case; docs updated.
2026-10-16 12:08:57 +03:00

259 lines
14 KiB
Python

"""Dataclass schema for full GUI config profiles."""
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
import json
import logging
from pathlib import Path
from typing import Protocol
from typing import Any, TypeVar
from python_app.models.run_config_schema import RunConfigModel
logger = logging.getLogger(__name__)
Scalar = str | int | float | bool
_ScalarT = TypeVar("_ScalarT", str, int, float, bool)
@dataclass(frozen=True, slots=True)
class Setting:
"""Qt-free binding and validation metadata for one persistent GUI field."""
widget: str = ""
live: str = ""
web: str = ""
group: str = ""
choices: tuple[str, ...] = ()
minimum: float | None = None
maximum: float | None = None
exclusive_minimum: bool = False
def setting(
default: _ScalarT, widget: str = "", *, live: str = "", web: str = "",
group: str = "", choices: tuple[str, ...] = (), minimum: float | None = None,
maximum: float | None = None, exclusive_minimum: bool = False,
) -> _ScalarT:
"""Declare a typed dataclass field and its single profile/live/web binding."""
return field(default=default, metadata={"setting": Setting(
widget, live, web or live, group, choices, minimum, maximum, exclusive_minimum,
)})
@dataclass(slots=True)
class GuiSwitchStateModel:
"""UI-only state for switch selection controls."""
combo_mode: str = setting("text", choices=('text', 'single'))
combos_text: str = setting("", '_combos_text')
single_input: str = setting("0", '_single_combo_input')
single_output: str = setting("0", '_single_combo_output')
@dataclass(slots=True)
class GuiPassThroughStateModel:
"""UI-only defaults for pass-through rendering."""
show_magnitude: bool = setting(True, '_show_magnitude_checkbox')
show_phase: bool = setting(True, '_show_phase_checkbox')
unwrap_phase: bool = setting(False, '_unwrap_phase_checkbox')
combo_filter: str = setting("", '_pass_through_combo_filter_input', web='combo_filter', group='Display')
fixed_y_enabled: bool = setting(False, '_pass_through_fixed_y_enabled', web='pass_through_fixed_y_enabled', group='Pass-through')
y_min_db: float = setting(-100.0, '_pass_through_y_min_db', web='pass_through_y_min_db', group='Pass-through')
y_max_db: float = setting(0.0, '_pass_through_y_max_db', web='pass_through_y_max_db', group='Pass-through')
@dataclass(slots=True)
class GuiBscanStateModel:
"""UI-only defaults for B-scan live settings."""
axis: str = setting("abs", '_bscan_axis', live='bscan_axis', group='B-scan', choices=('abs', 'real', 'phase'))
cut_m: float = setting(0.824, '_bscan_cut_m', live='bscan_cut_m', group='B-scan')
max_depth_m: float = setting(1.0, '_bscan_max_depth_m', live='bscan_max_depth_m', group='B-scan')
gain: float = setting(1.0, '_bscan_gain', live='bscan_gain', group='B-scan')
start_freq_mhz: float = setting(100.0, '_bscan_start_freq_mhz', live='bscan_start_freq_mhz', group='B-scan')
stop_freq_mhz: float = setting(8800.0, '_bscan_stop_freq_mhz', live='bscan_stop_freq_mhz', group='B-scan')
subtract_mean_ascan: bool = setting(False, '_bscan_subtract_mean_ascan', web='subtract_mean_ascan', group='Display')
# How many past sweeps the B-scan heatmap renders once acquisition is stopped.
# While running the window stays at the C++ replay window (see
# `_cpp_bscan_replay_window_for_config`); this only widens the stopped-mode view.
history_window_scans: int = setting(50, '_bscan_history_window')
@dataclass(slots=True)
class GuiGprStateModel:
"""UI-only defaults for coherent backprojection GPR live settings."""
input_positions: str = setting("", '_gpr_input_positions_input', live='gpr_input_positions', group='Geometry & depth')
output_positions: str = setting("", '_gpr_output_positions_input', live='gpr_output_positions', group='Geometry & depth')
min_depth_m: float = setting(2.0, '_gpr_min_depth_m', live='gpr_min_depth_m', group='Geometry & depth')
max_depth_m: float = setting(14.0, '_gpr_max_depth_m', live='gpr_max_depth_m', group='Geometry & depth')
range_comp_power: float = setting(0.1, '_gpr_range_comp_power', live='gpr_range_comp_power', group='Imaging', minimum=0)
# BP object-detection stop level, fraction of the global peak. Angle compensation and
# permittivity are fixed for coherent BP, so they are not exposed here.
object_min_frac: float = setting(0.7, '_gpr_object_min_frac', live='gpr_object_min_frac', group='Detection', minimum=0, maximum=1)
score_mode: str = setting("combined", '_gpr_score_mode', live='gpr_score_mode', group='Imaging', choices=('peak', 'combined'))
motion_mode: str = setting("int_minus", '_gpr_motion_mode', live='gpr_motion_mode', group='Motion', choices=('int_minus', 'int_focus'))
# Intra-sweep motion-correction inputs. Sweep time is derived from acquisition
# metadata; speed comes from the socket unless `ignore_socket_speed_enabled`,
# in which case `speed_m_s` from here is used.
look_angle_deg: float = setting(0.0, '_gpr_look_angle_deg', live='gpr_look_angle_deg', group='Motion')
direction_sign: float = setting(1.0, '_gpr_direction_sign', live='gpr_direction_sign', group='Motion')
speed_m_s: float = setting(0.0, '_gpr_speed_m_s', live='gpr_speed_m_s', group='Motion')
ignore_socket_speed_enabled: bool = setting(False, '_gpr_ignore_socket_speed_enabled', live='ignore_socket_speed', group='Motion')
max_detected_objects_to_draw: int = setting(
5, '_gpr_max_detected_objects_to_draw', live='gpr_max_detected_objects_to_draw', group='Detection', minimum=0,
)
draw_top_m_objects: int = setting(2, '_gpr_draw_top_m_objects', live='gpr_draw_top_m_objects', group='Detection', minimum=0)
# Cross-frame approach filter: show an object only after it persists as a
# motion-consistent track this many consecutive frames (<= 1 disables it).
object_approach_min_frames: int = setting(
3, '_gpr_object_approach_min_frames', live='gpr_object_approach_min_frames', group='Detection', minimum=1,
)
start_freq_mhz: float = setting(3000.0, '_gpr_start_freq_mhz', live='gpr_start_freq_mhz', group='Geometry & depth')
stop_freq_mhz: float = setting(6000.0, '_gpr_stop_freq_mhz', live='gpr_stop_freq_mhz', group='Geometry & depth')
background_subtract_enabled: bool = setting(True, '_gpr_background_subtract_enabled', live='gpr_background_subtract_enabled', group='Imaging')
background_mean_count: int = setting(10, '_gpr_background_mean_count', live='gpr_background_mean_count', group='Imaging')
remove_sidelobe_objects_enabled: bool = setting(
True, '_gpr_remove_sidelobe_objects_enabled', live='gpr_remove_sidelobe_objects_enabled', group='Imaging',
)
bp_ground_depth_m: float = setting(1.25, '_gpr_bp_ground_depth_m', live='gpr_bp_ground_depth_m', group='Geometry & depth', minimum=0)
pair_depth_equalize: bool = setting(True, '_gpr_pair_depth_equalize', live='pair_depth_equalize', group='Imaging')
render_mode: str = setting("heatmap", '_gpr_render_mode', web='render_mode', group='Display', choices=('heatmap', 'objects_only'))
visible_x_min_m: float = setting(-2.0, '_gpr_visible_x_min_m', live='gpr_visible_x_min_m', group='Visible window')
visible_x_max_m: float = setting(2.0, '_gpr_visible_x_max_m', live='gpr_visible_x_max_m', group='Visible window')
visible_z_min_m: float = setting(0.0, '_gpr_visible_z_min_m', live='gpr_visible_z_min_m', group='Visible window')
visible_z_max_m: float = setting(14.0, '_gpr_visible_z_max_m', live='gpr_visible_z_max_m', group='Visible window')
@dataclass(slots=True)
class GuiSar3dStateModel:
"""UI-only defaults for the 3D SAR (sar3d) live settings.
Values are the reference notebook's (Multi_depthnorm_clean.ipynb), whose
names the GUI shows: INPUT_IDX, F_START, MIN_DEPTH, LOOK_ANGLE_DEG, ...
"""
input_positions: str = setting("", '_sar3d_input_positions_input', live='gpr_input_positions', group='Geometry & depth')
output_positions: str = setting("", '_sar3d_output_positions_input', live='gpr_output_positions', group='Geometry & depth')
min_depth_m: float = setting(2.0, '_sar3d_min_depth_m', live='gpr_min_depth_m', group='Geometry & depth')
max_depth_m: float = setting(8.0, '_sar3d_max_depth_m', live='gpr_max_depth_m', group='Geometry & depth')
start_freq_mhz: float = setting(1500.0, '_sar3d_start_freq_mhz', live='gpr_start_freq_mhz', group='Geometry & depth')
stop_freq_mhz: float = setting(6000.0, '_sar3d_stop_freq_mhz', live='gpr_stop_freq_mhz', group='Geometry & depth')
speed_m_s: float = setting(0.0, '_sar3d_speed_m_s', live='gpr_speed_m_s', group='Motion')
ignore_socket_speed_enabled: bool = setting(False, '_sar3d_ignore_socket_speed_enabled', live='ignore_socket_speed', group='Motion')
look_angle_deg: float = setting(19.5, '_sar3d_look_angle_deg', live='gpr_look_angle_deg', group='Motion')
frame_radius: int = setting(0, '_sar3d_frame_radius', live='sar3d_frame_radius', group='3D SAR', minimum=0)
voxel_x_m: float = setting(0.025, '_sar3d_voxel_x_m', live='sar3d_voxel_x_m', group='3D SAR', minimum=0, exclusive_minimum=True)
voxel_y_m: float = setting(0.02, '_sar3d_voxel_y_m', live='sar3d_voxel_y_m', group='3D SAR', minimum=0, exclusive_minimum=True)
voxel_z_m: float = setting(0.02, '_sar3d_voxel_z_m', live='sar3d_voxel_z_m', group='3D SAR', minimum=0, exclusive_minimum=True)
bp_ground_depth_m: float = setting(1.25, '_sar3d_bp_ground_depth_m', live='sar3d_bp_ground_depth_m', group='3D SAR')
comp_range_power: float = setting(0.3, '_sar3d_comp_range_power', live='sar3d_comp_range_power', group='3D SAR')
use_compensation: bool = setting(True, '_sar3d_use_compensation', live='sar3d_use_compensation', group='3D SAR')
normalize_each_frame: bool = setting(True, '_sar3d_normalize_each_frame', live='sar3d_normalize_each_frame', group='3D SAR')
pair_depth_equalize: bool = setting(True, '_sar3d_pair_depth_equalize', live='pair_depth_equalize', group='3D SAR')
background_subtract_enabled: bool = setting(True, '_sar3d_background_subtract_enabled', live='gpr_background_subtract_enabled', group='3D SAR')
background_mean_count: int = setting(10, '_sar3d_background_mean_count', live='gpr_background_mean_count', group='3D SAR', minimum=0)
bg_gate_enabled: bool = setting(True, '_sar3d_bg_gate_enabled', live='sar3d_bg_gate_enabled', group='3D SAR')
clean_enabled: bool = setting(True, '_sar3d_clean_enabled', live='sar3d_clean_enabled', group='3D SAR')
x_size_m: float = setting(0.0, '_sar3d_x_size_m', live='sar3d_x_size_m', group='Scene', minimum=0)
y_min_m: float = setting(-0.5, '_sar3d_y_min_m', live='sar3d_y_min_m', group='Scene')
y_max_m: float = setting(2.0, '_sar3d_y_max_m', live='sar3d_y_max_m', group='Scene')
z_size_m: float = setting(0.0, '_sar3d_z_size_m', live='sar3d_z_size_m', group='Scene', minimum=0)
cloud_threshold: float = setting(0.65, '_sar3d_cloud_threshold', web='sar3d_cloud_threshold', group='Display', minimum=0, maximum=1)
zoom_percent: int = setting(100, '_sar3d_zoom_percent', web='sar3d_zoom_percent', group='Display', minimum=100, maximum=1000)
mark_enabled: bool = setting(True, '_sar3d_mark_enabled', web='sar3d_mark_enabled', group='Display')
@dataclass(slots=True)
class GuiProcessingStateModel:
"""UI-only processing-section defaults."""
selected_mode: str = setting(
"pass_through", '_processing_mode', live='processor_mode', group='Processor', choices=('pass_through', 'bscan', 'gpr', 'sar3d'),
)
pass_through: GuiPassThroughStateModel = field(default_factory=GuiPassThroughStateModel)
bscan: GuiBscanStateModel = field(default_factory=GuiBscanStateModel)
gpr: GuiGprStateModel = field(default_factory=GuiGprStateModel)
sar3d: GuiSar3dStateModel = field(default_factory=GuiSar3dStateModel)
@dataclass(slots=True)
class GuiDataActionsStateModel:
"""UI-only defaults for snapshot/export controls."""
save_path: str = setting("", '_save_path_input')
save_name: str = setting("snapshot_manual", '_save_name_input')
# How many freshly acquired measurements the "Start + Record" action writes to
# disk before it stops recording (acquisition keeps running).
record_count: int = setting(100, '_record_count')
@dataclass(slots=True)
class GuiPreprocessDialogStateModel:
"""UI-only defaults for preprocessing dialog controls."""
set_name: str = setting("set_001")
radar_config_dir: str = setting("")
use_all_radar_configs: bool = setting(False)
median_sweep_count: int = setting(5)
@dataclass(slots=True)
class GuiStateModel:
"""GUI-only profile payload stored under top-level `gui`."""
version: int = 1
switches: GuiSwitchStateModel = field(default_factory=GuiSwitchStateModel)
processing: GuiProcessingStateModel = field(default_factory=GuiProcessingStateModel)
data_actions: GuiDataActionsStateModel = field(default_factory=GuiDataActionsStateModel)
preprocess_dialog: GuiPreprocessDialogStateModel = field(default_factory=GuiPreprocessDialogStateModel)
@dataclass(slots=True)
class GuiProfileModel:
"""Full GUI profile with pipeline-compatible root config and optional UI state."""
run_config: RunConfigModel = field(default_factory=RunConfigModel)
gui: GuiStateModel | None = None
@staticmethod
def from_dict(payload: dict[str, Any]) -> GuiProfileModel:
"""Build profile from JSON-like payload using codec layer."""
from python_app.models.gui_profile_codec import gui_profile_from_dict
return gui_profile_from_dict(payload)
@classmethod
def load_from_path(cls, path: Path) -> GuiProfileModel:
"""Load a JSON file from disk and decode it into a profile model.
Raises ValueError when the file's JSON root is not an object.
"""
logger.debug("Loading GUI profile from %s", path)
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"Config profile root must be JSON object: {path}")
return cls.from_dict(payload)
def clone(self) -> GuiProfileModel:
"""Create deep copy while preserving whether `gui` is absent."""
return deepcopy(self)
def to_dict(self) -> dict[str, Any]:
"""Encode profile into JSON-serializable dictionary."""
from python_app.models.gui_profile_codec import gui_profile_to_dict
return gui_profile_to_dict(self)
class ProfileWriter(Protocol):
"""Publish the current profile, optionally using the acquisition provenance."""
def __call__(self, path: Path, /, *, allow_overwrite: bool = True,
dataset: bool = False) -> GuiProfileModel: ...