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