Port batch image export onto the sras_viewer package split
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.
This commit is contained in:
+140
-4
@@ -8,15 +8,20 @@ mask-overlay editor plus the crop and export steps.
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QButtonGroup, QCheckBox, QDialog, QDialogButtonBox, QFileDialog,
|
||||
QGroupBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QRadioButton,
|
||||
QScrollArea, QSpinBox, QVBoxLayout, QWidget,
|
||||
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, _CSS_HINT, _CSS_WARN, _group, _make_dspin, _wrap_label
|
||||
from .common import (
|
||||
CH_LABELS, VELOCITY_MODE_IDX, _CHANNEL_DISPLAY, _CSS_HINT, _CSS_WARN,
|
||||
_SPIN_MIN_W, _form, _group, _make_dspin, _wrap_label,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -240,6 +245,137 @@ class RowAverageFftOptionsDialog(QDialog):
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user