diff --git a/sras_viewer/common.py b/sras_viewer/common.py index 7d07a50..5793e90 100644 --- a/sras_viewer/common.py +++ b/sras_viewer/common.py @@ -2,8 +2,8 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import ( - QFormLayout, QFrame, QGroupBox, QLabel, QScrollArea, QSizePolicy, - QVBoxLayout, QWidget, + QDoubleSpinBox, QFormLayout, QFrame, QGroupBox, QLabel, QScrollArea, + QSizePolicy, QVBoxLayout, QWidget, ) from sras_format import CH1_IDX, CH3_IDX, CH4_IDX @@ -52,6 +52,42 @@ _SPIN_MIN_W = 96 # Small layout helpers # --------------------------------------------------------------------------- +class Jobs: + """Keys for SrasViewerWindow's background-job registry (_run_worker / + _job_running) and its progress dialogs — one place instead of string + literals scattered across window and dialogs.""" + LOAD = "load" + COMPUTE = "compute" + DC_PRECOMPUTE = "dc_precompute" + BATCH = "batch" + ALIGN = "align" + MANUAL_ALIGN_MASKS = "manual_align_masks" + MANUAL_ALIGN_CORRELATE = "manual_align_correlate" + + +def _make_dspin(lo: float, hi: float, decimals: int, *, suffix: str = "", + value: float | None = None, step: float | None = None) -> QDoubleSpinBox: + """A QDoubleSpinBox with the panel-standard construction.""" + spin = QDoubleSpinBox() + spin.setRange(lo, hi) + spin.setDecimals(decimals) + if suffix: + spin.setSuffix(suffix) + if step is not None: + spin.setSingleStep(step) + if value is not None: + spin.setValue(value) + spin.setMinimumWidth(_SPIN_MIN_W) + return spin + + +def _axes_extent(x_axis, y_axis, dx: float, dy: float) -> list[float]: + """Matplotlib imshow extent with half-pixel margins, Y flipped so row 0 + renders at the top.""" + return [x_axis[0] - dx / 2, x_axis[-1] + dx / 2, + y_axis[-1] + dy / 2, y_axis[0] - dy / 2] + + def _wrap_label(text: str = "", css: str | None = None) -> QLabel: """A word-wrapped QLabel that reports its *wrapped* height to the layout. diff --git a/sras_viewer/dialogs.py b/sras_viewer/dialogs.py index d1efed7..5589698 100644 --- a/sras_viewer/dialogs.py +++ b/sras_viewer/dialogs.py @@ -5,11 +5,11 @@ from typing import TYPE_CHECKING import matplotlib as mpl import numpy as np from matplotlib.backends.backend_qtagg import NavigationToolbar2QT -from PyQt6.QtCore import pyqtSignal +from PyQt6.QtCore import QSignalBlocker, pyqtSignal from PyQt6.QtWidgets import ( - QButtonGroup, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, - QGroupBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QRadioButton, - QSpinBox, QVBoxLayout, QWidget, + QButtonGroup, QComboBox, QDialog, QDialogButtonBox, QGroupBox, + QHBoxLayout, QLabel, QMessageBox, QPushButton, QRadioButton, QSpinBox, + QVBoxLayout, QWidget, ) import sras_compute as compute @@ -22,7 +22,7 @@ from sras_workers import Ch4MaskWorker, CrossCorrelateWorker from .canvases import ManualAlignOverlayCanvas from .common import ( - _CSS_HINT, _CSS_MUTED, _CSS_WARN, _SPIN_MIN_W, _form, _group, + _CSS_HINT, _CSS_MUTED, _CSS_WARN, Jobs, _axes_extent, _form, _group, _make_dspin, _scroll_panel, _wrap_label, ) @@ -268,8 +268,20 @@ class ManualAlignmentDialog(QDialog): panel_l = QVBoxLayout(panel) panel_l.setContentsMargins(0, 0, 0, 0) panel_l.setSpacing(8) + panel_l.addWidget(self._build_angle_group()) + panel_l.addWidget(self._build_adjust_group()) + panel_l.addWidget(self._build_step_group()) + panel_l.addWidget(self._build_threshold_group(dc_threshold_mv)) + panel_l.addWidget(self._build_correlate_group()) + panel_l.addWidget(self._build_actions_group()) + self.lbl_status = _wrap_label("", _CSS_MUTED) + panel_l.addWidget(self.lbl_status) + panel_l.addStretch() - # ---- Active Angle ------------------------------------------------- + root.addWidget(_scroll_panel(panel, 320)) + self._connect_controls() + + def _build_angle_group(self) -> QWidget: grp_angle, al = _group("Active Angle") self.combo_active_angle = QComboBox() for a in range(self._sras.n_angles): @@ -280,80 +292,52 @@ class ManualAlignmentDialog(QDialog): al.addWidget(self.combo_active_angle) self.lbl_active_note = _wrap_label("", _CSS_WARN) al.addWidget(self.lbl_active_note) - panel_l.addWidget(grp_angle) + return grp_angle - # ---- Manual Adjustment --------------------------------------------- + def _build_adjust_group(self) -> QWidget: self.grp_manual_adjust, mform_box = _group("Manual Adjustment") mform = _form() - self.spin_active_rotation_deg = QDoubleSpinBox() - self.spin_active_rotation_deg.setRange(-3600.0, 3600.0) - self.spin_active_rotation_deg.setDecimals(3) - self.spin_active_rotation_deg.setSuffix(" °") - self.spin_active_rotation_deg.setMinimumWidth(_SPIN_MIN_W) + self.spin_active_rotation_deg = _make_dspin(-3600.0, 3600.0, 3, suffix=" °") mform.addRow("Rotation:", self.spin_active_rotation_deg) - self.spin_active_shift_x_mm = QDoubleSpinBox() - self.spin_active_shift_x_mm.setRange(-1e5, 1e5) - self.spin_active_shift_x_mm.setDecimals(4) - self.spin_active_shift_x_mm.setSuffix(" mm") - self.spin_active_shift_x_mm.setMinimumWidth(_SPIN_MIN_W) + self.spin_active_shift_x_mm = _make_dspin(-1e5, 1e5, 4, suffix=" mm") mform.addRow("Shift X:", self.spin_active_shift_x_mm) - self.spin_active_shift_y_mm = QDoubleSpinBox() - self.spin_active_shift_y_mm.setRange(-1e5, 1e5) - self.spin_active_shift_y_mm.setDecimals(4) - self.spin_active_shift_y_mm.setSuffix(" mm") - self.spin_active_shift_y_mm.setMinimumWidth(_SPIN_MIN_W) + self.spin_active_shift_y_mm = _make_dspin(-1e5, 1e5, 4, suffix=" mm") mform.addRow("Shift Y:", self.spin_active_shift_y_mm) mform_box.addLayout(mform) - panel_l.addWidget(self.grp_manual_adjust) + return self.grp_manual_adjust - # ---- Nudge Step Sizes ------------------------------------------------ + def _build_step_group(self) -> QWidget: self.grp_step_sizes, sl = _group("Nudge Step Sizes") sform = _form() - self.spin_step_translate_mm = QDoubleSpinBox() - self.spin_step_translate_mm.setRange(0.0001, 1000.0) - self.spin_step_translate_mm.setDecimals(4) - self.spin_step_translate_mm.setSuffix(" mm") - self.spin_step_translate_mm.setValue(0.01) - self.spin_step_translate_mm.setMinimumWidth(_SPIN_MIN_W) + self.spin_step_translate_mm = _make_dspin(0.0001, 1000.0, 4, + suffix=" mm", value=0.01) sform.addRow("Translate step:", self.spin_step_translate_mm) - self.spin_step_rotate_deg = QDoubleSpinBox() - self.spin_step_rotate_deg.setRange(0.001, 90.0) - self.spin_step_rotate_deg.setDecimals(3) - self.spin_step_rotate_deg.setSuffix(" °") - self.spin_step_rotate_deg.setValue(0.1) - self.spin_step_rotate_deg.setMinimumWidth(_SPIN_MIN_W) + self.spin_step_rotate_deg = _make_dspin(0.001, 90.0, 3, + suffix=" °", value=0.1) sform.addRow("Rotate step:", self.spin_step_rotate_deg) - self.spin_step_multiplier = QDoubleSpinBox() - self.spin_step_multiplier.setRange(1.0, 1000.0) - self.spin_step_multiplier.setDecimals(1) - self.spin_step_multiplier.setValue(10.0) - self.spin_step_multiplier.setMinimumWidth(_SPIN_MIN_W) + self.spin_step_multiplier = _make_dspin(1.0, 1000.0, 1, value=10.0) sform.addRow("Coarse × (Shift):", self.spin_step_multiplier) sl.addLayout(sform) sl.addWidget(_wrap_label( "Arrow keys nudge X/Y translation; Q/E nudge rotation (CCW/CW). " "Hold Shift for the coarse step. Click the image once so it has " "keyboard focus.", _CSS_HINT)) - panel_l.addWidget(self.grp_step_sizes) + return self.grp_step_sizes - # ---- Mask Threshold --------------------------------------------------- + def _build_threshold_group(self, dc_threshold_mv: float) -> QWidget: self.grp_mask_threshold, tl = _group("Mask Threshold") tform = _form() - self.spin_mask_threshold_mv = QDoubleSpinBox() - self.spin_mask_threshold_mv.setRange(-500.0, 500.0) - self.spin_mask_threshold_mv.setDecimals(3) - self.spin_mask_threshold_mv.setSuffix(" mV") - self.spin_mask_threshold_mv.setValue(dc_threshold_mv) - self.spin_mask_threshold_mv.setMinimumWidth(_SPIN_MIN_W) + self.spin_mask_threshold_mv = _make_dspin(-500.0, 500.0, 3, + suffix=" mV", value=dc_threshold_mv) tform.addRow("DC threshold:", self.spin_mask_threshold_mv) tl.addLayout(tform) - panel_l.addWidget(self.grp_mask_threshold) + return self.grp_mask_threshold - # ---- Cross-Correlate (FFT) ----------------------------------------- + def _build_correlate_group(self) -> QWidget: self.grp_correlate, cl = _group("Cross-Correlate (FFT)") cform = _form() self.combo_correlate_source = QComboBox() @@ -361,13 +345,8 @@ class ManualAlignmentDialog(QDialog): self.combo_correlate_source.addItem(label, sources) cform.addRow("Correlate on:", self.combo_correlate_source) - self.spin_correlate_search_deg = QDoubleSpinBox() - self.spin_correlate_search_deg.setRange(0.0, 180.0) - self.spin_correlate_search_deg.setSingleStep(1.0) - self.spin_correlate_search_deg.setDecimals(1) - self.spin_correlate_search_deg.setSuffix(" °") - self.spin_correlate_search_deg.setValue(6.0) - self.spin_correlate_search_deg.setMinimumWidth(_SPIN_MIN_W) + self.spin_correlate_search_deg = _make_dspin(0.0, 180.0, 1, suffix=" °", + value=6.0, step=1.0) cform.addRow("Rotation search (±):", self.spin_correlate_search_deg) cl.addLayout(cform) self.btn_auto_correlate = QPushButton("Auto Cross-Correlate (vs Reference)") @@ -378,9 +357,9 @@ class ManualAlignmentDialog(QDialog): "reported angle is only the starting point of the search, and both " "of its signs are tried. Run this first, then nudge only for small " "corrections.", _CSS_HINT)) - panel_l.addWidget(self.grp_correlate) + return self.grp_correlate - # ---- Actions ------------------------------------------------------ + def _build_actions_group(self) -> QWidget: grp_actions, acl = _group("Actions") self.btn_auto_derotate = QPushButton("Auto De-rotate (use known angles)") self.btn_save = QPushButton("Save Alignment") @@ -388,14 +367,9 @@ class ManualAlignmentDialog(QDialog): self.btn_close = QPushButton("Close") for btn in (self.btn_auto_derotate, self.btn_save, self.btn_clear, self.btn_close): acl.addWidget(btn) - panel_l.addWidget(grp_actions) - - self.lbl_status = _wrap_label("", _CSS_MUTED) - panel_l.addWidget(self.lbl_status) - panel_l.addStretch() - - root.addWidget(_scroll_panel(panel, 320)) + return grp_actions + def _connect_controls(self): self.combo_active_angle.currentIndexChanged.connect(self._on_active_angle_changed) self.spin_active_rotation_deg.editingFinished.connect(self._on_rotation_spin_edited) self.spin_active_shift_x_mm.editingFinished.connect(self._on_shift_spin_edited) @@ -409,9 +383,8 @@ class ManualAlignmentDialog(QDialog): self.canvas.nudge_translate.connect(self._on_nudge_translate) self.canvas.nudge_rotate.connect(self._on_nudge_rotate) - self.combo_active_angle.blockSignals(True) - self.combo_active_angle.setCurrentIndex(self._active_angle) - self.combo_active_angle.blockSignals(False) + with QSignalBlocker(self.combo_active_angle): + self.combo_active_angle.setCurrentIndex(self._active_angle) self._on_active_angle_changed(self._active_angle) # ------------------------------------------------------------------ @@ -426,7 +399,7 @@ class ManualAlignmentDialog(QDialog): return self.lbl_status.setText(f"Preparing masks: 0/{len(missing)} angle(s) needed…") started = self._parent._run_worker( - "manual_align_masks", Ch4MaskWorker(self._sras, missing), + Jobs.MANUAL_ALIGN_MASKS, Ch4MaskWorker(self._sras, missing), connect=( ("angle_done", self._on_mask_angle_done), ("error", lambda msg: self.lbl_status.setText(f"Mask prep error: {msg}")), @@ -541,8 +514,7 @@ class ManualAlignmentDialog(QDialog): dx, dy = self._preview_pitch_mm x_axis = x0 + np.arange(n_cols) * dx y_axis = y0 + np.arange(n_rows) * dy - extent = [x_axis[0] - dx / 2, x_axis[-1] + dx / 2, - y_axis[-1] + dy / 2, y_axis[0] - dy / 2] + extent = _axes_extent(x_axis, y_axis, dx, dy) title = (f"Angle {self._active_angle} active " f"({self._sras.angles_deg[self._active_angle]:.1f}°)") self.canvas.show_overlay(rgba, extent, title) @@ -565,9 +537,8 @@ class ManualAlignmentDialog(QDialog): for spin, val in ((self.spin_active_rotation_deg, p.rotation_deg), (self.spin_active_shift_x_mm, p.shift_mm[0]), (self.spin_active_shift_y_mm, p.shift_mm[1])): - spin.blockSignals(True) - spin.setValue(val) - spin.blockSignals(False) + with QSignalBlocker(spin): + spin.setValue(val) def _on_nudge_translate(self, dir_x: int, dir_y: int, coarse: bool): if not self._masks_ready or self._active_angle == self._ref_angle_idx: @@ -655,7 +626,7 @@ class ManualAlignmentDialog(QDialog): self._set_controls_enabled(False) self.lbl_status.setText(f"Cross-correlating: 0/{self._correlate_total} angle(s)…") started = self._parent._run_worker( - "manual_align_correlate", worker, + Jobs.MANUAL_ALIGN_CORRELATE, worker, connect=( ("angle_done", self._on_correlate_angle_done), ("error", self._on_correlate_error), diff --git a/sras_viewer/main_window.py b/sras_viewer/main_window.py index b0fa6a5..07c3c71 100644 --- a/sras_viewer/main_window.py +++ b/sras_viewer/main_window.py @@ -5,11 +5,11 @@ from pathlib import Path import numpy as np from matplotlib.backends.backend_qtagg import NavigationToolbar2QT -from PyQt6.QtCore import QObject, QSettings, Qt, QThread +from PyQt6.QtCore import QObject, QSettings, QSignalBlocker, Qt, QThread from PyQt6.QtGui import QAction from PyQt6.QtWidgets import ( - QApplication, QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFileDialog, - QFrame, QHBoxLayout, QLabel, QMainWindow, QProgressDialog, QPushButton, + QApplication, QCheckBox, QComboBox, QDialog, QFileDialog, QFrame, + QHBoxLayout, QLabel, QMainWindow, QProgressDialog, QPushButton, QSizePolicy, QSpinBox, QSplitter, QVBoxLayout, QWidget, ) @@ -30,8 +30,8 @@ from sras_workers import ( from .canvases import ImageCanvas, WaveformCanvas from .common import ( CH1_DERIVED_MODES, CH_LABELS, CMAPS, VELOCITY_MODE_IDX, _CHANNEL_DISPLAY, - _CSS_BUSY, _CSS_HINT, _CSS_INFO, _CSS_MUTED, _CSS_WARN, _LEFT_PANEL_W, - _RIGHT_PANEL_W, _SPIN_MIN_W, _form, _group, _scroll_panel, _wrap_label, + _CSS_BUSY, _axes_extent, _CSS_HINT, _CSS_INFO, _CSS_MUTED, _CSS_WARN, _LEFT_PANEL_W, + _RIGHT_PANEL_W, Jobs, _form, _group, _make_dspin, _scroll_panel, _wrap_label, ) from .dialogs import FftOptionsDialog, ManualAlignmentDialog @@ -175,17 +175,23 @@ class SrasViewerWindow(QMainWindow): panel_layout = QVBoxLayout(panel) panel_layout.setContentsMargins(0, 0, 0, 0) panel_layout.setSpacing(8) + panel_layout.addWidget(self._build_file_group()) + panel_layout.addWidget(self._build_info_group()) + panel_layout.addWidget(self._build_view_group()) + panel_layout.addWidget(self._build_roi_group()) + panel_layout.addStretch() + return _scroll_panel(panel, _LEFT_PANEL_W) - # ---- File ------------------------------------------------------- + def _build_file_group(self) -> QWidget: grp_file, fl = _group("File") self.btn_open = QPushButton("Open .sras…") self.btn_open.clicked.connect(self._on_open) self.lbl_filename = _wrap_label("No file loaded", _CSS_MUTED) fl.addWidget(self.btn_open) fl.addWidget(self.lbl_filename) - panel_layout.addWidget(grp_file) + return grp_file - # ---- Scan info -------------------------------------------------- + def _build_info_group(self) -> QWidget: grp_info, il = _group("Scan Info") il.setSpacing(3) self._info = {} @@ -202,9 +208,9 @@ class SrasViewerWindow(QMainWindow): # background DC-precompute progress self.lbl_dc_precompute = _wrap_label("", _CSS_BUSY) il.addWidget(self.lbl_dc_precompute) - panel_layout.addWidget(grp_info) + return grp_info - # ---- View settings ---------------------------------------------- + def _build_view_group(self) -> QWidget: grp_view, vl = _group("View Settings") view_form = _form() @@ -244,14 +250,9 @@ class SrasViewerWindow(QMainWindow): # DC threshold (for RF / CH1 masking) self.grp_threshold, tl = _group("RF Mask Threshold (CH1 only)") thr_form = _form() - self.spin_threshold_mv = QDoubleSpinBox() - self.spin_threshold_mv.setRange(-500.0, 500.0) - self.spin_threshold_mv.setDecimals(3) - self.spin_threshold_mv.setSingleStep(0.025) - self.spin_threshold_mv.setSuffix(" mV") - self.spin_threshold_mv.setValue(50.0) + self.spin_threshold_mv = _make_dspin(-500.0, 500.0, 3, suffix=" mV", + value=50.0, step=0.025) self.spin_threshold_mv.setEnabled(False) - self.spin_threshold_mv.setMinimumWidth(_SPIN_MIN_W) self.spin_threshold_mv.editingFinished.connect(self._on_threshold_changed) thr_form.addRow("DC threshold:", self.spin_threshold_mv) tl.addLayout(thr_form) @@ -289,10 +290,9 @@ class SrasViewerWindow(QMainWindow): "Save the current CH1 image (one scan row per CSV line).") self.btn_export_csv.clicked.connect(self._on_export_csv) vl.addWidget(self.btn_export_csv) + return grp_view - panel_layout.addWidget(grp_view) - - # ---- ROI --------------------------------------------------------- + def _build_roi_group(self) -> QWidget: grp_roi, rl = _group("ROI (Region of Interest)") self.btn_draw_roi = QPushButton("Draw ROI") @@ -327,10 +327,7 @@ class SrasViewerWindow(QMainWindow): self.lbl_roi_npix = _wrap_label("pixels inside: —", _CSS_HINT) for lbl in (self.lbl_roi_center, self.lbl_roi_size, self.lbl_roi_npix): rl.addWidget(lbl) - - panel_layout.addWidget(grp_roi) - panel_layout.addStretch() - return _scroll_panel(panel, _LEFT_PANEL_W) + return grp_roi def _build_canvases(self) -> QWidget: splitter = QSplitter(Qt.Orientation.Vertical) @@ -372,14 +369,9 @@ class SrasViewerWindow(QMainWindow): # Velocity settings (visible only in velocity mode) self.grp_velocity, vel_l = _group("Velocity Settings (CH1 only)") vel_form = _form() - self.spin_grating_um = QDoubleSpinBox() - self.spin_grating_um.setRange(0.1, 1000.0) - self.spin_grating_um.setDecimals(2) - self.spin_grating_um.setSingleStep(0.5) - self.spin_grating_um.setSuffix(" µm") - self.spin_grating_um.setValue(25) + self.spin_grating_um = _make_dspin(0.1, 1000.0, 2, suffix=" µm", + value=25, step=0.5) self.spin_grating_um.setEnabled(False) - self.spin_grating_um.setMinimumWidth(_SPIN_MIN_W) self.spin_grating_um.editingFinished.connect(self._on_grating_changed) vel_form.addRow("Grating size:", self.spin_grating_um) vel_l.addLayout(vel_form) @@ -407,11 +399,8 @@ class SrasViewerWindow(QMainWindow): range_form = _form() for label, attr in (("min:", "spin_vmin"), ("max:", "spin_vmax")): - spin = QDoubleSpinBox() - spin.setRange(-1e9, 1e9) - spin.setDecimals(4) + spin = _make_dspin(-1e9, 1e9, 4) spin.setEnabled(False) - spin.setMinimumWidth(_SPIN_MIN_W) spin.editingFinished.connect(self._on_manual_range_changed) setattr(self, attr, spin) range_form.addRow(label, spin) @@ -494,7 +483,7 @@ class SrasViewerWindow(QMainWindow): def _load_file(self, path: str): started = self._run_worker( - "load", LoadWorker(path), + Jobs.LOAD, LoadWorker(path), connect=( ("finished", self._on_load_done), ("error", lambda msg: self.statusBar().showMessage(f"Error: {msg}")), @@ -530,25 +519,18 @@ class SrasViewerWindow(QMainWindow): self._dc_generation += 1 self.lbl_dc_precompute.setText("") - self._alignment_result = None - self._aligned_cache = {} - self._alignment_generation += 1 - self.chk_aligned_view.blockSignals(True) - self.chk_aligned_view.setChecked(False) - self.chk_aligned_view.setEnabled(False) - self.chk_aligned_view.blockSignals(False) + self._apply_alignment_result(None, view_checked=False) # Silently restore a previously-saved manual alignment, if any, so # the work survives closing and reopening the file. sidecar = load_manual_alignment(sras) if sidecar is not None: try: - self._alignment_result = build_manual_alignment( - sras, sidecar.ref_angle_idx, sidecar.dc_threshold_mv, - sidecar.per_angle) - self.chk_aligned_view.blockSignals(True) - self.chk_aligned_view.setChecked(True) - self.chk_aligned_view.blockSignals(False) + self._apply_alignment_result( + build_manual_alignment( + sras, sidecar.ref_angle_idx, sidecar.dc_threshold_mv, + sidecar.per_angle), + view_checked=True) self.statusBar().showMessage( f"Restored saved manual alignment from " f"{sidecar_path(sras.path).name}") @@ -564,17 +546,15 @@ class SrasViewerWindow(QMainWindow): self.lbl_filename.setText(sras.path.name) - self.spin_angle.blockSignals(True) - self.spin_angle.setRange(0, max(0, sras.n_angles - 1)) - self.spin_angle.setValue(0) - self.spin_angle.blockSignals(False) + with QSignalBlocker(self.spin_angle): + self.spin_angle.setRange(0, max(0, sras.n_angles - 1)) + self.spin_angle.setValue(0) # DC channels are cheap and give an instant, fluid overview of a # scan; CH1/Velocity require an FFT per pixel that can take minutes # on a large scan, so don't default to it. - self.combo_channel.blockSignals(True) - self.combo_channel.setCurrentIndex(CH4_IDX) - self.combo_channel.blockSignals(False) + with QSignalBlocker(self.combo_channel): + self.combo_channel.setCurrentIndex(CH4_IDX) self._update_controls_enabled(True) self._on_threshold_changed() # refresh ADC label with file calibration @@ -657,14 +637,14 @@ class SrasViewerWindow(QMainWindow): # Batch Convert actions pick their own files, independent of # whatever's currently open — only gated on no batch already running. - can_batch = not self._job_running("batch") + can_batch = not self._job_running(Jobs.BATCH) self._batch_dc_act.setEnabled(can_batch) self._batch_fft_act.setEnabled(can_batch) self._alignment_act.setEnabled( - has_file and s.n_angles > 1 and not self._job_running("align")) + has_file and s.n_angles > 1 and not self._job_running(Jobs.ALIGN)) self._manual_align_act.setEnabled( - has_file and s.n_angles > 1 and not self._job_running("align")) + has_file and s.n_angles > 1 and not self._job_running(Jobs.ALIGN)) self.chk_aligned_view.setEnabled(enabled and self._alignment_result is not None) self._update_roi_ui() @@ -676,7 +656,7 @@ class SrasViewerWindow(QMainWindow): # Background subtraction changes the FFT input, so it genuinely # invalidates the cached raw FFT (the cache key includes it) — # _refresh_display() recomputes only on a miss for the new state. - if self._sras is not None and self.combo_channel.currentIndex() in CH1_DERIVED_MODES: + if self._is_fft_mode(): self._refresh_display() def _on_grating_changed(self): @@ -693,7 +673,7 @@ class SrasViewerWindow(QMainWindow): # Threshold decides which pixels get an FFT at all, so changing it is # a genuine cache-key change — but the recompute reuses the cached DC4 # image to skip masked-out pixels. - if self._sras is not None and self.combo_channel.currentIndex() in CH1_DERIVED_MODES: + if self._is_fft_mode(): self._refresh_display() def _on_autoscale_toggled(self, checked: bool): @@ -810,9 +790,8 @@ class SrasViewerWindow(QMainWindow): def _on_draw_mode_changed(self, active: bool): # Keep the toggle button's visual state in sync with the canvas. - self.btn_draw_roi.blockSignals(True) - self.btn_draw_roi.setChecked(active) - self.btn_draw_roi.blockSignals(False) + with QSignalBlocker(self.btn_draw_roi): + self.btn_draw_roi.setChecked(active) def _on_clear_roi(self): self.image_canvas.clear_roi() @@ -859,6 +838,11 @@ class SrasViewerWindow(QMainWindow): return None return self._sras.samples_per_frame * self._fft_pad_factor + def _is_fft_mode(self) -> bool: + """Is the selected channel an FFT-derived (CH1/Velocity) mode?""" + return (self._sras is not None + and self.combo_channel.currentIndex() in CH1_DERIVED_MODES) + def _scale_for_display(self, freq_mhz: np.ndarray, ch_idx: int) -> np.ndarray: """Velocity is a pure post-multiply of the (already DC-masked) cached frequency image — never worth a recompute on its own.""" @@ -947,15 +931,13 @@ class SrasViewerWindow(QMainWindow): dx = x_axis[1] - x_axis[0] if len(x_axis) > 1 else s.pixel_x_mm dy = float(y_axis[1] - y_axis[0]) if len(y_axis) > 1 else 1.0 - extent = [x_axis[0] - dx / 2, x_axis[-1] + dx / 2, - y_axis[-1] + dy / 2, y_axis[0] - dy / 2] + extent = _axes_extent(x_axis, y_axis, dx, dy) if self.chk_auto.isChecked(): vmin, vmax = float(display_img.min()), float(display_img.max()) for spin, val in ((self.spin_vmin, vmin), (self.spin_vmax, vmax)): - spin.blockSignals(True) - spin.setValue(val) - spin.blockSignals(False) + with QSignalBlocker(spin): + spin.setValue(val) else: vmin, vmax = self.spin_vmin.value(), self.spin_vmax.value() @@ -988,7 +970,7 @@ class SrasViewerWindow(QMainWindow): # ------------------------------------------------------------------ def _start_compute(self): - if self._sras is None or self._job_running("compute"): + if self._sras is None or self._job_running(Jobs.COMPUTE): return # re-checked when the running compute finishes angle_idx = self.spin_angle.value() @@ -1013,7 +995,7 @@ class SrasViewerWindow(QMainWindow): is_fft_mode=is_fft, ) if not self._run_worker( - "compute", worker, + Jobs.COMPUTE, worker, connect=( ("finished", self._on_compute_done), ("error", lambda msg: self.statusBar().showMessage( @@ -1073,7 +1055,7 @@ class SrasViewerWindow(QMainWindow): worker = DcPrecomputeWorker(self._sras) self._run_worker( - "dc_precompute", worker, + Jobs.DC_PRECOMPUTE, worker, connect=( ("angle_done", lambda a, dc3, dc4, g=generation: self._on_dc_precompute_angle_done(g, a, dc3, dc4, n_angles)), @@ -1101,7 +1083,7 @@ class SrasViewerWindow(QMainWindow): # caught up and are still waiting), show it now. current_ch = self.combo_channel.currentIndex() if (angle_idx == self.spin_angle.value() - and not self._job_running("compute") + and not self._job_running(Jobs.COMPUTE) and current_ch in (CH3_IDX, CH4_IDX) and (self._current_angle != angle_idx or self._current_ch != current_ch)): self._refresh_display() @@ -1171,7 +1153,7 @@ class SrasViewerWindow(QMainWindow): # ------------------------------------------------------------------ def _on_batch_compute(self, mode: str): - if self._job_running("batch"): + if self._job_running(Jobs.BATCH): return label = "DC" if mode == "dc" else "FFT" paths, _ = QFileDialog.getOpenFileNames( @@ -1183,9 +1165,9 @@ class SrasViewerWindow(QMainWindow): self._batch_errors = [] worker = BatchCacheWorker(paths, mode, self.chk_bg_sub.isChecked()) started = self._run_worker( - "batch", worker, + Jobs.BATCH, worker, connect=( - ("progress", lambda pct: self._set_progress("batch", pct)), + ("progress", lambda pct: self._set_progress(Jobs.BATCH, pct)), ("file_done", self._on_batch_file_done), ("finished", lambda p=paths: self._on_batch_finished(p)), ), @@ -1197,16 +1179,16 @@ class SrasViewerWindow(QMainWindow): self._batch_dc_act.setEnabled(False) self._batch_fft_act.setEnabled(False) self._show_progress( - "batch", f"Batch computing {label} for {len(paths)} file(s)…", + Jobs.BATCH, f"Batch computing {label} for {len(paths)} file(s)…", maximum=100) def _on_batch_file_done(self, path: str, err: str): if err: self._batch_errors.append(f"{Path(path).name} — {err}") - self._show_progress("batch", f"Processed {Path(path).name}…") + self._show_progress(Jobs.BATCH, f"Processed {Path(path).name}…") def _on_batch_finished(self, paths: list[str]): - self._close_progress("batch") + self._close_progress(Jobs.BATCH) n_total = len(paths) n_failed = len(self._batch_errors) @@ -1240,7 +1222,7 @@ class SrasViewerWindow(QMainWindow): generation = self._alignment_generation started = self._run_worker( - "align", AngleAlignmentWorker(self._sras, ref_idx, threshold_mv), + Jobs.ALIGN, AngleAlignmentWorker(self._sras, ref_idx, threshold_mv), connect=( ("progress", lambda pct: self._set_progress("main", pct)), ("finished", lambda result, err, g=generation: @@ -1265,12 +1247,9 @@ class SrasViewerWindow(QMainWindow): if error_msg: self.statusBar().showMessage(f"Angle alignment failed: {error_msg}") return - self._alignment_result = result - self._aligned_cache = {} - self.chk_aligned_view.setEnabled(True) - self.chk_aligned_view.blockSignals(True) - self.chk_aligned_view.setChecked(True) - self.chk_aligned_view.blockSignals(False) + # No generation bump: this result *is* the current generation's. + self._apply_alignment_result(result, view_checked=True, + bump_generation=False) nr, nc = result.canvas_shape self.statusBar().showMessage( f"Angle alignment computed ({self._sras.n_angles} angles, " @@ -1320,32 +1299,32 @@ class SrasViewerWindow(QMainWindow): def _on_manual_align_dialog_closed(self, _result_code: int): self._manual_align_dialog = None - def _on_manual_alignment_saved(self, result, sidecar_path_str: str): + def _apply_alignment_result(self, result, *, view_checked: bool, + bump_generation: bool = True): + """Install (or clear, with result=None) the active alignment: reset + the aligned-image cache and set the Aligned View checkbox without + firing its change signal.""" self._alignment_result = result self._aligned_cache = {} - self._alignment_generation += 1 - self.chk_aligned_view.setEnabled(True) - self.chk_aligned_view.blockSignals(True) - self.chk_aligned_view.setChecked(True) - self.chk_aligned_view.blockSignals(False) + if bump_generation: + self._alignment_generation += 1 + with QSignalBlocker(self.chk_aligned_view): + self.chk_aligned_view.setChecked(view_checked) + self.chk_aligned_view.setEnabled(result is not None) + + def _manual_alignment_changed(self, result, message: str): + self._apply_alignment_result(result, view_checked=result is not None) self._update_controls_enabled(self._sras is not None) - self.statusBar().showMessage( - f"Manual alignment saved to {Path(sidecar_path_str).name}") + self.statusBar().showMessage(message) if self._current_image is not None: self._refresh_display() + def _on_manual_alignment_saved(self, result, sidecar_path_str: str): + self._manual_alignment_changed( + result, f"Manual alignment saved to {Path(sidecar_path_str).name}") + def _on_manual_alignment_cleared(self): - self._alignment_result = None - self._aligned_cache = {} - self._alignment_generation += 1 - self.chk_aligned_view.blockSignals(True) - self.chk_aligned_view.setChecked(False) - self.chk_aligned_view.setEnabled(False) - self.chk_aligned_view.blockSignals(False) - self._update_controls_enabled(self._sras is not None) - self.statusBar().showMessage("Manual alignment cleared.") - if self._current_image is not None: - self._refresh_display() + self._manual_alignment_changed(None, "Manual alignment cleared.") # ------------------------------------------------------------------ # FFT Options @@ -1369,7 +1348,7 @@ class SrasViewerWindow(QMainWindow): # Pad factor changes the FFT bin count, so it genuinely invalidates # the cached raw FFT (part of the cache key) — _refresh_display() # recomputes only on a cache miss. - if self._sras is not None and self.combo_channel.currentIndex() in CH1_DERIVED_MODES: + if self._is_fft_mode(): self._refresh_display() # ------------------------------------------------------------------