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:
+181
-3
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user