Add Fusion menu with Angle Alignment (rotation+translation registration)

Adds a background worker that aligns every scan angle onto one shared,
zero-padded canvas using a rigid transform only (no scaling): rotation
is taken analytically from the known scan angle, and only the residual
translation is found via FFT phase correlation of each angle's
binarized CH4 dc-mask. An "Aligned View" toggle then redisplays the
currently selected angle/channel resampled onto that shared canvas,
with ROI, CSV export, and waveform-click inspection all kept working.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-07-30 21:57:39 -05:00
parent f745efe7e0
commit a5770491d5
+477 -7
View File
@@ -28,6 +28,7 @@ import struct
import faulthandler
import numpy as np
from pathlib import Path
from dataclasses import dataclass
import os
faulthandler.enable() # print a native stack trace on SIGSEGV/SIGABRT/etc.
@@ -59,6 +60,7 @@ except ImportError:
pass
import scipy.fft as scipy_fft
import scipy.ndimage as scipy_ndimage
# Runtime-mutable settings changed via FftOptionsDialog
_fft_backend = "numpy" # "numpy" or "pyfftw"
@@ -707,6 +709,274 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
return img
# ---------------------------------------------------------------------------
# Angle alignment (Fusion menu)
#
# Puts every angle's images onto one shared, zero-padded pixel grid using a
# rigid transform only (rotation + translation, never scale). Rotation for
# angle `a` is the *known* scan-angle delta relative to a reference angle —
# never searched. Only the residual translation is found, via FFT phase
# correlation of each angle's binarized CH4 ("dc-mask") image.
#
# Rotation is done in physical mm space rather than on raw pixel indices:
# the x-pixel pitch (SrasFile.pixel_x_mm) is file-wide constant but the
# y-pixel pitch (row spacing) can differ from it, and for v6 files can even
# vary per angle. Rotating the raw index grid directly would implicitly
# assume square pixels and shear a non-square-pixel image — an unwanted
# effective anisotropic scale. Instead each angle gets one affine that maps
# shared-canvas pixel index -> mm -> undo rotation/shift -> that angle's own
# local mm -> that angle's own raw pixel index, matching the output->input
# convention scipy.ndimage.affine_transform expects.
# ---------------------------------------------------------------------------
@dataclass
class AngleTransform:
rotation_deg: float
shift_mm: tuple[float, float] # (dx_mm, dy_mm) found by phase correlation
matrix: np.ndarray # (2,2): canvas (row,col) -> this angle's raw (row,col)
offset: np.ndarray # (2,)
@dataclass
class AlignmentResult:
ref_angle_idx: int
dc_threshold_mv: float
canvas_shape: tuple[int, int] # (n_rows, n_cols)
canvas_dx_mm: float
canvas_dy_mm: float
canvas_origin_mm: tuple[float, float] # mm at canvas pixel index (0, 0)
per_angle: dict[int, AngleTransform]
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, same
assumption _redraw_image already makes when it builds the display
extent)."""
dx = sras.pixel_x_mm
y = sras.y_positions_mm(angle_idx)
dy = float(y[1] - y[0]) if len(y) > 1 else 1.0
return dx, dy
def _bbox_center_mm(sras: SrasFile, angle_idx: int) -> tuple[float, float]:
x = sras.x_axis_mm(angle_idx)
y = sras.y_positions_mm(angle_idx)
return float((x[0] + x[-1]) / 2.0), float((y[0] + y[-1]) / 2.0)
def _bbox_corners_mm(sras: SrasFile, angle_idx: int) -> np.ndarray:
"""4 corners (x, y) of this angle's raw mm bounding box, shape (4, 2)."""
x = sras.x_axis_mm(angle_idx)
y = sras.y_positions_mm(angle_idx)
return np.array([[xx, yy] for xx in (x[0], x[-1]) for yy in (y[0], y[-1])])
def _rotation_matrix(theta_deg: float) -> np.ndarray:
t = np.radians(theta_deg)
c, s = np.cos(t), np.sin(t)
return np.array([[c, -s], [s, c]]) # CCW rotation acting on (x, y)
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]
) -> 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.
Pipeline (all mm unless noted):
[X;Y] = A_out @ [row_out;col_out] + b_out # canvas idx -> ref-frame 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
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.
"""
theta = float(sras.angles_deg[angle_idx] - sras.angles_deg[ref_idx])
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)
x0_a = float(sras.x_start_mm[angle_idx])
y0_a = float(sras.y_positions_mm(angle_idx)[0])
A_out = np.array([[0.0, canvas_dx], [canvas_dy, 0.0]]) # [row,col] -> [X,Y]
b_out = np.array(canvas_origin_mm, dtype=np.float64)
D = np.array([[0.0, 1.0 / dy_a], [1.0 / dx_a, 0.0]]) # [x,y] -> [row,col]
shift = np.array(shift_mm, dtype=np.float64)
c_ref_v = np.array([cx_ref, cy_ref])
c_a_v = np.array([cx_a, cy_a])
origin_a = np.array([x0_a, y0_a])
matrix = D @ Rinv @ A_out
offset = D @ Rinv @ (b_out - c_ref_v - shift) + D @ (c_a_v - origin_a)
return matrix, offset
def apply_alignment(result: AlignmentResult, angle_idx: int, img: np.ndarray,
order: int = 0) -> np.ndarray:
"""Resample any already-computed 2D image for `angle_idx` (same shape as
that angle's raw (n_rows, n_frames) — e.g. compute_dc_image /
compute_rf_image output) onto the shared alignment canvas. order=0
(nearest) avoids blending real data with zero-padding or with
masked-out (0-valued) CH1/velocity pixels at mask edges. Channel-
agnostic: the same per-angle transform (found from the CH4 mask) works
for any channel's image of that angle."""
t = result.per_angle[angle_idx]
return scipy_ndimage.affine_transform(
img.astype(np.float32, copy=False), t.matrix, offset=t.offset,
output_shape=result.canvas_shape, order=order,
mode="constant", cval=0.0)
def _block_mean_downsample(img: np.ndarray, factor: int) -> np.ndarray:
if factor <= 1:
return img
h, w = img.shape
h2, w2 = (h // factor) * factor, (w // factor) * factor
trimmed = img[:h2, :w2]
return trimmed.reshape(h2 // factor, factor, w2 // factor, factor).mean(axis=(1, 3))
def _phase_correlate_shift(ref_img: np.ndarray, mov_img: np.ndarray) -> tuple[int, int]:
"""FFT normalized cross-power-spectrum phase correlation. Returns the
integer (dr, dc) pixel shift of mov_img relative to ref_img; both must
be the same shape. Risk: if the true shift is near +/- half the array
size, wraparound can bias the peak — mitigated by the generous
margin_frac padding in _working_canvas_for_pair, which keeps the true
residual shift small relative to the correlation canvas."""
F1 = scipy_fft.fft2(ref_img.astype(np.float64))
F2 = scipy_fft.fft2(mov_img.astype(np.float64))
R = F1 * np.conj(F2)
R /= np.maximum(np.abs(R), 1e-12)
corr = scipy_fft.ifft2(R).real
dr, dc = np.unravel_index(np.argmax(corr), corr.shape)
h, w = corr.shape
if dr > h // 2:
dr -= h
if dc > w // 2:
dc -= w
return int(dr), int(dc)
def _working_canvas_for_pair(sras: SrasFile, ref_idx: int, a_idx: int,
dx: float, dy: float, margin_frac: float = 0.3
) -> tuple[tuple[float, float], tuple[int, int]]:
"""Union of the reference's own raw bbox and angle a's raw bbox rotated
(about its own center) into the ref frame with zero shift, padded by
margin_frac on each side — sized generously so the true phase-
correlation shift lands well inside the canvas (see
_phase_correlate_shift's wraparound note)."""
theta = float(sras.angles_deg[a_idx] - sras.angles_deg[ref_idx])
R = _rotation_matrix(theta)
c_a = np.array(_bbox_center_mm(sras, a_idx))
c_ref = np.array(_bbox_center_mm(sras, ref_idx))
pts = list(_bbox_corners_mm(sras, ref_idx))
for corner in _bbox_corners_mm(sras, a_idx):
pts.append(R @ (corner - c_a) + c_ref)
pts = np.array(pts)
x_min, y_min = pts.min(axis=0)
x_max, y_max = pts.max(axis=0)
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 = (x_min, y_min if dy > 0 else y_max)
return origin, (n_rows, n_cols)
def _compute_angle_alignment(sras: SrasFile, ref_angle_idx: int,
dc_threshold_mv: float,
progress_cb=None) -> AlignmentResult:
"""Top-level alignment driver. Runs on a background thread (see
AngleAlignmentWorker) — deliberately recomputes CH4 DC images from
scratch via compute_dc_image rather than reading the GUI-thread
_dc_cache dict, mirroring PreprocessWorker's existing precedent."""
n = sras.n_angles # already the *complete*-angle count for aborted v6 scans
dx_ref, dy_ref = _pixel_pitch_mm(sras, ref_angle_idx)
# ---- Step 1: binarized CH4 mask per angle, native per-angle grid -----
masks: dict[int, np.ndarray] = {}
for a in range(n):
dc4 = adc_to_mv(compute_dc_image(sras, a, CH4_IDX),
sras.ch_ymult_mv[CH4_IDX], sras.ch_yoff_adc[CH4_IDX],
sras.ch_yzero_mv[CH4_IDX])
masks[a] = (dc4 >= dc_threshold_mv).astype(np.float32)
if progress_cb:
progress_cb(int((a + 1) / n * 25))
# ---- Step 2: coarse correlation stage (downsample first, then rotate)
# Downsampling before affine_transform (not after) is what keeps this
# tractable for a v6 scan with thousands of rows/frames per angle.
max_dim = max(max(m.shape) for m in masks.values())
factor = max(1, int(np.ceil(max_dim / 1024)))
dx_c, dy_c = dx_ref * factor, dy_ref * factor
masks_small = {a: _block_mean_downsample(m, factor) for a, m in masks.items()}
shifts_mm: dict[int, tuple[float, float]] = {ref_angle_idx: (0.0, 0.0)}
for a in range(n):
if a == ref_angle_idx:
continue
work_origin, work_shape = _working_canvas_for_pair(
sras, ref_angle_idx, a, dx_c, dy_c, margin_frac=0.3)
# Coarse canvas->raw affine at native pitch, then rescale by /factor
# so it maps coarse-canvas idx -> coarse (downsampled) raw idx —
# exact for block-mean downsampling (up to the trimmed remainder).
m_a, o_a = _build_affine_canvas_to_raw(
sras, a, ref_angle_idx, (0.0, 0.0), dx_c, dy_c, work_origin)
m_ref, o_ref = _build_affine_canvas_to_raw(
sras, ref_angle_idx, ref_angle_idx, (0.0, 0.0), dx_c, dy_c, work_origin)
rotated_a = scipy_ndimage.affine_transform(
masks_small[a], m_a / factor, offset=o_a / factor,
output_shape=work_shape, order=0, mode="constant", cval=0.0)
embedded_ref = scipy_ndimage.affine_transform(
masks_small[ref_angle_idx], m_ref / factor, offset=o_ref / factor,
output_shape=work_shape, order=0, mode="constant", cval=0.0)
dr, dc = _phase_correlate_shift(embedded_ref, rotated_a)
shifts_mm[a] = (dc * dx_c, dr * dy_c)
if progress_cb:
progress_cb(25 + int((a + 1) / n * 50))
# ---- Step 3: union bounding box over all angles (rotation+shift applied)
corners_ref_frame = []
for a in range(n):
theta = float(sras.angles_deg[a] - sras.angles_deg[ref_angle_idx])
R = _rotation_matrix(theta)
c_a = np.array(_bbox_center_mm(sras, a))
c_ref = np.array(_bbox_center_mm(sras, ref_angle_idx))
shift = np.array(shifts_mm[a])
for corner in _bbox_corners_mm(sras, a):
corners_ref_frame.append(R @ (corner - c_a) + c_ref + shift)
corners_ref_frame = np.array(corners_ref_frame)
x_min, y_min = corners_ref_frame.min(axis=0)
x_max, y_max = corners_ref_frame.max(axis=0)
n_cols = int(np.ceil((x_max - x_min) / dx_ref)) + 1
n_rows = int(np.ceil((y_max - y_min) / abs(dy_ref))) + 1
canvas_origin_mm = (float(x_min), float(y_min if dy_ref > 0 else y_max))
canvas_shape = (n_rows, n_cols)
# ---- Step 4: final per-angle full-resolution affine (canvas -> raw idx)
per_angle: dict[int, AngleTransform] = {}
for a in range(n):
matrix, offset = _build_affine_canvas_to_raw(
sras, a, ref_angle_idx, shifts_mm[a], dx_ref, dy_ref, canvas_origin_mm)
theta = float(sras.angles_deg[a] - sras.angles_deg[ref_angle_idx])
per_angle[a] = AngleTransform(theta, shifts_mm[a], matrix, offset)
if progress_cb:
progress_cb(75 + int((a + 1) / n * 25))
return AlignmentResult(ref_angle_idx, dc_threshold_mv, canvas_shape,
dx_ref, dy_ref, canvas_origin_mm, per_angle)
# ---------------------------------------------------------------------------
# Background workers
# ---------------------------------------------------------------------------
@@ -880,6 +1150,31 @@ class PreprocessWorker(QObject):
self.finished.emit(str(exc))
class AngleAlignmentWorker(QObject):
"""Computes rotation+translation alignment for every angle in *sras*,
referenced to *ref_angle_idx*, from each angle's binarized CH4 mask.
Rotation is analytic (from sras.angles_deg); only translation is found
by phase correlation. Mirrors PreprocessWorker's progress/finished shape.
"""
progress = pyqtSignal(int) # 0100
finished = pyqtSignal(object, str) # AlignmentResult|None, error msg ("" = success)
def __init__(self, sras: SrasFile, ref_angle_idx: int, dc_threshold_mv: float):
super().__init__()
self._sras = sras
self._ref = ref_angle_idx
self._threshold = dc_threshold_mv
def run(self):
try:
result = _compute_angle_alignment(
self._sras, self._ref, self._threshold,
progress_cb=lambda pct: self.progress.emit(pct))
self.finished.emit(result, "")
except Exception as exc:
self.finished.emit(None, str(exc))
# ---------------------------------------------------------------------------
# ROI (free quadrilateral in data coordinates)
# ---------------------------------------------------------------------------
@@ -1516,6 +1811,13 @@ class SrasViewerWindow(QMainWindow):
self._dc_precompute_worker: DcPrecomputeWorker | None = None
self._dc_generation: int = 0
# Angle alignment ("Fusion" menu)
self._alignment_result: AlignmentResult | None = None
self._alignment_thread: QThread | None = None
self._alignment_worker: AngleAlignmentWorker | None = None
self._alignment_generation: int = 0
self._aligned_cache: dict[tuple, np.ndarray] = {}
self._build_ui()
if initial_path:
@@ -1637,6 +1939,18 @@ class SrasViewerWindow(QMainWindow):
self.chk_bg_sub.toggled.connect(self._on_bg_sub_toggled)
vl.addWidget(self.chk_bg_sub)
# Aligned View (Fusion → Angle Alignment result)
self.chk_aligned_view = QCheckBox("Aligned View (Fusion)")
self.chk_aligned_view.setChecked(False)
self.chk_aligned_view.setEnabled(False)
self.chk_aligned_view.setToolTip(
"Show the current angle/channel resampled onto the shared,\n"
"rotation+translation-aligned canvas from Fusion → Angle\n"
"Alignment. Uncheck to see the raw per-angle scan grid."
)
self.chk_aligned_view.toggled.connect(self._on_aligned_view_toggled)
vl.addWidget(self.chk_aligned_view)
# Velocity settings (visible only in velocity mode)
self.grp_velocity = QGroupBox("Velocity Settings (CH1 only)")
vel_l = QVBoxLayout(self.grp_velocity)
@@ -1805,6 +2119,15 @@ class SrasViewerWindow(QMainWindow):
self._preprocess_act.triggered.connect(self._on_preprocess)
fft_menu.addAction(self._preprocess_act)
fusion_menu = menubar.addMenu("&Fusion")
self._alignment_act = QAction("Angle &Alignment", self)
self._alignment_act.setStatusTip(
"Compute a rotation+translation alignment across all angles "
"(from CH4 masks) and enable Aligned View. Requires >1 angle.")
self._alignment_act.setEnabled(False)
self._alignment_act.triggered.connect(self._on_angle_alignment)
fusion_menu.addAction(self._alignment_act)
# ------------------------------------------------------------------
# Drag-and-drop
# ------------------------------------------------------------------
@@ -1872,6 +2195,18 @@ class SrasViewerWindow(QMainWindow):
self._fft_cache = {}
self.lbl_dc_precompute.setText("")
# Same for any alignment result — belongs to the previous file's
# geometry. Bump the generation counter so a still-running
# alignment worker's result is discarded when it lands (see
# _on_alignment_done).
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)
# 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()
@@ -1968,6 +2303,12 @@ class SrasViewerWindow(QMainWindow):
can_preprocess = (enabled and s is not None and s.version != 6
and self._preprocess_thread is None)
self._preprocess_act.setEnabled(can_preprocess)
# Angle alignment: needs >1 angle and no alignment already running
can_align = (enabled and s is not None and s.n_angles > 1
and self._alignment_thread is None)
self._alignment_act.setEnabled(can_align)
self.chk_aligned_view.setEnabled(
enabled and self._alignment_result is not None)
self._update_roi_ui()
def _on_channel_changed(self):
@@ -2056,6 +2397,10 @@ class SrasViewerWindow(QMainWindow):
npix = 0
if self._sras is not None:
try:
# Deliberately always the raw per-angle grid, even when
# Aligned View is on: _on_export_roi_csv also exports on the
# raw grid (never synthetically-resampled pixels), so this
# readout must match what Export ROI actually writes.
mask = roi.mask_for_grid(self._sras.x_axis_mm(self._current_angle),
self._sras.y_positions_mm(self._current_angle))
npix = int(mask.sum())
@@ -2191,6 +2536,35 @@ class SrasViewerWindow(QMainWindow):
return freq_mhz * self.spin_grating_um.value()
return freq_mhz
# ------------------------------------------------------------------
# Aligned View (Fusion) display helpers
# ------------------------------------------------------------------
def _aligned_cache_key(self, angle_idx: int, ch_idx: int) -> tuple:
"""Mirrors _fft_cache's key granularity so a stale aligned image is
never shown after bg_sub/threshold/pad/grating changes."""
if ch_idx in CH1_DERIVED_MODES:
return (angle_idx, ch_idx, self.chk_bg_sub.isChecked(),
self._current_n_fft(), self.spin_threshold_mv.value(),
self.spin_grating_um.value() if ch_idx == VELOCITY_MODE_IDX else None)
return (angle_idx, ch_idx)
def _aligned_canvas_axes(self) -> tuple[np.ndarray, np.ndarray]:
r = self._alignment_result
n_rows, n_cols = r.canvas_shape
x_axis = r.canvas_origin_mm[0] + np.arange(n_cols) * r.canvas_dx_mm
y_axis = r.canvas_origin_mm[1] + np.arange(n_rows) * r.canvas_dy_mm
return x_axis, y_axis
def _get_aligned_display_image(self, raw_img: np.ndarray, angle_idx: int,
ch_idx: int) -> np.ndarray:
key = self._aligned_cache_key(angle_idx, ch_idx)
cached = self._aligned_cache.get(key)
if cached is None:
cached = apply_alignment(self._alignment_result, angle_idx, raw_img)
self._aligned_cache[key] = cached
return cached
def _refresh_display(self):
"""Show the image for the current angle/channel/threshold, using
cached data whenever possible and only falling back to a
@@ -2398,8 +2772,19 @@ class SrasViewerWindow(QMainWindow):
def _redraw_image(self, img: np.ndarray):
s = self._sras
angle_idx = self._current_angle
ch_idx = self._current_ch
aligned = (self.chk_aligned_view.isChecked()
and self._alignment_result is not None
and angle_idx in self._alignment_result.per_angle)
if aligned:
display_img = self._get_aligned_display_image(img, angle_idx, ch_idx)
x_axis, y_axis = self._aligned_canvas_axes()
else:
display_img = img
x_axis = s.x_axis_mm(angle_idx)
y_axis = s.y_positions_mm(angle_idx)
dx = x_axis[1] - x_axis[0] if len(x_axis) > 1 else s.pixel_x_mm
dy = float(y_axis[1] - y_axis[0]) if len(y_axis) > 1 else 1.0
@@ -2411,7 +2796,7 @@ class SrasViewerWindow(QMainWindow):
]
if self.chk_auto.isChecked():
vmin, vmax = float(img.min()), float(img.max())
vmin, vmax = float(display_img.min()), float(display_img.max())
for spin, val in ((self.spin_vmin, vmin), (self.spin_vmax, vmax)):
spin.blockSignals(True)
spin.setValue(val)
@@ -2420,7 +2805,6 @@ class SrasViewerWindow(QMainWindow):
vmin = self.spin_vmin.value()
vmax = self.spin_vmax.value()
ch_idx = self._current_ch
angle_deg = s.angles_deg[self._current_angle]
ch_label = CH_LABELS[ch_idx]
@@ -2440,9 +2824,11 @@ class SrasViewerWindow(QMainWindow):
colorbar_label = "mV"
title = f"{CH_NAMES[ch_idx]} | {mode_str} | {angle_deg:.1f}°"
if aligned:
title += " [Aligned]"
self.image_canvas.show_image(
img, extent,
display_img, extent,
cmap=self.combo_cmap.currentText(),
vmin=vmin, vmax=vmax,
xlabel="X (mm)", ylabel="Y (mm)",
@@ -2451,7 +2837,8 @@ class SrasViewerWindow(QMainWindow):
)
self.statusBar().showMessage(
f"{s.path.name} | {ch_label} @ {angle_deg:.1f}° "
f"| {img.shape[1]} × {img.shape[0]} px | {unit}"
f"| {display_img.shape[1]} × {display_img.shape[0]} px | {unit}"
f"{' | Aligned' if aligned else ''}"
)
# ------------------------------------------------------------------
@@ -2461,16 +2848,31 @@ class SrasViewerWindow(QMainWindow):
def _on_pixel_clicked(self, row_idx: int, frame_idx: int):
if self._sras is None or self._current_image is None:
return
angle_idx = self._current_angle
if (self.chk_aligned_view.isChecked() and self._alignment_result is not None
and angle_idx in self._alignment_result.per_angle):
# The click landed on the shared aligned canvas — invert the
# same canvas->raw affine used to display it back to a raw
# (row, frame) index before looking up the waveform.
t = self._alignment_result.per_angle[angle_idx]
raw = t.matrix @ np.array([row_idx, frame_idx], dtype=np.float64) + t.offset
row_idx, frame_idx = int(round(raw[0])), int(round(raw[1]))
n_rows_a = int(self._sras.n_rows[angle_idx])
n_frames_a = int(self._sras.n_frames[angle_idx])
if not (0 <= row_idx < n_rows_a and 0 <= frame_idx < n_frames_a):
self.statusBar().showMessage(
"No source waveform here (padding region of the aligned canvas).")
return
self.lbl_wave_hint.hide()
ch_idx = self._current_ch
if ch_idx in CH1_DERIVED_MODES:
self.wave_canvas.show_rf_waveform(
self._sras, self._current_angle, row_idx, frame_idx,
self._sras, angle_idx, row_idx, frame_idx,
apply_bg_sub=self.chk_bg_sub.isChecked(),
)
else:
self.wave_canvas.show_dc_waveform(
self._sras, self._current_angle, ch_idx, row_idx, frame_idx
self._sras, angle_idx, ch_idx, row_idx, frame_idx
)
# ------------------------------------------------------------------
@@ -2565,6 +2967,74 @@ class SrasViewerWindow(QMainWindow):
self._preprocess_act.setEnabled(
self._sras is not None and self._sras.version != 6)
# ------------------------------------------------------------------
# Fusion: angle alignment
# ------------------------------------------------------------------
def _on_angle_alignment(self):
if self._sras is None or self._sras.n_angles <= 1:
return
if self._alignment_thread is not None:
return
ref_idx = 0
threshold_mv = self.spin_threshold_mv.value()
generation = self._alignment_generation
# Claim self._alignment_thread before anything below that can pump
# the Qt event loop — see the comment in _start_compute for why.
self._alignment_worker = AngleAlignmentWorker(self._sras, ref_idx, threshold_mv)
self._alignment_thread = QThread()
self._alignment_worker.moveToThread(self._alignment_thread)
self._alignment_thread.started.connect(self._alignment_worker.run)
self._alignment_worker.progress.connect(self._on_alignment_progress)
self._alignment_worker.finished.connect(
lambda result, err, g=generation: self._on_alignment_done(g, result, err))
self._alignment_worker.finished.connect(self._alignment_thread.quit)
self._alignment_thread.finished.connect(self._on_alignment_thread_finished)
self._alignment_act.setEnabled(False)
self._show_progress(
f"Computing angle alignment ({self._sras.n_angles} angles, "
f"ref=angle 0, CH4 mask ≥ {threshold_mv:.3f} mV)…"
)
self._alignment_thread.start()
def _on_alignment_progress(self, pct: int):
if self._progress_dlg is not None:
self._progress_dlg.setValue(pct)
def _on_alignment_thread_finished(self):
# See the comment in _on_compute_thread_finished: wait() before
# releasing our reference to avoid destroying a QThread whose OS
# thread hasn't fully joined yet.
if self._alignment_thread is not None:
self._alignment_thread.wait()
self._alignment_thread = None
self._update_controls_enabled(self._sras is not None)
def _on_alignment_done(self, generation: int, result, error_msg: str):
self._close_progress()
if generation != self._alignment_generation:
return # a new file was loaded while this was computing — discard
if error_msg:
self.statusBar().showMessage(f"Angle alignment failed: {error_msg}")
return
self._alignment_result = result
self._aligned_cache = {}
self.chk_aligned_view.setEnabled(True)
self.chk_aligned_view.blockSignals(True)
self.chk_aligned_view.setChecked(True)
self.chk_aligned_view.blockSignals(False)
nr, nc = result.canvas_shape
self.statusBar().showMessage(
f"Angle alignment computed ({self._sras.n_angles} angles, "
f"canvas {nc}×{nr} px).")
self._refresh_display()
def _on_aligned_view_toggled(self, checked: bool):
if self._current_image is not None:
self._redraw_image(self._current_image)
# ------------------------------------------------------------------
# FFT Options
# ------------------------------------------------------------------
@@ -2597,7 +3067,7 @@ class SrasViewerWindow(QMainWindow):
if self._dc_precompute_worker is not None:
self._dc_precompute_worker.stop()
for attr in ("_load_thread", "_compute_thread", "_preprocess_thread",
"_dc_precompute_thread"):
"_dc_precompute_thread", "_alignment_thread"):
t = getattr(self, attr, None)
if t is not None:
t.quit()