Add Export Fused ROI CSV export

Lets a user export the current ROI as one CSV with a column per selected
angle's value (RF peak freq, Bias A, Bias B, or velocity), once angles share
a common (x, y) grid -- either via a live Fusion alignment result or because
the open file is itself a previous Alignment Wizard export whose angles
already share one grid on disk. Never triggers a background compute; angles
without cached/stored data for the chosen value type are simply unavailable
in the picker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-08-09 19:11:11 -05:00
parent d5914b5793
commit 191d1b8946
6 changed files with 659 additions and 30 deletions
+3 -1
View File
@@ -23,5 +23,7 @@ from .canvases import ( # noqa: E4
AlignOverlayCanvas, ImageCanvas, RoiQuad, WaveformCanvas,
)
from .common import CH_LABELS, CMAPS, VELOCITY_MODE_IDX # noqa: E402,F401
from .dialogs import FftOptionsDialog, RowAverageFftOptionsDialog # noqa: E402,F401
from .dialogs import ( # noqa: E402,F401
FftOptionsDialog, FusedRoiExportDialog, RowAverageFftOptionsDialog,
)
from .main_window import SrasViewerWindow, main # noqa: E402,F401
+181 -3
View File
@@ -5,14 +5,18 @@ alignment wizard's first page (see align_wizard.py), which needed the same
mask-overlay editor plus the crop and export steps.
"""
from pathlib import Path
from PyQt6.QtWidgets import (
QButtonGroup, QDialog, QDialogButtonBox, QGroupBox, QHBoxLayout, QLabel,
QRadioButton, QSpinBox, QVBoxLayout,
QButtonGroup, QCheckBox, QDialog, QDialogButtonBox, QFileDialog,
QGroupBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QRadioButton,
QScrollArea, QSpinBox, QVBoxLayout, QWidget,
)
from sras_compute import PYFFTW_AVAILABLE
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES
from .common import _CSS_HINT, _make_dspin
from .common import CH_LABELS, VELOCITY_MODE_IDX, _CSS_HINT, _CSS_WARN, _group, _make_dspin, _wrap_label
# ---------------------------------------------------------------------------
@@ -235,3 +239,177 @@ class RowAverageFftOptionsDialog(QDialog):
def get_threshold_mv(self) -> float:
return self._spin_threshold.value()
# ---------------------------------------------------------------------------
# Export Fused ROI dialog
# ---------------------------------------------------------------------------
class FusedRoiExportDialog(QDialog):
"""Choose a value type and which angles to fuse for Export Fused ROI.
Every angle in the file is listed (a live AlignmentResult's per_angle
always covers every angle, and the raw-shared-grid path needs no per-
angle transform at all). Each checkbox is disabled — and auto-unchecked
— whenever *availability_fn(angle_idx, ch_idx)* is False for the
currently selected value type; switching the value-type radio
re-evaluates every checkbox live, since availability is per (angle,
value type) rather than just per angle — e.g. DC may be ready
everywhere while FFT is ready nowhere.
"""
_VALUE_MODES = (CH1_IDX, CH3_IDX, CH4_IDX, VELOCITY_MODE_IDX)
def __init__(self, parent=None, *,
angles: list[tuple[int, float]],
availability_fn,
default_ch_idx: int,
out_dir: str,
stem: str,
grid_note: str):
super().__init__(parent)
self.setWindowTitle("Export Fused ROI")
self.setModal(True)
self.setMinimumWidth(420)
self._availability_fn = availability_fn
self._out_dir = out_dir
self._stem = stem
self._path_user_chosen = False
layout = QVBoxLayout(self)
layout.addWidget(_wrap_label(grid_note, _CSS_HINT))
# ---- Value type --------------------------------------------------
grp_val, vl = _group("Value to Export")
self._val_group = QButtonGroup(self)
self._val_buttons: dict[int, QRadioButton] = {}
for ch_idx, label in zip(self._VALUE_MODES, CH_LABELS):
rb = QRadioButton(label)
self._val_group.addButton(rb, id=ch_idx)
self._val_buttons[ch_idx] = rb
vl.addWidget(rb)
self._val_buttons[default_ch_idx].setChecked(True)
self._val_group.idClicked.connect(self._on_value_type_changed)
layout.addWidget(grp_val)
# ---- Angles --------------------------------------------------
grp_ang, al = _group("Angles to Include")
sel_row = QHBoxLayout()
btn_all = QPushButton("Select All Available")
btn_none = QPushButton("Select None")
btn_all.clicked.connect(self._on_select_all_available)
btn_none.clicked.connect(self._on_select_none)
sel_row.addWidget(btn_all)
sel_row.addWidget(btn_none)
al.addLayout(sel_row)
scroll_inner = QWidget()
scroll_layout = QVBoxLayout(scroll_inner)
self._angle_checks: dict[int, QCheckBox] = {}
for angle_idx, angle_deg in angles:
cb = QCheckBox(f"{angle_deg:.1f}° (angle {angle_idx})")
self._angle_checks[angle_idx] = cb
cb.toggled.connect(self._update_accept_enabled)
scroll_layout.addWidget(cb)
scroll = QScrollArea()
scroll.setWidget(scroll_inner)
scroll.setWidgetResizable(True)
scroll.setMaximumHeight(220)
al.addWidget(scroll)
self._lbl_none_available = _wrap_label("", _CSS_WARN)
al.addWidget(self._lbl_none_available)
layout.addWidget(grp_ang)
# ---- Output path --------------------------------------------------
grp_out, ol = _group("Output File")
path_row = QHBoxLayout()
self._edit_path = QLineEdit()
self._edit_path.setReadOnly(True)
path_row.addWidget(self._edit_path, 1)
btn_browse = QPushButton("Browse…")
btn_browse.clicked.connect(self._on_browse)
path_row.addWidget(btn_browse)
ol.addLayout(path_row)
layout.addWidget(grp_out)
# ---- Buttons -----------------------------------------------------
buttons = QDialogButtonBox()
self._btn_export = buttons.addButton(
"Export", QDialogButtonBox.ButtonRole.AcceptRole)
self._btn_export.clicked.connect(self.accept)
buttons.addButton("Cancel", QDialogButtonBox.ButtonRole.RejectRole
).clicked.connect(self.reject)
layout.addWidget(buttons)
self._refresh_default_path()
self._apply_availability()
# ---- internals -----------------------------------------------------
def _current_ch_idx(self) -> int:
return self._val_group.checkedId()
def _apply_availability(self):
ch_idx = self._current_ch_idx()
n_ok = 0
for angle_idx, cb in self._angle_checks.items():
ok = self._availability_fn(angle_idx, ch_idx)
cb.setEnabled(ok)
if ok:
n_ok += 1
cb.setToolTip("")
else:
cb.setChecked(False)
cb.setToolTip(
f"No cached/stored {CH_LABELS[ch_idx]} data for this "
"angle yet — view it in the main window (or run "
"Batch Compute) first.")
self._lbl_none_available.setText(
"" if n_ok else "No angle has this value type ready yet.")
self._update_accept_enabled()
def _on_value_type_changed(self, _id: int):
self._apply_availability()
if not self._path_user_chosen:
self._refresh_default_path()
def _on_select_all_available(self):
for cb in self._angle_checks.values():
if cb.isEnabled():
cb.setChecked(True)
def _on_select_none(self):
for cb in self._angle_checks.values():
cb.setChecked(False)
def _refresh_default_path(self):
ch_idx = self._current_ch_idx()
name = f"{self._stem}_fused_roi_{CH_NAMES[ch_idx]}.csv"
self._edit_path.setText(str(Path(self._out_dir) / name))
self._update_accept_enabled()
def _on_browse(self):
path, _ = QFileDialog.getSaveFileName(
self, "Export Fused ROI as CSV", self._edit_path.text(),
"CSV files (*.csv);;All files (*)")
if path:
self._edit_path.setText(path)
self._path_user_chosen = True
self._update_accept_enabled()
def _update_accept_enabled(self):
any_checked = any(cb.isChecked() for cb in self._angle_checks.values())
self._btn_export.setEnabled(any_checked and bool(self._edit_path.text()))
# ---- getters ---------------------------------------------------------
def get_ch_idx(self) -> int:
return self._current_ch_idx()
def get_selected_angles(self) -> list[int]:
return sorted(a for a, cb in self._angle_checks.items() if cb.isChecked())
def get_output_path(self) -> str:
return self._edit_path.text()
+196 -24
View File
@@ -34,7 +34,9 @@ from .common import (
_wrap_label,
)
from .align_wizard import AlignmentWizard
from .dialogs import FftOptionsDialog, RowAverageFftOptionsDialog
from .dialogs import (
FftOptionsDialog, FusedRoiExportDialog, RowAverageFftOptionsDialog,
)
# ---------------------------------------------------------------------------
# Main window
@@ -335,6 +337,11 @@ class SrasViewerWindow(QMainWindow):
self.btn_export_roi.clicked.connect(self._on_export_roi_csv)
rl.addWidget(self.btn_export_roi)
self.btn_export_fused_roi = QPushButton("Export Fused ROI…")
self.btn_export_fused_roi.setEnabled(False)
self.btn_export_fused_roi.clicked.connect(self._on_export_fused_roi_csv)
rl.addWidget(self.btn_export_fused_roi)
self.lbl_roi_center = _wrap_label("centroid: —", _CSS_HINT)
self.lbl_roi_size = _wrap_label("bbox: —", _CSS_HINT)
self.lbl_roi_npix = _wrap_label("pixels inside: —", _CSS_HINT)
@@ -826,6 +833,91 @@ class SrasViewerWindow(QMainWindow):
self.statusBar().showMessage(
f"Exported ROI ({n_pix} pixels) to {Path(path).name}")
def _on_export_fused_roi_csv(self):
s = self._sras
if s is None:
return
roi = self.image_canvas.get_roi()
if roi is None or not self._fused_grid_ready():
return # button is disabled in these states; defensive no-op
angles = [(a, float(s.angles_deg[a])) for a in range(s.n_angles)]
grid_note = (
"Using the live Fusion alignment canvas."
if self._alignment_result is not None else
"Using the file's shared raw grid (no live alignment result — "
"this file's angles already share one grid).")
dlg = FusedRoiExportDialog(
self, angles=angles,
availability_fn=lambda a, c: self._cached_value_image(a, c) is not None,
default_ch_idx=self.combo_channel.currentIndex(),
out_dir=str(s.path.parent), stem=s.path.stem, grid_note=grid_note)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
self._write_fused_roi_csv(
roi, dlg.get_ch_idx(), dlg.get_selected_angles(), dlg.get_output_path())
def _write_fused_roi_csv(self, roi, ch_idx: int, angle_idxs: list[int],
out_path: str):
"""Mask once on the fused grid, then one value column per selected
angle. No row/frame index columns: once angles are fused onto one
shared canvas there is no single meaningful raw (row, frame) per
output pixel, unlike the single-angle _on_export_roi_csv above."""
s = self._sras
if s is None or not angle_idxs or not out_path:
return
x_axis, y_axis = self._fused_export_axes()
mask = roi.mask_for_grid(x_axis, y_axis)
if not mask.any():
self.statusBar().showMessage(
"ROI does not overlap any pixel on the fused grid")
return
columns: list[np.ndarray] = []
used: list[int] = []
skipped: list[int] = []
for a in angle_idxs:
img = self._fused_value_image(a, ch_idx)
if img is None or img.shape != mask.shape:
# Defensive only: nothing else can mutate the caches between
# dialog-accept and this synchronous call in a single-
# threaded GUI callback, so this should never trigger — skip
# the angle rather than abort the whole export.
skipped.append(a)
continue
columns.append(img[mask].astype(np.float64))
used.append(a)
if not columns:
self.statusBar().showMessage(
"Nothing to export — no selected angle had data")
return
X, Y = np.meshgrid(np.asarray(x_axis, dtype=np.float64),
np.asarray(y_axis, dtype=np.float64))
data = np.column_stack([X[mask], Y[mask], *columns])
ch_name = CH_NAMES[ch_idx]
corners_str = " ".join(f"({p[0]:.6g},{p[1]:.6g})" for p in roi.corners())
angle_list_str = ", ".join(f"{s.angles_deg[a]:.4g}°" for a in used)
header_cols = ["x_mm", "y_mm"] + [f"v_{s.angles_deg[a]:.4g}deg" for a in used]
header = (
f"# ROI quad corners (BL BR TR TL) mm: {corners_str}\n"
f"# source: {s.path.name}, value={ch_name}, "
f"grid={'aligned canvas' if self._alignment_result is not None else 'shared raw grid'}\n"
f"# angles (deg): {angle_list_str}\n"
f"# n_pixels={int(mask.sum())}\n"
+ ",".join(header_cols)
)
np.savetxt(out_path, data, delimiter=",",
fmt=["%.6g"] * data.shape[1], header=header, comments="")
note = (f" ({len(skipped)} angle(s) skipped — no longer available)"
if skipped else "")
self.statusBar().showMessage(
f"Exported fused ROI ({int(mask.sum())} pixels, {len(used)} "
f"angle(s)) to {Path(out_path).name}{note}")
# ------------------------------------------------------------------
# ROI
# ------------------------------------------------------------------
@@ -855,6 +947,7 @@ class SrasViewerWindow(QMainWindow):
self.lbl_roi_npix.setText("pixels inside: —")
self.btn_clear_roi.setEnabled(False)
self.btn_export_roi.setEnabled(False)
self._update_fused_export_enabled()
return
cen = roi.centroid()
@@ -878,6 +971,26 @@ class SrasViewerWindow(QMainWindow):
self.lbl_roi_npix.setText(f"pixels inside: {npix}")
self.btn_clear_roi.setEnabled(True)
self.btn_export_roi.setEnabled(self._current_image is not None and npix > 0)
self._update_fused_export_enabled()
def _update_fused_export_enabled(self):
s = self._sras
roi = self.image_canvas.get_roi() if s is not None else None
if s is None:
ready, reason = False, "Open a file first."
elif roi is None:
ready, reason = False, "Draw an ROI first."
elif not self._fused_grid_ready():
ready, reason = False, (
"Angles need a common grid to fuse: run Fusion → Alignment "
"Wizard, or open a file the wizard already exported.")
else:
ready, reason = True, (
"Export the ROI as one CSV with a column per selected "
"angle's value (choose which value and which angles in "
"the dialog).")
self.btn_export_fused_roi.setEnabled(ready)
self.btn_export_fused_roi.setToolTip(reason)
# ------------------------------------------------------------------
# Display
@@ -934,6 +1047,46 @@ class SrasViewerWindow(QMainWindow):
self._aligned_cache[key] = cached
return cached
# ------------------------------------------------------------------
# Fused ROI export (Export Fused ROI…)
# ------------------------------------------------------------------
def _fused_grid_ready(self) -> bool:
"""Is there a common (x, y) grid to fuse angles onto right now?
Either a live alignment result exists (angles are reconciled onto its
canvas via apply_alignment), or — with no alignment run this session
— the open file's raw per-angle grids already coincide, which is true
for a .sras file the Alignment Wizard itself previously exported (see
scan_format.md, "Files written by the viewer's Alignment Wizard")."""
return (self._sras is not None
and (self._alignment_result is not None
or self._sras.angles_share_raw_grid()))
def _fused_export_axes(self) -> tuple[np.ndarray, np.ndarray]:
"""(x_axis, y_axis) a fused ROI export masks and labels against: the
alignment canvas when a live result exists — the more current,
deliberate source of truth even if the raw grids happen to already
match too — else the grid every angle already shares."""
if self._alignment_result is not None:
return self._aligned_canvas_axes()
s = self._sras
return s.x_axis_mm(0), s.y_positions_mm(0)
def _fused_value_image(self, angle_idx: int, ch_idx: int) -> np.ndarray | None:
"""One angle's image on the fused grid for (angle, channel), scaled
exactly like the on-screen display — so a Velocity export and the
on-screen Velocity image share the same scaling code. None if
nothing is cached/stored for this angle/channel without a compute."""
raw = self._cached_value_image(angle_idx, ch_idx)
if raw is None:
return None
img = (self._scale_for_display(raw, ch_idx)
if ch_idx in CH1_DERIVED_MODES else raw)
if self._alignment_result is not None:
return self._get_aligned_display_image(img, angle_idx, ch_idx)
return img # angles already share the raw grid — no resample needed
def _stored_fft_image(self, angle_idx: int) -> np.ndarray | None:
"""The open file's own stored peak-frequency image for this angle,
masked and ready to display, or None if the file has nothing stored
@@ -967,6 +1120,42 @@ class SrasViewerWindow(QMainWindow):
dc4_mv=self._dc_cache.get((angle_idx, CH4_IDX)),
allow_dc_recompute=False)
def _cached_value_image(self, angle_idx: int, ch_idx: int) -> np.ndarray | None:
"""The already-available (no compute) image for (angle, channel):
this window's own in-session dicts first, then the file's stored
v5/v7 cache blocks — the same two tiers, in the same cost order, that
_refresh_display and Export Fused ROI both need, so both share this
one lookup rather than drifting apart. None means genuinely nothing
is cached/stored, i.e. only a real compute could produce it — and
neither caller here is allowed to trigger one: _refresh_display falls
back to _start_compute() itself, and a fused export simply treats the
angle as unavailable.
For CH1/Velocity this is the unscaled, already-masked peak-frequency
(MHz) image; callers displaying/exporting Velocity must still run it
through _scale_for_display.
"""
s = self._sras
if s is None:
return None
if ch_idx in CH1_DERIVED_MODES:
key = self._fft_cache_key(angle_idx)
raw = self._fft_cache.get(key)
if raw is None:
raw = self._stored_fft_image(angle_idx)
if raw is not None:
# Masking the stored image is cheap but not free; keep the
# result so revisiting this angle costs nothing at all.
self._fft_cache[key] = raw
return raw
# A stored DC image needs no post-processing, so the file's own
# parsed array is served directly — as the compute path already
# does, _current_image is treated as read-only by every consumer.
cached = self._dc_cache.get((angle_idx, ch_idx))
if cached is None:
cached = s.cached_dc_mv(angle_idx, ch_idx)
return cached
def _refresh_display(self):
"""Show the image for the current angle/channel/threshold, using
cached data whenever possible and only falling back to a background
@@ -983,29 +1172,12 @@ class SrasViewerWindow(QMainWindow):
angle_idx = self.spin_angle.value()
ch_idx = self.combo_channel.currentIndex()
if ch_idx in CH1_DERIVED_MODES:
key = self._fft_cache_key(angle_idx)
raw = self._fft_cache.get(key)
if raw is None:
raw = self._stored_fft_image(angle_idx)
if raw is not None:
# Masking the stored image is cheap but not free; keep the
# result so revisiting this angle costs nothing at all.
self._fft_cache[key] = raw
if raw is not None:
self._show_image_now(self._scale_for_display(raw, ch_idx),
angle_idx, ch_idx)
return
else:
# A stored DC image needs no post-processing, so the file's own
# parsed array is served directly — as the compute path already
# does, _current_image is treated as read-only by every consumer.
cached = self._dc_cache.get((angle_idx, ch_idx))
if cached is None:
cached = self._sras.cached_dc_mv(angle_idx, ch_idx)
if cached is not None:
self._show_image_now(cached, angle_idx, ch_idx)
return
raw = self._cached_value_image(angle_idx, ch_idx)
if raw is not None:
img = (self._scale_for_display(raw, ch_idx)
if ch_idx in CH1_DERIVED_MODES else raw)
self._show_image_now(img, angle_idx, ch_idx)
return
# Nothing cached for these settings — need a real compute. Changing
# the DC threshold changes *which* pixels get an FFT at all, so it