From a5770491d5b0fdc39bf2a9931073b0b7d48541ca Mon Sep 17 00:00:00 2001 From: Thomas Ales Date: Thu, 30 Jul 2026 21:57:39 -0500 Subject: [PATCH] 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 --- sras_viewer.py | 488 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 479 insertions(+), 9 deletions(-) diff --git a/sras_viewer.py b/sras_viewer.py index 1001ae1..9b7e3ca 100644 --- a/sras_viewer.py +++ b/sras_viewer.py @@ -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 - x_axis = s.x_axis_mm(angle_idx) - y_axis = s.y_positions_mm(angle_idx) + 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()