Add FFT cross-correlation to Manual Alignment, with tunable options
Manual mode previously offered only Auto De-rotate (rotation from known scan angles) plus keyboard nudging, so every angle's translation had to be found entirely by eye — fine for a small correction, unworkable for the tens-of-mm scatter a real many-angle scan can have between angles before any translation search runs at all. Add an "Auto Cross-Correlate (vs Reference)" action that, for every non-reference angle, sets rotation to the known analytic angle (same as Auto De-rotate) and translation to the FFT-phase-correlation best fit against angle #0 (always the reference/ground truth). This is meant to land every angle's outline roughly stacked on the others so keyboard nudging only has to make small corrections afterward, per the intended workflow: cross-correlate first, then nudge. Exposes two tunable options, since real data may correlate better one way than the other: "Correlate on" (raw CH4 signal, minus its own minimum -- the default, robust to a shared threshold not suiting every angle's real signal level -- or the thresholded binary mask), and "Search margin" (how far a shift the phase correlation searches before wraparound bias becomes a risk). Runs on a background thread (new CrossCorrelateWorker in sras_workers.py) since correlating many high-resolution angles can take long enough to visibly freeze the dialog otherwise. The underlying per-angle correlation math is factored out of compute_angle_alignment's Step 2 into a new, reusable correlate_translation_mm, so the existing automatic Fusion -> Angle Alignment action and the new manual button now share one implementation instead of two copies of the same phase-correlation logic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+97
-19
@@ -46,7 +46,7 @@ from sras_format import (
|
||||
)
|
||||
from sras_workers import (
|
||||
AngleAlignmentWorker, BatchCacheWorker, Ch4MaskWorker, ComputeWorker,
|
||||
DcPrecomputeWorker, LoadWorker,
|
||||
CrossCorrelateWorker, DcPrecomputeWorker, LoadWorker,
|
||||
)
|
||||
|
||||
faulthandler.enable() # print a native stack trace on SIGSEGV/SIGABRT/etc.
|
||||
@@ -818,27 +818,31 @@ class ManualAlignmentDialog(QDialog):
|
||||
|
||||
Shows every angle's binarized CH4 (Bias B) mask overlaid in a distinct
|
||||
color at partial opacity on one shared canvas, so translation/rotation
|
||||
misalignment is visible by eye — the thing the automatic phase-
|
||||
correlation step (compute_angle_alignment) sometimes gets wrong. The user
|
||||
picks an "active" angle and nudges its rotation+translation with the
|
||||
keyboard; Auto De-rotate sets every non-reference angle's rotation to the
|
||||
known, analytic scan-angle delta without touching any translation the
|
||||
user has already dialed in. Save writes a JSON sidecar next to the .sras
|
||||
file and hands a freshly-built, full-resolution AlignmentResult back to
|
||||
the main window — the exact same object shape compute_angle_alignment
|
||||
produces, so every existing Aligned-View code path (apply_alignment,
|
||||
_aligned_canvas_axes, the pixel-inspector inverse-transform) works
|
||||
completely unmodified.
|
||||
misalignment is visible by eye. Reference angle (always index 0) is
|
||||
ground truth and never moves; every other angle is aligned to it. The
|
||||
user picks an "active" angle and nudges its rotation+translation with
|
||||
the keyboard; Auto De-rotate sets every non-reference angle's rotation to
|
||||
the known, analytic scan-angle delta without touching any translation;
|
||||
Auto Cross-Correlate does the same rotation and additionally sets
|
||||
translation to the FFT-phase-correlation best fit against the reference
|
||||
(see compute.correlate_translation_mm) — meant to get every angle roughly
|
||||
stacked on top of each other so keyboard nudging only has to make small
|
||||
corrections, not find a coarse alignment from scratch. Save writes a
|
||||
JSON sidecar next to the .sras file and hands a freshly-built, full-
|
||||
resolution AlignmentResult back to the main window — the exact same
|
||||
object shape compute_angle_alignment produces, so every existing
|
||||
Aligned-View code path (apply_alignment, _aligned_canvas_axes, the
|
||||
pixel-inspector inverse-transform) works completely unmodified.
|
||||
|
||||
Non-modal by design (shown via .show(), never .exec() or setModal(True))
|
||||
so the user can still interact with the main window. Talks back to
|
||||
SrasViewerWindow two ways: it reuses parent._run_worker/_jobs directly
|
||||
for its own (rare) background mask-fetch step, so the main window's
|
||||
existing shutdown/lifecycle plumbing covers it for free, and it emits
|
||||
alignment_saved / alignment_cleared signals for the two moments that
|
||||
should actually mutate the main window's persistent state — everything
|
||||
else (nudging, Auto De-rotate, threshold edits) stays purely local to
|
||||
this dialog until Save.
|
||||
for its background mask-fetch and cross-correlate steps, so the main
|
||||
window's existing shutdown/lifecycle plumbing covers both for free, and
|
||||
it emits alignment_saved / alignment_cleared signals for the two moments
|
||||
that should actually mutate the main window's persistent state —
|
||||
everything else (nudging, Auto De-rotate, Auto Cross-Correlate, threshold
|
||||
edits) stays purely local to this dialog until Save.
|
||||
"""
|
||||
|
||||
alignment_saved = pyqtSignal(object, str) # AlignmentResult, sidecar path (str)
|
||||
@@ -998,6 +1002,31 @@ class ManualAlignmentDialog(QDialog):
|
||||
tl.addLayout(tform)
|
||||
panel_l.addWidget(self.grp_mask_threshold)
|
||||
|
||||
# ---- Cross-Correlate (FFT) -----------------------------------------
|
||||
self.grp_correlate, cl = _group("Cross-Correlate (FFT)")
|
||||
cform = _form()
|
||||
self.combo_correlate_source = QComboBox()
|
||||
self.combo_correlate_source.addItems(
|
||||
["Raw signal (recommended)", "Thresholded mask"])
|
||||
cform.addRow("Correlate on:", self.combo_correlate_source)
|
||||
|
||||
self.spin_correlate_margin = QDoubleSpinBox()
|
||||
self.spin_correlate_margin.setRange(0.05, 2.0)
|
||||
self.spin_correlate_margin.setSingleStep(0.05)
|
||||
self.spin_correlate_margin.setDecimals(2)
|
||||
self.spin_correlate_margin.setValue(0.30)
|
||||
self.spin_correlate_margin.setMinimumWidth(_SPIN_MIN_W)
|
||||
cform.addRow("Search margin (× extent):", self.spin_correlate_margin)
|
||||
cl.addLayout(cform)
|
||||
self.btn_auto_correlate = QPushButton("Auto Cross-Correlate (vs Reference)")
|
||||
cl.addWidget(self.btn_auto_correlate)
|
||||
cl.addWidget(_wrap_label(
|
||||
"Sets rotation to the known scan angle and translation to the "
|
||||
"FFT-correlated best fit for every non-reference angle. Run this "
|
||||
"first, then use manual nudging only for small corrections.",
|
||||
_CSS_HINT))
|
||||
panel_l.addWidget(self.grp_correlate)
|
||||
|
||||
# ---- Actions ------------------------------------------------------
|
||||
grp_actions, acl = _group("Actions")
|
||||
self.btn_auto_derotate = QPushButton("Auto De-rotate (use known angles)")
|
||||
@@ -1012,7 +1041,7 @@ class ManualAlignmentDialog(QDialog):
|
||||
panel_l.addWidget(self.lbl_status)
|
||||
panel_l.addStretch()
|
||||
|
||||
root.addWidget(_scroll_panel(panel, 300))
|
||||
root.addWidget(_scroll_panel(panel, 320))
|
||||
|
||||
self.combo_active_angle.currentIndexChanged.connect(self._on_active_angle_changed)
|
||||
self.spin_active_rotation_deg.editingFinished.connect(self._on_rotation_spin_edited)
|
||||
@@ -1020,6 +1049,7 @@ class ManualAlignmentDialog(QDialog):
|
||||
self.spin_active_shift_y_mm.editingFinished.connect(self._on_shift_spin_edited)
|
||||
self.spin_mask_threshold_mv.editingFinished.connect(self._on_mask_threshold_edited)
|
||||
self.btn_auto_derotate.clicked.connect(self._on_auto_derotate)
|
||||
self.btn_auto_correlate.clicked.connect(self._on_auto_correlate)
|
||||
self.btn_save.clicked.connect(self._on_save)
|
||||
self.btn_clear.clicked.connect(self._on_clear)
|
||||
self.btn_close.clicked.connect(self.close)
|
||||
@@ -1241,6 +1271,53 @@ class ManualAlignmentDialog(QDialog):
|
||||
f"Rotation set to the known scan angle for {n_changed} angle(s) "
|
||||
"(translation left untouched).")
|
||||
|
||||
def _on_auto_correlate(self):
|
||||
if not self._masks_ready:
|
||||
return
|
||||
angles = [a for a in range(self._sras.n_angles) if a != self._ref_angle_idx]
|
||||
if not angles:
|
||||
return
|
||||
use_mask = self.combo_correlate_source.currentIndex() == 1
|
||||
worker = CrossCorrelateWorker(
|
||||
self._sras, self._ref_angle_idx, angles, self._dc4_mv, self._pivot_mm,
|
||||
use_mask=use_mask, dc_threshold_mv=self.spin_mask_threshold_mv.value(),
|
||||
margin_frac=self.spin_correlate_margin.value())
|
||||
self._correlate_done_count = 0
|
||||
self._correlate_total = len(angles)
|
||||
self._set_controls_enabled(False)
|
||||
self.lbl_status.setText(f"Cross-correlating: 0/{self._correlate_total} angle(s)…")
|
||||
started = self._parent._run_worker(
|
||||
"manual_align_correlate", worker,
|
||||
connect=(
|
||||
("angle_done", self._on_correlate_angle_done),
|
||||
("error", self._on_correlate_error),
|
||||
),
|
||||
on_done=self._finish_auto_correlate)
|
||||
if not started:
|
||||
self._set_controls_enabled(True)
|
||||
self.lbl_status.setText("Could not start cross-correlation (busy) — try again.")
|
||||
|
||||
def _on_correlate_angle_done(self, angle_idx: int, rotation_deg: float,
|
||||
shift_x_mm: float, shift_y_mm: float):
|
||||
self._angle_params[angle_idx] = ManualAngleParams(rotation_deg, (shift_x_mm, shift_y_mm))
|
||||
self._correlate_done_count += 1
|
||||
self.lbl_status.setText(
|
||||
f"Cross-correlating: {self._correlate_done_count}/{self._correlate_total} angle(s)…")
|
||||
|
||||
def _on_correlate_error(self, msg: str):
|
||||
self.lbl_status.setText(f"Cross-correlation error: {msg}")
|
||||
|
||||
def _finish_auto_correlate(self):
|
||||
self._sync_active_spinboxes()
|
||||
self._rebuild_preview_canvas()
|
||||
self._set_controls_enabled(True)
|
||||
source = "thresholded mask" if self.combo_correlate_source.currentIndex() == 1 \
|
||||
else "raw signal"
|
||||
self.lbl_status.setText(
|
||||
f"Cross-correlated {self._correlate_done_count} angle(s) against "
|
||||
f"Angle {self._ref_angle_idx} using the {source}. Nudge from here "
|
||||
"for any remaining fine correction.")
|
||||
|
||||
def _on_save(self):
|
||||
threshold = self.spin_mask_threshold_mv.value()
|
||||
resolved = dict(self._angle_params) # already concrete floats
|
||||
@@ -1284,6 +1361,7 @@ class ManualAlignmentDialog(QDialog):
|
||||
self.grp_manual_adjust.setEnabled(enabled and self._active_angle != self._ref_angle_idx)
|
||||
self.grp_step_sizes.setEnabled(enabled)
|
||||
self.grp_mask_threshold.setEnabled(enabled)
|
||||
self.grp_correlate.setEnabled(enabled)
|
||||
self.btn_auto_derotate.setEnabled(enabled)
|
||||
self.btn_save.setEnabled(enabled)
|
||||
self.btn_clear.setEnabled(enabled)
|
||||
|
||||
Reference in New Issue
Block a user