Refactor/parallel and dedup #1
+255
-10
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user