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:
+477
-7
@@ -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) # 0–100
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user