From 02bbe83446964c8deb114c7fa62d49fd822b93bd Mon Sep 17 00:00:00 2001 From: Guriy Date: Tue, 23 Jun 2026 12:34:48 +0300 Subject: [PATCH 1/2] added reference png export --- .gitignore | 3 + .../app_window_preprocess_mixin.py | 51 ++++++++++++ python_app/gui/trace_png_export.py | 77 +++++++++++++++++++ python_app/storage/npz/store.py | 10 +++ 4 files changed, 141 insertions(+) create mode 100644 python_app/gui/trace_png_export.py diff --git a/.gitignore b/.gitignore index 9bafc13..a1b437c 100644 --- a/.gitignore +++ b/.gitignore @@ -224,3 +224,6 @@ __marimo__/ .streamlit/secrets.toml python_app/runtime SHARE_INTERNET_TO_PI.md + +CLAUDE.md +./docs \ No newline at end of file diff --git a/python_app/gui/controllers/app_window_preprocess_mixin.py b/python_app/gui/controllers/app_window_preprocess_mixin.py index 382725b..0a9c8bb 100644 --- a/python_app/gui/controllers/app_window_preprocess_mixin.py +++ b/python_app/gui/controllers/app_window_preprocess_mixin.py @@ -3,6 +3,7 @@ from __future__ import annotations from python_app.gui.preprocess_dialog import PreprocessDialog +from python_app.gui.trace_png_export import export_trace_png from python_app.orchestration.preprocess_assets import ( PREPROCESS_ASSET_SPECS, VISIBLE_PREPROCESS_ASSET_KEYS, @@ -701,6 +702,12 @@ class AppWindowPreprocessMixin: f"{display_name} sequence completed and saved: set={set_name}, " f"radar_variants={len(saved_sets)} [{saved_summary}]" ) + self._export_multi_radar_preview_pngs( + session=session, + saved_sets=saved_sets, + kind=kind, + set_name=set_name, + ) else: dialog.set_status(f"{display_name} set saved: {set_name} ({len(collection.traces)} traces)") self._log(f"{display_name} sequence completed and saved: set={set_name}, key={radar_key}") @@ -708,6 +715,50 @@ class AppWindowPreprocessMixin: except Exception as exc: # noqa: BLE001 self._show_exception("Failed to save preprocess set", exc) + def _export_multi_radar_preview_pngs( + self, + *, + session: MultiRadarSequentialCaptureSession, + saved_sets: list, + kind: str, + set_name: str, + ) -> None: + """Save amplitude/phase PNGs for every captured combo across all radar configs. + + The live preview only shows the last config of each combo, so this persists a + graph for the other configs too, under the store's ``preview_png/`` tree. The + set is already saved by the time we get here, so any rendering failure is + logged but never aborts the save. + """ + channel = preprocess_asset_channel(kind) + display_name = preprocess_asset_display_name(kind) + # batch.traces and saved_sets are both ordered by the session's radar + # variants, so index i refers to the same config in both. + saved_count = 0 + for batch in session.captured_batches(): + for trace, saved in zip(batch.traces, saved_sets): + combo_label = f"input={trace.combo.input} output={trace.combo.output}" + try: + png_path = self._store.preview_png_dir(kind, set_name, saved.radar_key) / ( + f"i{trace.combo.input}_o{trace.combo.output}.png" + ) + export_trace_png( + trace, + png_path, + channel=channel, + title=f"{display_name} | {saved.display_name} | {combo_label}", + ) + saved_count += 1 + except Exception as exc: # noqa: BLE001 + self._log( + f"Failed to save preview PNG for {saved.display_name} ({combo_label}): {exc}" + ) + if saved_count and saved_sets: + png_root = self._store.preview_png_dir(kind, set_name, saved_sets[0].radar_key).parent + self._log( + f"Saved {saved_count} preview PNG(s) for {len(saved_sets)} radar config(s) under {png_root}" + ) + def _abort_capture_sequence(self, *, resume_pipeline: bool = True) -> None: """Abort active capture session and optionally resume pipeline.""" if self._capture_session is None: diff --git a/python_app/gui/trace_png_export.py b/python_app/gui/trace_png_export.py new file mode 100644 index 0000000..ebb1c92 --- /dev/null +++ b/python_app/gui/trace_png_export.py @@ -0,0 +1,77 @@ +"""Render captured traces (amplitude + phase) to standalone PNG files. + +The preprocess preview pane only shows the *last* radar variant of each combo, +so during a multi-config reference capture the operator never sees the other +variants. This helper renders the same amplitude/phase plot pair used in the +preview into off-screen PNG files, letting us persist a graph for every config. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import numpy as np +import pyqtgraph as pg +from PyQt6.QtCore import QRectF +from pyqtgraph.exporters import ImageExporter + +from python_app.models.dataset_model import TraceData + +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) + + +def export_trace_png( + trace: TraceData, + output_path: Path, + *, + channel: str, + title: str, +) -> None: + """Render one trace's amplitude (dB) and wrapped phase (deg) panes to a PNG. + + Builds an off-screen ``GraphicsLayoutWidget`` (never shown), plots the same + curves as the live preview, and exports the scene as a PNG. Requires a + running ``QApplication`` (always true inside the GUI). + """ + samples = trace.s11 if channel == "s11" else trace.s21 + magnitude_db = 20.0 * np.log10(np.maximum(np.abs(samples), 1e-12)) + phase_deg = np.degrees(np.angle(samples)) + + layout = pg.GraphicsLayoutWidget(show=False, size=_EXPORT_SIZE) + layout.setBackground(_BACKGROUND) + try: + layout.addLabel(title, row=0, col=0, colspan=2) + + magnitude_plot = layout.addPlot(row=1, col=0) + magnitude_plot.showGrid(x=True, y=True, alpha=0.2) + magnitude_plot.setLabel("bottom", "Frequency", units="Hz") + magnitude_plot.setLabel("left", "Magnitude", units="dB") + magnitude_plot.plot(trace.frequency_hz, magnitude_db, pen=_MAGNITUDE_PEN) + + phase_plot = layout.addPlot(row=1, col=1) + phase_plot.showGrid(x=True, y=True, alpha=0.2) + phase_plot.setLabel("bottom", "Frequency", units="Hz") + phase_plot.setLabel("left", "Phase", units="deg") + phase_plot.plot(trace.frequency_hz, phase_deg, pen=_PHASE_PEN) + + # An off-screen widget never receives a resizeEvent, so its central + # GraphicsLayout keeps its small preferred size and both plots would be + # squeezed into the left edge of the image. Force the layout geometry to + # the target size, doing manually what a resizeEvent normally triggers. + layout.ci.setGeometry(QRectF(0, 0, _EXPORT_SIZE[0], _EXPORT_SIZE[1])) + + output_path.parent.mkdir(parents=True, exist_ok=True) + exporter = ImageExporter(layout.scene()) + exporter.parameters()["width"] = _EXPORT_SIZE[0] + exporter.export(str(output_path)) + finally: + layout.close() + layout.deleteLater() diff --git a/python_app/storage/npz/store.py b/python_app/storage/npz/store.py index 344560d..2598960 100644 --- a/python_app/storage/npz/store.py +++ b/python_app/storage/npz/store.py @@ -411,5 +411,15 @@ class NpzStore(StoreApi): """Return directory for set kind and radar key.""" return self._root_dir / kind / radar_key + def preview_png_dir(self, kind: str, set_name: str, radar_key: str) -> Path: + """Return directory for preview PNGs of one set/radar variant. + + Lives under ``preview_png/`` inside the store so saved graphs sit next to + the data they describe, grouped by set name then radar variant. The set + name is operator-supplied, so it is sanitized; ``radar_key`` is already + filesystem-safe by construction. + """ + return self._root_dir / "preview_png" / kind / sanitize_path_component(set_name) / radar_key + __all__ = ["NpzStore", "radar_key_from_config"] From d5228288755db3eb37a6bdfde7957d9dd9d550cf Mon Sep 17 00:00:00 2001 From: Guriy Date: Tue, 23 Jun 2026 13:52:31 +0300 Subject: [PATCH 2/2] some fixes --- .../gui/controllers/app_window_preprocess_mixin.py | 12 ++++++------ python_app/workflows/multi_radar_capture_workflow.py | 9 +++++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/python_app/gui/controllers/app_window_preprocess_mixin.py b/python_app/gui/controllers/app_window_preprocess_mixin.py index 0a9c8bb..143b6af 100644 --- a/python_app/gui/controllers/app_window_preprocess_mixin.py +++ b/python_app/gui/controllers/app_window_preprocess_mixin.py @@ -725,18 +725,18 @@ class AppWindowPreprocessMixin: ) -> None: """Save amplitude/phase PNGs for every captured combo across all radar configs. - The live preview only shows the last config of each combo, so this persists a - graph for the other configs too, under the store's ``preview_png/`` tree. The + The live preview only shows one trace per config, so this persists a graph for + every config and every combo (port) under the store's ``preview_png/`` tree. + Iterating per config over its full trace set (not the per-batch preview trace) + is what makes matrix radars save all ports instead of only the last one. The set is already saved by the time we get here, so any rendering failure is logged but never aborts the save. """ channel = preprocess_asset_channel(kind) display_name = preprocess_asset_display_name(kind) - # batch.traces and saved_sets are both ordered by the session's radar - # variants, so index i refers to the same config in both. saved_count = 0 - for batch in session.captured_batches(): - for trace, saved in zip(batch.traces, saved_sets): + for saved in saved_sets: + for trace in session.traces_for_radar_key(saved.radar_key): combo_label = f"input={trace.combo.input} output={trace.combo.output}" try: png_path = self._store.preview_png_dir(kind, set_name, saved.radar_key) / ( diff --git a/python_app/workflows/multi_radar_capture_workflow.py b/python_app/workflows/multi_radar_capture_workflow.py index 3796c99..09aa0d3 100644 --- a/python_app/workflows/multi_radar_capture_workflow.py +++ b/python_app/workflows/multi_radar_capture_workflow.py @@ -313,6 +313,15 @@ class MultiRadarSequentialCaptureSession: """Return completed combo batches in capture order.""" return list(self._captured_batches) + def traces_for_radar_key(self, radar_key: str) -> list[TraceData]: + """Return every captured trace (one per combo) for one radar variant. + + Unlike a batch's ``traces`` (one entry per variant, holding only the + preview trace), this returns the full per-combo set, so matrix radars + expose all ports rather than just the last one. + """ + return list(self._traces_by_radar_key.get(radar_key, [])) + def radar_variant_count(self) -> int: """Return how many radar variants are captured per combo.""" return len(self._radar_variants)