The sar3d processor is rewritten as a checked port of Multi_antenna_3D_clean_v1.ipynb: NVRTC kernels, timing from the trace stamps, notebook parameter names and one cubic SAR_VOXEL_M. It adds front, side, top and 3D views, drops gpr_cpp and the parity tools, and the GUI speed now applies only while the socket speed is ignored.
329 lines
14 KiB
Python
329 lines
14 KiB
Python
"""UI construction mixin for the main radar control window.
|
|
|
|
Layout is intentionally split into two independent plot surfaces:
|
|
- single `PlotWidget` for B-scan heatmap rendering;
|
|
- single `PlotWidget` for GPR accumulator/annotation rendering;
|
|
- stacked magnitude/phase `PlotWidget`s for pass-through traces.
|
|
|
|
`_set_plot_mode()` switches between these surfaces via `QStackedWidget`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from PyQt6.QtWidgets import (
|
|
QComboBox,
|
|
QFrame,
|
|
QGridLayout,
|
|
QGroupBox,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QPushButton,
|
|
QScrollArea,
|
|
QStackedWidget,
|
|
QTextEdit,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
import pyqtgraph as pg
|
|
|
|
from python_app.gui.controllers.app_window_plot.sar3d_plot_mixin import SAR3D_VIEWS
|
|
from python_app.gui.controllers.sections import (
|
|
build_data_actions_group,
|
|
build_primary_actions_group,
|
|
build_preprocess_summary_group,
|
|
build_processing_group,
|
|
build_radar_group,
|
|
build_switch_group,
|
|
)
|
|
from python_app.logging_setup import LOG_LEVELS
|
|
|
|
|
|
class AppWindowUiMixin:
|
|
"""Build and wire all static widgets used by :class:`AppWindow`.
|
|
|
|
This mixin only creates UI objects and connects lightweight UI signals.
|
|
Runtime logic (pipeline start/stop, polling, drawing, snapshot saving)
|
|
is implemented in other mixins.
|
|
"""
|
|
|
|
def _build_ui(self) -> None:
|
|
"""Build complete main-window layout.
|
|
|
|
High-level structure:
|
|
1. Left: plot stack (`B-scan` page + `trace magnitude/phase` page).
|
|
2. Middle: narrow toggle button to collapse/expand settings.
|
|
3. Right: scrollable settings panel + status/history + runtime log.
|
|
"""
|
|
self.setWindowTitle("Radar System Control")
|
|
root = QWidget(self)
|
|
self.setCentralWidget(root)
|
|
|
|
layout = QHBoxLayout(root)
|
|
layout.setContentsMargins(12, 12, 12, 12)
|
|
layout.setSpacing(14)
|
|
|
|
self._build_plot_area(layout, root)
|
|
self._build_settings_toggle(layout)
|
|
self._build_settings_panel(layout, root)
|
|
|
|
self._toggle_settings_panel(visible=True)
|
|
self.resize(1650, 940)
|
|
|
|
def _build_plot_area(self, root_layout: QHBoxLayout, root: QWidget) -> None:
|
|
"""Build left plot area with stacked B-scan and trace pages."""
|
|
# Plot stack holds mutually exclusive visualization surfaces.
|
|
# We create both upfront and only switch active page at runtime.
|
|
self._plot_stack = QStackedWidget(root)
|
|
self._build_bscan_plot_page()
|
|
self._build_gpr_plot_page()
|
|
self._build_sar3d_plot_page()
|
|
self._build_trace_plot_page()
|
|
|
|
# Default view on startup is pass-through traces.
|
|
self._plot_stack.setCurrentWidget(self._trace_plots_container)
|
|
root_layout.addWidget(self._plot_stack, stretch=11)
|
|
|
|
@staticmethod
|
|
def _create_plot_widget(*, background: str) -> pg.PlotWidget:
|
|
"""Create PlotWidget with pyqtgraph context menu disabled for PyQt6 compatibility."""
|
|
return pg.PlotWidget(background=background, enableMenu=False)
|
|
|
|
def _build_bscan_plot_page(self) -> None:
|
|
"""Create B-scan page in plot stack."""
|
|
# B-scan surface: one PlotWidget used as canvas for ImageItem heatmap.
|
|
self._bscan_plot = self._create_plot_widget(background="#0f141c")
|
|
self._bscan_plot.showGrid(x=True, y=True, alpha=0.2)
|
|
self._configure_bscan_plot_axes()
|
|
self._plot_stack.addWidget(self._bscan_plot)
|
|
|
|
def _build_trace_plot_page(self) -> None:
|
|
"""Create pass-through page with magnitude and phase plots."""
|
|
# Pass-through surface: container with two synchronized line plots.
|
|
# Upper plot shows magnitude, lower plot shows phase.
|
|
self._trace_plots_container = QWidget()
|
|
trace_layout = QVBoxLayout(self._trace_plots_container)
|
|
trace_layout.setContentsMargins(0, 0, 0, 0)
|
|
trace_layout.setSpacing(6)
|
|
|
|
self._trace_magnitude_plot = self._create_plot_widget(background="#0f141c")
|
|
self._trace_magnitude_plot.showGrid(x=True, y=True, alpha=0.2)
|
|
self._trace_magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
|
self._trace_magnitude_plot.getPlotItem().showAxis("bottom", show=False)
|
|
self._trace_magnitude_plot.getPlotItem().setDownsampling(mode="peak")
|
|
self._trace_magnitude_plot.getPlotItem().setClipToView(True)
|
|
trace_layout.addWidget(self._trace_magnitude_plot, stretch=1)
|
|
|
|
self._trace_phase_plot = self._create_plot_widget(background="#0f141c")
|
|
self._trace_phase_plot.showGrid(x=True, y=True, alpha=0.2)
|
|
self._trace_phase_plot.setLabel("left", "Phase", units="deg")
|
|
self._trace_phase_plot.setLabel("bottom", "Frequency", units="Hz")
|
|
self._trace_phase_plot.getPlotItem().setDownsampling(mode="peak")
|
|
self._trace_phase_plot.getPlotItem().setClipToView(True)
|
|
trace_layout.addWidget(self._trace_phase_plot, stretch=1)
|
|
|
|
# Legends are created lazily only when trace mode draws line series.
|
|
self._trace_magnitude_legend = None
|
|
self._trace_phase_legend = None
|
|
self._trace_magnitude_legend_combo_keys = set()
|
|
self._trace_phase_legend_combo_keys = set()
|
|
self._trace_magnitude_curves = {}
|
|
self._trace_phase_curves = {}
|
|
# Cap rendered points per trace (magnitude and phase alike): beyond ~1
|
|
# point/pixel there is no visual gain, and decimation keeps pass-through
|
|
# responsive with many combos on screen.
|
|
self._trace_render_max_points = 800
|
|
|
|
self._plot_stack.addWidget(self._trace_plots_container)
|
|
|
|
def _build_gpr_plot_page(self) -> None:
|
|
"""Create GPR page in plot stack."""
|
|
self._gpr_plot = self._create_plot_widget(background="#0f141c")
|
|
self._gpr_plot.showGrid(x=True, y=True, alpha=0.2)
|
|
self._configure_gpr_plot_axes()
|
|
self._plot_stack.addWidget(self._gpr_plot)
|
|
|
|
def _build_sar3d_plot_page(self) -> None:
|
|
"""Create the 3D SAR page: front, side, top and 3D views in a 2x2 grid."""
|
|
self._sar3d_plots_container = QWidget()
|
|
layout = QGridLayout(self._sar3d_plots_container)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(6)
|
|
self._sar3d_plots = {}
|
|
for index, name in enumerate(SAR3D_VIEWS):
|
|
self._sar3d_plots[name] = self._create_plot_widget(background="#0f141c")
|
|
layout.addWidget(self._sar3d_plots[name], index // 2, index % 2)
|
|
self._init_sar3d_views()
|
|
self._clear_sar3d_plot()
|
|
self._plot_stack.addWidget(self._sar3d_plots_container)
|
|
|
|
def _build_settings_toggle(self, root_layout: QHBoxLayout) -> None:
|
|
"""Create narrow button used to collapse or show settings panel."""
|
|
self._settings_toggle_button = QPushButton("<")
|
|
self._settings_toggle_button.setObjectName("settingsToggleButton")
|
|
self._settings_toggle_button.setFixedWidth(26)
|
|
self._settings_toggle_button.clicked.connect(lambda: self._toggle_settings_panel())
|
|
root_layout.addWidget(self._settings_toggle_button, stretch=0)
|
|
|
|
def _build_settings_panel(self, root_layout: QHBoxLayout, root: QWidget) -> None:
|
|
"""Build right settings panel with controls, history summary, and log."""
|
|
self._settings_panel = QWidget(root)
|
|
self._settings_panel.setMinimumWidth(610)
|
|
right_layout = QVBoxLayout(self._settings_panel)
|
|
right_layout.setContentsMargins(0, 0, 0, 0)
|
|
right_layout.setSpacing(10)
|
|
|
|
# Build log early so `_show_error()` can append text even during
|
|
# subsequent group construction if something fails.
|
|
self._log_panel = self._build_log_panel()
|
|
|
|
right_layout.addWidget(build_primary_actions_group(self), stretch=0)
|
|
right_layout.addWidget(self._build_settings_scroll(), stretch=1)
|
|
|
|
self._status_label = QLabel("Status: idle", self._settings_panel)
|
|
self._status_label.setObjectName("statusLabel")
|
|
right_layout.addWidget(self._status_label)
|
|
|
|
self._history_label = QLabel("History: raw=0, preprocessed=0, results=0", self._settings_panel)
|
|
self._history_label.setObjectName("hintLabel")
|
|
right_layout.addWidget(self._history_label)
|
|
|
|
right_layout.addWidget(self._log_panel, stretch=0)
|
|
root_layout.addWidget(self._settings_panel, stretch=7)
|
|
|
|
def _build_log_panel(self) -> QWidget:
|
|
"""Build runtime log block with collapsible log body."""
|
|
self._log_panel_title = QLabel("Runtime Log", self._settings_panel)
|
|
self._log_panel_title.setObjectName("hintLabel")
|
|
|
|
self._log_toggle_button = QPushButton(self._settings_panel)
|
|
self._log_toggle_button.setObjectName("sectionToggleButton")
|
|
self._log_toggle_button.clicked.connect(lambda: self._toggle_log_panel())
|
|
|
|
# Log-level selector: live verbosity control, persisted to run_config.
|
|
self._log_level_label = QLabel("Level", self._settings_panel)
|
|
self._log_level_label.setObjectName("hintLabel")
|
|
self._log_level_combo = QComboBox(self._settings_panel)
|
|
self._log_level_combo.setObjectName("logLevelCombo")
|
|
self._log_level_combo.addItems([name.capitalize() for name in LOG_LEVELS])
|
|
self._log_level_combo.setToolTip("Logging verbosity — applied live and saved to run_config")
|
|
current_level = self._defaults_config.logging.level.capitalize()
|
|
current_index = self._log_level_combo.findText(current_level)
|
|
if current_index >= 0:
|
|
self._log_level_combo.setCurrentIndex(current_index)
|
|
# Connect AFTER seeding the value so reflecting the config does not save it back.
|
|
self._log_level_combo.currentTextChanged.connect(self._on_log_level_selected)
|
|
|
|
self._log_box = QTextEdit(self._settings_panel)
|
|
self._log_box.setObjectName("runtimeLogBox")
|
|
self._log_box.setReadOnly(True)
|
|
self._log_box.setUndoRedoEnabled(False)
|
|
self._log_box.setMinimumHeight(170)
|
|
self._log_box.document().setMaximumBlockCount(1200)
|
|
|
|
panel = QWidget(self._settings_panel)
|
|
panel_layout = QVBoxLayout(panel)
|
|
panel_layout.setContentsMargins(0, 0, 0, 0)
|
|
panel_layout.setSpacing(6)
|
|
|
|
header_row = QWidget(panel)
|
|
header_layout = QHBoxLayout(header_row)
|
|
header_layout.setContentsMargins(0, 0, 0, 0)
|
|
header_layout.setSpacing(8)
|
|
header_layout.addWidget(self._log_panel_title)
|
|
header_layout.addStretch(1)
|
|
header_layout.addWidget(self._log_level_label)
|
|
header_layout.addWidget(self._log_level_combo)
|
|
header_layout.addWidget(self._log_toggle_button)
|
|
|
|
panel_layout.addWidget(header_row)
|
|
panel_layout.addWidget(self._log_box)
|
|
|
|
self._toggle_log_panel(visible=True)
|
|
return panel
|
|
|
|
def _build_settings_scroll(self) -> QScrollArea:
|
|
"""Build scroll area with all control groups in display order."""
|
|
# All control groups are placed into a scroll area so right panel
|
|
# remains usable on smaller screens and with future extra controls.
|
|
controls = QWidget(self._settings_panel)
|
|
controls_layout = QVBoxLayout(controls)
|
|
controls_layout.setContentsMargins(0, 0, 12, 0)
|
|
controls_layout.setSpacing(10)
|
|
for group in self._build_control_groups():
|
|
controls_layout.addWidget(group)
|
|
controls_layout.addStretch(1)
|
|
|
|
scroll = QScrollArea(self._settings_panel)
|
|
scroll.setWidgetResizable(True)
|
|
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
|
scroll.setWidget(controls)
|
|
return scroll
|
|
|
|
def _build_control_groups(self) -> list[QGroupBox]:
|
|
"""Create all settings groups in top-to-bottom order."""
|
|
return [
|
|
build_data_actions_group(self),
|
|
build_switch_group(self),
|
|
build_preprocess_summary_group(self),
|
|
build_processing_group(self),
|
|
build_radar_group(self),
|
|
]
|
|
|
|
def _toggle_settings_panel(self, *, visible: bool | None = None) -> None:
|
|
"""Toggle settings panel visibility or force a specific state.
|
|
|
|
When `visible` is `None`, state is toggled.
|
|
When `visible` is set, panel visibility is forced to that value.
|
|
"""
|
|
if visible is None:
|
|
visible = not self._settings_panel.isVisible()
|
|
self._settings_panel.setVisible(visible)
|
|
if visible:
|
|
self._settings_toggle_button.setText(">")
|
|
self._settings_toggle_button.setToolTip("Hide settings panel")
|
|
else:
|
|
self._settings_toggle_button.setText("<")
|
|
self._settings_toggle_button.setToolTip("Show settings panel")
|
|
|
|
def _toggle_log_panel(self, *, visible: bool | None = None) -> None:
|
|
"""Toggle runtime log body visibility or force a specific state."""
|
|
if visible is None:
|
|
visible = not self._log_box.isVisible()
|
|
|
|
self._log_box.setVisible(visible)
|
|
if visible:
|
|
self._log_toggle_button.setText("Collapse")
|
|
self._log_toggle_button.setToolTip("Hide runtime log entries")
|
|
else:
|
|
self._log_toggle_button.setText("Expand")
|
|
self._log_toggle_button.setToolTip("Show runtime log entries")
|
|
|
|
def _set_plot_mode(self, mode: str) -> None:
|
|
"""Switch visible plot page according to processing mode.
|
|
|
|
`bscan` -> show `self._bscan_plot`
|
|
`gpr` and `legacy_gpr` -> show `self._gpr_plot`
|
|
`sar3d` -> show `self._sar3d_plots_container`
|
|
otherwise -> show `self._trace_plots_container`
|
|
"""
|
|
if mode == "bscan":
|
|
self._plot_stack.setCurrentWidget(self._bscan_plot)
|
|
return
|
|
if mode in {"gpr", "legacy_gpr"}:
|
|
self._plot_stack.setCurrentWidget(self._gpr_plot)
|
|
return
|
|
if mode == "sar3d":
|
|
self._plot_stack.setCurrentWidget(self._sar3d_plots_container)
|
|
return
|
|
self._plot_stack.setCurrentWidget(self._trace_plots_container)
|
|
|
|
@staticmethod
|
|
def _set_combo_current_text(combo: QComboBox, value: str) -> None:
|
|
"""Select combo item by text, appending it when missing."""
|
|
index = combo.findText(value)
|
|
if index >= 0:
|
|
combo.setCurrentIndex(index)
|
|
return
|
|
combo.addItem(value)
|
|
combo.setCurrentIndex(combo.count() - 1)
|