aca501ae5c
origin/main split sras_viewer.py into the sras_viewer/ package (dialogs,
main_window, common, canvases) after the batch-export feature was built
against the old monolith, so merging origin/main into main lost the feature
to the sras_viewer.py deletion. Re-add it in the new structure:
BatchExportDialog in dialogs.py, a Jobs.EXPORT job key in common.py, and
the menu action/worker wiring in main_window.py, following the Jobs-key
and dialog-class conventions the split already established rather than the
old ad hoc "main" progress-key naming the original commit used.
Also carries forward two test fixes bundled in the original batch-export
commit that the parallel pytest conversion (007089d) had ported from
tools/test_refactor.py without them: the int16-vs-float32 accumulator
expectation in test_sras_average, and asserting the sras_average.py
subprocess calls actually succeed.
552 lines
22 KiB
Python
552 lines
22 KiB
Python
"""FFT option dialogs.
|
||
|
||
Angle alignment used to live here too, as ManualAlignmentDialog; it is now the
|
||
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, QCheckBox, QDialog, QDialogButtonBox, QDoubleSpinBox,
|
||
QFileDialog, QGridLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit,
|
||
QMessageBox, 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 sras_workers import ExportChannel
|
||
|
||
from .common import (
|
||
CH_LABELS, VELOCITY_MODE_IDX, _CHANNEL_DISPLAY, _CSS_HINT, _CSS_WARN,
|
||
_SPIN_MIN_W, _form, _group, _make_dspin, _wrap_label,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# FFT Options dialog
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class FftOptionsDialog(QDialog):
|
||
"""Configure FFT backend and zero-padding.
|
||
|
||
Changes take effect only when the user clicks Apply. Cancel discards
|
||
all pending edits. The live 'frequency resolution' label updates as
|
||
the user adjusts the pad factor so they can see the trade-off before
|
||
committing.
|
||
"""
|
||
|
||
def __init__(self, parent=None, *,
|
||
current_backend: str,
|
||
current_pad_factor: int,
|
||
samples_per_frame: int | None,
|
||
sample_rate_hz: float | None,
|
||
grating_um: float):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("FFT Options")
|
||
self.setModal(True)
|
||
self.setMinimumWidth(380)
|
||
|
||
self._samples_per_frame = samples_per_frame
|
||
self._sample_rate_hz = sample_rate_hz
|
||
self._grating_um = grating_um
|
||
|
||
layout = QVBoxLayout(self)
|
||
|
||
# ---- Backend ---------------------------------------------------
|
||
grp_backend = QGroupBox("FFT Backend")
|
||
bl = QVBoxLayout(grp_backend)
|
||
|
||
self._btn_scipy = QRadioButton("SciPy FFT (pocketfft) (always available)")
|
||
self._btn_pyfftw = QRadioButton(
|
||
"pyFFTW (faster for large arrays)" if PYFFTW_AVAILABLE
|
||
else "pyFFTW (not installed — run: pip install pyfftw)")
|
||
self._btn_pyfftw.setEnabled(PYFFTW_AVAILABLE)
|
||
|
||
self._backend_group = QButtonGroup(self)
|
||
self._backend_group.addButton(self._btn_scipy, id=0)
|
||
self._backend_group.addButton(self._btn_pyfftw, id=1)
|
||
|
||
if current_backend == "pyfftw" and PYFFTW_AVAILABLE:
|
||
self._btn_pyfftw.setChecked(True)
|
||
else:
|
||
self._btn_scipy.setChecked(True)
|
||
|
||
bl.addWidget(self._btn_scipy)
|
||
bl.addWidget(self._btn_pyfftw)
|
||
layout.addWidget(grp_backend)
|
||
|
||
# ---- Zero-padding ----------------------------------------------
|
||
grp_zp = QGroupBox("Zero-Padding")
|
||
zl = QVBoxLayout(grp_zp)
|
||
|
||
pad_row = QHBoxLayout()
|
||
pad_row.addWidget(QLabel("Pad factor:"))
|
||
self._spin_pad = QSpinBox()
|
||
self._spin_pad.setRange(1, 256)
|
||
self._spin_pad.setValue(max(1, current_pad_factor))
|
||
self._spin_pad.setToolTip(
|
||
"Multiply the waveform length by this factor via zero-padding\n"
|
||
"before computing the FFT.\n"
|
||
"1 = no padding (natural length).\n"
|
||
"Powers of 2 (2, 4, 8 …) give the best performance."
|
||
)
|
||
self._spin_pad.valueChanged.connect(self._update_info)
|
||
pad_row.addWidget(self._spin_pad)
|
||
zl.addLayout(pad_row)
|
||
|
||
self._lbl_nfft = QLabel()
|
||
self._lbl_freq_res = QLabel()
|
||
self._lbl_vel_res = QLabel()
|
||
for lbl in (self._lbl_nfft, self._lbl_freq_res, self._lbl_vel_res):
|
||
lbl.setStyleSheet(_CSS_HINT)
|
||
zl.addWidget(lbl)
|
||
|
||
layout.addWidget(grp_zp)
|
||
|
||
# ---- Buttons ---------------------------------------------------
|
||
buttons = QDialogButtonBox()
|
||
buttons.addButton("Apply", QDialogButtonBox.ButtonRole.AcceptRole
|
||
).clicked.connect(self.accept)
|
||
buttons.addButton("Cancel", QDialogButtonBox.ButtonRole.RejectRole
|
||
).clicked.connect(self.reject)
|
||
layout.addWidget(buttons)
|
||
|
||
self._update_info()
|
||
|
||
def _update_info(self):
|
||
spf = self._samples_per_frame
|
||
sr = self._sample_rate_hz
|
||
pad = self._spin_pad.value()
|
||
|
||
if spf is None or sr is None:
|
||
self._lbl_nfft.setText("Load a file to preview FFT parameters.")
|
||
self._lbl_freq_res.setText("")
|
||
self._lbl_vel_res.setText("")
|
||
return
|
||
|
||
n_fft = spf * pad
|
||
freq_res_hz = sr / n_fft
|
||
freq_res_mhz = freq_res_hz / 1e6
|
||
# v (m/s) = freq (MHz) × grating (µm)
|
||
vel_res_ms = freq_res_mhz * self._grating_um
|
||
|
||
self._lbl_nfft.setText(f"FFT points: {spf} × {pad} = {n_fft:,}")
|
||
self._lbl_freq_res.setText(
|
||
f"Frequency bin: {freq_res_mhz:.4f} MHz ({freq_res_hz / 1e3:.2f} kHz)")
|
||
self._lbl_vel_res.setText(
|
||
f"Velocity bin: {vel_res_ms:.3f} m/s "
|
||
f"(at grating = {self._grating_um:.2f} µm)")
|
||
|
||
def get_backend(self) -> str:
|
||
return "pyfftw" if self._btn_pyfftw.isChecked() and PYFFTW_AVAILABLE else "scipy"
|
||
|
||
def get_pad_factor(self) -> int:
|
||
return max(1, self._spin_pad.value())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Row-Averaged FFT Options dialog
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class RowAverageFftOptionsDialog(QDialog):
|
||
"""Configure the same-row, distance-weighted neighbor averaging applied
|
||
to each pixel's CH1 waveform before 'Batch Compute Row-Averaged FFT and
|
||
Store' re-runs the FFT peak search — a same-row SNR cleanup pass, never
|
||
mixing across rows/Y (see sras_compute._row_average_waveforms).
|
||
|
||
Unlike the plain FFT batch action (which stores unmasked and defers
|
||
masking to display time), the DC threshold here is required up front:
|
||
it decides which same-row neighbors are eligible to contribute to a
|
||
pixel's average, so it can't be deferred.
|
||
|
||
Changes take effect only when the user clicks Apply. Cancel discards
|
||
all pending edits.
|
||
"""
|
||
|
||
def __init__(self, parent=None, *,
|
||
current_n: int,
|
||
current_threshold_mv: float,
|
||
pixel_x_mm: float | None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Row-Averaged FFT Options")
|
||
self.setModal(True)
|
||
self.setMinimumWidth(380)
|
||
|
||
self._pixel_x_mm = pixel_x_mm
|
||
|
||
layout = QVBoxLayout(self)
|
||
|
||
# ---- Neighbor window ---------------------------------------------
|
||
grp_window = QGroupBox("Same-Row Neighbor Window")
|
||
wl = QVBoxLayout(grp_window)
|
||
|
||
n_row = QHBoxLayout()
|
||
n_row.addWidget(QLabel("Neighbor half-width (n):"))
|
||
self._spin_n = QSpinBox()
|
||
self._spin_n.setRange(1, 50)
|
||
self._spin_n.setValue(max(1, current_n))
|
||
self._spin_n.setToolTip(
|
||
"Each pixel's CH1 waveform is averaged with up to n same-row\n"
|
||
"neighbors on each side, distance-weighted (Gaussian) and\n"
|
||
"counting only neighbors that already pass the DC threshold\n"
|
||
"below. Never mixes across rows/Y.")
|
||
self._spin_n.valueChanged.connect(self._update_info)
|
||
n_row.addWidget(self._spin_n)
|
||
wl.addLayout(n_row)
|
||
|
||
self._lbl_width = QLabel()
|
||
self._lbl_width.setStyleSheet(_CSS_HINT)
|
||
wl.addWidget(self._lbl_width)
|
||
|
||
layout.addWidget(grp_window)
|
||
|
||
# ---- DC threshold ------------------------------------------------
|
||
grp_thr = QGroupBox("Neighbor Validity")
|
||
tl = QVBoxLayout(grp_thr)
|
||
thr_row = QHBoxLayout()
|
||
thr_row.addWidget(QLabel("DC threshold:"))
|
||
self._spin_threshold = _make_dspin(-500.0, 500.0, 3, suffix=" mV",
|
||
value=current_threshold_mv, step=0.025)
|
||
self._spin_threshold.setToolTip(
|
||
"A same-row neighbor only contributes to a pixel's average if\n"
|
||
"its own CH4 signal is at or above this threshold -- the same\n"
|
||
"test used for RF mask display. A pixel below threshold stays\n"
|
||
"masked, exactly as today; it is never rescued by its neighbors.")
|
||
thr_row.addWidget(self._spin_threshold)
|
||
tl.addLayout(thr_row)
|
||
layout.addWidget(grp_thr)
|
||
|
||
# ---- Buttons -----------------------------------------------------
|
||
buttons = QDialogButtonBox()
|
||
buttons.addButton("Apply", QDialogButtonBox.ButtonRole.AcceptRole
|
||
).clicked.connect(self.accept)
|
||
buttons.addButton("Cancel", QDialogButtonBox.ButtonRole.RejectRole
|
||
).clicked.connect(self.reject)
|
||
layout.addWidget(buttons)
|
||
|
||
self._update_info()
|
||
|
||
def _update_info(self):
|
||
n = self._spin_n.value()
|
||
if self._pixel_x_mm is None:
|
||
self._lbl_width.setText("Load a file to preview the window's physical width.")
|
||
return
|
||
width_um = 2 * n * self._pixel_x_mm * 1e3
|
||
self._lbl_width.setText(
|
||
f"Window: ±{n} px = {width_um:.2f} µm full width "
|
||
f"(pixel pitch {self._pixel_x_mm * 1e3:.3g} µm)")
|
||
|
||
def get_half_width(self) -> int:
|
||
return self._spin_n.value()
|
||
|
||
def get_threshold_mv(self) -> float:
|
||
return self._spin_threshold.value()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Batch export dialog (Export -> Batch Export Images...)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class BatchExportDialog(QDialog):
|
||
"""Configure a batch PNG export of DC/RF/Velocity maps across every angle
|
||
of the currently open file.
|
||
|
||
Each channel's colorbar range is entered here and held fixed for every
|
||
exported angle (rather than auto-scaled per image, today's live-view
|
||
default) so the exported images are directly comparable to each other.
|
||
"""
|
||
|
||
# (ch_idx, is_velocity, label, colorbar unit, filename tag)
|
||
_ROWS = [
|
||
(CH1_IDX, False, CH_LABELS[CH1_IDX], _CHANNEL_DISPLAY[CH1_IDX][2],
|
||
CH_NAMES[CH1_IDX]),
|
||
(CH3_IDX, False, CH_LABELS[CH3_IDX], _CHANNEL_DISPLAY[CH3_IDX][2],
|
||
CH_NAMES[CH3_IDX]),
|
||
(CH4_IDX, False, CH_LABELS[CH4_IDX], _CHANNEL_DISPLAY[CH4_IDX][2],
|
||
CH_NAMES[CH4_IDX]),
|
||
(CH1_IDX, True, CH_LABELS[VELOCITY_MODE_IDX],
|
||
_CHANNEL_DISPLAY[VELOCITY_MODE_IDX][2], CH_NAMES[VELOCITY_MODE_IDX]),
|
||
]
|
||
# DC is cheap and precomputed on load; CH1/Velocity need a per-pixel FFT
|
||
# that can take minutes, so don't default to exporting them (same
|
||
# rationale as SrasViewerWindow._on_load_done's channel default).
|
||
_DEFAULT_CHECKED = {CH3_IDX, CH4_IDX}
|
||
|
||
def __init__(self, parent, *, default_dir: str, default_prefix: str,
|
||
default_ranges: dict[tuple[int, bool], tuple[float, float]]):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Batch Export Images")
|
||
self.setModal(True)
|
||
self.setMinimumWidth(460)
|
||
|
||
layout = QVBoxLayout(self)
|
||
|
||
# ---- Output ------------------------------------------------------
|
||
grp_out, ol = _group("Output")
|
||
dir_row = QHBoxLayout()
|
||
self._edit_dir = QLineEdit(default_dir)
|
||
btn_browse = QPushButton("Browse…")
|
||
btn_browse.clicked.connect(self._on_browse)
|
||
dir_row.addWidget(self._edit_dir)
|
||
dir_row.addWidget(btn_browse)
|
||
out_form = _form()
|
||
out_form.addRow("Folder:", dir_row)
|
||
self._edit_prefix = QLineEdit(default_prefix)
|
||
out_form.addRow("File prefix:", self._edit_prefix)
|
||
ol.addLayout(out_form)
|
||
layout.addWidget(grp_out)
|
||
|
||
# ---- Channels ------------------------------------------------------
|
||
grp_ch, cl = _group("Channels (fixed range, applied to every angle)")
|
||
grid = QGridLayout()
|
||
grid.setHorizontalSpacing(8)
|
||
grid.setVerticalSpacing(6)
|
||
grid.addWidget(_wrap_label("min:", _CSS_HINT), 0, 1)
|
||
grid.addWidget(_wrap_label("max:", _CSS_HINT), 0, 2)
|
||
|
||
self._rows: list[tuple[QCheckBox, QDoubleSpinBox, QDoubleSpinBox]] = []
|
||
for i, (ch_idx, is_velocity, label, unit, _tag) in enumerate(self._ROWS, 1):
|
||
chk = QCheckBox(label + (f" [{unit}]" if unit else ""))
|
||
chk.setChecked(not is_velocity and ch_idx in self._DEFAULT_CHECKED)
|
||
vmin, vmax = default_ranges.get((ch_idx, is_velocity), (0.0, 1.0))
|
||
spin_min, spin_max = QDoubleSpinBox(), QDoubleSpinBox()
|
||
for spin, val in ((spin_min, vmin), (spin_max, vmax)):
|
||
spin.setRange(-1e9, 1e9)
|
||
spin.setDecimals(4)
|
||
spin.setMinimumWidth(_SPIN_MIN_W)
|
||
spin.setValue(val)
|
||
grid.addWidget(chk, i, 0)
|
||
grid.addWidget(spin_min, i, 1)
|
||
grid.addWidget(spin_max, i, 2)
|
||
self._rows.append((chk, spin_min, spin_max))
|
||
cl.addLayout(grid)
|
||
layout.addWidget(grp_ch)
|
||
|
||
# ---- Buttons --------------------------------------------------
|
||
buttons = QDialogButtonBox()
|
||
buttons.addButton("Export", QDialogButtonBox.ButtonRole.AcceptRole
|
||
).clicked.connect(self.accept)
|
||
buttons.addButton("Cancel", QDialogButtonBox.ButtonRole.RejectRole
|
||
).clicked.connect(self.reject)
|
||
layout.addWidget(buttons)
|
||
|
||
def _on_browse(self):
|
||
d = QFileDialog.getExistingDirectory(
|
||
self, "Select Output Folder", self._edit_dir.text())
|
||
if d:
|
||
self._edit_dir.setText(d)
|
||
|
||
def accept(self):
|
||
"""Validate before closing — Cancel bypasses this entirely."""
|
||
if not self.get_prefix():
|
||
QMessageBox.warning(self, "Batch Export", "Enter a file prefix.")
|
||
return
|
||
if not self.get_output_dir():
|
||
QMessageBox.warning(self, "Batch Export", "Choose an output folder.")
|
||
return
|
||
selected = self.get_selected_channels()
|
||
if not selected:
|
||
QMessageBox.warning(
|
||
self, "Batch Export", "Select at least one channel to export.")
|
||
return
|
||
for ch in selected:
|
||
if not ch.vmin < ch.vmax:
|
||
QMessageBox.warning(
|
||
self, "Batch Export", f"{ch.label}: min must be less than max.")
|
||
return
|
||
super().accept()
|
||
|
||
def get_output_dir(self) -> str:
|
||
return self._edit_dir.text().strip()
|
||
|
||
def get_prefix(self) -> str:
|
||
return self._edit_prefix.text().strip()
|
||
|
||
def get_selected_channels(self) -> list[ExportChannel]:
|
||
result = []
|
||
for (chk, spin_min, spin_max), (ch_idx, is_velocity, label, unit, tag) in zip(
|
||
self._rows, self._ROWS):
|
||
if chk.isChecked():
|
||
result.append(ExportChannel(
|
||
ch_idx=ch_idx, is_velocity=is_velocity,
|
||
vmin=spin_min.value(), vmax=spin_max.value(),
|
||
label=label, unit=unit, tag=tag))
|
||
return result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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()
|
||
|