Merge branch 'some-additions'
export reference png added
This commit is contained in:
@@ -224,3 +224,6 @@ __marimo__/
|
||||
.streamlit/secrets.toml
|
||||
python_app/runtime
|
||||
SHARE_INTERNET_TO_PI.md
|
||||
|
||||
CLAUDE.md
|
||||
./docs
|
||||
@@ -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 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)
|
||||
saved_count = 0
|
||||
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) / (
|
||||
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:
|
||||
|
||||
@@ -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()
|
||||
@@ -484,5 +484,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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user