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:
+196
-24
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user