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.
|
which matters because Python 3.14 on macOS spawns rather than forks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import scipy.fft as scipy_fft
|
import scipy.fft as scipy_fft
|
||||||
@@ -397,6 +399,21 @@ class AlignmentResult:
|
|||||||
per_angle: dict[int, AngleTransform]
|
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]:
|
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
|
"""(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
|
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,
|
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
|
"""Angle *angle_idx*'s bbox corners, rotated about its own centroid into
|
||||||
the reference frame and translated by *shift_mm*. Shape (4, 2)."""
|
the reference frame and translated by *shift_mm*. Shape (4, 2).
|
||||||
R = _rotation_matrix(_theta_deg(sras, angle_idx, ref_idx))
|
|
||||||
|
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_a = np.array(_bbox_center_mm(sras, angle_idx))
|
||||||
c_ref = np.array(_bbox_center_mm(sras, ref_idx))
|
c_ref = np.array(_bbox_center_mm(sras, ref_idx))
|
||||||
shift = np.asarray(shift_mm, dtype=np.float64)
|
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,
|
def _build_affine_canvas_to_raw(sras: SrasFile, angle_idx: int, ref_idx: int,
|
||||||
shift_mm: tuple[float, float],
|
shift_mm: tuple[float, float],
|
||||||
canvas_dx: float, canvas_dy: 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]:
|
) -> tuple[np.ndarray, np.ndarray]:
|
||||||
"""matrix, offset s.t. raw_index = matrix @ [row_out, col_out] + offset,
|
"""matrix, offset s.t. raw_index = matrix @ [row_out, col_out] + offset,
|
||||||
matching scipy.ndimage.affine_transform's output->input convention.
|
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
|
[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
|
[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
|
where theta = angles_deg[angle_idx] - angles_deg[ref_idx] (unless
|
||||||
each angle's own raw-bbox mm centroid (the rotation pivot — this keeps
|
*theta_deg* overrides it — see _corners_in_ref_frame's note, used by the
|
||||||
rotated content centered, minimizing required canvas padding), and
|
manual-alignment path), c_ref/c_a are each angle's own raw-bbox mm
|
||||||
A_out/D are the index<->mm scaling matrices for the canvas pitch and
|
centroid (the rotation pivot — this keeps rotated content centered,
|
||||||
this angle's own native pitch respectively.
|
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_a, cy_a = _bbox_center_mm(sras, angle_idx)
|
||||||
cx_ref, cy_ref = _bbox_center_mm(sras, ref_idx)
|
cx_ref, cy_ref = _bbox_center_mm(sras, ref_idx)
|
||||||
dx_a, dy_a = _pixel_pitch_mm(sras, angle_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).
|
# Back-compat alias for the pre-split private name (used by tooling).
|
||||||
_compute_angle_alignment = compute_angle_alignment
|
_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
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import matplotlib as mpl
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
|
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
|
||||||
from matplotlib.figure import Figure
|
from matplotlib.figure import Figure
|
||||||
from matplotlib.patches import Polygon
|
from matplotlib.patches import Polygon
|
||||||
from matplotlib.path import Path as MplPath
|
from matplotlib.path import Path as MplPath
|
||||||
from PyQt6.QtCore import QObject, Qt, QThread, pyqtSignal
|
from PyQt6.QtCore import QObject, Qt, QThread, pyqtSignal
|
||||||
from PyQt6.QtGui import QAction
|
from PyQt6.QtGui import QAction, QKeyEvent
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QApplication, QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
QApplication, QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
||||||
QDoubleSpinBox, QFileDialog, QFormLayout, QFrame, QGroupBox, QHBoxLayout,
|
QDoubleSpinBox, QFileDialog, QFormLayout, QFrame, QGroupBox, QHBoxLayout,
|
||||||
QLabel, QMainWindow, QProgressDialog, QPushButton, QRadioButton, QScrollArea,
|
QLabel, QMainWindow, QMessageBox, QProgressDialog, QPushButton, QRadioButton,
|
||||||
QSizePolicy, QSpinBox, QSplitter, QVBoxLayout, QWidget,
|
QScrollArea, QSizePolicy, QSpinBox, QSplitter, QVBoxLayout, QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
import sras_compute as compute
|
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 (
|
from sras_format import (
|
||||||
CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES, SrasFile, adc_to_mv, mv_to_adc,
|
CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES, SrasFile, adc_to_mv, mv_to_adc,
|
||||||
_FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC,
|
_FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC,
|
||||||
)
|
)
|
||||||
from sras_workers import (
|
from sras_workers import (
|
||||||
AngleAlignmentWorker, BatchCacheWorker, ComputeWorker, DcPrecomputeWorker,
|
AngleAlignmentWorker, BatchCacheWorker, Ch4MaskWorker, ComputeWorker,
|
||||||
LoadWorker,
|
DcPrecomputeWorker, LoadWorker,
|
||||||
)
|
)
|
||||||
|
|
||||||
faulthandler.enable() # print a native stack trace on SIGSEGV/SIGABRT/etc.
|
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())
|
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
|
# Main window
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -788,6 +1329,7 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self._alignment_result = None
|
self._alignment_result = None
|
||||||
self._alignment_generation: int = 0
|
self._alignment_generation: int = 0
|
||||||
self._aligned_cache: dict[tuple, np.ndarray] = {}
|
self._aligned_cache: dict[tuple, np.ndarray] = {}
|
||||||
|
self._manual_align_dialog: ManualAlignmentDialog | None = None
|
||||||
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
|
|
||||||
@@ -1137,6 +1679,16 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self._alignment_act.triggered.connect(self._on_angle_alignment)
|
self._alignment_act.triggered.connect(self._on_angle_alignment)
|
||||||
fusion_menu.addAction(self._alignment_act)
|
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")
|
convert_menu = menubar.addMenu("&Convert")
|
||||||
self._batch_dc_act = QAction("Batch Compute DC and &Store…", self)
|
self._batch_dc_act = QAction("Batch Compute DC and &Store…", self)
|
||||||
self._batch_dc_act.setStatusTip(
|
self._batch_dc_act.setStatusTip(
|
||||||
@@ -1196,6 +1748,13 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self._sras = sras
|
self._sras = sras
|
||||||
self._current_image = None
|
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
|
# Caches (and any in-flight DC precompute) belong to the previous
|
||||||
# file's geometry — discard and start fresh. Bumping the generation
|
# file's geometry — discard and start fresh. Bumping the generation
|
||||||
# counters makes any still-running worker's result get dropped when
|
# 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.setEnabled(False)
|
||||||
self.chk_aligned_view.blockSignals(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
|
# A ROI from the previous file no longer matches the new scan's
|
||||||
# geometry, so discard it on every load.
|
# geometry, so discard it on every load.
|
||||||
self.image_canvas.clear_roi()
|
self.image_canvas.clear_roi()
|
||||||
@@ -1318,6 +1897,8 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
|
|
||||||
self._alignment_act.setEnabled(
|
self._alignment_act.setEnabled(
|
||||||
has_file and s.n_angles > 1 and not self._job_running("align"))
|
has_file and s.n_angles > 1 and not self._job_running("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.chk_aligned_view.setEnabled(enabled and self._alignment_result is not None)
|
||||||
self._update_roi_ui()
|
self._update_roi_ui()
|
||||||
|
|
||||||
@@ -1934,6 +2515,73 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
f"canvas {nc}×{nr} px).")
|
f"canvas {nc}×{nr} px).")
|
||||||
self._refresh_display()
|
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
|
# FFT Options
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -1960,6 +2608,9 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def closeEvent(self, event):
|
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
|
# Signal every cancellable worker first, then wait. Waiting without
|
||||||
# signalling means sitting out whatever is in flight — on a large
|
# signalling means sitting out whatever is in flight — on a large
|
||||||
# scan a single angle is ~40 s.
|
# scan a single angle is ~40 s.
|
||||||
|
|||||||
@@ -304,3 +304,45 @@ class AngleAlignmentWorker(QObject):
|
|||||||
self.finished.emit(result, "")
|
self.finished.emit(result, "")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.finished.emit(None, str(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
|
Covers the interactions a manual smoke test would: load, switch angles and
|
||||||
channels, background DC precompute, lazy FFT compute, threshold and bg-sub
|
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]
|
Usage: QT_QPA_PLATFORM=offscreen python tools/test_gui.py [file.sras]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
import numpy as np # noqa: E402
|
import numpy as np # noqa: E402
|
||||||
from PyQt6.QtCore import QEventLoop, QTimer # noqa: E402
|
from PyQt6.QtCore import QEventLoop, Qt, QTimer # noqa: E402
|
||||||
from PyQt6.QtWidgets import QApplication # 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))
|
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_format import CH1_IDX, CH3_IDX, CH4_IDX # noqa: E402
|
||||||
from sras_viewer import RoiQuad, SrasViewerWindow, VELOCITY_MODE_IDX # noqa: E402
|
from sras_viewer import RoiQuad, SrasViewerWindow, VELOCITY_MODE_IDX # noqa: E402
|
||||||
import tools.make_test_sras as gen # 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())
|
check("Export ROI enabled", win.btn_export_roi.isEnabled())
|
||||||
|
|
||||||
csv_path = tmpdir / "roi.csv"
|
csv_path = tmpdir / "roi.csv"
|
||||||
from unittest.mock import patch
|
|
||||||
with patch("sras_viewer.QFileDialog.getSaveFileName",
|
with patch("sras_viewer.QFileDialog.getSaveFileName",
|
||||||
return_value=(str(csv_path), "")):
|
return_value=(str(csv_path), "")):
|
||||||
win._on_export_roi_csv()
|
win._on_export_roi_csv()
|
||||||
@@ -233,6 +237,136 @@ def main():
|
|||||||
win.image_canvas._img_shape == s.image_shape(0),
|
win.image_canvas._img_shape == s.image_shape(0),
|
||||||
str(win.image_canvas._img_shape))
|
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")
|
print("\npixel inspector")
|
||||||
win.chk_aligned_view.setChecked(False)
|
win.chk_aligned_view.setChecked(False)
|
||||||
pump(100)
|
pump(100)
|
||||||
|
|||||||
Reference in New Issue
Block a user