282 lines
16 KiB
Python
282 lines
16 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',
|
|
)
|
|
imaging_plane_y_m: float = setting(0.0, '_gpr_imaging_plane_y_m', live='gpr_imaging_plane_y_m', group='Geometry & depth')
|
|
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 GuiLegacyGprStateModel:
|
|
"""UI-only defaults for legacy point/extended GPR live settings."""
|
|
|
|
mode: str = setting("point", '_legacy_gpr_config_mode', live='legacy_gpr_mode', group='Mode', choices=('point', 'extended'))
|
|
input_positions: str = setting("", '_legacy_gpr_input_positions_input', live='gpr_input_positions', group='Geometry & depth')
|
|
output_positions: str = setting("", '_legacy_gpr_output_positions_input', live='gpr_output_positions', group='Geometry & depth')
|
|
min_depth_m: float = setting(2.0, '_legacy_gpr_min_depth_m', live='gpr_min_depth_m', group='Geometry & depth')
|
|
max_depth_m: float = setting(14.0, '_legacy_gpr_max_depth_m', live='gpr_max_depth_m', group='Geometry & depth')
|
|
comp_power: float = setting(0.2, '_legacy_gpr_comp_power', live='gpr_comp_power', group='Detection', minimum=0)
|
|
start_freq_mhz: float = setting(3000.0, '_legacy_gpr_start_freq_mhz', live='gpr_start_freq_mhz', group='Geometry & depth')
|
|
stop_freq_mhz: float = setting(6000.0, '_legacy_gpr_stop_freq_mhz', live='gpr_stop_freq_mhz', group='Geometry & depth')
|
|
speed_m_s: float = setting(0.0, '_legacy_gpr_speed_m_s', live='gpr_speed_m_s', group='Motion')
|
|
ignore_socket_speed_enabled: bool = setting(False, '_legacy_gpr_ignore_socket_speed_enabled', live='ignore_socket_speed', group='Motion')
|
|
look_angle_deg: float = setting(0.0, '_legacy_gpr_look_angle_deg', live='gpr_look_angle_deg', group='Motion')
|
|
apply_freq_phase_correction: bool = setting(
|
|
True, '_legacy_gpr_apply_freq_phase_correction', live='gpr_apply_freq_phase_correction', group='Motion',
|
|
)
|
|
reference_mode: str = setting(
|
|
"frame_center", '_legacy_gpr_reference_mode', live='gpr_reference_mode', group='Motion', choices=('frame_center', 'first_tx_event'),
|
|
)
|
|
snr_thresh: float = setting(4.5, '_legacy_gpr_snr_thresh', live='gpr_snr_thresh', group='Detection', minimum=0)
|
|
snr_comp_max: float = setting(25.0, '_legacy_gpr_snr_comp_max', live='gpr_snr_comp_max', group='Detection', minimum=0)
|
|
background_subtract_enabled: bool = setting(
|
|
True, '_legacy_gpr_background_subtract_enabled', live='gpr_background_subtract_enabled', group='Imaging',
|
|
)
|
|
background_mean_count: int = setting(10, '_legacy_gpr_background_mean_count', live='gpr_background_mean_count', group='Imaging')
|
|
render_mode: str = setting("heatmap", '_legacy_gpr_render_mode', web='render_mode', group='Display', choices=('heatmap', 'objects_only'))
|
|
min_visible_pair_count: int = setting(
|
|
1, '_legacy_gpr_min_visible_pair_count', live='legacy_gpr_min_visible_pair_count', group='Detection', minimum=1,
|
|
)
|
|
visible_x_min_m: float = setting(-2.0, '_legacy_gpr_visible_x_min_m', live='gpr_visible_x_min_m', group='Visible window')
|
|
visible_x_max_m: float = setting(2.0, '_legacy_gpr_visible_x_max_m', live='gpr_visible_x_max_m', group='Visible window')
|
|
visible_z_min_m: float = setting(0.0, '_legacy_gpr_visible_z_min_m', live='gpr_visible_z_min_m', group='Visible window')
|
|
visible_z_max_m: float = setting(14.0, '_legacy_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_antenna_3D_clean_v1.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_m: float = setting(0.02, '_sar3d_voxel_m', live='sar3d_voxel_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')
|
|
|
|
|
|
@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', 'legacy_gpr', 'sar3d'),
|
|
)
|
|
pass_through: GuiPassThroughStateModel = field(default_factory=GuiPassThroughStateModel)
|
|
bscan: GuiBscanStateModel = field(default_factory=GuiBscanStateModel)
|
|
gpr: GuiGprStateModel = field(default_factory=GuiGprStateModel)
|
|
legacy_gpr: GuiLegacyGprStateModel = field(default_factory=GuiLegacyGprStateModel)
|
|
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: ...
|