Replace the two Fusion alignment actions with a three-step wizard

Alignment was two disconnected menu actions. `Angle Alignment` ran the
registration with ref_angle_idx hard-coded to 0, no exposed parameters and
no way to retry; `Manual Alignment...` was a separate dialog that
deliberately refused to inherit the automatic result, so a bad fit meant
starting over by hand. Neither told you whether the alignment was any
good, and neither produced anything durable beyond an in-memory result.

Fusion -> Alignment Wizard... now covers all of it in three steps:

  1. Correlate. Reference angle, DC threshold, source, rotation seed and
     sign, search window, coarse step and fine grid are all on the page,
     and Run/Re-run is repeatable. Angles start pre-rotated from the
     stage angles in the file, so the page is informative before any
     correlation runs. The verdict is a picture: every angle's DC mask
     reprojected onto the shared canvas and summed, coloured by how many
     angles cover each pixel, so a good alignment reads as one saturated
     plateau and a bad one as a fringe of low-count halos. A per-angle
     fit table flags angles that did not register or that disagree with
     their stage angle by more than a degree.
  2. Crop. An axis-aligned rectangle on that canvas, with numeric
     canvas-pixel boxes synced both ways and a live size estimate.
     "Fit to full overlap" uses a largest-rectangle sweep rather than a
     bounding box: the overlap region of several rotated scans is roughly
     a disc, whose bounding box has corners no angle covers.
  3. Save. Writes the aligned, cropped stack to a new .sras.

Because the wizard replaces both actions it absorbs the old dialog's
by-eye nudge editor — without it, a scan the search cannot fit would
have no fallback at all. ManualAlignmentDialog is therefore deleted
rather than left orphaned, and its tests move to the wizard.

Two bugs found while driving it end to end and fixed here: QSpinBox
setRange clamps and emits valueChanged, which committed a 1x1 crop
before the default preset could run; and the mm round trip returns an
exact pixel boundary as 11.000000000000002, so a bare ceil() added a
spurious column on every rectangle edit.

