From 3c835f45927d28865f6625f788607dd9119c9898 Mon Sep 17 00:00:00 2001 From: Thomas Ales Date: Fri, 31 Jul 2026 09:19:17 -0500 Subject: [PATCH] Add manual angle alignment mode with overlay, keyboard nudge, and save/clear Automatic alignment's phase-correlation translation search is unreliable, so add a Fusion -> Manual Alignment dialog: every angle's CH4 threshold mask overlaid at once with distinct colors/opacity, a selector for the active angle, arrow keys to nudge translation and Q/E to nudge rotation, an Auto De-rotate button that snaps to the known scan angle, and Save/Clear controls backed by a JSON sidecar that's restored automatically on reload. Co-Authored-By: Claude Sonnet 5 --- sras_compute.py | 265 +++++++++++++++++- sras_viewer.py | 663 +++++++++++++++++++++++++++++++++++++++++++++- sras_workers.py | 42 +++ tools/test_gui.py | 142 +++++++++- 4 files changed, 1092 insertions(+), 20 deletions(-) diff --git a/sras_compute.py b/sras_compute.py index fc01b06..37f7b16 100644 --- a/sras_compute.py +++ b/sras_compute.py @@ -6,9 +6,11 @@ multiprocessing child can import it without loading Qt or matplotlib — which matters because Python 3.14 on macOS spawns rather than forks. """ +import json import os from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass +from pathlib import Path import numpy as np import scipy.fft as scipy_fft @@ -397,6 +399,21 @@ class AlignmentResult: per_angle: dict[int, AngleTransform] +@dataclass +class ManualAngleParams: + """One angle's manual-alignment state, independent of any canvas. + + rotation_deg/shift_mm are exactly AngleTransform's non-derived fields — + the pair a canvas-bound AngleTransform's matrix/offset get built from + once a canvas is decided (build_manual_alignment). Defaults to identity + (no rotation, no shift): a fresh angle with no prior alignment is shown + raw, exactly as scanned — the same "fully unaligned" state Clear + Alignment resets back to. + """ + rotation_deg: float = 0.0 + shift_mm: tuple[float, float] = (0.0, 0.0) + + def _pixel_pitch_mm(sras: SrasFile, angle_idx: int) -> tuple[float, float]: """(dx, dy) mm/pixel for one angle: dx is the file-wide constant pixel_x_mm; dy is this angle's own row spacing (assumed uniform, the same @@ -429,10 +446,18 @@ def _theta_deg(sras: SrasFile, angle_idx: int, ref_idx: int) -> float: def _corners_in_ref_frame(sras: SrasFile, angle_idx: int, ref_idx: int, - shift_mm=(0.0, 0.0)) -> np.ndarray: + shift_mm=(0.0, 0.0), + theta_deg: float | None = None) -> np.ndarray: """Angle *angle_idx*'s bbox corners, rotated about its own centroid into - the reference frame and translated by *shift_mm*. Shape (4, 2).""" - R = _rotation_matrix(_theta_deg(sras, angle_idx, ref_idx)) + the reference frame and translated by *shift_mm*. Shape (4, 2). + + theta_deg overrides the analytic angles_deg-derived rotation used by + default — the manual-alignment path (union_canvas_mm) passes a + user-chosen rotation here (which may differ from the known scan-angle + delta) without needing a parallel code path. + """ + theta = _theta_deg(sras, angle_idx, ref_idx) if theta_deg is None else theta_deg + R = _rotation_matrix(theta) c_a = np.array(_bbox_center_mm(sras, angle_idx)) c_ref = np.array(_bbox_center_mm(sras, ref_idx)) shift = np.asarray(shift_mm, dtype=np.float64) @@ -443,7 +468,8 @@ def _corners_in_ref_frame(sras: SrasFile, angle_idx: int, ref_idx: int, def _build_affine_canvas_to_raw(sras: SrasFile, angle_idx: int, ref_idx: int, shift_mm: tuple[float, float], canvas_dx: float, canvas_dy: float, - canvas_origin_mm: tuple[float, float] + canvas_origin_mm: tuple[float, float], + theta_deg: float | None = None ) -> tuple[np.ndarray, np.ndarray]: """matrix, offset s.t. raw_index = matrix @ [row_out, col_out] + offset, matching scipy.ndimage.affine_transform's output->input convention. @@ -453,13 +479,16 @@ def _build_affine_canvas_to_raw(sras: SrasFile, angle_idx: int, ref_idx: int, [lx;ly] = R(theta)^T @ ([X;Y]-c_ref-shift) + c_a # undo rotation+shift -> angle a's local mm [row;col] = D @ ([lx;ly] - [x_start_a; y0_a]) # local mm -> angle a's raw idx - where theta = angles_deg[angle_idx] - angles_deg[ref_idx], c_ref/c_a are - each angle's own raw-bbox mm centroid (the rotation pivot — this keeps - rotated content centered, minimizing required canvas padding), and - A_out/D are the index<->mm scaling matrices for the canvas pitch and - this angle's own native pitch respectively. + where theta = angles_deg[angle_idx] - angles_deg[ref_idx] (unless + *theta_deg* overrides it — see _corners_in_ref_frame's note, used by the + manual-alignment path), c_ref/c_a are each angle's own raw-bbox mm + centroid (the rotation pivot — this keeps rotated content centered, + minimizing required canvas padding), and A_out/D are the index<->mm + scaling matrices for the canvas pitch and this angle's own native pitch + respectively. """ - Rinv = _rotation_matrix(_theta_deg(sras, angle_idx, ref_idx)).T + theta = _theta_deg(sras, angle_idx, ref_idx) if theta_deg is None else theta_deg + Rinv = _rotation_matrix(theta).T cx_a, cy_a = _bbox_center_mm(sras, angle_idx) cx_ref, cy_ref = _bbox_center_mm(sras, ref_idx) dx_a, dy_a = _pixel_pitch_mm(sras, angle_idx) @@ -648,3 +677,219 @@ def compute_angle_alignment(sras: SrasFile, ref_angle_idx: int, # Back-compat alias for the pre-split private name (used by tooling). _compute_angle_alignment = compute_angle_alignment + + +# --------------------------------------------------------------------------- +# Manual alignment (Fusion menu -> Manual Alignment... dialog) +# +# Skips compute_angle_alignment's mask + phase-correlation search entirely: +# every angle's rotation_deg/shift_mm is supplied directly by the caller +# (nudged by eye against a live multi-angle mask overlay, or pre-seeded from +# a previous compute_angle_alignment run or a saved sidecar). Building the +# final AlignmentResult from already-known per-angle parameters is pure +# closed-form matrix math (union_canvas_mm + _build_affine_canvas_to_raw) +# with no per-pixel image work at all, so build_manual_alignment is cheap +# enough to call synchronously on the GUI thread on every edit. The only +# genuinely expensive per-pixel operation anywhere in this flow is +# reproject_mask, and only ManualAlignmentDialog's own downsampled preview +# calls that per keystroke — see that class's docstring for how it limits +# each nudge to reprojecting only the actively-edited angle. +# --------------------------------------------------------------------------- + +def union_canvas_mm(sras: SrasFile, ref_angle_idx: int, dx: float, dy: float, + per_angle_params: dict[int, ManualAngleParams], + margin_frac: float = 0.0 + ) -> tuple[tuple[float, float], tuple[int, int]]: + """Shared-canvas origin (mm) and (n_rows, n_cols) at pitch (dx, dy) that + contains every angle's footprint after applying its own rotation+shift — + the generalisation of compute_angle_alignment's Step 3 to arbitrary (not + just _theta_deg-analytic) per-angle rotation. Angles missing from + per_angle_params default to identity (e.g. a sidecar saved before a + rescan added more angles). + + margin_frac pads the box on every side: 0 for a final canvas (this then + reproduces compute_angle_alignment's own Step-3 math exactly, when every + angle's rotation_deg equals the analytic delta and shift_mm matches); + nonzero for ManualAlignmentDialog's downsampled preview canvas, which + needs headroom so an ordinary translation nudge of the active angle never + has to trigger a full canvas resize (see that class's docstring — an + extreme nudge can still, in principle, push content past this padding; + accepted as a known edge case, same as _phase_correlate_shift's + wraparound risk note). + """ + n = sras.n_angles + corners = np.vstack([ + _corners_in_ref_frame( + sras, a, ref_angle_idx, + per_angle_params.get(a, ManualAngleParams()).shift_mm, + theta_deg=per_angle_params.get(a, ManualAngleParams()).rotation_deg) + for a in range(n)]) + x_min, y_min = corners.min(axis=0) + x_max, y_max = corners.max(axis=0) + if margin_frac: + pad_x, pad_y = (x_max - x_min) * margin_frac, (y_max - y_min) * margin_frac + x_min, x_max = x_min - pad_x, x_max + pad_x + y_min, y_max = y_min - pad_y, y_max + pad_y + n_cols = int(np.ceil((x_max - x_min) / dx)) + 1 + n_rows = int(np.ceil((y_max - y_min) / abs(dy))) + 1 + origin = (float(x_min), float(y_min if dy > 0 else y_max)) + return origin, (n_rows, n_cols) + + +def reproject_mask(sras: SrasFile, angle_idx: int, ref_angle_idx: int, + mask: np.ndarray, rotation_deg: float, + shift_mm: tuple[float, float], + canvas_dx: float, canvas_dy: float, + canvas_origin_mm: tuple[float, float], + canvas_shape: tuple[int, int]) -> np.ndarray: + """Resample one angle's binary/float mask onto an arbitrary canvas via an + explicit rotation+shift — the single building block + ManualAlignmentDialog's live preview repeatedly calls (once per + keystroke, for only the actively-nudged angle), since it bypasses + compute_angle_alignment's phase-correlation search entirely and just + takes rotation_deg/shift_mm as given. order=0 (nearest) matches + apply_alignment's own reasoning: a binary mask must never be blended with + zero-padding. + """ + matrix, offset = _build_affine_canvas_to_raw( + sras, angle_idx, ref_angle_idx, shift_mm, canvas_dx, canvas_dy, + canvas_origin_mm, theta_deg=rotation_deg) + return scipy_ndimage.affine_transform( + mask.astype(np.float32, copy=False), matrix, offset=offset, + output_shape=canvas_shape, order=0, mode="constant", cval=0.0) + + +def build_manual_alignment(sras: SrasFile, ref_angle_idx: int, + dc_threshold_mv: float, + per_angle_params: dict[int, ManualAngleParams] + ) -> AlignmentResult: + """Build a full, full-resolution AlignmentResult from user-supplied + per-angle rotation+shift — the Manual Alignment counterpart to + compute_angle_alignment, skipping its mask/phase-correlation search + entirely (every angle's transform here is exactly what the caller + supplied). Pure matrix/bbox math, no image data touched anywhere in this + function, so it is cheap enough to call synchronously on the GUI thread. + The reference angle's params are always forced to identity, regardless + of what per_angle_params holds for it — it defines the shared origin and + must never be transformed. + """ + n = sras.n_angles + dx_ref, dy_ref = _pixel_pitch_mm(sras, ref_angle_idx) + params = {a: per_angle_params.get(a, ManualAngleParams()) for a in range(n)} + params[ref_angle_idx] = ManualAngleParams() + + canvas_origin_mm, canvas_shape = union_canvas_mm( + sras, ref_angle_idx, dx_ref, dy_ref, params, margin_frac=0.0) + + per_angle: dict[int, AngleTransform] = {} + for a in range(n): + p = params[a] + matrix, offset = _build_affine_canvas_to_raw( + sras, a, ref_angle_idx, p.shift_mm, dx_ref, dy_ref, + canvas_origin_mm, theta_deg=p.rotation_deg) + per_angle[a] = AngleTransform(p.rotation_deg, p.shift_mm, matrix, offset) + + return AlignmentResult(ref_angle_idx, dc_threshold_mv, canvas_shape, + dx_ref, dy_ref, canvas_origin_mm, per_angle) + + +# ---- Sidecar persistence (.sras.align.json) ------------------------- +# +# Lives here, not sras_format.py: sras_format.py is scoped to the versioned +# binary .sras spec itself (see scan_format.md); a manual alignment is a +# viewer-computed *derived* artifact, analogous in kind to AlignmentResult — +# so it belongs with the alignment math it serialises, which already lives +# in this module. json + pathlib are both stdlib, so this doesn't add a new +# dependency to a module whose only load-bearing constraint is staying free +# of Qt/matplotlib for cheap multiprocessing-child imports. + +@dataclass +class ManualAlignmentSidecar: + ref_angle_idx: int + dc_threshold_mv: float + per_angle: dict[int, ManualAngleParams] + + +def sidecar_path(sras_path) -> Path: + """.sras.align.json next to the scan file. A thin, independently + testable function since save/load/delete and the GUI's status messages + all need the identical path.""" + p = Path(sras_path) + return p.with_name(p.name + ".align.json") + + +def save_manual_alignment(sras: SrasFile, ref_angle_idx: int, + dc_threshold_mv: float, + per_angle: dict[int, ManualAngleParams]) -> Path: + """Write the sidecar JSON for sras.path (overwriting any existing one) + and return the path written. + + Schema (schema_version 1): + { + "schema_version": 1, + "ref_angle_idx": , + "dc_threshold_mv": , + "per_angle": { + "": {"rotation_deg": , "shift_mm": [, ]}, + ... + } + } + Angle indices are JSON object keys, so they round-trip as strings — + load_manual_alignment converts them back to int. + """ + path = sidecar_path(sras.path) + payload = { + "schema_version": 1, + "ref_angle_idx": ref_angle_idx, + "dc_threshold_mv": dc_threshold_mv, + "per_angle": { + str(a): {"rotation_deg": p.rotation_deg, "shift_mm": list(p.shift_mm)} + for a, p in per_angle.items() + }, + } + path.write_text(json.dumps(payload, indent=2)) + return path + + +def load_manual_alignment(sras: SrasFile) -> ManualAlignmentSidecar | None: + """Read 's sidecar JSON if present, else None — never raises: + a missing, corrupt, foreign, or version-mismatched JSON file must not + block opening the .sras file itself (see SrasViewerWindow._on_load_done). + Per-angle entries for an angle index no longer present in *sras* (e.g. + re-scanned with fewer angles) are silently dropped. + """ + path = sidecar_path(sras.path) + if not path.exists(): + return None + try: + raw = json.loads(path.read_text()) + if raw.get("schema_version") != 1: + return None + per_angle = { + int(a): ManualAngleParams( + float(v["rotation_deg"]), + (float(v["shift_mm"][0]), float(v["shift_mm"][1]))) + for a, v in raw.get("per_angle", {}).items() + if int(a) < sras.n_angles + } + return ManualAlignmentSidecar( + ref_angle_idx=int(raw.get("ref_angle_idx", 0)), + dc_threshold_mv=float(raw.get("dc_threshold_mv", 0.0)), + per_angle=per_angle) + except (OSError, ValueError, KeyError, TypeError, IndexError, + json.JSONDecodeError): + return None + + +def delete_manual_alignment(sras: SrasFile) -> bool: + """Delete the sidecar if present. Returns whether a file actually existed + to delete, so Clear Alignment's status message can say so. Genuine I/O + errors (permission denied, read-only share) propagate — the caller + (ManualAlignmentDialog._on_clear) surfaces them rather than silently + pretending the destructive action succeeded.""" + path = sidecar_path(sras.path) + try: + path.unlink() + return True + except FileNotFoundError: + return False diff --git a/sras_viewer.py b/sras_viewer.py index c09d578..e6f2730 100644 --- a/sras_viewer.py +++ b/sras_viewer.py @@ -19,29 +19,34 @@ import faulthandler import sys from pathlib import Path +import matplotlib as mpl import numpy as np from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT from matplotlib.figure import Figure from matplotlib.patches import Polygon from matplotlib.path import Path as MplPath from PyQt6.QtCore import QObject, Qt, QThread, pyqtSignal -from PyQt6.QtGui import QAction +from PyQt6.QtGui import QAction, QKeyEvent from PyQt6.QtWidgets import ( QApplication, QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFileDialog, QFormLayout, QFrame, QGroupBox, QHBoxLayout, - QLabel, QMainWindow, QProgressDialog, QPushButton, QRadioButton, QScrollArea, - QSizePolicy, QSpinBox, QSplitter, QVBoxLayout, QWidget, + QLabel, QMainWindow, QMessageBox, QProgressDialog, QPushButton, QRadioButton, + QScrollArea, QSizePolicy, QSpinBox, QSplitter, QVBoxLayout, QWidget, ) import sras_compute as compute -from sras_compute import PYFFTW_AVAILABLE, apply_alignment +from sras_compute import ( + PYFFTW_AVAILABLE, ManualAngleParams, apply_alignment, build_manual_alignment, + delete_manual_alignment, load_manual_alignment, save_manual_alignment, + sidecar_path, +) from sras_format import ( CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES, SrasFile, adc_to_mv, mv_to_adc, _FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC, ) from sras_workers import ( - AngleAlignmentWorker, BatchCacheWorker, ComputeWorker, DcPrecomputeWorker, - LoadWorker, + AngleAlignmentWorker, BatchCacheWorker, Ch4MaskWorker, ComputeWorker, + DcPrecomputeWorker, LoadWorker, ) faulthandler.enable() # print a native stack trace on SIGSEGV/SIGABRT/etc. @@ -738,6 +743,542 @@ class FftOptionsDialog(QDialog): return max(1, self._spin_pad.value()) +# --------------------------------------------------------------------------- +# Manual alignment dialog (Fusion -> Manual Alignment...) +# --------------------------------------------------------------------------- + +class ManualAlignOverlayCanvas(FigureCanvasQTAgg): + """Renders ManualAlignmentDialog's multi-angle mask overlay and turns + keyboard input into translate/rotate nudge requests for whichever angle + the dialog currently has active. + + A pure input+render widget — it holds no alignment state and never + touches SrasFile itself; ManualAlignmentDialog owns all of that and + decides, from these signals, whether a cheap single-layer refresh or a + full preview-canvas rebuild is needed. + + FigureCanvasQTAgg is a real QWidget, so keyPressEvent works like on any + other widget, but Qt only ever delivers key events to whichever widget + currently has focus — StrongFocus, plus grabbing focus on click and once + right after the dialog is shown, are both required or arrow keys + silently do nothing. + + Rotate keys are letters (Q/E), not punctuation (comma/period or + brackets): Shift+letter still reports the same Qt.Key on every platform, + whereas Shift+comma/bracket can report a different virtual key + (Key_Less / Key_BraceLeft) depending on platform and keyboard layout — + which would silently break the "Shift = coarse step" modifier for + rotation specifically. Arrow keys have no such hazard. + """ + nudge_translate = pyqtSignal(int, int, bool) # dir_x, dir_y in {-1,0,1}; coarse + nudge_rotate = pyqtSignal(int, bool) # dir in {-1,1} (CCW/CW); coarse + + _TRANSLATE_KEYS = { + Qt.Key.Key_Left: (-1, 0), + Qt.Key.Key_Right: (1, 0), + Qt.Key.Key_Up: (0, -1), + Qt.Key.Key_Down: (0, 1), + } + _ROTATE_KEYS = {Qt.Key.Key_Q: 1, Qt.Key.Key_E: -1} # CCW, CW + + def __init__(self, parent=None): + fig = Figure(figsize=(6, 6), tight_layout=True) + self.ax = fig.add_subplot(111) + super().__init__(fig) + self.setParent(parent) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.mpl_connect("button_press_event", lambda _e: self.setFocus()) + + def show_overlay(self, rgba: np.ndarray, extent: list[float], title: str): + self.figure.clf() + self.ax = self.figure.add_subplot(111) + self.ax.imshow(rgba, extent=extent, origin="upper", aspect="auto") + self.ax.set_xlabel("X (mm)") + self.ax.set_ylabel("Y (mm)") + self.ax.set_title(title) + self.draw_idle() # coalesces rapid redraws — matters for key-repeat. + + def keyPressEvent(self, event: QKeyEvent): + key = event.key() + coarse = bool(event.modifiers() & Qt.KeyboardModifier.ShiftModifier) + if key in self._TRANSLATE_KEYS: + dx, dy = self._TRANSLATE_KEYS[key] + self.nudge_translate.emit(dx, dy, coarse) + event.accept() + elif key in self._ROTATE_KEYS: + self.nudge_rotate.emit(self._ROTATE_KEYS[key], coarse) + event.accept() + else: + super().keyPressEvent(event) + + +class ManualAlignmentDialog(QDialog): + """Non-modal manual angle-alignment editor (Fusion -> Manual Alignment...). + + Shows every angle's binarized CH4 (Bias B) mask overlaid in a distinct + color at partial opacity on one shared canvas, so translation/rotation + misalignment is visible by eye — the thing the automatic phase- + correlation step (compute_angle_alignment) sometimes gets wrong. The user + picks an "active" angle and nudges its rotation+translation with the + keyboard; Auto De-rotate sets every non-reference angle's rotation to the + known, analytic scan-angle delta without touching any translation the + user has already dialed in. Save writes a JSON sidecar next to the .sras + file and hands a freshly-built, full-resolution AlignmentResult back to + the main window — the exact same object shape compute_angle_alignment + produces, so every existing Aligned-View code path (apply_alignment, + _aligned_canvas_axes, the pixel-inspector inverse-transform) works + completely unmodified. + + Non-modal by design (shown via .show(), never .exec() or setModal(True)) + so the user can still interact with the main window. Talks back to + SrasViewerWindow two ways: it reuses parent._run_worker/_jobs directly + for its own (rare) background mask-fetch step, so the main window's + existing shutdown/lifecycle plumbing covers it for free, and it emits + alignment_saved / alignment_cleared signals for the two moments that + should actually mutate the main window's persistent state — everything + else (nudging, Auto De-rotate, threshold edits) stays purely local to + this dialog until Save. + """ + + alignment_saved = pyqtSignal(object, str) # AlignmentResult, sidecar path (str) + alignment_cleared = pyqtSignal() + + _PREVIEW_MARGIN_FRAC = 0.15 + _BASE_ALPHA = 0.42 + _ACTIVE_ALPHA = 0.75 + _MAX_PREVIEW_DIM = 1024 + + def __init__(self, parent: "SrasViewerWindow", sras: SrasFile, *, + ref_angle_idx: int, dc_threshold_mv: float, + seed_per_angle: dict[int, ManualAngleParams] | None, + cached_dc4_mv: dict[int, np.ndarray]): + super().__init__(parent) + self._parent = parent + self._sras = sras + self._ref_angle_idx = ref_angle_idx + self._downsample_factor = 1 + self._dc4_mv: dict[int, np.ndarray] = {} + self._masks_small: dict[int, np.ndarray] = {} + self._preview_layers: dict[int, np.ndarray] = {} + self._preview_origin_mm = (0.0, 0.0) + self._preview_shape = (1, 1) + self._preview_dx_mm = self._preview_dy_mm = 1.0 + self._masks_ready = False + + self.setWindowTitle(f"Manual Alignment — {sras.path.name}") + self.resize(1150, 760) + + self._seed_initial_params(seed_per_angle) + n = sras.n_angles + cmap = mpl.colormaps["tab10"] if n <= 10 else mpl.colormaps["tab20"] + self._angle_colors = {a: cmap(a % cmap.N)[:3] for a in range(n)} + self._active_angle = 1 if ref_angle_idx == 0 and n > 1 else 0 + + self._build_ui(dc_threshold_mv) + self._set_controls_enabled(False) # re-enabled once masks are ready + self._start_mask_prep(cached_dc4_mv) + + def showEvent(self, event): + super().showEvent(event) + self.canvas.setFocus() + + # ------------------------------------------------------------------ + # Construction + # ------------------------------------------------------------------ + + def _seed_initial_params(self, seed_per_angle: dict[int, ManualAngleParams] | None): + seed = seed_per_angle or {} + self._angle_params: dict[int, ManualAngleParams] = { + a: (ManualAngleParams(seed[a].rotation_deg, seed[a].shift_mm) + if a in seed else ManualAngleParams()) + for a in range(self._sras.n_angles) + } + self._angle_params[self._ref_angle_idx] = ManualAngleParams() + + def _build_ui(self, dc_threshold_mv: float): + root = QHBoxLayout(self) + + self.canvas = ManualAlignOverlayCanvas() + left = QWidget() + left_l = QVBoxLayout(left) + left_l.setContentsMargins(0, 0, 0, 0) + left_l.setSpacing(4) + left_l.addWidget(NavigationToolbar2QT(self.canvas, left)) + left_l.addWidget(self.canvas) + root.addWidget(left, stretch=1) + + panel = QWidget() + panel_l = QVBoxLayout(panel) + panel_l.setContentsMargins(0, 0, 0, 0) + panel_l.setSpacing(8) + + # ---- Active Angle ------------------------------------------------- + grp_angle, al = _group("Active Angle") + self.combo_active_angle = QComboBox() + for a in range(self._sras.n_angles): + label = f"Angle {a} ({self._sras.angles_deg[a]:.1f}°)" + if a == self._ref_angle_idx: + label += " [reference]" + self.combo_active_angle.addItem(label) + 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) + + # ---- Manual Adjustment --------------------------------------------- + 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) + 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) + 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) + mform.addRow("Shift Y:", self.spin_active_shift_y_mm) + mform_box.addLayout(mform) + panel_l.addWidget(self.grp_manual_adjust) + + # ---- Nudge Step Sizes ------------------------------------------------ + 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) + 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) + 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) + 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) + + # ---- Mask Threshold --------------------------------------------------- + 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) + tform.addRow("DC threshold:", self.spin_mask_threshold_mv) + tl.addLayout(tform) + panel_l.addWidget(self.grp_mask_threshold) + + # ---- Actions ------------------------------------------------------ + grp_actions, acl = _group("Actions") + self.btn_auto_derotate = QPushButton("Auto De-rotate (use known angles)") + self.btn_save = QPushButton("Save Alignment") + self.btn_clear = QPushButton("Clear Alignment…") + 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, 300)) + + 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) + self.spin_active_shift_y_mm.editingFinished.connect(self._on_shift_spin_edited) + self.spin_mask_threshold_mv.editingFinished.connect(self._on_mask_threshold_edited) + self.btn_auto_derotate.clicked.connect(self._on_auto_derotate) + self.btn_save.clicked.connect(self._on_save) + self.btn_clear.clicked.connect(self._on_clear) + self.btn_close.clicked.connect(self.close) + 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) + self._on_active_angle_changed(self._active_angle) + + # ------------------------------------------------------------------ + # Mask preparation (initial CH4 fetch + threshold + downsample) + # ------------------------------------------------------------------ + + def _start_mask_prep(self, cached_dc4_mv: dict[int, np.ndarray]): + self._dc4_mv = dict(cached_dc4_mv) + missing = [a for a in range(self._sras.n_angles) if a not in self._dc4_mv] + if not missing: + self._finish_mask_prep() + 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), + connect=( + ("angle_done", self._on_mask_angle_done), + ("error", lambda msg: self.lbl_status.setText(f"Mask prep error: {msg}")), + ), + on_done=self._finish_mask_prep) + if not started: + self.lbl_status.setText( + "Could not start mask preparation (busy) — close and reopen.") + + def _on_mask_angle_done(self, angle_idx: int, dc4_mv: np.ndarray): + self._dc4_mv[angle_idx] = dc4_mv + self.lbl_status.setText( + f"Preparing masks: {len(self._dc4_mv)}/{self._sras.n_angles} ready…") + + def _finish_mask_prep(self): + if len(self._dc4_mv) < self._sras.n_angles: + return # a mask-worker error left some angles unfetched + max_dim = max(max(img.shape) for img in self._dc4_mv.values()) + self._downsample_factor = max(1, int(np.ceil(max_dim / self._MAX_PREVIEW_DIM))) + self._recompute_masks_small() + self._rebuild_preview_canvas() + self._set_controls_enabled(True) + self.lbl_status.setText("Ready.") + + def _recompute_masks_small(self): + """Threshold + downsample every angle's already-in-memory full-res + CH4 mV image. Cheap (a compare + block-mean), so this re-runs in + full whenever the mask-threshold spin box changes — no re-fetch.""" + threshold = self.spin_mask_threshold_mv.value() + factor = self._downsample_factor + self._masks_small = { + a: compute._block_mean_downsample( + (img >= threshold).astype(np.float32), factor) + for a, img in self._dc4_mv.items() + } + + # ------------------------------------------------------------------ + # Preview canvas: full rebuild vs. incremental single-layer refresh + # ------------------------------------------------------------------ + + def _rebuild_preview_canvas(self): + """Full geometry rebuild: recomputes the shared preview canvas's + origin/shape (rotation can grow the union bbox — translation alone + cannot, per the padding baked in via _PREVIEW_MARGIN_FRAC) and every + angle's reprojected mask layer. Triggered by: dialog open, + mask-threshold change, Auto De-rotate, a rotation nudge/edit of the + active angle. NOT triggered by a translation-only nudge — see + _refresh_active_preview_layer.""" + dx_ref, dy_ref = compute._pixel_pitch_mm(self._sras, self._ref_angle_idx) + factor = self._downsample_factor + dx_c, dy_c = dx_ref * factor, dy_ref * factor + origin, shape = compute.union_canvas_mm( + self._sras, self._ref_angle_idx, dx_c, dy_c, self._angle_params, + margin_frac=self._PREVIEW_MARGIN_FRAC) + self._preview_origin_mm, self._preview_shape = origin, shape + self._preview_dx_mm, self._preview_dy_mm = dx_c, dy_c + self._preview_layers = { + a: compute.reproject_mask( + self._sras, a, self._ref_angle_idx, self._masks_small[a], + self._angle_params[a].rotation_deg, self._angle_params[a].shift_mm, + dx_c, dy_c, origin, shape) + for a in range(self._sras.n_angles) + } + self._redraw_overlay() + + def _refresh_active_preview_layer(self): + """Cheap path for a translation-only nudge/edit of the active angle: + reproject just that one angle's downsampled mask onto the *existing* + preview canvas — every other angle's cached layer is untouched.""" + a = self._active_angle + self._preview_layers[a] = compute.reproject_mask( + self._sras, a, self._ref_angle_idx, self._masks_small[a], + self._angle_params[a].rotation_deg, self._angle_params[a].shift_mm, + self._preview_dx_mm, self._preview_dy_mm, + self._preview_origin_mm, self._preview_shape) + self._redraw_overlay() + + def _redraw_overlay(self): + """Alpha-composite every angle's colored mask layer into one RGBA + image ("all thresholds overlaid with varying opacity"). Each angle + keeps a fixed, distinct color regardless of which is active; the + active angle is drawn last (on top) at a visibly higher alpha so + it's easy to track while nudging.""" + if not self._preview_layers: + return # mask prep hasn't finished yet — nothing to draw + n_rows, n_cols = self._preview_shape + rgba = np.zeros((n_rows, n_cols, 4), dtype=np.float32) + order = sorted(range(self._sras.n_angles), key=lambda a: a == self._active_angle) + for a in order: + layer = self._preview_layers.get(a) + if layer is None: + continue + alpha = self._ACTIVE_ALPHA if a == self._active_angle else self._BASE_ALPHA + color = self._angle_colors[a] + fg_a = layer * alpha + for c in range(3): + rgba[..., c] = color[c] * fg_a + rgba[..., c] * rgba[..., 3] * (1 - fg_a) + rgba[..., 3] = fg_a + rgba[..., 3] * (1 - fg_a) + + x0, y0 = self._preview_origin_mm + dx, dy = self._preview_dx_mm, self._preview_dy_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] + title = (f"Angle {self._active_angle} active " + f"({self._sras.angles_deg[self._active_angle]:.1f}°)") + self.canvas.show_overlay(rgba, extent, title) + + # ------------------------------------------------------------------ + # Angle selection / nudge / edit handlers + # ------------------------------------------------------------------ + + def _on_active_angle_changed(self, angle_idx: int): + self._active_angle = angle_idx + is_ref = angle_idx == self._ref_angle_idx + self.grp_manual_adjust.setEnabled(self._masks_ready and not is_ref) + self.lbl_active_note.setText( + "Reference angle — defines the shared origin, not adjustable." if is_ref else "") + self._sync_active_spinboxes() + self._redraw_overlay() + + def _sync_active_spinboxes(self): + p = self._angle_params[self._active_angle] + 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) + + 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: + return + step = self.spin_step_translate_mm.value() + if coarse: + step *= self.spin_step_multiplier.value() + p = self._angle_params[self._active_angle] + p.shift_mm = (p.shift_mm[0] + dir_x * step, p.shift_mm[1] + dir_y * step) + self._sync_active_spinboxes() + self._refresh_active_preview_layer() + + def _on_nudge_rotate(self, direction: int, coarse: bool): + if not self._masks_ready or self._active_angle == self._ref_angle_idx: + return + step = self.spin_step_rotate_deg.value() + if coarse: + step *= self.spin_step_multiplier.value() + self._angle_params[self._active_angle].rotation_deg += direction * step + self._sync_active_spinboxes() + self._rebuild_preview_canvas() + + def _on_rotation_spin_edited(self): + if self._active_angle == self._ref_angle_idx: + return + self._angle_params[self._active_angle].rotation_deg = self.spin_active_rotation_deg.value() + self._rebuild_preview_canvas() + + def _on_shift_spin_edited(self): + if self._active_angle == self._ref_angle_idx: + return + p = self._angle_params[self._active_angle] + p.shift_mm = (self.spin_active_shift_x_mm.value(), self.spin_active_shift_y_mm.value()) + self._refresh_active_preview_layer() + + def _on_mask_threshold_edited(self): + if not self._masks_ready: + return + self._recompute_masks_small() + self._rebuild_preview_canvas() + + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ + + def _on_auto_derotate(self): + n_changed = 0 + for a in range(self._sras.n_angles): + if a == self._ref_angle_idx: + continue + self._angle_params[a].rotation_deg = compute._theta_deg( + self._sras, a, self._ref_angle_idx) + n_changed += 1 + self._sync_active_spinboxes() + self._rebuild_preview_canvas() + self.lbl_status.setText( + f"Rotation set to the known scan angle for {n_changed} angle(s) " + "(translation left untouched).") + + def _on_save(self): + threshold = self.spin_mask_threshold_mv.value() + resolved = dict(self._angle_params) # already concrete floats + try: + path = save_manual_alignment(self._sras, self._ref_angle_idx, threshold, resolved) + result = build_manual_alignment(self._sras, self._ref_angle_idx, threshold, resolved) + except OSError as exc: + QMessageBox.warning(self, "Save Alignment Failed", str(exc)) + return + self.lbl_status.setText(f"Saved to {path.name}.") + self.alignment_saved.emit(result, str(path)) + + def _on_clear(self): + reply = QMessageBox.question( + self, "Clear Alignment", + "This resets every angle back to raw/unaligned (0° rotation, no " + "shift) and deletes the saved alignment file for this scan, if " + "any. This cannot be undone. Continue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No) + if reply != QMessageBox.StandardButton.Yes: + return + try: + existed = delete_manual_alignment(self._sras) + except OSError as exc: + QMessageBox.warning(self, "Clear Alignment Failed", + f"Could not delete the saved alignment file: {exc}") + return + self._angle_params = {a: ManualAngleParams() for a in range(self._sras.n_angles)} + self._sync_active_spinboxes() + self._rebuild_preview_canvas() + self.lbl_status.setText( + "Alignment cleared; saved file removed." if existed + else "Alignment cleared (there was no saved file).") + self.alignment_cleared.emit() + + def _set_controls_enabled(self, enabled: bool): + self._masks_ready = enabled + self.combo_active_angle.setEnabled(enabled) + self.grp_manual_adjust.setEnabled(enabled and self._active_angle != self._ref_angle_idx) + self.grp_step_sizes.setEnabled(enabled) + self.grp_mask_threshold.setEnabled(enabled) + self.btn_auto_derotate.setEnabled(enabled) + self.btn_save.setEnabled(enabled) + self.btn_clear.setEnabled(enabled) + + # --------------------------------------------------------------------------- # Main window # --------------------------------------------------------------------------- @@ -788,6 +1329,7 @@ class SrasViewerWindow(QMainWindow): self._alignment_result = None self._alignment_generation: int = 0 self._aligned_cache: dict[tuple, np.ndarray] = {} + self._manual_align_dialog: ManualAlignmentDialog | None = None self._build_ui() @@ -1137,6 +1679,16 @@ class SrasViewerWindow(QMainWindow): self._alignment_act.triggered.connect(self._on_angle_alignment) fusion_menu.addAction(self._alignment_act) + self._manual_align_act = QAction("&Manual Alignment…", self) + self._manual_align_act.setStatusTip( + "Open an interactive dialog to align angles by eye: overlaid CH4 " + "threshold masks, keyboard nudge (translate + rotate), auto " + "de-rotate to the known scan angles, and save/clear a persistent " + "alignment.") + self._manual_align_act.setEnabled(False) + self._manual_align_act.triggered.connect(self._on_manual_alignment) + fusion_menu.addAction(self._manual_align_act) + convert_menu = menubar.addMenu("&Convert") self._batch_dc_act = QAction("Batch Compute DC and &Store…", self) self._batch_dc_act.setStatusTip( @@ -1196,6 +1748,13 @@ class SrasViewerWindow(QMainWindow): self._sras = sras self._current_image = None + # A manual-alignment dialog bound to the previous file must not + # survive a reload — its per-angle state (and the sras it was + # constructed against) no longer matches the new file's geometry. + if self._manual_align_dialog is not None: + self._manual_align_dialog.close() + self._manual_align_dialog = None + # Caches (and any in-flight DC precompute) belong to the previous # file's geometry — discard and start fresh. Bumping the generation # counters makes any still-running worker's result get dropped when @@ -1213,6 +1772,26 @@ class SrasViewerWindow(QMainWindow): self.chk_aligned_view.setEnabled(False) self.chk_aligned_view.blockSignals(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.statusBar().showMessage( + f"Restored saved manual alignment from " + f"{sidecar_path(sras.path).name}") + except Exception as exc: + # A corrupt/foreign sidecar or a rescan that shrank n_angles + # below ref_angle_idx must not block opening the .sras file. + self.statusBar().showMessage( + f"Could not restore saved alignment: {exc}") + # A ROI from the previous file no longer matches the new scan's # geometry, so discard it on every load. self.image_canvas.clear_roi() @@ -1318,6 +1897,8 @@ class SrasViewerWindow(QMainWindow): self._alignment_act.setEnabled( has_file and s.n_angles > 1 and not self._job_running("align")) + self._manual_align_act.setEnabled( + has_file and s.n_angles > 1 and not self._job_running("align")) self.chk_aligned_view.setEnabled(enabled and self._alignment_result is not None) self._update_roi_ui() @@ -1934,6 +2515,73 @@ class SrasViewerWindow(QMainWindow): f"canvas {nc}×{nr} px).") self._refresh_display() + # ------------------------------------------------------------------ + # Fusion: manual alignment + # ------------------------------------------------------------------ + + def _on_manual_alignment(self): + if self._sras is None or self._sras.n_angles <= 1: + return + if self._manual_align_dialog is not None: + self._manual_align_dialog.raise_() + self._manual_align_dialog.activateWindow() + return + + ref_idx = 0 + threshold_mv = self.spin_threshold_mv.value() + seed: dict[int, ManualAngleParams] = {} + if (self._alignment_result is not None + and self._alignment_result.ref_angle_idx == ref_idx): + seed = {a: ManualAngleParams(t.rotation_deg, t.shift_mm) + for a, t in self._alignment_result.per_angle.items()} + threshold_mv = self._alignment_result.dc_threshold_mv + else: + sidecar = load_manual_alignment(self._sras) + if sidecar is not None and sidecar.ref_angle_idx == ref_idx: + seed = dict(sidecar.per_angle) + threshold_mv = sidecar.dc_threshold_mv + + cached_dc4 = {a: img for (a, ch), img in self._dc_cache.items() if ch == CH4_IDX} + dlg = ManualAlignmentDialog( + self, self._sras, ref_angle_idx=ref_idx, dc_threshold_mv=threshold_mv, + seed_per_angle=seed, cached_dc4_mv=cached_dc4) + dlg.alignment_saved.connect(self._on_manual_alignment_saved) + dlg.alignment_cleared.connect(self._on_manual_alignment_cleared) + dlg.finished.connect(self._on_manual_align_dialog_closed) + dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + self._manual_align_dialog = dlg + dlg.show() + + 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): + 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) + self._update_controls_enabled(self._sras is not None) + self.statusBar().showMessage( + f"Manual alignment saved to {Path(sidecar_path_str).name}") + if self._current_image is not None: + self._refresh_display() + + 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() + # ------------------------------------------------------------------ # FFT Options # ------------------------------------------------------------------ @@ -1960,6 +2608,9 @@ class SrasViewerWindow(QMainWindow): # ------------------------------------------------------------------ def closeEvent(self, event): + if self._manual_align_dialog is not None: + self._manual_align_dialog.close() + # Signal every cancellable worker first, then wait. Waiting without # signalling means sitting out whatever is in flight — on a large # scan a single angle is ~40 s. diff --git a/sras_workers.py b/sras_workers.py index 1a21e50..0068136 100644 --- a/sras_workers.py +++ b/sras_workers.py @@ -304,3 +304,45 @@ class AngleAlignmentWorker(QObject): self.finished.emit(result, "") except Exception as exc: self.finished.emit(None, str(exc)) + + +class Ch4MaskWorker(QObject): + """Fetches each requested angle's CH4 (Bias B) DC image in mV, for + ManualAlignmentDialog's initial threshold-mask overlay. + + Reuses dc_image_mv, which prefers a stored v5/v7 cache over recomputing + from raw waveforms, so this only does real work for a file that hasn't + gone through the v7 "Convert" batch step and for angles the main + window's own DcPrecomputeWorker (which runs automatically right after + every file load) hasn't reached yet. In the common case — the user opens + Fusion -> Manual Alignment after DC precompute has already finished — + *angle_indices* is empty and this worker is never even constructed (see + ManualAlignmentDialog._start_mask_prep). + """ + angle_done = pyqtSignal(int, np.ndarray) # angle_idx, dc4_mv + finished = pyqtSignal() + error = pyqtSignal(str) + + def __init__(self, sras: SrasFile, angle_indices: list[int]): + super().__init__() + self._sras = sras + self._angles = angle_indices + + def run(self): + try: + n_workers, budget = compute.plan_angle_level(self._sras) + pool = ThreadPoolExecutor(max_workers=n_workers) + try: + futures = { + pool.submit(dc_image_mv, self._sras, a, CH4_IDX, + max_workers=1, budget=budget): a + for a in self._angles + } + for fut in as_completed(futures): + a = futures[fut] + self.angle_done.emit(a, fut.result()) + finally: + pool.shutdown(wait=True) + self.finished.emit() + except Exception as exc: + self.error.emit(str(exc)) diff --git a/tools/test_gui.py b/tools/test_gui.py index c44e6da..915dbd9 100644 --- a/tools/test_gui.py +++ b/tools/test_gui.py @@ -4,24 +4,29 @@ signals and worker threads under the offscreen platform plugin. Covers the interactions a manual smoke test would: load, switch angles and channels, background DC precompute, lazy FFT compute, threshold and bg-sub -changes, angle alignment, aligned view, ROI draw/move, and CSV export. +changes, angle alignment, manual angle alignment, aligned view, ROI +draw/move, and CSV export. Usage: QT_QPA_PLATFORM=offscreen python tools/test_gui.py [file.sras] """ +import json import os import sys import tempfile from pathlib import Path +from unittest.mock import patch os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import numpy as np # noqa: E402 -from PyQt6.QtCore import QEventLoop, QTimer # noqa: E402 -from PyQt6.QtWidgets import QApplication # noqa: E402 +from PyQt6.QtCore import QEventLoop, Qt, QTimer # noqa: E402 +from PyQt6.QtTest import QTest # noqa: E402 +from PyQt6.QtWidgets import QApplication, QMessageBox # noqa: E402 sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +import sras_compute as compute # noqa: E402 from sras_format import CH1_IDX, CH3_IDX, CH4_IDX # noqa: E402 from sras_viewer import RoiQuad, SrasViewerWindow, VELOCITY_MODE_IDX # noqa: E402 import tools.make_test_sras as gen # noqa: E402 @@ -170,7 +175,6 @@ def main(): check("Export ROI enabled", win.btn_export_roi.isEnabled()) csv_path = tmpdir / "roi.csv" - from unittest.mock import patch with patch("sras_viewer.QFileDialog.getSaveFileName", return_value=(str(csv_path), "")): win._on_export_roi_csv() @@ -233,6 +237,136 @@ def main(): win.image_canvas._img_shape == s.image_shape(0), str(win.image_canvas._img_shape)) + print("\nmanual alignment (Fusion)") + check("manual alignment action enabled", win._manual_align_act.isEnabled()) + + # --- Open, seeding from the still-live automatic AlignmentResult -------- + win._on_manual_alignment() + check("dialog opened", win._manual_align_dialog is not None) + dlg = win._manual_align_dialog + check("mask prep needed no background worker (already DC-cached)", + not win._job_running("manual_align_masks")) + r = win._alignment_result # still the automatic result from the block above + if r is not None: + check("seeded rotation matches the automatic result", + all(dlg._angle_params[a].rotation_deg == r.per_angle[a].rotation_deg + for a in range(s.n_angles))) + check("seeded shift matches the automatic result", + all(dlg._angle_params[a].shift_mm == r.per_angle[a].shift_mm + for a in range(s.n_angles))) + + # --- Reference angle is locked ------------------------------------------- + dlg.combo_active_angle.setCurrentIndex(dlg._ref_angle_idx) + pump(30) + before_ref = dlg._angle_params[dlg._ref_angle_idx] + dlg._on_nudge_translate(1, 0, False) + dlg._on_nudge_rotate(1, False) + check("reference angle group disabled", not dlg.grp_manual_adjust.isEnabled()) + check("reference angle untouched by nudge attempts", + dlg._angle_params[dlg._ref_angle_idx] == before_ref) + + # --- Nudging a real angle (fine + coarse, translate + rotate) ----------- + active = 1 if s.n_angles > 1 else 0 + dlg.combo_active_angle.setCurrentIndex(active) + pump(30) + before = dlg._angle_params[active].shift_mm + dlg._on_nudge_translate(1, 0, False) # fine +X + fine_step = dlg.spin_step_translate_mm.value() + check("fine translate nudge moved shift_x by exactly one fine step", + abs(dlg._angle_params[active].shift_mm[0] - (before[0] + fine_step)) < 1e-9) + + before = dlg._angle_params[active].shift_mm + dlg._on_nudge_translate(0, -1, True) # coarse -Y + coarse_step = fine_step * dlg.spin_step_multiplier.value() + check("coarse translate nudge uses the multiplier", + abs(dlg._angle_params[active].shift_mm[1] - (before[1] - coarse_step)) < 1e-9) + + before_rot = dlg._angle_params[active].rotation_deg + dlg._on_nudge_rotate(1, False) + check("rotate nudge changed rotation_deg", + dlg._angle_params[active].rotation_deg != before_rot) + check("preview canvas rebuilt for every angle after a rotation nudge", + len(dlg._preview_layers) == s.n_angles) + + # --- Real key-event wiring (proves keyPressEvent -> signal -> slot) ----- + before = dlg._angle_params[active].shift_mm + QTest.keyClick(dlg.canvas, Qt.Key.Key_Right) + check("a real Right-arrow key event nudged shift_x", + dlg._angle_params[active].shift_mm[0] > before[0]) + + # --- Auto De-rotate: rotation only, translation untouched --------------- + shift_before_derotate = dlg._angle_params[active].shift_mm + dlg._on_auto_derotate() + expected_theta = compute._theta_deg(s, active, dlg._ref_angle_idx) + check("auto de-rotate set the known analytic angle", + abs(dlg._angle_params[active].rotation_deg - expected_theta) < 1e-6) + check("auto de-rotate left translation untouched", + dlg._angle_params[active].shift_mm == shift_before_derotate) + check("reference angle stays identity after auto de-rotate", + dlg._angle_params[dlg._ref_angle_idx].rotation_deg == 0.0) + + # --- Save ----------------------------------------------------------------- + dlg._on_save() + sidecar = compute.sidecar_path(s.path) + check("sidecar file written", sidecar.exists()) + raw = json.loads(sidecar.read_text()) if sidecar.exists() else {} + check("sidecar schema_version is 1", raw.get("schema_version") == 1) + check("sidecar per_angle round-trips the dialog's resolved params", + all(raw.get("per_angle", {}).get(str(a), {}).get("rotation_deg") + == dlg._angle_params[a].rotation_deg for a in range(s.n_angles))) + check("main window's alignment_result replaced by the manual build", + win._alignment_result is not None + and win._alignment_result.per_angle[active].rotation_deg + == dlg._angle_params[active].rotation_deg) + check("Aligned View auto-enabled after Save", + win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked()) + + # --- Clear (with confirmation) -------------------------------------------- + with patch("sras_viewer.QMessageBox.question", + return_value=QMessageBox.StandardButton.Yes): + dlg._on_clear() + check("sidecar file deleted", not sidecar.exists()) + check("dialog params reset to identity", + all(dlg._angle_params[a] == compute.ManualAngleParams() + for a in range(s.n_angles))) + check("main window alignment_result cleared", win._alignment_result is None) + check("Aligned View disabled after Clear", + not win.chk_aligned_view.isEnabled() and not win.chk_aligned_view.isChecked()) + + dlg.close() + pump(150) + check("dialog reference released on close", win._manual_align_dialog is None) + + # --- Sidecar auto-restore on next load ------------------------------------ + win._on_manual_alignment() + dlg = win._manual_align_dialog + dlg.combo_active_angle.setCurrentIndex(active) + pump(30) + dlg._on_auto_derotate() + dlg._on_nudge_translate(1, 1, True) + saved_rotation = dlg._angle_params[active].rotation_deg + saved_shift = dlg._angle_params[active].shift_mm + dlg._on_save() + dlg.close() + pump(150) + + old_sras_id = id(win._sras) + win._load_file(str(path)) # reload the same file fresh + check("file reloaded", wait_until( + lambda: win._sras is not None and id(win._sras) != old_sras_id)) + s = win._sras + check("manual dialog force-closed by a reload", win._manual_align_dialog is None) + check("reload restores the saved manual alignment automatically", + win._alignment_result is not None) + if win._alignment_result is not None: + check("restored rotation matches what was saved", + abs(win._alignment_result.per_angle[active].rotation_deg + - saved_rotation) < 1e-9) + check("restored shift matches what was saved", + win._alignment_result.per_angle[active].shift_mm == saved_shift) + check("Aligned View auto-checked after restoring a saved alignment", + win.chk_aligned_view.isChecked()) + print("\npixel inspector") win.chk_aligned_view.setChecked(False) pump(100)