From 4947d5a14ed7510ae0c88d6b42f722befd1f3287 Mon Sep 17 00:00:00 2001 From: Ayzen Date: Tue, 6 Oct 2026 14:16:14 +0300 Subject: [PATCH] Polish GUI design --- README.md | 37 +++++---- python_app/gui/antenna_geometry_editor.py | 6 +- .../gui/controllers/config_controller.py | 20 +++-- .../controllers/live_settings_controller.py | 3 +- python_app/gui/controllers/log_controller.py | 14 ++-- .../controllers/radar_settings_controller.py | 2 +- .../gui/controllers/renderers/common.py | 5 +- python_app/gui/controllers/renderers/gpr.py | 1 - python_app/gui/controllers/renderers/sar3d.py | 9 +- .../sections/data_actions_section.py | 7 +- .../controllers/sections/layout_helpers.py | 1 + .../sections/preprocess_summary_section.py | 2 +- .../sections/primary_actions_section.py | 15 +++- .../sections/processing_section.py | 65 +++++++-------- .../gui/controllers/sections/radar_section.py | 27 +++--- .../controllers/sections/switch_section.py | 4 +- python_app/gui/object_calibration_dialog.py | 48 ++++++----- python_app/gui/preprocess_dialog.py | 34 ++++---- python_app/gui/theme.py | 83 +++++-------------- python_app/gui/trace_png_export.py | 6 +- python_app/gui/window_view.py | 24 +++--- python_app/tests/test_sar3d_plot.py | 3 +- python_app/webui/static/app.js | 8 +- python_app/webui/static/index.html | 20 ++--- python_app/webui/static/style.css | 13 +-- 25 files changed, 222 insertions(+), 235 deletions(-) diff --git a/README.md b/README.md index 0ca219a..cc39404 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,13 @@ controls, processing settings and a copy of the live plot through an embedded we (default 2 cm). The radar motion is corrected from each trace's capture timestamps, so the sweep timing needs no parameters. +The desktop uses a light theme with dark plot canvases. **Mode** stays below the +**Actions** buttons while the settings sections scroll; the settings panel and +runtime log can be collapsed with their arrow buttons. SAR3D status, peak +coordinates and level appear above the projections. + For B-scans on every radar model, **B-scan pair** selects a pair from the displayed -history independently of the run combinations. **Next combination** advances output +history independently of the run combinations. **Next Combination** advances output first, then input, and wraps to the first recorded pair; it is enabled with at least two pairs. Desktop and browser selectors redraw the cached B-scan immediately. @@ -186,7 +191,7 @@ The preprocessor still requires S21 calibration/reference sets. Start the GUI: ./start.sh --profile run_config.json ``` -Open **Preprocessing**, use **Create Neutral S21 Sets**, then **Start**. This makes +Open **Preprocessing…**, use **Create Neutral S21 Sets**, then **Start**. This makes identity calibration + zero reference, preserving the simulated S21 samples. **Dataset replay (recorded scene).** When @@ -220,18 +225,18 @@ The embedded server listens at `http://:8080`; set `RADAR_SYSTEM_WEBUI_PORT` to use another port. It is available in desktop and headless mode. Browser actions are: -- **Start**, **Single Capture**, **Stop**, **Tmp Reference** and **Remove Last Measurement**; +- **Start**, **Single Capture**, **Stop**, **Capture Temporary Reference** and **Remove Last Measurement**; - load a profile from `run_configs/*.json` using **Load**; -- **Save Last 1000** and **Start + Record** with a path, optional name and recording count; -- edit processor settings and antenna tables with **Apply**, or clear history with **Reset history**; -- select a **B-scan pair** or advance with **Next combination**. +- **Save Last 1000** and **Start + Record to Disk** with a path, optional name and recording count; +- edit processor settings and antenna tables with **Apply**, or clear history with **Clear Runtime History**; +- select a **B-scan pair** or advance with **Next Combination**. Copy profiles from `run_config_examples/` into `run_configs/` to offer them in the browser. JSON export and calibration dialogs are desktop actions. PNG capture runs only while a browser is connected and the plot has changed, including zoom and resize, at most five times per second. Each client retains the latest frame, status and settings independently; reconnecting refreshes settings, and **Apply** -sends only edited fields. +sends only edited fields. Field labels, units and checkboxes match the desktop form. ## Data and preprocessing @@ -243,7 +248,7 @@ The limit is fixed; legacy profile `save_count` values are ignored. Saving keeps an active disk recording running. Without raw/preprocessed taps, result snapshots remain available, but S21 JSON requires retained traces. -**Start + Record to Disk** (**Start + Record** in the browser) has a separate count +**Start + Record to Disk** has a separate count for future results. It keeps collections received after arming until that count, Stop, or exhaustion of the bounded writer queue. If storage falls behind, recording stops accepting data, drains the queue and final chunk, and reports the limit. @@ -264,7 +269,7 @@ and actual `speed_m_s` (including speed supplied by the locator). Settings changed while acquisition is stopped are recorded for the reprocessed results as well. **Remove Last Measurement** uses capture order and removes matching collections from every stage; the processor suppresses that sweep if it is still pending. -**Clear Runtime History** (**Reset history** in the browser) clears all retained +**Clear Runtime History** clears all retained stages and rejects late results from before the cutoff. These actions leave saved files intact. A sweep is identified by `(collection_id, capture_start_ns)`; all stages preserve the producer's capture window. `monotonic_ns` is diagnostic only. @@ -287,15 +292,17 @@ are retained; new files and directories respect the process umask. Earlier NPZ/J remain supported. Invalid trace shapes, duplicate combinations and non-finite calibration/reference values are rejected before a saved set is replaced. Median captures also require identical frequency axes and channel coverage. -Open **Preprocessing** to create or select the ordinary S21 calibration and +Open **Preprocessing…** to create or select the ordinary S21 calibration and empty-scene reference. The processing order is through calibration → calibrated reference subtraction → notch → object correction → the selected processor. ### Object calibration -**Create…** in the **Object Calibration** row opens a separate dialog. Use -**Capture data** to take fresh preprocessed sweeps, then enter the measured -reflector centre X/Y/Z in metres and use **Calculate and save**. +**Create…** in the **Object calibration** row opens a separate dialog. Use +**Capture Data** to take fresh preprocessed sweeps, then enter the measured +reflector centre X/Y/Z in metres and use **Calculate and Save**. The optional +**Calculation Settings** section expands inside the scrollable dialog body; +the calculation, cancellation and close buttons remain visible below it. Coordinates follow the notebook: X across the array, Y above the ground, Z along the ground. Height above ground, radar pitch and sphere radius follow `MIMO_calibration.ipynb`; @@ -490,11 +497,11 @@ filtering before object correction. Object capture checks both acquisition processes and releases its pause only after completion or cancellation; losing the producer reports a failure immediately. `SnapshotController` coordinates manual exports, paused Clear/Remove operations -and Start + Record. +and Start + Record to Disk. History deletion is retained before publishing it to the processor, so a failed settings write can be retried without restoring deleted sweeps. `RecordingController` owns a bounded writer session; its disk thread receives data and storage only. -A processor restart during Start + Record does not finish the newly armed session. +A processor restart during Start + Record to Disk does not finish the newly armed session. Temporary references and neutral sets clear retained processing history without restarting the locator while the pipeline is paused. diff --git a/python_app/gui/antenna_geometry_editor.py b/python_app/gui/antenna_geometry_editor.py index 30b12fb..6973c98 100644 --- a/python_app/gui/antenna_geometry_editor.py +++ b/python_app/gui/antenna_geometry_editor.py @@ -53,8 +53,8 @@ class AntennaGeometryEditor(QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self._table) buttons = QHBoxLayout() - self._add_button = QPushButton("Add antenna", self) - self._remove_button = QPushButton("Remove selected", self) + self._add_button = QPushButton("Add Antenna", self) + self._remove_button = QPushButton("Remove Selected", self) buttons.addWidget(self._add_button) buttons.addWidget(self._remove_button) layout.addLayout(buttons) @@ -104,7 +104,7 @@ class AntennaGeometryEditor(QWidget): except (ValueError, RuntimeError) as exc: with QSignalBlocker(self): self.set_records(self._valid_records) - QMessageBox.warning(self, "Invalid antenna geometry", str(exc)) + QMessageBox.warning(self, "Invalid Antenna Geometry", str(exc)) return self._pending_row = None self._valid_records = records diff --git a/python_app/gui/controllers/config_controller.py b/python_app/gui/controllers/config_controller.py index a237a3b..daa25eb 100644 --- a/python_app/gui/controllers/config_controller.py +++ b/python_app/gui/controllers/config_controller.py @@ -10,7 +10,7 @@ import math from pathlib import Path from PyQt6.QtCore import QSignalBlocker -from PyQt6.QtWidgets import QCheckBox, QComboBox, QDoubleSpinBox, QSpinBox +from PyQt6.QtWidgets import QCheckBox, QComboBox, QDoubleSpinBox, QFormLayout, QLabel, QSpinBox from python_app.storage.npz.paths import radar_config_filename_prefix from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor @@ -300,7 +300,18 @@ class ConfigController: def web_schema(self) -> list[dict[str, object]]: schema = [] for name, (group, widget) in self._web_widgets(str(self._scalar("_processing_mode"))).items(): - entry: dict[str, object] = {"name": name, "group": group, "enabled": widget.isEnabled(), "value": _read_widget(widget)} + if isinstance(widget, QCheckBox): + label = widget.text() + else: + parent = widget.parentWidget() + assert parent is not None + form = parent.layout() + assert isinstance(form, QFormLayout) + caption = form.labelForField(widget) + assert isinstance(caption, QLabel) + label = caption.text() + entry: dict[str, object] = {"name": name, "label": label, "group": group, + "enabled": widget.isEnabled(), "value": _read_widget(widget)} if isinstance(widget, QComboBox): entry.update(kind="select", options=[widget.itemText(i) for i in range(widget.count())]) elif isinstance(widget, QCheckBox): @@ -370,6 +381,5 @@ class ConfigController: raise def mode_summary(self, mode: str) -> str: - values = (f"{binding.spec.web}={self._scalar(binding.spec.widget)}" - for binding in active_web_fields(mode) if binding.spec.web != "processor_mode") - return "(" + ", ".join(values) + ")" + return ", ".join(f"{binding.spec.web}={self._scalar(binding.spec.widget)}" + for binding in active_web_fields(mode) if binding.spec.web != "processor_mode") diff --git a/python_app/gui/controllers/live_settings_controller.py b/python_app/gui/controllers/live_settings_controller.py index bdd37f4..ff37d49 100644 --- a/python_app/gui/controllers/live_settings_controller.py +++ b/python_app/gui/controllers/live_settings_controller.py @@ -200,4 +200,5 @@ class LiveSettingsController: self.active_mode = mode self._pipeline.plots.set_mode(mode) self.set_mode_page(mode) - self._log.info(f"Processing mode selected: {mode} {self.config.mode_summary(mode)}") + self._log.info(f"Processing mode selected: {mode}") + self._log.debug(f"Processing settings ({mode}): {self.config.mode_summary(mode)}") diff --git a/python_app/gui/controllers/log_controller.py b/python_app/gui/controllers/log_controller.py index e103339..71da8b8 100644 --- a/python_app/gui/controllers/log_controller.py +++ b/python_app/gui/controllers/log_controller.py @@ -12,9 +12,9 @@ from pathlib import Path import threading import traceback -from PyQt6.QtCore import QSignalBlocker, QTimer +from PyQt6.QtCore import QSignalBlocker, QTimer, Qt from PyQt6.QtGui import QTextCursor -from PyQt6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QTextEdit, QVBoxLayout, QWidget +from PyQt6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QMessageBox, QToolButton, QTextEdit, QVBoxLayout, QWidget from python_app.gui.settings_defaults import env_enabled from python_app.logging_setup import LOG_LEVELS, add_handler, configure_logging, get_logger, package_logger, set_log_level @@ -102,8 +102,9 @@ class LogController: self.level.setToolTip("Logging verbosity — applied live and saved to run_config") self.level.currentTextChanged.connect(self.set_level) header.addWidget(self.level) - self._toggle = QPushButton("Collapse") - self._toggle.setObjectName("sectionToggleButton") + self._toggle = QToolButton() + self._toggle.setAutoRaise(True) + self._toggle.setArrowType(Qt.ArrowType.DownArrow) self._toggle.setToolTip("Hide runtime log entries") self._toggle.clicked.connect(self.toggle) header.addWidget(self._toggle) @@ -112,7 +113,8 @@ class LogController: self._box.setObjectName("runtimeLogBox") self._box.setReadOnly(True) self._box.setUndoRedoEnabled(False) - self._box.setMinimumHeight(170) + self._box.setMinimumHeight(130) + self._box.setMaximumHeight(140) document = self._box.document() assert document is not None document.setMaximumBlockCount(1200) @@ -133,7 +135,7 @@ class LogController: def toggle(self) -> None: visible = self._box.isHidden() self._box.setVisible(visible) - self._toggle.setText("Collapse" if visible else "Expand") + self._toggle.setArrowType(Qt.ArrowType.DownArrow if visible else Qt.ArrowType.RightArrow) self._toggle.setToolTip("Hide runtime log entries" if visible else "Show runtime log entries") def flush(self) -> None: diff --git a/python_app/gui/controllers/radar_settings_controller.py b/python_app/gui/controllers/radar_settings_controller.py index 408e702..b01acf4 100644 --- a/python_app/gui/controllers/radar_settings_controller.py +++ b/python_app/gui/controllers/radar_settings_controller.py @@ -87,7 +87,7 @@ class RadarSettingsController: def apply_limits(self, limits: dict[str, float | int] | None) -> bool: previous, self.limits = self.limits, limits - labels = ("Start Hz", "Stop Hz", "Points", "IF BW Hz", "Stimulus Power dBm") + labels = ("Start (Hz)", "Stop (Hz)", "Points", "IF bandwidth (Hz)", "Stimulus power (dBm)") edits = self._radar.sweep_edits hint = self._radar.limits_hint if limits is None: diff --git a/python_app/gui/controllers/renderers/common.py b/python_app/gui/controllers/renderers/common.py index cbd5778..893fe41 100644 --- a/python_app/gui/controllers/renderers/common.py +++ b/python_app/gui/controllers/renderers/common.py @@ -3,9 +3,11 @@ import numpy as np import pyqtgraph as pg +from python_app.gui.theme import PLOT_BACKGROUND + def create_plot(title: str = "") -> pg.PlotWidget: - plot = pg.PlotWidget(background="#0f141c", enableMenu=False, title=title) + plot = pg.PlotWidget(background=PLOT_BACKGROUND, enableMenu=False, title=title) plot.showGrid(x=True, y=True, alpha=0.2) return plot @@ -20,4 +22,3 @@ def build_lut(stops: list[str], *, size: int = 256) -> np.ndarray: for channel in range(3): lut[:, channel] = np.interp(sample_positions, stop_positions, stop_colors[:, channel]).astype(np.uint8) return lut - diff --git a/python_app/gui/controllers/renderers/gpr.py b/python_app/gui/controllers/renderers/gpr.py index 0502366..b03fccf 100644 --- a/python_app/gui/controllers/renderers/gpr.py +++ b/python_app/gui/controllers/renderers/gpr.py @@ -64,7 +64,6 @@ class GprRenderer: def __init__(self) -> None: self.widget = create_plot("GPR coherent BP") - self.widget.setBackground("#0f141c") plot = self.widget.getPlotItem() assert plot is not None self._plot = plot diff --git a/python_app/gui/controllers/renderers/sar3d.py b/python_app/gui/controllers/renderers/sar3d.py index 9e512f5..ebb4606 100644 --- a/python_app/gui/controllers/renderers/sar3d.py +++ b/python_app/gui/controllers/renderers/sar3d.py @@ -39,7 +39,7 @@ SAR3D_VIEWS: dict[str, tuple[str, str, str, tuple[int, int] | None]] = { "sar3d_front": ("Front (X-Y)", "X", "Y", (0, 1)), "sar3d_side": ("Side (Z-Y)", "Z", "Y", (2, 1)), "sar3d_top": ("Top (X-Z)", "X", "Z", (0, 2)), - "sar3d_oblique": ("3D projection", "", "", None), + "sar3d_oblique": ("3D Projection", "", "", None), } # Corners of the scene box indexed by bits (x, y, z); an edge joins corners one bit apart. @@ -209,10 +209,9 @@ class Sar3dRenderer: (x0, x1), _, (z0, z1) = bounds ground = (np.array([[x0, 0.0, z0], [x1, 0.0, z0], [x1, 0.0, z1], [x0, 0.0, z1], [x0, 0.0, z0]]) @ basis.table.T).T self.ground.setData(ground[0], ground[1]) + summary = f"SAR3D: ready — {status[1]}" if status else "SAR3D: ready" if level is not None: x, y, z = peak_xyz - self.plots["sar3d_oblique"].setTitle( - f"3D projection | max X {x:+.2f}, Y {y:+.2f}, Z {z:.2f} m | level {level:.3g}" - ) - self.status_label.setText(f"SAR3D: ready — {status[1]}" if status else "SAR3D: ready") + summary += f" | max X {x:+.2f}, Y {y:+.2f}, Z {z:.2f} m | level {level:.3g}" + self.status_label.setText(summary) return True diff --git a/python_app/gui/controllers/sections/data_actions_section.py b/python_app/gui/controllers/sections/data_actions_section.py index 52b0b82..b071022 100644 --- a/python_app/gui/controllers/sections/data_actions_section.py +++ b/python_app/gui/controllers/sections/data_actions_section.py @@ -34,8 +34,9 @@ def build_data_actions_group(gui: GuiStateModel) -> DataView: ) save_vna_json_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) remove_last_button = QPushButton("Remove Last Measurement") + remove_last_button.setProperty("role", "destructive") remove_last_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - capture_tmp_reference_button = QPushButton("Capture Tmp Reference") + capture_tmp_reference_button = QPushButton("Capture Temporary Reference") capture_tmp_reference_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) record_button = QPushButton("Start + Record to Disk") record_button.setToolTip( @@ -61,7 +62,7 @@ def build_data_actions_group(gui: GuiStateModel) -> DataView: record_count_row = QHBoxLayout() record_count_row.setSpacing(8) - record_count_row.addWidget(QLabel("Number of Sweeps to Record")) + record_count_row.addWidget(QLabel("Sweeps to record")) record_count_row.addWidget(record_count) record_count_row.addStretch(1) layout.addLayout(record_count_row) @@ -69,7 +70,7 @@ def build_data_actions_group(gui: GuiStateModel) -> DataView: path_row = QHBoxLayout() path_row.setSpacing(8) save_path_input = view.add("_save_path_input", QLineEdit(str(data_defaults.save_path))) - browse_button = QPushButton("Browse") + browse_button = QPushButton("Browse…") path_row.addWidget(QLabel("Path")) path_row.addWidget(save_path_input, stretch=1) path_row.addWidget(browse_button) diff --git a/python_app/gui/controllers/sections/layout_helpers.py b/python_app/gui/controllers/sections/layout_helpers.py index 97d781d..43156bb 100644 --- a/python_app/gui/controllers/sections/layout_helpers.py +++ b/python_app/gui/controllers/sections/layout_helpers.py @@ -22,6 +22,7 @@ def build_two_column_form_widget( form.setContentsMargins(0, 0, 0, 0) form.setHorizontalSpacing(10) form.setVerticalSpacing(6) + form.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapLongRows) form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) for row in column_rows: if isinstance(row, tuple): diff --git a/python_app/gui/controllers/sections/preprocess_summary_section.py b/python_app/gui/controllers/sections/preprocess_summary_section.py index 2da7b01..99ef0ae 100644 --- a/python_app/gui/controllers/sections/preprocess_summary_section.py +++ b/python_app/gui/controllers/sections/preprocess_summary_section.py @@ -20,7 +20,7 @@ def build_preprocess_summary_group() -> PreprocessView: for key in PREPROCESS_ASSET_NAMES: label = QLabel("") view.labels[key] = label - rows.append((preprocess_asset_display_name(key), label)) + rows.append((preprocess_asset_display_name(key).capitalize(), label)) layout.addWidget(build_two_column_form_widget(group, rows, split_index=(len(rows) + 1) // 2)) return view diff --git a/python_app/gui/controllers/sections/primary_actions_section.py b/python_app/gui/controllers/sections/primary_actions_section.py index 1081e7d..8d06894 100644 --- a/python_app/gui/controllers/sections/primary_actions_section.py +++ b/python_app/gui/controllers/sections/primary_actions_section.py @@ -1,16 +1,18 @@ """Pinned primary actions; controllers connect their own buttons.""" -from PyQt6.QtWidgets import QGridLayout, QGroupBox, QPushButton, QSizePolicy +from PyQt6.QtWidgets import QComboBox, QFormLayout, QGridLayout, QGroupBox, QPushButton, QSizePolicy, QWidget from python_app.gui.controllers.sections.views import PrimaryView -def build_primary_actions_group() -> PrimaryView: +def build_primary_actions_group(mode: QComboBox) -> PrimaryView: view = PrimaryView(group=QGroupBox("Actions"), start=QPushButton("Start"), single_capture=QPushButton("Single Capture"), stop=QPushButton("Stop"), - apply_radar=QPushButton("Apply Radar"), load=QPushButton("Load Config"), - save=QPushButton("Save Config"), preprocess=QPushButton("Preprocessing"), + apply_radar=QPushButton("Apply Radar"), load=QPushButton("Load Config…"), + save=QPushButton("Save Config…"), preprocess=QPushButton("Preprocessing…"), clear=QPushButton("Clear Runtime History")) + view.start.setProperty("role", "primary") + view.clear.setProperty("role", "destructive") layout = QGridLayout(view.group) layout.setHorizontalSpacing(8) layout.setVerticalSpacing(8) @@ -20,4 +22,9 @@ def build_primary_actions_group() -> PrimaryView: layout.addWidget(button, index // 3, index % 3, 1, 2 if button is view.clear else 1) for column in range(3): layout.setColumnStretch(column, 1) + mode_row = QWidget(view.group) + mode_form = QFormLayout(mode_row) + mode_form.setContentsMargins(0, 0, 0, 0) + mode_form.addRow("Mode", mode) + layout.addWidget(mode_row, 3, 0, 1, 3) return view diff --git a/python_app/gui/controllers/sections/processing_section.py b/python_app/gui/controllers/sections/processing_section.py index 5c3ffac..f69f7b8 100644 --- a/python_app/gui/controllers/sections/processing_section.py +++ b/python_app/gui/controllers/sections/processing_section.py @@ -75,8 +75,8 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces unwrap_phase_checkbox, ("Switch combos", pass_through_combo_filter_input), pass_through_fixed_y_enabled, - ("Y min dB", pass_through_y_min_db), - ("Y max dB", pass_through_y_max_db), + ("Y min (dB)", pass_through_y_min_db), + ("Y max (dB)", pass_through_y_max_db), ], split_index=5, ) @@ -98,11 +98,11 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces processing_mode_pages, [ ("Axis", bscan_axis), - ("Cut m", bscan_cut_m), - ("Max depth m", bscan_max_depth_m), + ("Cut (m)", bscan_cut_m), + ("Max depth (m)", bscan_max_depth_m), ("Gain", bscan_gain), - ("Start MHz", bscan_start_freq_mhz), - ("Stop MHz", bscan_stop_freq_mhz), + ("Start (MHz)", bscan_start_freq_mhz), + ("Stop (MHz)", bscan_stop_freq_mhz), ("Scans to show (stopped)", bscan_history_window), bscan_subtract_mean_ascan, ], @@ -188,27 +188,27 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces [ ("Input positions", gpr_input_positions_input), ("Output positions", gpr_output_positions_input), - ("Min depth m", gpr_min_depth_m), - ("Max depth m", gpr_max_depth_m), - ("Range comp power", gpr_range_comp_power), - ("Object min frac", gpr_object_min_frac), + ("Min depth (m)", gpr_min_depth_m), + ("Max depth (m)", gpr_max_depth_m), + ("Range compensation power", gpr_range_comp_power), + ("Object threshold fraction", gpr_object_min_frac), ("Score mode", gpr_score_mode), ("Motion mode", gpr_motion_mode), - ("Look angle deg", gpr_look_angle_deg), + ("Look angle (°)", gpr_look_angle_deg), ("Direction sign", gpr_direction_sign), gpr_ignore_socket_speed_enabled, - ("Speed m/s", gpr_speed_m_s), + ("Speed (m/s)", gpr_speed_m_s), ("Render mode", gpr_render_mode), ("Max detected objects", gpr_max_detected_objects_to_draw), ("Draw top M objects", gpr_draw_top_m_objects), ("Approach min frames", gpr_object_approach_min_frames), - ("Start MHz", gpr_start_freq_mhz), - ("Stop MHz", gpr_stop_freq_mhz), - ("Imaging plane Y m", gpr_imaging_plane_y_m), - ("Visible X min m", gpr_visible_x_min_m), - ("Visible X max m", gpr_visible_x_max_m), - ("Visible Z min m", gpr_visible_z_min_m), - ("Visible Z max m", gpr_visible_z_max_m), + ("Start (MHz)", gpr_start_freq_mhz), + ("Stop (MHz)", gpr_stop_freq_mhz), + ("Imaging plane Y (m)", gpr_imaging_plane_y_m), + ("Visible X min (m)", gpr_visible_x_min_m), + ("Visible X max (m)", gpr_visible_x_max_m), + ("Visible Z min (m)", gpr_visible_z_min_m), + ("Visible Z max (m)", gpr_visible_z_max_m), gpr_background_subtract_enabled, ("Mean count", gpr_background_mean_count), gpr_remove_sidelobe_objects_enabled, @@ -259,24 +259,24 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces ("Relative permittivity", gpr_relative_permittivity), ("Input positions", legacy_gpr_input_positions_input), ("Output positions", legacy_gpr_output_positions_input), - ("Min depth m", legacy_gpr_min_depth_m), - ("Max depth m", legacy_gpr_max_depth_m), - ("Comp power", legacy_gpr_comp_power), - ("SNR thresh", legacy_gpr_snr_thresh), - ("SNR comp max", legacy_gpr_snr_comp_max), - ("Speed m/s", legacy_gpr_speed_m_s), + ("Min depth (m)", legacy_gpr_min_depth_m), + ("Max depth (m)", legacy_gpr_max_depth_m), + ("Compensation power", legacy_gpr_comp_power), + ("SNR threshold", legacy_gpr_snr_thresh), + ("Max SNR compensation", legacy_gpr_snr_comp_max), + ("Speed (m/s)", legacy_gpr_speed_m_s), legacy_gpr_ignore_socket_speed_enabled, ("Render mode", legacy_gpr_render_mode), ("Min visible pairs", legacy_gpr_min_visible_pair_count), - ("Start MHz", legacy_gpr_start_freq_mhz), - ("Stop MHz", legacy_gpr_stop_freq_mhz), - ("Look angle deg", legacy_gpr_look_angle_deg), + ("Start (MHz)", legacy_gpr_start_freq_mhz), + ("Stop (MHz)", legacy_gpr_stop_freq_mhz), + ("Look angle (°)", legacy_gpr_look_angle_deg), ("Reference mode", legacy_gpr_reference_mode), legacy_gpr_apply_freq_phase_correction, - ("Visible X min m", legacy_gpr_visible_x_min_m), - ("Visible X max m", legacy_gpr_visible_x_max_m), - ("Visible Z min m", legacy_gpr_visible_z_min_m), - ("Visible Z max m", legacy_gpr_visible_z_max_m), + ("Visible X min (m)", legacy_gpr_visible_x_min_m), + ("Visible X max (m)", legacy_gpr_visible_x_max_m), + ("Visible Z min (m)", legacy_gpr_visible_z_min_m), + ("Visible Z max (m)", legacy_gpr_visible_z_max_m), legacy_gpr_background_subtract_enabled, ("Mean count", legacy_gpr_background_mean_count), ], @@ -345,7 +345,6 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces ) processing_mode_pages.addWidget(sar3d_page) - form.addRow("Mode", processing_mode) form.addRow(processing_mode_pages) form.addRow(gpr_common_page) diff --git a/python_app/gui/controllers/sections/radar_section.py b/python_app/gui/controllers/sections/radar_section.py index 9d654b5..b98f723 100644 --- a/python_app/gui/controllers/sections/radar_section.py +++ b/python_app/gui/controllers/sections/radar_section.py @@ -39,25 +39,20 @@ def build_radar_group(config: RunConfigModel) -> RadarView: power_input = view.add("_power_input", QLineEdit(f"{defaults.sweep.power_dbm:g}")) power_input.setToolTip("Stimulus power configured in the active profile.") - radar_start_label = QLabel("Start Hz") - radar_stop_label = QLabel("Stop Hz") - radar_points_label = QLabel("Points") - radar_ifbw_label = QLabel("IF BW Hz") - radar_power_label = QLabel("Stimulus Power dBm") + radar_labels = tuple(QLabel(text) for text in ( + "Start (Hz)", "Stop (Hz)", "Points", "IF bandwidth (Hz)", "Stimulus power (dBm)", + )) + for label in radar_labels: + label.setWordWrap(True) radar_limits_hint = QLabel("Device limits are not available.") radar_limits_hint.setObjectName("hintLabel") + radar_limits_hint.setWordWrap(True) vna_layout.addWidget( build_two_column_form_widget( group, - [ - (radar_start_label, start_hz_input), - (radar_stop_label, stop_hz_input), - (radar_points_label, points_input), - (radar_ifbw_label, ifbw_input), - (radar_power_label, power_input), - ], + list(zip(radar_labels, (start_hz_input, stop_hz_input, points_input, ifbw_input, power_input))), ) ) vna_layout.addWidget(radar_limits_hint) @@ -71,7 +66,7 @@ def build_radar_group(config: RunConfigModel) -> RadarView: adc_radar_settings_panel.setVisible(config.is_kamil_adc) return RadarView(group=group, fields=view.fields, sweep_edits=(start_hz_input, stop_hz_input, points_input, ifbw_input, power_input), - sweep_labels=(radar_start_label, radar_stop_label, radar_points_label, radar_ifbw_label, radar_power_label), + sweep_labels=radar_labels, limits_hint=radar_limits_hint, vna_panel=vna_radar_settings_panel, adc_panel=adc_radar_settings_panel, optical_enabled=optical_enabled, optical_mode=optical_mode, optical_common=optical_common, optical_manual=optical_manual, optical_variation=optical_variation, @@ -144,9 +139,9 @@ def _build_adc_settings_panel(view: SectionView, config: RunConfigModel): ("TTY path", adc_tty_path_input), ("Arguments", adc_args_input), ("Environment JSON", adc_env_input), - ("Startup timeout s", adc_startup_timeout_s_input), - ("Read timeout s", adc_sweep_timeout_s_input), - ("Stop timeout s", adc_stop_timeout_s_input), + ("Startup timeout (s)", adc_startup_timeout_s_input), + ("Read timeout (s)", adc_sweep_timeout_s_input), + ("Stop timeout (s)", adc_stop_timeout_s_input), ("Board enabled", optical_enabled_checkbox), ("Board port", optical_port_input), ("Board mode", optical_mode_combo), diff --git a/python_app/gui/controllers/sections/switch_section.py b/python_app/gui/controllers/sections/switch_section.py index d15a8b3..7802204 100644 --- a/python_app/gui/controllers/sections/switch_section.py +++ b/python_app/gui/controllers/sections/switch_section.py @@ -29,7 +29,7 @@ def build_switch_group(config: RunConfigModel, gui: GuiStateModel) -> SwitchView global_form = QFormLayout() global_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) settling_ms = view.add("_settling_ms", QLineEdit(str(config.runtime.settling_ms))) - global_form.addRow("Settling ms", settling_ms) + global_form.addRow("Settling (ms)", settling_ms) layout.addLayout(global_form) combos_text = view.add("_combos_text", QLineEdit(str(switch_defaults.combos_text))) @@ -71,7 +71,7 @@ def build_switch_group(config: RunConfigModel, gui: GuiStateModel) -> SwitchView display_combo = view.add("_display_combo", QComboBox()) display_combo.setEnabled(False) display_row.addWidget(display_combo, stretch=1) - next_combo_button = QPushButton("Next combination") + next_combo_button = QPushButton("Next Combination") next_combo_button.setEnabled(False) display_row.addWidget(next_combo_button) layout.addLayout(display_row) diff --git a/python_app/gui/object_calibration_dialog.py b/python_app/gui/object_calibration_dialog.py index d165cc1..60445d1 100644 --- a/python_app/gui/object_calibration_dialog.py +++ b/python_app/gui/object_calibration_dialog.py @@ -10,11 +10,12 @@ import pyqtgraph as pg from PyQt6.QtCore import QSignalBlocker, QRectF, Qt, pyqtSignal from PyQt6.QtWidgets import ( QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFormLayout, QGroupBox, - QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, + QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QScrollArea, QTableWidget, QTableWidgetItem, QToolButton, QVBoxLayout, QWidget, ) from python_app.models.dataset_model import SweepCollection +from python_app.gui.theme import PLOT_BACKGROUND class ObjectCalibrationDialog(QDialog): @@ -33,7 +34,13 @@ class ObjectCalibrationDialog(QDialog): self._provenance: dict | None = None self._capture_pending = self._calculating = False self._calibration: dict | None = None - layout = QVBoxLayout(self) + root_layout = QVBoxLayout(self) + scroll = QScrollArea(self) + scroll.setWidgetResizable(True) + content = QWidget(scroll) + layout = QVBoxLayout(content) + scroll.setWidget(content) + root_layout.addWidget(scroll) name_row = QHBoxLayout() name_row.addWidget(QLabel("Set name", self)) self._set_name_input = QLineEdit(set_name, self) @@ -49,6 +56,7 @@ class ObjectCalibrationDialog(QDialog): explanation.setWordWrap(True) layout.addWidget(explanation) self._table = QTableWidget(0, 5, self) + self._table.setMaximumHeight(140) self._table.setHorizontalHeaderLabels(["Target", "Sweeps", "X (m)", "Y (m)", "Z (m)"]) horizontal, vertical = self._table.horizontalHeader(), self._table.verticalHeader() assert horizontal is not None and vertical is not None @@ -57,9 +65,10 @@ class ObjectCalibrationDialog(QDialog): self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) layout.addWidget(self._table) actions = QHBoxLayout() - self._capture_button = QPushButton("Capture data", self) + self._capture_button = QPushButton("Capture Data", self) self._capture_button.clicked.connect(self.capture_requested.emit) - self._remove_button = QPushButton("Remove selected target", self) + self._remove_button = QPushButton("Remove Selected Target", self) + self._remove_button.setProperty("role", "destructive") self._remove_button.clicked.connect(self._remove_capture) actions.addWidget(self._capture_button) actions.addWidget(self._remove_button) @@ -68,27 +77,27 @@ class ObjectCalibrationDialog(QDialog): self._settings: dict[str, QDoubleSpinBox | QCheckBox | QComboBox] = {} form = QFormLayout() for key, title, default, low, high in ( - ("sphere_radius_m", "Sphere radius, m (0 = point)", .16, 0, 100), - ("antenna_height_m", "Antenna origin height above ground, m", .92, 0, 100), - ("pitch_deg", "Radar downward tilt, °", 10, -90, 90), + ("sphere_radius_m", "Sphere radius (m, 0 = point)", .16, 0, 100), + ("antenna_height_m", "Antenna origin height above ground (m)", .92, 0, 100), + ("pitch_deg", "Radar downward tilt (°)", 10, -90, 90), ): form.addRow(title, self._number(key, default, low, high)) layout.addLayout(form) # A disclosure header: only the header shows until the operator opens it. advanced_toggle = QToolButton(self) - advanced_toggle.setText("Calculation settings") + advanced_toggle.setText("Calculation Settings") advanced_toggle.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) advanced_toggle.setArrowType(Qt.ArrowType.RightArrow) advanced_toggle.setAutoRaise(True) advanced = QGroupBox(self) advanced_form = QFormLayout(advanced) for key, title, default, low, high in ( - ("f_min", "Estimation band start, MHz", 2000, 0, 100000), - ("f_max", "Estimation band stop, MHz", 6000, 0, 100000), - ("gate_half_m", "Echo path gate half-width, m", .15, .000001, 100), - ("gate_taper_m", "Gate taper width, m", .03, .000001, 100), - ("coarse_range_search_m", "Coarse path search, ±m (0 = off)", .6, 0, 100), - ("refine_range_m", "Coordinate refinement, ±m", .1, 0, 100), + ("f_min", "Estimation band start (MHz)", 2000, 0, 100000), + ("f_max", "Estimation band stop (MHz)", 6000, 0, 100000), + ("gate_half_m", "Echo path gate half-width (m)", .15, .001, 100), + ("gate_taper_m", "Gate taper width (m)", .03, .001, 100), + ("coarse_range_search_m", "Coarse path search (±m, 0 = off)", .6, 0, 100), + ("refine_range_m", "Coordinate refinement (±m)", .1, 0, 100), ): advanced_form.addRow(title, self._number(key, default, low, high)) axes = QWidget(advanced) @@ -123,7 +132,7 @@ class ObjectCalibrationDialog(QDialog): self._target_combo = QComboBox(self) layout.addWidget(self._target_combo) self._phase_view = pg.GraphicsLayoutWidget(parent=self) - self._phase_view.setBackground("#0f141c") + self._phase_view.setBackground(PLOT_BACKGROUND) self._phase_view.setMinimumHeight(270) self._phase_plots = [] self._phase_images = [] @@ -154,9 +163,10 @@ class ObjectCalibrationDialog(QDialog): self._status_label.setWordWrap(True) layout.addWidget(self._status_label) footer = QHBoxLayout() - self._calculate_button = QPushButton("Calculate and save", self) + self._calculate_button = QPushButton("Calculate and Save", self) + self._calculate_button.setProperty("role", "primary") self._calculate_button.clicked.connect(self.calculate_requested.emit) - self._cancel_button = QPushButton("Cancel operation", self) + self._cancel_button = QPushButton("Cancel Operation", self) self._cancel_button.clicked.connect(self.cancel_requested.emit) close_button = QPushButton("Close", self) close_button.clicked.connect(self.reject) @@ -164,7 +174,7 @@ class ObjectCalibrationDialog(QDialog): footer.addWidget(self._cancel_button) footer.addStretch(1) footer.addWidget(close_button) - layout.addLayout(footer) + root_layout.addLayout(footer) self._table.itemChanged.connect(self._measurements_changed) self._target_combo.currentIndexChanged.connect(self._draw_phase_effect) for field in [*self._settings.values(), *self._refine_axes.values()]: @@ -175,7 +185,7 @@ class ObjectCalibrationDialog(QDialog): def _number(self, key: str, value: float, low: float, high: float) -> QDoubleSpinBox: field = QDoubleSpinBox(self) - field.setDecimals(6) + field.setDecimals(0 if key in {"f_min", "f_max"} else 1 if key == "pitch_deg" else 3) field.setRange(low, high) field.setSingleStep(100 if key in {"f_min", "f_max"} else .1 if key == "pitch_deg" else .01) field.setValue(value) diff --git a/python_app/gui/preprocess_dialog.py b/python_app/gui/preprocess_dialog.py index 7815112..9a5538a 100644 --- a/python_app/gui/preprocess_dialog.py +++ b/python_app/gui/preprocess_dialog.py @@ -26,6 +26,7 @@ from PyQt6.QtWidgets import ( import numpy as np import pyqtgraph as pg +from python_app.gui.controllers.renderers.common import create_plot from python_app.models.gui_profile_schema import GuiPreprocessDialogStateModel from python_app.models.dataset_model import TraceData from python_app.orchestration.preprocess_assets import ( @@ -113,7 +114,7 @@ class PreprocessDialog(QDialog): layout.addLayout(self._build_median_sweep_row(group)) layout.addWidget(self._build_radar_config_group(group)) - layout.addWidget(self._build_selector_group("S21", tuple(PREPROCESS_ASSET_NAMES), group)) + layout.addLayout(self._build_selectors(group)) return group def _build_median_sweep_row(self, parent: QGroupBox) -> QHBoxLayout: @@ -141,7 +142,7 @@ class PreprocessDialog(QDialog): self._radar_config_dir_input = QLineEdit(group) self._radar_config_dir_input.setPlaceholderText("Directory with radar JSON configs") self._radar_config_dir_input.editingFinished.connect(self.radar_config_dir_changed.emit) - browse_button = QPushButton("Browse...", group) + browse_button = QPushButton("Browse…", group) browse_button.clicked.connect(self._choose_radar_config_dir) dir_row.addWidget(QLabel("Directory")) dir_row.addWidget(self._radar_config_dir_input, stretch=1) @@ -160,14 +161,13 @@ class PreprocessDialog(QDialog): layout.addWidget(self._use_all_radar_configs_checkbox) return group - def _build_selector_group(self, title: str, keys: tuple[str, ...], parent: QGroupBox) -> QGroupBox: - """Build one selector subgroup for a channel family.""" - group = QGroupBox(title, parent) - form = QFormLayout(group) + def _build_selectors(self, parent: QGroupBox) -> QFormLayout: + """Build the S21 set selectors in capture order.""" + form = QFormLayout() form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) - for key in keys: - combo = QComboBox(group) + for key in PREPROCESS_ASSET_NAMES: + combo = QComboBox(parent) combo.setPlaceholderText("") combo.setProperty("optional", key not in REQUIRED_PREPROCESS_ASSET_KEYS) self._set_combo_items(combo, [], "") @@ -175,7 +175,7 @@ class PreprocessDialog(QDialog): self._set_combos[key] = combo field = combo if key == "s21_object_calibration": - field = QWidget(group) + field = QWidget(parent) row = QHBoxLayout(field) row.setContentsMargins(0, 0, 0, 0) row.addWidget(combo, stretch=1) @@ -183,12 +183,12 @@ class PreprocessDialog(QDialog): self._create_object_calibration_button.setToolTip("Calculate a MIMO calibration from reflector measurements.") self._create_object_calibration_button.clicked.connect(self.create_object_calibration_requested.emit) row.addWidget(self._create_object_calibration_button) - form.addRow(self._asset_row_label(key), field) - return group + form.addRow(preprocess_asset_display_name(key).capitalize(), field) + return form def _build_sequence_group(self) -> QGroupBox: """Build sequential-capture controls and capture log.""" - group = QGroupBox("Sequential Capture (Fill Full N*M)", self) + group = QGroupBox("Capture All Antenna Pairs", self) layout = QGridLayout(group) self._active_kind_label = QLabel("", group) @@ -239,6 +239,7 @@ class PreprocessDialog(QDialog): self._save_sequence_button.setEnabled(False) self._abort_button = QPushButton("Abort Sequence", parent) + self._abort_button.setProperty("role", "destructive") self._abort_button.clicked.connect(self.abort_sequence_requested.emit) self._abort_button.setEnabled(False) @@ -270,7 +271,6 @@ class PreprocessDialog(QDialog): placeholder = QLabel("Preview will appear after the first successful capture.", host) placeholder.setWordWrap(True) - placeholder.setMinimumHeight(320) layout.addWidget(placeholder) self._preview_host_layout = layout @@ -531,8 +531,7 @@ class PreprocessDialog(QDialog): @staticmethod def _build_preview_axis(left_label: str, left_units: str) -> "pg.PlotWidget": """Return one PlotWidget configured with the project's preview style.""" - plot = pg.PlotWidget(background="#101418", enableMenu=False) - plot.showGrid(x=True, y=True, alpha=0.2) + plot = create_plot() plot.setLabel("bottom", "Frequency", units="Hz") plot.setLabel("left", left_label, units=left_units) plot.setMinimumHeight(320) @@ -554,11 +553,6 @@ class PreprocessDialog(QDialog): self._radar_config_dir_input.setText(selected_dir) self.radar_config_dir_changed.emit() - @staticmethod - def _asset_row_label(key: str) -> str: - """Return short selector label for one preprocess asset.""" - return preprocess_asset_display_name(key) - @staticmethod def _set_combo_items(combo: QComboBox, names: list[str], current_text: str) -> None: """Replace combo contents and keep previous value when still available.""" diff --git a/python_app/gui/theme.py b/python_app/gui/theme.py index bb7fc7f..733eace 100644 --- a/python_app/gui/theme.py +++ b/python_app/gui/theme.py @@ -5,23 +5,15 @@ from __future__ import annotations from PyQt6.QtGui import QColor, QPalette from PyQt6.QtWidgets import QApplication, QStyleFactory +PLOT_BACKGROUND = "#0f141c" _LIGHT_STYLESHEET = """ -QMainWindow, QDialog { - background-color: #f3f6fb; -} - -QWidget { - color: #1f2937; - font-size: 13px; -} - QGroupBox { background-color: #ffffff; border: 1px solid #d7dee8; - border-radius: 10px; - margin-top: 14px; - padding: 10px; + border-radius: 6px; + margin-top: 12px; + padding: 6px; } QGroupBox::title { @@ -35,11 +27,11 @@ QGroupBox::title { QPushButton { background-color: #f8fafc; border: 1px solid #cad4e1; - border-radius: 8px; - padding: 7px 12px; + border-radius: 4px; + padding: 3px 10px; } -QPushButton:hover { +QPushButton:hover:enabled { background-color: #eef3f9; } @@ -47,70 +39,30 @@ QPushButton:pressed { background-color: #e4ebf4; } -QPushButton:checked { +QPushButton:checked, QPushButton[role="primary"] { background-color: #2f7ee6; border-color: #2f7ee6; color: #ffffff; } -QPushButton:checked:hover { +QPushButton:checked:hover:enabled, QPushButton[role="primary"]:hover:enabled { background-color: #3b8bf4; } +QPushButton[role="destructive"] { + color: #a73545; + border-color: #cf9ba4; +} + QPushButton:disabled { color: #98a4b3; background-color: #f3f5f8; border-color: #dde4ec; } -QPushButton#settingsToggleButton { - min-width: 26px; - max-width: 26px; - padding: 6px 0px; - border-radius: 7px; - font-weight: 700; -} - -QPushButton#sectionToggleButton { - min-width: 84px; - padding: 5px 10px; - border-radius: 7px; -} - -QLineEdit, -QPlainTextEdit, -QTextEdit, -QComboBox, -QSpinBox, -QDoubleSpinBox { - background-color: #ffffff; - border: 1px solid #c9d4e1; - border-radius: 7px; - padding: 5px 8px; - selection-background-color: #cfe3ff; -} - -QLineEdit:focus, -QPlainTextEdit:focus, -QTextEdit:focus, -QComboBox:focus, -QSpinBox:focus, -QDoubleSpinBox:focus { - border: 1px solid #2f7ee6; -} - QTextEdit#runtimeLogBox { font-family: "DejaVu Sans Mono"; font-size: 12px; - padding: 6px 8px; - color: #1f2937; - background-color: #fbfdff; - border-color: #c9d4e1; -} - -QComboBox::drop-down { - border: none; - width: 18px; } QScrollArea { @@ -121,7 +73,6 @@ QScrollArea { QLabel#statusLabel { color: #35507a; font-weight: 600; - padding: 2px 1px; } QLabel#hintLabel { @@ -133,6 +84,9 @@ QLabel#hintLabel { def apply_light_theme(app: QApplication) -> None: """Apply a minimal light theme shared by all windows.""" app.setStyle(QStyleFactory.create("Fusion")) + font = app.font() + font.setPointSize(10) + app.setFont(font) palette = QPalette() palette.setColor(QPalette.ColorRole.Window, QColor("#f3f6fb")) @@ -148,5 +102,8 @@ def apply_light_theme(app: QApplication) -> None: palette.setColor(QPalette.ColorRole.Link, QColor("#2f7ee6")) palette.setColor(QPalette.ColorRole.Highlight, QColor("#2f7ee6")) palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff")) + for role in (QPalette.ColorRole.Text, QPalette.ColorRole.ButtonText, QPalette.ColorRole.WindowText): + palette.setColor(QPalette.ColorGroup.Disabled, role, QColor("#98a4b3")) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, QColor("#f3f5f8")) app.setPalette(palette) app.setStyleSheet(_LIGHT_STYLESHEET) diff --git a/python_app/gui/trace_png_export.py b/python_app/gui/trace_png_export.py index 16c27a6..b4bf1f7 100644 --- a/python_app/gui/trace_png_export.py +++ b/python_app/gui/trace_png_export.py @@ -17,12 +17,10 @@ from PyQt6.QtCore import QRectF from pyqtgraph.exporters import ImageExporter from python_app.models.dataset_model import TraceData +from python_app.gui.theme import PLOT_BACKGROUND logger = logging.getLogger(__name__) -# Matches the preview pane styling in preprocess_dialog so saved PNGs and the -# on-screen preview look the same. -_BACKGROUND = "#101418" _MAGNITUDE_PEN = pg.mkPen("#4cc9f0", width=1.8) _PHASE_PEN = pg.mkPen("#f48c06", width=1.8) _EXPORT_SIZE = (1400, 520) @@ -45,7 +43,7 @@ def export_trace_png( phase_deg = np.degrees(np.angle(samples)) layout = pg.GraphicsLayoutWidget(show=False, size=_EXPORT_SIZE) - layout.setBackground(_BACKGROUND) + layout.setBackground(PLOT_BACKGROUND) try: layout.ci.addLabel(title, row=0, col=0, colspan=2) diff --git a/python_app/gui/window_view.py b/python_app/gui/window_view.py index 5a03a9a..50392b3 100644 --- a/python_app/gui/window_view.py +++ b/python_app/gui/window_view.py @@ -2,9 +2,10 @@ from pathlib import Path +from PyQt6.QtCore import Qt from PyQt6.QtWidgets import ( - QFileDialog, QFrame, QHBoxLayout, QLabel, QMainWindow, QPushButton, - QScrollArea, QVBoxLayout, QWidget, + QFileDialog, QFrame, QHBoxLayout, QLabel, QMainWindow, + QScrollArea, QToolButton, QVBoxLayout, QWidget, ) from python_app.gui.controllers.history_controller import HistoryController @@ -29,9 +30,9 @@ class WindowView: layout.setContentsMargins(12, 12, 12, 12) layout.setSpacing(14) layout.addWidget(plot_stack, stretch=11) - self._toggle = QPushButton(">") - self._toggle.setObjectName("settingsToggleButton") - self._toggle.setFixedWidth(26) + self._toggle = QToolButton() + self._toggle.setAutoRaise(True) + self._toggle.setArrowType(Qt.ArrowType.RightArrow) self._toggle.setToolTip("Hide settings panel") self._toggle.clicked.connect(self.toggle_settings) layout.addWidget(self._toggle) @@ -40,12 +41,12 @@ class WindowView: settings_layout = QVBoxLayout(self.settings_panel) settings_layout.setContentsMargins(0, 0, 0, 0) settings_layout.setSpacing(10) - self.primary = build_primary_actions_group() + self.processing = build_processing_group(config, gui) + self.primary = build_primary_actions_group(self.processing.mode) settings_layout.addWidget(self.primary.group) self.data = build_data_actions_group(gui) self.switches = build_switch_group(config, gui) self.preprocess = build_preprocess_summary_group() - self.processing = build_processing_group(config, gui) self.radar = build_radar_group(config) self.data.browse.clicked.connect(lambda: self.browse_save_path(project_root)) self.sections = (self.data, self.switches, self.preprocess, self.processing, self.radar) @@ -63,10 +64,13 @@ class WindowView: settings_layout.addWidget(scroll, stretch=1) self.status = QLabel("Status: idle") self.status.setObjectName("statusLabel") + self.status.setWordWrap(True) self._history = QLabel("History: raw=0, preprocessed=0, results=0") self._history.setObjectName("hintLabel") - settings_layout.addWidget(self.status) - settings_layout.addWidget(self._history) + status_row = QHBoxLayout() + status_row.addWidget(self.status, stretch=1) + status_row.addWidget(self._history, alignment=Qt.AlignmentFlag.AlignRight) + settings_layout.addLayout(status_row) settings_layout.addWidget(log_panel) layout.addWidget(self.settings_panel, stretch=7) parent.resize(1650, 940) @@ -74,7 +78,7 @@ class WindowView: def toggle_settings(self) -> None: visible = self.settings_panel.isHidden() self.settings_panel.setVisible(visible) - self._toggle.setText(">" if visible else "<") + self._toggle.setArrowType(Qt.ArrowType.RightArrow if visible else Qt.ArrowType.LeftArrow) self._toggle.setToolTip("Hide settings panel" if visible else "Show settings panel") def update_history(self, history: HistoryController) -> None: diff --git a/python_app/tests/test_sar3d_plot.py b/python_app/tests/test_sar3d_plot.py index 98fa5a9..dce36d4 100644 --- a/python_app/tests/test_sar3d_plot.py +++ b/python_app/tests/test_sar3d_plot.py @@ -51,7 +51,8 @@ class Sar3dPlotTest(unittest.TestCase): def test_all_four_projections_have_visible_images_and_correct_peak_axes(self) -> None: self.assertTrue(self.renderer.draw(_projections())) - self.assertEqual(self.renderer.status_label.text(), "SAR3D: ready — CUDA") + self.assertEqual(self.renderer.status_label.text(), + "SAR3D: ready — CUDA | max X +0.50, Y -0.20, Z 2.00 m | level 2") positions = {"sar3d_front": (0.5, -0.2), "sar3d_side": (2, -0.2), "sar3d_top": (0.5, 2), "sar3d_oblique": (0.5, -0.2)} for name, position in positions.items(): diff --git a/python_app/webui/static/app.js b/python_app/webui/static/app.js index 3851b82..5b74add 100644 --- a/python_app/webui/static/app.js +++ b/python_app/webui/static/app.js @@ -117,7 +117,7 @@ btnNextCombo.addEventListener("click", () => changeDisplayCombo("/api/next_combo settingsToggle.addEventListener("click", () => sidePanel.classList.toggle("collapsed")); // The form is built ENTIRELY from the schema the server derives from the Qt widgets -// (field, group, kind, options, ranges, value, enabled). The web hardcodes nothing +// (field, label, group, kind, options, ranges, value, enabled). The web hardcodes nothing // and shows only the active mode's fields, so it always mirrors the desktop. const fieldInputs = {}; // field name -> { el, kind, dirty } let formSignature = ""; // field names currently in the form (detect mode/structure change) @@ -146,7 +146,7 @@ function makeGeometryEditor(entry) { input.type = "number"; input.step = index === 0 ? "1" : "any"; input.required = true; - input.setAttribute("aria-label", `${entry.name} ${key}`); + input.setAttribute("aria-label", `${entry.label} ${key}`); if (index === 0) { input.min = "0"; input.max = entry.position_count - 1; } input.value = record[key] ?? 0; input.disabled = entry.enabled === false; @@ -177,7 +177,7 @@ function makeGeometryEditor(entry) { }; el.setRecords(entry.value); add.type = "button"; - add.textContent = "Add antenna"; + add.textContent = "Add Antenna"; add.addEventListener("click", () => { if (body.rows.length >= entry.position_count) return; const positions = new Set(Array.from(body.rows, (row) => Number(row.querySelector("input").value))); @@ -196,7 +196,7 @@ function makeFieldRow(entry) { row.className = "field"; if (entry.kind === "geometry") row.classList.add("geometry-field"); const label = document.createElement("label"); - label.textContent = entry.kind === "geometry" ? (entry.name === "tx_geometry" ? "Tx antenna positions" : "Rx antenna positions") : entry.name; + label.textContent = entry.label; label.htmlFor = "f_" + entry.name; row.appendChild(label); diff --git a/python_app/webui/static/index.html b/python_app/webui/static/index.html index 9d5ce9d..7a06144 100644 --- a/python_app/webui/static/index.html +++ b/python_app/webui/static/index.html @@ -10,21 +10,21 @@
Radar System
- + - - + +
- Processor output + Processor Output
@@ -35,7 +35,7 @@