Verified: 103 pass, the 6 test_stored_cache failures are pre-existing on
main, and tools/check_equivalence.py is byte-identical to main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-08-07 23:00:59 -05:00
parent 6d0e30b9ce
commit f40c965b74
15 changed files with 2037 additions and 964 deletions
+68 -127
View File
@@ -16,15 +16,14 @@ from PyQt6.QtWidgets import (
import sras_compute as compute
from sras_compute import (
ManualAngleParams, apply_alignment, build_manual_alignment,
load_manual_alignment, sidecar_path,
load_manual_alignment, save_manual_alignment, sidecar_path,
)
from sras_format import (
CH3_IDX, CH4_IDX, CH_NAMES, SrasFile, mv_to_adc,
_FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC,
)
from sras_workers import (
AngleAlignmentWorker, BatchCacheWorker, ComputeWorker, DcPrecomputeWorker,
LoadWorker,
BatchCacheWorker, ComputeWorker, DcPrecomputeWorker, LoadWorker,
)
from .canvases import ImageCanvas, WaveformCanvas
@@ -33,7 +32,8 @@ from .common import (
_CSS_BUSY, _axes_extent, _CSS_HINT, _CSS_INFO, _CSS_MUTED, _CSS_WARN, _LEFT_PANEL_W,
_RIGHT_PANEL_W, Jobs, _form, _group, _make_dspin, _scroll_panel, _wrap_label,
)
from .dialogs import FftOptionsDialog, ManualAlignmentDialog, RowAverageFftOptionsDialog
from .align_wizard import AlignmentWizard
from .dialogs import FftOptionsDialog, RowAverageFftOptionsDialog
# ---------------------------------------------------------------------------
# Main window
@@ -99,7 +99,7 @@ class SrasViewerWindow(QMainWindow):
self._alignment_result = None
self._alignment_generation: int = 0
self._aligned_cache: dict[tuple, np.ndarray] = {}
self._manual_align_dialog: ManualAlignmentDialog | None = None
self._align_wizard: AlignmentWizard | None = None
self._build_ui()
@@ -430,23 +430,13 @@ class SrasViewerWindow(QMainWindow):
fft_menu.addAction(fft_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)
self._manual_align_act = QAction("&Manual Alignment…", self)
self._manual_align_act.setStatusTip(
"Open an interactive dialog to align angles by eye: overlaid CH4 "
"threshold masks, keyboard nudge (translate + rotate), auto "
"de-rotate to the known scan angles, and save/clear a persistent "
"alignment.")
self._manual_align_act.setEnabled(False)
self._manual_align_act.triggered.connect(self._on_manual_alignment)
fusion_menu.addAction(self._manual_align_act)
self._wizard_act = QAction("Alignment &Wizard…", self)
self._wizard_act.setStatusTip(
"Align the angles, crop to a region of interest, and save the "
"aligned data as a new .sras file. Requires >1 angle.")
self._wizard_act.setEnabled(False)
self._wizard_act.triggered.connect(self._on_alignment_wizard)
fusion_menu.addAction(self._wizard_act)
convert_menu = menubar.addMenu("&Convert")
self._batch_dc_act = QAction("Batch Compute DC and &Store…", self)
@@ -522,9 +512,9 @@ class SrasViewerWindow(QMainWindow):
# A manual-alignment dialog bound to the previous file must not
# survive a reload — its per-angle state (and the sras it was
# constructed against) no longer matches the new file's geometry.
if self._manual_align_dialog is not None:
self._manual_align_dialog.close()
self._manual_align_dialog = None
if self._align_wizard is not None:
self._align_wizard.close()
self._align_wizard = None
# Caches (and any in-flight DC precompute) belong to the previous
# file's geometry — discard and start fresh. Bumping the generation
@@ -661,10 +651,8 @@ class SrasViewerWindow(QMainWindow):
self._batch_fft_act.setEnabled(can_batch)
self._batch_fft_rowavg_act.setEnabled(can_batch)
self._alignment_act.setEnabled(
has_file and s.n_angles > 1 and not self._job_running(Jobs.ALIGN))
self._manual_align_act.setEnabled(
has_file and s.n_angles > 1 and not self._job_running(Jobs.ALIGN))
self._wizard_act.setEnabled(
has_file and s.n_angles > 1 and self._align_wizard is None)
self.chk_aligned_view.setEnabled(enabled and self._alignment_result is not None)
self._update_roi_ui()
@@ -1143,27 +1131,16 @@ class SrasViewerWindow(QMainWindow):
# Progress dialogs
# ------------------------------------------------------------------
def _show_progress(self, key: str, message: str, maximum: int = 0,
on_cancel=None):
def _show_progress(self, key: str, message: str, maximum: int = 0):
"""Show (or relabel) the progress dialog under *key*. maximum=0 gives
an indeterminate busy indicator.
*on_cancel* adds a Cancel button wired to it. Almost every job here is
short enough that a cancel button would only invite a click that does
nothing, hence the no-button default; the aligned export is the
exception, since it can spend minutes writing gigabytes.
"""
an indeterminate busy indicator."""
dlg = self._progress_dlgs.get(key)
if dlg is not None:
dlg.setLabelText(message)
return
dlg = QProgressDialog(message, "", 0, maximum, self)
dlg.setWindowTitle("Please wait…")
if on_cancel is None:
dlg.setCancelButton(None)
else:
dlg.setCancelButtonText("Cancel")
dlg.canceled.connect(on_cancel)
dlg.setCancelButton(None)
dlg.setWindowModality(Qt.WindowModality.WindowModal)
dlg.setMinimumDuration(300) # only appears if it takes > 300 ms
dlg.show()
@@ -1289,90 +1266,68 @@ class SrasViewerWindow(QMainWindow):
# Fusion: angle alignment
# ------------------------------------------------------------------
def _on_angle_alignment(self):
def _on_alignment_wizard(self):
if self._sras is None or self._sras.n_angles <= 1:
return
ref_idx = 0
threshold_mv = self.spin_threshold_mv.value()
generation = self._alignment_generation
started = self._run_worker(
Jobs.ALIGN, AngleAlignmentWorker(self._sras, ref_idx, threshold_mv),
connect=(
("progress", lambda pct: self._set_progress("main", pct)),
("finished", lambda result, err, g=generation:
self._on_alignment_done(g, result, err)),
),
on_done=lambda: self._update_controls_enabled(self._sras is not None),
)
if not started:
return
self._alignment_act.setEnabled(False)
self._show_progress(
"main",
f"Computing angle alignment ({self._sras.n_angles} angles, "
f"ref=angle 0, CH4 mask ≥ {threshold_mv:.3f} mV)…",
maximum=100)
def _on_alignment_done(self, generation: int, result, error_msg: str):
self._close_progress("main")
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
# No generation bump: this result *is* the current generation's.
self._apply_alignment_result(result, view_checked=True,
bump_generation=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()
# ------------------------------------------------------------------
# Fusion: manual alignment
# ------------------------------------------------------------------
def _on_manual_alignment(self):
if self._sras is None or self._sras.n_angles <= 1:
return
if self._manual_align_dialog is not None:
self._manual_align_dialog.raise_()
self._manual_align_dialog.activateWindow()
if self._align_wizard is not None:
self._align_wizard.raise_()
self._align_wizard.activateWindow()
return
ref_idx = 0
threshold_mv = self.spin_threshold_mv.value()
seed: dict[int, ManualAngleParams] = {}
# Seed only from a previously *saved manual* alignment (this dialog's
# own Save also writes this sidecar) -- never from self._alignment_result
# when it holds the automatic Fusion -> Angle Alignment's output. That
# path's translation comes from FFT phase correlation, which is the
# very thing manual mode exists to work around; inheriting it here
# would silently reintroduce the same bad translations under a
# "manual" label, on top of the (correct) analytic rotation, which is
# exactly what makes manual mode look like it "still does the same
# thing" the automatic one does.
# Seed only from a previously *saved* alignment, never from
# self._alignment_result: the wizard's first page starts every angle
# pre-rotated from the stage angles and expects to own the parameters
# from there, so inheriting a half-edited in-memory state would make
# "Reset to pre-rotation only" mean something different each time.
sidecar = load_manual_alignment(self._sras)
if sidecar is not None and sidecar.ref_angle_idx == ref_idx:
seed = dict(sidecar.per_angle)
threshold_mv = sidecar.dc_threshold_mv
cached_dc4 = {a: img for (a, ch), img in self._dc_cache.items() if ch == CH4_IDX}
dlg = ManualAlignmentDialog(
wiz = AlignmentWizard(
self, self._sras, ref_angle_idx=ref_idx, dc_threshold_mv=threshold_mv,
seed_per_angle=seed, cached_dc4_mv=cached_dc4)
dlg.alignment_saved.connect(self._on_manual_alignment_saved)
dlg.alignment_cleared.connect(self._on_manual_alignment_cleared)
dlg.finished.connect(self._on_manual_align_dialog_closed)
dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self._manual_align_dialog = dlg
dlg.show()
wiz.alignment_ready.connect(self._on_wizard_finished)
wiz.finished.connect(self._on_wizard_closed)
wiz.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self._align_wizard = wiz
self._update_controls_enabled(True)
wiz.show()
def _on_manual_align_dialog_closed(self, _result_code: int):
self._manual_align_dialog = None
def _on_wizard_closed(self, _result_code: int):
self._align_wizard = None
self._update_controls_enabled(self._sras is not None)
def _on_wizard_finished(self, result, out_path: str):
"""The wizard exported a file; make the session match what was written.
Applies the *cropped* result, so Aligned View shows exactly the extent
that went into the file rather than the wider uncropped canvas, and
saves the sidecar for the input scan so reopening it lands in the same
place.
"""
if result is None:
return
self._apply_alignment_result(result, view_checked=True)
note = ""
try:
save_manual_alignment(
self._sras, result.ref_angle_idx, result.dc_threshold_mv,
{a: ManualAngleParams(t.rotation_deg, t.shift_mm)
for a, t in result.per_angle.items()})
except OSError as exc:
note = f" (could not save the sidecar: {exc})"
self._update_controls_enabled(self._sras is not None)
nr, nc = result.canvas_shape
self.statusBar().showMessage(
f"Aligned scan written to {Path(out_path).name}; Aligned View now "
f"shows the exported {nc}×{nr} px region.{note}")
if self._current_image is not None:
self._refresh_display()
def _apply_alignment_result(self, result, *, view_checked: bool,
bump_generation: bool = True):
@@ -1387,20 +1342,6 @@ class SrasViewerWindow(QMainWindow):
self.chk_aligned_view.setChecked(view_checked)
self.chk_aligned_view.setEnabled(result is not None)
def _manual_alignment_changed(self, result, message: str):
self._apply_alignment_result(result, view_checked=result is not None)
self._update_controls_enabled(self._sras is not None)
self.statusBar().showMessage(message)
if self._current_image is not None:
self._refresh_display()
def _on_manual_alignment_saved(self, result, sidecar_path_str: str):
self._manual_alignment_changed(
result, f"Manual alignment saved to {Path(sidecar_path_str).name}")
def _on_manual_alignment_cleared(self):
self._manual_alignment_changed(None, "Manual alignment cleared.")
# ------------------------------------------------------------------
# FFT Options
# ------------------------------------------------------------------
@@ -1429,8 +1370,8 @@ class SrasViewerWindow(QMainWindow):
# ------------------------------------------------------------------
def closeEvent(self, event):
if self._manual_align_dialog is not None:
self._manual_align_dialog.close()
if self._align_wizard is not None:
self._align_wizard.close()
# Signal every cancellable worker first, then wait. Waiting without
# signalling means sitting out whatever is in flight — on a large