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:
+479
-9
@@ -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) # 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)
|
# 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
|
||||||
x_axis = s.x_axis_mm(angle_idx)
|
ch_idx = self._current_ch
|
||||||
y_axis = s.y_positions_mm(angle_idx)
|
|
||||||
|
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
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user