added white theme, some features to processing and saving

This commit is contained in:
Ayzen
2026-04-13 10:44:01 +03:00
parent 61685d7a4d
commit 5d2f95f959
20 changed files with 295 additions and 48 deletions
@@ -168,7 +168,8 @@ class AppWindowLiveProcessingMixin:
f"cut={self._bscan_cut_m.value():g} m, "
f"max_depth={self._bscan_max_depth_m.value():g} m, "
f"gain={self._bscan_gain.value():g}, "
f"freq={self._bscan_start_freq_mhz.value():g}..{self._bscan_stop_freq_mhz.value():g} MHz)"
f"freq={self._bscan_start_freq_mhz.value():g}..{self._bscan_stop_freq_mhz.value():g} MHz, "
f"subtract_mean_ascan={self._bscan_subtract_mean_ascan.isChecked()})"
)
elif mode == "gpr":
self._log(
@@ -169,6 +169,7 @@ class AppWindowConfigProfileIOMixin:
self._bscan_gain,
self._bscan_start_freq_mhz,
self._bscan_stop_freq_mhz,
self._bscan_subtract_mean_ascan,
self._gpr_config_mode,
self._gpr_relative_permittivity,
self._gpr_tx_geometry_input,
@@ -228,6 +229,7 @@ class AppWindowConfigProfileIOMixin:
self._bscan_gain.setValue(float(gui_state.processing.bscan.gain))
self._bscan_start_freq_mhz.setValue(float(gui_state.processing.bscan.start_freq_mhz))
self._bscan_stop_freq_mhz.setValue(float(gui_state.processing.bscan.stop_freq_mhz))
self._bscan_subtract_mean_ascan.setChecked(bool(gui_state.processing.bscan.subtract_mean_ascan))
self._set_combo_current_text(self._gpr_config_mode, str(config.gpr.mode))
self._gpr_relative_permittivity.setValue(float(config.gpr.relative_permittivity))
@@ -157,6 +157,7 @@ class AppWindowConfigStateBuildersMixin:
gain=1.0,
start_freq_mhz=100.0,
stop_freq_mhz=8800.0,
subtract_mean_ascan=False,
),
gpr=GuiGprStateModel(
input_positions=self._default_gpr_input_positions_from_config(config),
@@ -235,6 +236,7 @@ class AppWindowConfigStateBuildersMixin:
gain=float(self._bscan_gain.value()),
start_freq_mhz=float(self._bscan_start_freq_mhz.value()),
stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()),
subtract_mean_ascan=bool(self._bscan_subtract_mean_ascan.isChecked()),
),
gpr=GuiGprStateModel(
input_positions=self._gpr_input_positions_input.text().strip(),
@@ -39,6 +39,8 @@ def _result_tail(
def build_bscan_signature(
live_config: ProcessingLiveConfig,
*,
subtract_mean_ascan_enabled: bool,
result_history: list[ResultCollection],
history_limit: int,
floor_collection_id: int,
@@ -57,11 +59,25 @@ def build_bscan_signature(
float(live_config.bscan_gain),
float(live_config.bscan_start_freq_mhz),
float(live_config.bscan_stop_freq_mhz),
bool(subtract_mean_ascan_enabled),
int(floor_collection_id),
tuple((int(collection.collection_id), int(collection.monotonic_ns), len(collection.blocks)) for collection in result_tail),
)
def apply_mean_ascan_subtraction(
history: deque[np.ndarray],
*,
enabled: bool,
) -> np.ndarray:
"""Subtract mean A-scan across sweeps when enabled."""
sweeps = np.vstack(history).astype(np.float32, copy=False)
if not enabled:
return sweeps
mean_trace = np.mean(sweeps, axis=0, dtype=np.float32)
return sweeps - mean_trace
def rebuild_bscan_history_from_results(
result_history: list[ResultCollection],
history_limit: int,
@@ -183,7 +199,10 @@ class AppWindowBscanPlotMixin:
if not history or depth_axis is None:
return False
sweeps = np.vstack(history).astype(np.float32, copy=False)
sweeps = apply_mean_ascan_subtraction(
history,
enabled=bool(self._bscan_subtract_mean_ascan.isChecked()),
)
if sweeps.size == 0:
return False
@@ -229,6 +248,7 @@ class AppWindowBscanPlotMixin:
result_history = list(self._result_history)
return build_bscan_signature(
live_config=live_config,
subtract_mean_ascan_enabled=bool(self._bscan_subtract_mean_ascan.isChecked()),
result_history=result_history,
history_limit=self._bscan_history_limit,
floor_collection_id=self._bscan_history_floor_collection_id,
@@ -18,9 +18,9 @@ class AppWindowSnapshotMixin:
return snapshot_dir / "config_profile.json"
@staticmethod
def _vna_json_config_profile_path(output_root: Path, output_stem: str) -> Path:
def _vna_json_config_profile_path(output_dir: Path) -> Path:
"""Return companion config-profile path for one VNA-history JSON export batch."""
return output_root / f"{output_stem}_config_profile.json"
return output_dir / "config_profile.json"
def _save_snapshot(self) -> None:
"""Save runtime snapshot in numpy-directory format."""
@@ -98,8 +98,9 @@ class AppWindowSnapshotMixin:
output_stem = str(summary.get("output_stem", "")).strip()
if not output_stem:
raise RuntimeError("VNA history JSON export did not report output_stem for config companion save")
output_dir = Path(str(summary.get("output_dir", "")).strip() or output_paths[0].parent)
config_profile_path = self._vna_json_config_profile_path(output_root, output_stem)
config_profile_path = self._vna_json_config_profile_path(output_dir)
try:
self._write_gui_profile_to_path(config_profile_path, allow_overwrite=False)
except Exception as exc: # noqa: BLE001
@@ -110,6 +111,7 @@ class AppWindowSnapshotMixin:
"VNA history JSON files were saved, but the adjacent config profile could not be written",
details=(
f"output_root={output_root}\n"
f"output_dir={output_dir}\n"
f"config_profile_path={config_profile_path}\n"
f"saved_json_files={len(output_paths)}\n"
f"{exported_preview}\n\n"
@@ -134,6 +134,9 @@ def build_processing_group(owner) -> QGroupBox:
owner._bscan_stop_freq_mhz.setSingleStep(10.0)
owner._bscan_stop_freq_mhz.setValue(float(bscan_defaults.stop_freq_mhz))
owner._bscan_subtract_mean_ascan = QCheckBox("Subtract mean A-scan")
owner._bscan_subtract_mean_ascan.setChecked(bool(bscan_defaults.subtract_mean_ascan))
bscan_page = _build_processing_mode_page(
owner._processing_mode_pages,
[
@@ -143,8 +146,9 @@ def build_processing_group(owner) -> QGroupBox:
("Gain", owner._bscan_gain),
("Start MHz", owner._bscan_start_freq_mhz),
("Stop MHz", owner._bscan_stop_freq_mhz),
owner._bscan_subtract_mean_ascan,
],
split_index=3,
split_index=4,
)
owner._processing_mode_pages.addWidget(bscan_page)
@@ -314,6 +318,7 @@ def build_processing_group(owner) -> QGroupBox:
owner._bscan_gain.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._bscan_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._bscan_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._bscan_subtract_mean_ascan.toggled.connect(owner._on_processing_live_settings_changed)
owner._gpr_input_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed)
owner._gpr_output_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed)
owner._gpr_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
+3 -3
View File
@@ -14,14 +14,14 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from python_app.gui.app_window import AppWindow
from python_app.gui.theme import apply_dark_theme
from python_app.gui.theme import apply_light_theme
def main() -> int:
"""Run Qt event loop and show main radar control window."""
app = QApplication(sys.argv)
apply_dark_theme(app)
pg.setConfigOptions(antialias=True, foreground="#dbe4f1")
apply_light_theme(app)
pg.setConfigOptions(antialias=True, background="#ffffff", foreground="#334155")
window = AppWindow(PROJECT_ROOT)
window.showMaximized()
return app.exec()
+34 -34
View File
@@ -6,19 +6,19 @@ from PyQt6.QtGui import QColor, QPalette
from PyQt6.QtWidgets import QApplication, QStyleFactory
_DARK_STYLESHEET = """
_LIGHT_STYLESHEET = """
QMainWindow, QDialog {
background-color: #0f131a;
background-color: #f3f6fb;
}
QWidget {
color: #e7edf7;
color: #1f2937;
font-size: 13px;
}
QGroupBox {
background-color: #151b24;
border: 1px solid #263142;
background-color: #ffffff;
border: 1px solid #d7dee8;
border-radius: 10px;
margin-top: 14px;
padding: 10px;
@@ -28,39 +28,39 @@ QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 6px;
color: #9fb4ce;
color: #526277;
font-weight: 600;
}
QPushButton {
background-color: #1d2735;
border: 1px solid #314055;
background-color: #f8fafc;
border: 1px solid #cad4e1;
border-radius: 8px;
padding: 7px 12px;
}
QPushButton:hover {
background-color: #243246;
background-color: #eef3f9;
}
QPushButton:pressed {
background-color: #1a2432;
background-color: #e4ebf4;
}
QPushButton:checked {
background-color: #2f7ee6;
border-color: #5d88bd;
border-color: #2f7ee6;
color: #ffffff;
}
QPushButton:checked:hover {
background-color: #3c89ee;
background-color: #3b8bf4;
}
QPushButton:disabled {
color: #6b7d95;
background-color: #151d27;
border-color: #232e3d;
color: #98a4b3;
background-color: #f3f5f8;
border-color: #dde4ec;
}
QPushButton#settingsToggleButton {
@@ -83,11 +83,11 @@ QTextEdit,
QComboBox,
QSpinBox,
QDoubleSpinBox {
background-color: #101721;
border: 1px solid #2e3b4e;
background-color: #ffffff;
border: 1px solid #c9d4e1;
border-radius: 7px;
padding: 5px 8px;
selection-background-color: #2f7ee6;
selection-background-color: #cfe3ff;
}
QLineEdit:focus,
@@ -96,7 +96,7 @@ QTextEdit:focus,
QComboBox:focus,
QSpinBox:focus,
QDoubleSpinBox:focus {
border: 1px solid #5d88bd;
border: 1px solid #2f7ee6;
}
QTextEdit#runtimeLogBox {
@@ -116,34 +116,34 @@ QScrollArea {
}
QLabel#statusLabel {
color: #94b4d9;
color: #35507a;
font-weight: 600;
padding: 2px 1px;
}
QLabel#hintLabel {
color: #7f94af;
color: #6c7b8d;
}
"""
def apply_dark_theme(app: QApplication) -> None:
"""Apply a minimal modern dark theme shared by all windows."""
def apply_light_theme(app: QApplication) -> None:
"""Apply a minimal light theme shared by all windows."""
app.setStyle(QStyleFactory.create("Fusion"))
palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor("#0f131a"))
palette.setColor(QPalette.ColorRole.WindowText, QColor("#e7edf7"))
palette.setColor(QPalette.ColorRole.Base, QColor("#101721"))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#151b24"))
palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#151b24"))
palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#e7edf7"))
palette.setColor(QPalette.ColorRole.Text, QColor("#e7edf7"))
palette.setColor(QPalette.ColorRole.Button, QColor("#1d2735"))
palette.setColor(QPalette.ColorRole.ButtonText, QColor("#e7edf7"))
palette.setColor(QPalette.ColorRole.Window, QColor("#f3f6fb"))
palette.setColor(QPalette.ColorRole.WindowText, QColor("#1f2937"))
palette.setColor(QPalette.ColorRole.Base, QColor("#ffffff"))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#eef3f8"))
palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#ffffff"))
palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#1f2937"))
palette.setColor(QPalette.ColorRole.Text, QColor("#1f2937"))
palette.setColor(QPalette.ColorRole.Button, QColor("#f8fafc"))
palette.setColor(QPalette.ColorRole.ButtonText, QColor("#1f2937"))
palette.setColor(QPalette.ColorRole.BrightText, QColor("#ffffff"))
palette.setColor(QPalette.ColorRole.Link, QColor("#5d88bd"))
palette.setColor(QPalette.ColorRole.Link, QColor("#2f7ee6"))
palette.setColor(QPalette.ColorRole.Highlight, QColor("#2f7ee6"))
palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff"))
app.setPalette(palette)
app.setStyleSheet(_DARK_STYLESHEET)
app.setStyleSheet(_LIGHT_STYLESHEET)
+7
View File
@@ -152,6 +152,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
gui.processing.bscan.stop_freq_mhz,
"gui.processing.bscan",
),
subtract_mean_ascan=_optional_bool(
bscan_object,
"subtract_mean_ascan",
gui.processing.bscan.subtract_mean_ascan,
"gui.processing.bscan",
),
),
gpr=GuiGprStateModel(
input_positions=_optional_string(
@@ -359,6 +365,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
"gain": gui.processing.bscan.gain,
"start_freq_mhz": gui.processing.bscan.start_freq_mhz,
"stop_freq_mhz": gui.processing.bscan.stop_freq_mhz,
"subtract_mean_ascan": gui.processing.bscan.subtract_mean_ascan,
},
"gpr": {
"input_positions": gui.processing.gpr.input_positions,
+1
View File
@@ -42,6 +42,7 @@ class GuiBscanStateModel:
gain: float = 1.0
start_freq_mhz: float = 100.0
stop_freq_mhz: float = 8800.0
subtract_mean_ascan: bool = False
@dataclass(slots=True)
+19
View File
@@ -9,6 +9,7 @@ from python_app.models.run_config_schema import (
GprRxGeometryModel,
GprTxGeometryModel,
PreprocessAssetModel,
PreprocessNotchModel,
RunConfigModel,
)
from python_app.models.run_config_validation import load_ring_payload, load_switch_payload, validate_gpr_model
@@ -137,6 +138,18 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
_as_dict(s11_preprocess_payload.get("reference"), "preprocess.s11.reference"),
model.preprocess.s11.reference,
)
notch_payload = _as_dict(preprocess_payload.get("notch"), "preprocess.notch")
model.preprocess.notch = PreprocessNotchModel(
enabled=bool(notch_payload.get("enabled", model.preprocess.notch.enabled)),
taper_width_hz=float(notch_payload.get("taper_width_hz", model.preprocess.notch.taper_width_hz)),
taper_type=str(notch_payload.get("taper_type", model.preprocess.notch.taper_type)),
bands_hz=[],
)
bands_payload = notch_payload.get("bands_hz", [])
if isinstance(bands_payload, list):
for band in bands_payload:
if isinstance(band, (list, tuple)) and len(band) == 2:
model.preprocess.notch.bands_hz.append((float(band[0]), float(band[1])))
model.gpr.mode = str(gpr_payload.get("mode", model.gpr.mode))
model.gpr.relative_permittivity = float(
@@ -282,6 +295,12 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"bundle_path": model.preprocess.s11.reference.bundle_path,
},
},
"notch": {
"enabled": model.preprocess.notch.enabled,
"bands_hz": [[low_hz, high_hz] for low_hz, high_hz in model.preprocess.notch.bands_hz],
"taper_width_hz": model.preprocess.notch.taper_width_hz,
"taper_type": model.preprocess.notch.taper_type,
},
},
"gpr": {
"mode": model.gpr.mode,
+2
View File
@@ -8,6 +8,7 @@ from python_app.models.run_config_schema import (
GprTxGeometryModel,
LocatorServerRuntimeModel,
PreprocessAssetModel,
PreprocessNotchModel,
PreprocessModel,
RadarModel,
RadarSweepModel,
@@ -33,6 +34,7 @@ __all__ = [
"GprTxGeometryModel",
"LocatorServerRuntimeModel",
"PreprocessAssetModel",
"PreprocessNotchModel",
"PreprocessModel",
"RadarModel",
"RadarSweepModel",
+11
View File
@@ -132,12 +132,23 @@ class S11PreprocessModel:
reference: PreprocessAssetModel = field(default_factory=PreprocessAssetModel)
@dataclass(slots=True)
class PreprocessNotchModel:
"""Optional frequency-domain notch filter applied after calibration and reference subtraction."""
enabled: bool = False
bands_hz: list[tuple[float, float]] = field(default_factory=list)
taper_width_hz: float = 40_000_000.0
taper_type: str = "cosine"
@dataclass(slots=True)
class PreprocessModel:
"""Selected preprocessing artifacts for live acquisition."""
s21: S21PreprocessModel = field(default_factory=S21PreprocessModel)
s11: S11PreprocessModel = field(default_factory=S11PreprocessModel)
notch: PreprocessNotchModel = field(default_factory=PreprocessNotchModel)
@dataclass(slots=True)
+1 -1
View File
@@ -32,4 +32,4 @@
"gpr_background_mean_count": 10,
"history_command_seq": 0,
"history_command": "none"
}
}
+13 -1
View File
@@ -108,6 +108,18 @@
"set_name": "",
"bundle_path": ""
}
},
"notch": {
"enabled": true,
"bands_hz": [
[790000000.0, 850000000.0],
[1790000000.0, 1890000000.0],
[1970000000.0, 2050000000.0],
[2080000000.0, 2560000000.0],
[2650000000.0, 2700000000.0]
],
"taper_width_hz": 40000000.0,
"taper_type": "cosine"
}
},
"gpr": {
@@ -169,4 +181,4 @@
"slot_size_bytes": 2097152
}
}
}
}
+13 -2
View File
@@ -31,6 +31,11 @@ class NpzStore(StoreApi):
self._root_dir = root_dir
self._root_dir.mkdir(parents=True, exist_ok=True)
@staticmethod
def _vna_json_output_dir(output_root_dir: Path, output_stem: str) -> Path:
"""Return per-export directory for VNA history JSON files."""
return output_root_dir / output_stem
def save_set(self, kind: str, radar_key: str, set_name: str, collection: SweepCollection) -> None:
"""Persist named preprocess set as NPZ and metadata JSON."""
set_dir = self._set_dir(kind, radar_key)
@@ -217,7 +222,9 @@ class NpzStore(StoreApi):
output_stem = sanitize_path_component(output_stem)
output_root_dir.mkdir(parents=True, exist_ok=True)
output_path = output_root_dir / f"{output_stem}_vna_bscan_history.json"
output_dir = self._vna_json_output_dir(output_root_dir, output_stem)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"{output_stem}_vna_bscan_history.json"
if output_path.exists():
raise FileExistsError(f"Output JSON file already exists: {output_path}")
@@ -253,6 +260,7 @@ class NpzStore(StoreApi):
summary["channel"] = str(channel)
summary["primary_stage"] = str(primary_stage)
summary["output_stem"] = output_stem
summary["output_dir"] = str(output_dir)
summary["output_path"] = str(output_path)
return output_path, summary
@@ -275,6 +283,8 @@ class NpzStore(StoreApi):
output_stem = output_name.strip() or datetime.utcnow().strftime("snapshot_%Y%m%d_%H%M%S")
output_stem = sanitize_path_component(output_stem)
output_root_dir.mkdir(parents=True, exist_ok=True)
output_dir = self._vna_json_output_dir(output_root_dir, output_stem)
output_dir.mkdir(parents=True, exist_ok=True)
selected_raw, selected_preprocessed, selected_results, selection_summary = select_aligned_histories(
raw_history,
@@ -296,7 +306,7 @@ class NpzStore(StoreApi):
output_paths: list[Path] = []
payloads: list[dict[str, Any]] = []
for input_index, output_index in combos:
output_path = output_root_dir / (
output_path = output_dir / (
f"{output_stem}_i{input_index}_o{output_index}_{channel}_vna_bscan_history.json"
)
if output_path.exists():
@@ -330,6 +340,7 @@ class NpzStore(StoreApi):
summary["channel"] = str(channel)
summary["primary_stage"] = str(primary_stage)
summary["output_stem"] = output_stem
summary["output_dir"] = str(output_dir)
summary["output_paths"] = [str(path) for path in output_paths]
return output_paths, summary