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 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-07-31 09:19:17 -05:00
parent 013c7739a1
commit 3c835f4592
4 changed files with 1092 additions and 20 deletions
+255 -10
View File
@@ -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 (<name>.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:
"""<name>.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": <int>,
"dc_threshold_mv": <float>,
"per_angle": {
"<angle_idx>": {"rotation_deg": <float>, "shift_mm": [<dx_mm>, <dy_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 <sras.path>'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
+657 -6
View File
@@ -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.
+42
View File
@@ -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))
+138 -4
View File
@@ -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)