Dedup viewer Qt boilerplate after the split

- One _apply_alignment_result() replaces the five copies of the
  cache-clear / generation-bump / Aligned-View-checkbox dance, and
  _on_manual_alignment_saved/_cleared collapse into a shared
  _manual_alignment_changed.
- QSignalBlocker context managers replace every hand-rolled
  blockSignals(True)/set/blockSignals(False) triple (11 sites).
- _make_dspin() replaces the 11 four-to-seven-line QDoubleSpinBox
  constructions; _axes_extent() replaces the duplicated imshow-extent
  formula; _is_fft_mode() replaces the repeated CH1-mode guard.
- Jobs constants replace the stringly-typed background-job keys.
- The two 160-line widget builders split along their section banners:
  _build_left_panel -> file/info/view/roi groups, the manual-alignment
  _build_ui -> six per-group builders + _connect_controls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-08-06 10:56:50 -05:00
parent 26a34f7436
commit f0f622b9ab
3 changed files with 172 additions and 186 deletions
+38 -2
View File
@@ -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.
+47 -76
View File
@@ -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)
with QSignalBlocker(self.combo_active_angle):
self.combo_active_angle.setCurrentIndex(self._active_angle)
self.combo_active_angle.blockSignals(False)
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)
with QSignalBlocker(spin):
spin.setValue(val)
spin.blockSignals(False)
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),
+78 -99
View File
@@ -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(
self._apply_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)
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)
with QSignalBlocker(self.spin_angle):
self.spin_angle.setRange(0, max(0, sras.n_angles - 1))
self.spin_angle.setValue(0)
self.spin_angle.blockSignals(False)
# 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)
with QSignalBlocker(self.combo_channel):
self.combo_channel.setCurrentIndex(CH4_IDX)
self.combo_channel.blockSignals(False)
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)
with QSignalBlocker(self.btn_draw_roi):
self.btn_draw_roi.setChecked(active)
self.btn_draw_roi.blockSignals(False)
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)
with QSignalBlocker(spin):
spin.setValue(val)
spin.blockSignals(False)
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 = {}
if bump_generation:
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)
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()
# ------------------------------------------------------------------