Polish GUI design

This commit is contained in:
Ayzen
2026-10-06 14:16:14 +03:00
parent 5bb86b7165
commit 4947d5a14e
25 changed files with 222 additions and 235 deletions
+22 -15
View File
@@ -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://<host>: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.
+3 -3
View File
@@ -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
@@ -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")
@@ -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)}")
+8 -6
View File
@@ -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:
@@ -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:
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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):
@@ -20,7 +20,7 @@ def build_preprocess_summary_group() -> PreprocessView:
for key in PREPROCESS_ASSET_NAMES:
label = QLabel("<not selected>")
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
@@ -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
@@ -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)
@@ -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),
@@ -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)
+29 -19
View File
@@ -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)
+14 -20
View File
@@ -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("<not selected>")
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("<none>", 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."""
+20 -63
View File
@@ -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)
+2 -4
View File
@@ -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)
+14 -10
View File
@@ -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:
+2 -1
View File
@@ -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():
+4 -4
View File
@@ -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);
+10 -10
View File
@@ -10,21 +10,21 @@
<header class="topbar">
<div class="brand">Radar System</div>
<div class="controls">
<button id="btn-start" class="btn">Start</button>
<button id="btn-start" class="btn primary">Start</button>
<button id="btn-single" class="btn">Single Capture</button>
<button id="btn-stop" class="btn">Stop</button>
<button id="btn-tmp-ref" class="btn">Tmp Reference</button>
<button id="btn-remove-last" class="btn">Remove Last Measurement</button>
<button id="btn-tmp-ref" class="btn">Capture Temporary Reference</button>
<button id="btn-remove-last" class="btn destructive">Remove Last Measurement</button>
</div>
</header>
<main class="layout">
<section class="plot-panel">
<div class="plot-head">
<span class="plot-title">Processor output</span>
<span class="plot-title">Processor Output</span>
<div id="display-combo-controls" class="config-row" hidden>
<select id="display-combo" class="config-control" aria-label="B-scan channel combination"></select>
<button id="btn-next-combo" class="btn">Next combination</button>
<button id="btn-next-combo" class="btn">Next Combination</button>
</div>
</div>
<div id="sar3d-status" class="sar3d-status" role="status" hidden></div>
@@ -35,7 +35,7 @@
<aside class="side-panel">
<div class="config-section">
<div class="config-title">Config profile</div>
<div class="config-title">Config Profile</div>
<div class="config-row">
<select id="config-select" class="config-control" aria-label="Run config"></select>
<button id="btn-load-config" class="btn">Load</button>
@@ -44,7 +44,7 @@
</div>
<div class="config-section">
<div class="config-title">Save / record dataset</div>
<div class="config-title">Save / Record Dataset</div>
<div class="config-row">
<input id="save-path" class="config-control" type="text" placeholder="Save path" aria-label="Save path" />
</div>
@@ -55,20 +55,20 @@
<div class="config-row">
<input id="record-count" class="config-control" type="number" min="1" step="1"
placeholder="Sweeps to record" aria-label="Number of sweeps to record" />
<button id="btn-start-recording" class="btn">Start + Record</button>
<button id="btn-start-recording" class="btn">Start + Record to Disk</button>
</div>
<div class="config-active" id="recording-status"></div>
</div>
<div class="panel-head" id="settings-toggle">
<span class="panel-title">Processor settings</span>
<span class="panel-title">Processor Settings</span>
<span class="chevron" id="settings-chevron">▾</span>
</div>
<div class="panel-body" id="settings-body">
<div id="settings-fields" class="settings-fields"></div>
<div class="settings-actions">
<button id="btn-apply" class="btn primary">Apply</button>
<button id="btn-reset-history" class="btn">Reset history</button>
<button id="btn-reset-history" class="btn destructive">Clear Runtime History</button>
</div>
<div id="settings-note" class="note"></div>
</div>
+7 -6
View File
@@ -78,6 +78,7 @@ body {
color: #ffffff;
}
.btn.primary:hover:not(:disabled) { background: var(--accent-hover); }
.btn.destructive { color: #a73545; border-color: #cf9ba4; }
.btn:disabled {
color: var(--disabled-text);
background: var(--disabled-bg);
@@ -125,6 +126,7 @@ body {
overflow: hidden;
}
#plot { display: block; width: 100%; height: 100%; object-fit: contain; }
#plot:not([src]) { display: none; }
/* Side panel */
.side-panel {
@@ -154,7 +156,7 @@ body {
border-radius: 7px;
padding: 5px 7px;
color: var(--text);
font-family: var(--mono);
font-family: inherit;
font-size: 12px;
}
.config-control:focus { outline: none; border-color: var(--accent); }
@@ -197,8 +199,7 @@ body {
.group-title {
margin: 12px 0 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.6px;
text-transform: capitalize;
color: var(--muted);
font-weight: 700;
}
@@ -227,7 +228,7 @@ body {
border-radius: 7px;
padding: 4px 7px;
color: var(--text);
font-family: var(--mono);
font-family: inherit;
font-size: 12px;
}
.field input:focus,
@@ -251,7 +252,7 @@ body {
border-radius: 7px;
padding: 4px 7px;
color: var(--text);
font-family: var(--mono);
font-family: inherit;
font-size: 12px;
}
.field textarea:focus { outline: none; border-color: var(--accent); }
@@ -262,7 +263,7 @@ body {
padding: 10px 12px;
border-top: 1px solid var(--border-soft);
}
.settings-actions .btn { flex: 1; }
.settings-actions .btn { flex: auto; white-space: nowrap; }
.note {
padding: 0 12px 10px;
color: var(--muted);