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 faulthandler
import numpy as np import numpy as np
from pathlib import Path from pathlib import Path
from dataclasses import dataclass
import os import os
faulthandler.enable() # print a native stack trace on SIGSEGV/SIGABRT/etc. faulthandler.enable() # print a native stack trace on SIGSEGV/SIGABRT/etc.
@@ -59,6 +60,7 @@ except ImportError:
pass pass
import scipy.fft as scipy_fft import scipy.fft as scipy_fft
import scipy.ndimage as scipy_ndimage
# Runtime-mutable settings changed via FftOptionsDialog # Runtime-mutable settings changed via FftOptionsDialog
_fft_backend = "numpy" # "numpy" or "pyfftw" _fft_backend = "numpy" # "numpy" or "pyfftw"
@@ -707,6 +709,274 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
return img 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 # Background workers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -880,6 +1150,31 @@ class PreprocessWorker(QObject):
self.finished.emit(str(exc)) 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) # ROI (free quadrilateral in data coordinates)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1516,6 +1811,13 @@ class SrasViewerWindow(QMainWindow):
self._dc_precompute_worker: DcPrecomputeWorker | None = None self._dc_precompute_worker: DcPrecomputeWorker | None = None
self._dc_generation: int = 0 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() self._build_ui()
if initial_path: if initial_path:
@@ -1637,6 +1939,18 @@ class SrasViewerWindow(QMainWindow):
self.chk_bg_sub.toggled.connect(self._on_bg_sub_toggled) self.chk_bg_sub.toggled.connect(self._on_bg_sub_toggled)
vl.addWidget(self.chk_bg_sub) 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) # Velocity settings (visible only in velocity mode)
self.grp_velocity = QGroupBox("Velocity Settings (CH1 only)") self.grp_velocity = QGroupBox("Velocity Settings (CH1 only)")
vel_l = QVBoxLayout(self.grp_velocity) vel_l = QVBoxLayout(self.grp_velocity)
@@ -1805,6 +2119,15 @@ class SrasViewerWindow(QMainWindow):
self._preprocess_act.triggered.connect(self._on_preprocess) self._preprocess_act.triggered.connect(self._on_preprocess)
fft_menu.addAction(self._preprocess_act) 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 # Drag-and-drop
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -1872,6 +2195,18 @@ class SrasViewerWindow(QMainWindow):
self._fft_cache = {} self._fft_cache = {}
self.lbl_dc_precompute.setText("") 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 # 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()
@@ -1968,6 +2303,12 @@ class SrasViewerWindow(QMainWindow):
can_preprocess = (enabled and s is not None and s.version != 6 can_preprocess = (enabled and s is not None and s.version != 6
and self._preprocess_thread is None) and self._preprocess_thread is None)
self._preprocess_act.setEnabled(can_preprocess) 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() self._update_roi_ui()
def _on_channel_changed(self): def _on_channel_changed(self):
@@ -2056,6 +2397,10 @@ class SrasViewerWindow(QMainWindow):
npix = 0 npix = 0
if self._sras is not None: if self._sras is not None:
try: 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), mask = roi.mask_for_grid(self._sras.x_axis_mm(self._current_angle),
self._sras.y_positions_mm(self._current_angle)) self._sras.y_positions_mm(self._current_angle))
npix = int(mask.sum()) npix = int(mask.sum())
@@ -2191,6 +2536,35 @@ class SrasViewerWindow(QMainWindow):
return freq_mhz * self.spin_grating_um.value() return freq_mhz * self.spin_grating_um.value()
return freq_mhz 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): def _refresh_display(self):
"""Show the image for the current angle/channel/threshold, using """Show the image for the current angle/channel/threshold, using
cached data whenever possible and only falling back to a cached data whenever possible and only falling back to a
@@ -2398,8 +2772,19 @@ class SrasViewerWindow(QMainWindow):
def _redraw_image(self, img: np.ndarray): def _redraw_image(self, img: np.ndarray):
s = self._sras s = self._sras
angle_idx = self._current_angle 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) x_axis = s.x_axis_mm(angle_idx)
y_axis = s.y_positions_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 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 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(): 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)): for spin, val in ((self.spin_vmin, vmin), (self.spin_vmax, vmax)):
spin.blockSignals(True) spin.blockSignals(True)
spin.setValue(val) spin.setValue(val)
@@ -2420,7 +2805,6 @@ class SrasViewerWindow(QMainWindow):
vmin = self.spin_vmin.value() vmin = self.spin_vmin.value()
vmax = self.spin_vmax.value() vmax = self.spin_vmax.value()
ch_idx = self._current_ch
angle_deg = s.angles_deg[self._current_angle] angle_deg = s.angles_deg[self._current_angle]
ch_label = CH_LABELS[ch_idx] ch_label = CH_LABELS[ch_idx]
@@ -2440,9 +2824,11 @@ class SrasViewerWindow(QMainWindow):
colorbar_label = "mV" colorbar_label = "mV"
title = f"{CH_NAMES[ch_idx]} | {mode_str} | {angle_deg:.1f}°" title = f"{CH_NAMES[ch_idx]} | {mode_str} | {angle_deg:.1f}°"
if aligned:
title += " [Aligned]"
self.image_canvas.show_image( self.image_canvas.show_image(
img, extent, display_img, extent,
cmap=self.combo_cmap.currentText(), cmap=self.combo_cmap.currentText(),
vmin=vmin, vmax=vmax, vmin=vmin, vmax=vmax,
xlabel="X (mm)", ylabel="Y (mm)", xlabel="X (mm)", ylabel="Y (mm)",
@@ -2451,7 +2837,8 @@ class SrasViewerWindow(QMainWindow):
) )
self.statusBar().showMessage( self.statusBar().showMessage(
f"{s.path.name} | {ch_label} @ {angle_deg:.1f}° " 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): def _on_pixel_clicked(self, row_idx: int, frame_idx: int):
if self._sras is None or self._current_image is None: if self._sras is None or self._current_image is None:
return 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() self.lbl_wave_hint.hide()
ch_idx = self._current_ch ch_idx = self._current_ch
if ch_idx in CH1_DERIVED_MODES: if ch_idx in CH1_DERIVED_MODES:
self.wave_canvas.show_rf_waveform( 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(), apply_bg_sub=self.chk_bg_sub.isChecked(),
) )
else: else:
self.wave_canvas.show_dc_waveform( 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._preprocess_act.setEnabled(
self._sras is not None and self._sras.version != 6) 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 # FFT Options
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -2597,7 +3067,7 @@ class SrasViewerWindow(QMainWindow):
if self._dc_precompute_worker is not None: if self._dc_precompute_worker is not None:
self._dc_precompute_worker.stop() self._dc_precompute_worker.stop()
for attr in ("_load_thread", "_compute_thread", "_preprocess_thread", for attr in ("_load_thread", "_compute_thread", "_preprocess_thread",
"_dc_precompute_thread"): "_dc_precompute_thread", "_alignment_thread"):
t = getattr(self, attr, None) t = getattr(self, attr, None)
if t is not None: if t is not None:
t.quit() t.quit()