f40c965b74
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>
1412 lines
58 KiB
Python
1412 lines
58 KiB
Python
"""The alignment wizard: correlate, crop, export.
|
||
|
||
Replaces what used to be two disconnected menu actions. `Angle Alignment` ran
|
||
the registration with every parameter hard-coded and no way to retry, and
|
||
`Manual Alignment…` was a separate dialog that deliberately refused to inherit
|
||
the automatic result — so a bad fit meant starting over by hand, and neither
|
||
path told you whether the alignment was actually any good.
|
||
|
||
The three steps are the three decisions:
|
||
|
||
1. Correlate. Every parameter that affects the fit is on the page, the run is
|
||
repeatable, and the verdict is a picture: each angle's DC mask reprojected
|
||
onto the shared canvas and summed, so the colour of a pixel is *how many
|
||
angles cover it*. A good alignment is one saturated plateau at N; a bad one
|
||
is a fan of low-count halos. Angles start pre-rotated by the stage angles
|
||
stored in the file, so the page is informative before any correlation runs.
|
||
Manual nudging lives here too, since this page is now the only place to
|
||
correct an angle the search cannot fit.
|
||
2. Crop. Axis-aligned only, because that is the only crop .sras geometry can
|
||
express — a free quadrilateral would have to be squared off behind the
|
||
user's back.
|
||
3. Export. Writes the aligned, cropped data to a new .sras.
|
||
|
||
State lives on the wizard rather than in QWizardPage.registerField: the pages
|
||
share numpy arrays, ManualAngleParams and an AlignmentResult, none of which are
|
||
scalar widget properties.
|
||
"""
|
||
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING
|
||
|
||
import matplotlib as mpl
|
||
import numpy as np
|
||
from matplotlib.backends.backend_qtagg import NavigationToolbar2QT
|
||
from PyQt6.QtCore import QSignalBlocker, Qt, pyqtSignal
|
||
from PyQt6.QtWidgets import (
|
||
QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QHeaderView, QLabel,
|
||
QLineEdit, QMessageBox, QProgressBar, QPushButton, QSpinBox,
|
||
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QWizard, QWizardPage,
|
||
)
|
||
|
||
import sras_align_export as export
|
||
import sras_compute as compute
|
||
from sras_compute import ManualAngleParams, build_manual_alignment
|
||
from sras_format import SrasFile
|
||
from sras_workers import AlignedExportWorker, Ch4MaskWorker, CrossCorrelateWorker
|
||
|
||
from .canvases import AlignOverlayCanvas, ImageCanvas, RoiQuad, count_colormap
|
||
from .common import (
|
||
_CSS_HINT, _CSS_INFO, _CSS_MUTED, _CSS_WARN, Jobs, _axes_extent, _form,
|
||
_group, _make_dspin, _scroll_panel, _wrap_label,
|
||
)
|
||
|
||
if TYPE_CHECKING:
|
||
from .main_window import SrasViewerWindow
|
||
|
||
# Longest side of the coarsened preview the mask stack is built on. Same
|
||
# reasoning as the old manual dialog: a full-resolution reprojection per angle
|
||
# per edit is far more detail than an alignment can be judged by eye at.
|
||
_MAX_PREVIEW_DIM = 1024
|
||
|
||
# Alpha for the per-angle colour view (the active angle sits on top, brighter).
|
||
_BASE_ALPHA = 0.42
|
||
_ACTIVE_ALPHA = 0.75
|
||
|
||
_PANEL_W = 372
|
||
|
||
# (label, sources for register_angle_to_reference) — "both" costs roughly double
|
||
# but removes the failure mode where the one chosen source happens to be
|
||
# uninformative for a single angle.
|
||
_CORRELATE_SOURCES = (
|
||
("Both, keep best", ("signal", "mask")),
|
||
("Raw signal", ("signal",)),
|
||
("Thresholded mask", ("mask",)),
|
||
)
|
||
|
||
# (label, seed_deg override or None for "the file's stage angle", signs to
|
||
# sweep, sign used for the pre-rotation *preview*). The stage's rotational sense
|
||
# relative to this module's math-positive convention is not knowable from the
|
||
# file, which is why "both" exists and is the default; the explicit signs are
|
||
# for a user who has established which way their own stage turns.
|
||
_PREROTATE_MODES = (
|
||
("Stage angle, both signs", None, (-1, 1), 1),
|
||
("Stage angle, + delta only", None, (1,), 1),
|
||
("Stage angle, − delta only", None, (-1,), -1),
|
||
("No pre-rotation", 0.0, (1,), 0),
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class WizardState:
|
||
"""Everything the three pages share."""
|
||
dc4_mv: dict[int, np.ndarray] = field(default_factory=dict)
|
||
masks_small: dict[int, np.ndarray] = field(default_factory=dict)
|
||
downsample: tuple[int, int] = (1, 1)
|
||
params: dict[int, ManualAngleParams] = field(default_factory=dict)
|
||
fits: dict[int, tuple[float, str]] = field(default_factory=dict)
|
||
ref_angle_idx: int = 0
|
||
threshold_mv: float = 0.0
|
||
result: compute.AlignmentResult | None = None # full, uncropped
|
||
counts: np.ndarray | None = None # coarse overlap counts
|
||
layers: dict[int, np.ndarray] = field(default_factory=dict)
|
||
preview_pitch_mm: tuple[float, float] = (1.0, 1.0)
|
||
preview_shape: tuple[int, int] = (1, 1)
|
||
geometry_generation: int = 0
|
||
crop: tuple[int, int, int, int] | None = None # canvas (row0,col0,nr,nc)
|
||
cropped_result: compute.AlignmentResult | None = None
|
||
out_path: str = ""
|
||
exported_path: str = ""
|
||
|
||
|
||
class AlignmentWizard(QWizard):
|
||
"""Three-page alignment + export flow (Fusion → Alignment Wizard…).
|
||
|
||
Non-modal by design, like the dialog it replaces: an export can take minutes
|
||
on a real scan and the user should still be able to look at the data.
|
||
|
||
Background work goes through parent._run_worker, which inherits the main
|
||
window's job registry, thread joining and shutdown handling. Two rules that
|
||
module's docstring establishes and every launch here follows: disable the
|
||
trigger *before* calling it (a re-entrant click must not be able to start a
|
||
second thread over the first), and never ignore its return value — False
|
||
means another job holds the key.
|
||
"""
|
||
|
||
PAGE_CORRELATE, PAGE_ROI, PAGE_SAVE = 0, 1, 2
|
||
|
||
# cropped AlignmentResult, written path
|
||
alignment_ready = pyqtSignal(object, str)
|
||
|
||
def __init__(self, parent: "SrasViewerWindow", sras: SrasFile, *,
|
||
ref_angle_idx: int, dc_threshold_mv: float,
|
||
seed_per_angle: dict[int, ManualAngleParams] | None,
|
||
cached_dc4_mv: dict[int, np.ndarray]):
|
||
super().__init__(parent)
|
||
self._parent = parent
|
||
self._sras = sras
|
||
self._cached_dc4 = dict(cached_dc4_mv)
|
||
self._seed = dict(seed_per_angle or {})
|
||
self._closing = False
|
||
|
||
self.state = WizardState(
|
||
ref_angle_idx=ref_angle_idx, threshold_mv=dc_threshold_mv,
|
||
params={a: ManualAngleParams() for a in range(sras.n_angles)})
|
||
|
||
n = sras.n_angles
|
||
cmap = mpl.colormaps["tab10"] if n <= 10 else mpl.colormaps["tab20"]
|
||
self.angle_colors = {a: cmap(a % cmap.N)[:3] for a in range(n)}
|
||
|
||
self.setWindowTitle(f"Alignment Wizard — {sras.path.name}")
|
||
self.setWizardStyle(QWizard.WizardStyle.ModernStyle)
|
||
self.setOption(QWizard.WizardOption.HaveHelpButton, False)
|
||
self.setOption(QWizard.WizardOption.NoBackButtonOnStartPage, True)
|
||
# IndependentPages must stay OFF: it suppresses cleanupPage, which is
|
||
# how the ROI page discards a crop whose canvas is about to change.
|
||
self.setOption(QWizard.WizardOption.IndependentPages, False)
|
||
self.resize(1220, 820)
|
||
|
||
self.setPage(self.PAGE_CORRELATE, CorrelatePage(self))
|
||
self.setPage(self.PAGE_ROI, RoiPage(self))
|
||
self.setPage(self.PAGE_SAVE, SavePage(self))
|
||
self.setStartId(self.PAGE_CORRELATE)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Shared helpers the pages use
|
||
# ------------------------------------------------------------------
|
||
|
||
@property
|
||
def sras(self) -> SrasFile:
|
||
return self._sras
|
||
|
||
def seed_params(self) -> dict[int, ManualAngleParams]:
|
||
"""Per-angle starting parameters: a saved sidecar if one matches,
|
||
otherwise identity (the pre-rotation is applied on top by page 1)."""
|
||
return {a: ManualAngleParams(self._seed[a].rotation_deg,
|
||
self._seed[a].shift_mm)
|
||
if a in self._seed else ManualAngleParams()
|
||
for a in range(self._sras.n_angles)}
|
||
|
||
def rebuild_result(self):
|
||
"""Recompute the full AlignmentResult from the current parameters.
|
||
|
||
Closed-form matrix and bbox maths with no per-pixel work, so it is cheap
|
||
enough to call on every edit — which is what keeps the preview honest
|
||
about the canvas a rotation change implies.
|
||
"""
|
||
st = self.state
|
||
st.result = build_manual_alignment(
|
||
self._sras, st.ref_angle_idx, st.threshold_mv, st.params)
|
||
|
||
def rebuild_stack(self):
|
||
"""Reproject every angle's mask onto a coarsened view of the *final*
|
||
canvas and sum them into an overlap-count image.
|
||
|
||
The preview deliberately shares the final canvas's origin and uses a
|
||
pitch that is an integer multiple of it, rather than the padded,
|
||
unsnapped preview canvas the old manual dialog used. That is what lets
|
||
the crop page turn a rectangle drawn in millimetres into an exact
|
||
integer window of the real canvas, with no second coordinate frame to
|
||
reconcile.
|
||
"""
|
||
st = self.state
|
||
if st.result is None or not st.masks_small:
|
||
return
|
||
fy, fx = st.downsample
|
||
n_rows, n_cols = st.result.canvas_shape
|
||
st.preview_pitch_mm = (st.result.canvas_dx_mm * fx,
|
||
st.result.canvas_dy_mm * fy)
|
||
st.preview_shape = (max(1, -(-n_rows // fy)), max(1, -(-n_cols // fx)))
|
||
|
||
st.layers = {}
|
||
counts = np.zeros(st.preview_shape, dtype=np.int16)
|
||
for a in range(self._sras.n_angles):
|
||
p = st.params[a]
|
||
layer = compute.reproject_mask(
|
||
self._sras, a, st.ref_angle_idx, st.masks_small[a],
|
||
p.rotation_deg, p.shift_mm, st.preview_pitch_mm,
|
||
st.result.canvas_origin_mm, st.preview_shape,
|
||
src_downsample=st.downsample)
|
||
st.layers[a] = layer
|
||
counts += (layer > 0.5).astype(np.int16)
|
||
st.counts = counts
|
||
|
||
def preview_extent(self) -> list[float]:
|
||
st = self.state
|
||
x0, y0 = st.result.canvas_origin_mm
|
||
dx, dy = st.preview_pitch_mm
|
||
n_rows, n_cols = st.preview_shape
|
||
return _axes_extent(x0 + np.arange(n_cols) * dx,
|
||
y0 + np.arange(n_rows) * dy, dx, dy)
|
||
|
||
def mm_to_canvas(self, x_mm: float, y_mm: float) -> tuple[float, float]:
|
||
"""(col, row) in *final* canvas pixels, fractional."""
|
||
st = self.state
|
||
x0, y0 = st.result.canvas_origin_mm
|
||
return ((x_mm - x0) / st.result.canvas_dx_mm,
|
||
(y_mm - y0) / st.result.canvas_dy_mm)
|
||
|
||
def canvas_to_mm(self, col: float, row: float) -> tuple[float, float]:
|
||
st = self.state
|
||
x0, y0 = st.result.canvas_origin_mm
|
||
return (x0 + col * st.result.canvas_dx_mm,
|
||
y0 + row * st.result.canvas_dy_mm)
|
||
|
||
def job_running(self) -> bool:
|
||
return any(self._parent._job_running(k) for k in
|
||
(Jobs.ALIGN_MASKS, Jobs.ALIGN_CORRELATE, Jobs.ALIGN_EXPORT))
|
||
|
||
# ------------------------------------------------------------------
|
||
# Lifetime
|
||
# ------------------------------------------------------------------
|
||
|
||
def reject(self):
|
||
"""Refuse to close while a job is in flight.
|
||
|
||
The running worker's signals are connected to bound methods of this
|
||
wizard and its pages; letting Qt delete them under a live thread is a
|
||
crash, not an inconvenience. Ask the worker to stop and let the job's
|
||
own completion close us.
|
||
"""
|
||
if self.job_running():
|
||
for page_id in (self.PAGE_CORRELATE, self.PAGE_SAVE):
|
||
page = self.page(page_id)
|
||
if page is not None:
|
||
page.request_stop()
|
||
self._closing = True
|
||
return
|
||
super().reject()
|
||
|
||
def closeEvent(self, event):
|
||
"""Window-manager close goes through the same deferral as Cancel."""
|
||
if self.job_running():
|
||
self.reject()
|
||
event.ignore()
|
||
return
|
||
super().closeEvent(event)
|
||
|
||
def accept(self):
|
||
st = self.state
|
||
self.alignment_ready.emit(st.cropped_result, st.exported_path)
|
||
super().accept()
|
||
|
||
def maybe_close_after_job(self):
|
||
"""Called by a page when its job finishes; completes a deferred close."""
|
||
if self._closing and not self.job_running():
|
||
self._closing = False
|
||
super().reject()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Page 1 — cross-correlation
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class CorrelatePage(QWizardPage):
|
||
"""Register every angle against the reference, and show whether it worked.
|
||
|
||
The stack image is the point of the page. Numbers alone ("score 0.42") do
|
||
not tell you whether a five-angle fusion is usable; an overlap-count image
|
||
does, immediately.
|
||
"""
|
||
|
||
def __init__(self, wizard: AlignmentWizard):
|
||
super().__init__(wizard)
|
||
self._wiz = wizard
|
||
self._sras = wizard.sras
|
||
self._busy = False
|
||
self._masks_ready = False
|
||
self._active_angle = 0
|
||
self._worker = None
|
||
self._done_count = 0
|
||
self._total = 0
|
||
|
||
self.setTitle("Step 1 — Cross-correlate the angles")
|
||
self.setSubTitle(
|
||
"Angles start pre-rotated by the stage angles stored in the scan. "
|
||
"Run the correlation, then check the stack: every pixel is coloured "
|
||
"by how many angles cover it, so a good alignment is one solid "
|
||
"plateau.")
|
||
self._build_ui()
|
||
|
||
# ---- construction -------------------------------------------------
|
||
|
||
def _build_ui(self):
|
||
root = QHBoxLayout(self)
|
||
|
||
self.canvas = AlignOverlayCanvas()
|
||
left = QWidget()
|
||
ll = QVBoxLayout(left)
|
||
ll.setContentsMargins(0, 0, 0, 0)
|
||
ll.setSpacing(4)
|
||
ll.addWidget(NavigationToolbar2QT(self.canvas, left))
|
||
ll.addWidget(self.canvas, stretch=1)
|
||
self.lbl_overlap = _wrap_label("", _CSS_INFO)
|
||
ll.addWidget(self.lbl_overlap)
|
||
self.lbl_warn = _wrap_label("", _CSS_WARN)
|
||
ll.addWidget(self.lbl_warn)
|
||
root.addWidget(left, stretch=1)
|
||
|
||
panel = QWidget()
|
||
pl = QVBoxLayout(panel)
|
||
pl.setContentsMargins(0, 0, 0, 0)
|
||
pl.setSpacing(8)
|
||
pl.addWidget(self._build_view_group())
|
||
pl.addWidget(self._build_reg_group())
|
||
pl.addWidget(self._build_run_group())
|
||
pl.addWidget(self._build_nudge_group())
|
||
pl.addWidget(self._build_table_group())
|
||
self.lbl_status = _wrap_label("", _CSS_MUTED)
|
||
pl.addWidget(self.lbl_status)
|
||
pl.addStretch()
|
||
root.addWidget(_scroll_panel(panel, _PANEL_W))
|
||
|
||
self._connect()
|
||
|
||
def _build_view_group(self) -> QWidget:
|
||
grp, lay = _group("View")
|
||
self.combo_view = _combo(["Overlap count", "Per-angle colours"])
|
||
lay.addWidget(self.combo_view)
|
||
return grp
|
||
|
||
def _build_reg_group(self) -> QWidget:
|
||
grp, lay = _group("Registration")
|
||
form = _form()
|
||
|
||
self.combo_ref = _combo(
|
||
f"Angle {a} ({self._sras.angles_deg[a]:.1f}°)"
|
||
for a in range(self._sras.n_angles))
|
||
self.combo_ref.setCurrentIndex(self._wiz.state.ref_angle_idx)
|
||
form.addRow("Reference:", self.combo_ref)
|
||
|
||
self.spin_threshold = _make_dspin(-500.0, 500.0, 3, suffix=" mV",
|
||
value=self._wiz.state.threshold_mv)
|
||
form.addRow("DC threshold:", self.spin_threshold)
|
||
|
||
self.combo_source = _combo(
|
||
(label, sources) for label, sources in _CORRELATE_SOURCES)
|
||
form.addRow("Correlate on:", self.combo_source)
|
||
|
||
self.combo_prerotate = _combo(
|
||
(label, (seed, signs, disp))
|
||
for label, seed, signs, disp in _PREROTATE_MODES)
|
||
form.addRow("Pre-rotate:", self.combo_prerotate)
|
||
|
||
self.chk_lock_rotation = QCheckBox("Lock rotation to the seed")
|
||
form.addRow("", self.chk_lock_rotation)
|
||
|
||
self.spin_search_deg = _make_dspin(0.0, 180.0, 1, suffix=" °",
|
||
value=6.0, step=1.0)
|
||
form.addRow("Rotation search (±):", self.spin_search_deg)
|
||
|
||
self.spin_coarse_step = _make_dspin(0.1, 30.0, 2, suffix=" °", value=2.0)
|
||
form.addRow("Coarse step:", self.spin_coarse_step)
|
||
|
||
self.combo_fine_dim = _combo((f"{dim} px", dim) for dim in (320, 640, 1024))
|
||
self.combo_fine_dim.setCurrentIndex(1)
|
||
form.addRow("Fine grid:", self.combo_fine_dim)
|
||
|
||
lay.addLayout(form)
|
||
lay.addWidget(_wrap_label(
|
||
"Pre-rotation only seeds the search — the rotation is still found "
|
||
"from image content, and both signs of the stage's reported angle "
|
||
"are tried unless you narrow it. Locking instead pins rotation to "
|
||
"the stage angle and searches translation only.", _CSS_HINT))
|
||
return grp
|
||
|
||
def _build_run_group(self) -> QWidget:
|
||
grp, lay = _group("Run")
|
||
self.btn_correlate = QPushButton("Run / Re-run Correlation")
|
||
lay.addWidget(self.btn_correlate)
|
||
self.progress = QProgressBar()
|
||
self.progress.setRange(0, 100)
|
||
self.progress.setValue(0)
|
||
lay.addWidget(self.progress)
|
||
self.btn_reset = QPushButton("Reset to pre-rotation only")
|
||
lay.addWidget(self.btn_reset)
|
||
return grp
|
||
|
||
def _build_nudge_group(self) -> QWidget:
|
||
self.grp_nudge, lay = _group("Manual Correction")
|
||
form = _form()
|
||
self.combo_active = _combo(
|
||
f"Angle {a} ({self._sras.angles_deg[a]:.1f}°)"
|
||
for a in range(self._sras.n_angles))
|
||
form.addRow("Active angle:", self.combo_active)
|
||
|
||
self.spin_rot = _make_dspin(-3600.0, 3600.0, 3, suffix=" °")
|
||
form.addRow("Rotation:", self.spin_rot)
|
||
self.spin_shift_x = _make_dspin(-1e5, 1e5, 4, suffix=" mm")
|
||
form.addRow("Shift X:", self.spin_shift_x)
|
||
self.spin_shift_y = _make_dspin(-1e5, 1e5, 4, suffix=" mm")
|
||
form.addRow("Shift Y:", self.spin_shift_y)
|
||
|
||
self.spin_step_translate = _make_dspin(0.0001, 1000.0, 4, suffix=" mm",
|
||
value=0.01)
|
||
form.addRow("Translate step:", self.spin_step_translate)
|
||
self.spin_step_rotate = _make_dspin(0.001, 90.0, 3, suffix=" °", value=0.1)
|
||
form.addRow("Rotate step:", self.spin_step_rotate)
|
||
self.spin_step_mult = _make_dspin(1.0, 1000.0, 1, value=10.0)
|
||
form.addRow("Coarse × (Shift):", self.spin_step_mult)
|
||
lay.addLayout(form)
|
||
lay.addWidget(_wrap_label(
|
||
"Arrow keys nudge translation; Q/E nudge rotation (CCW/CW). Hold "
|
||
"Shift for the coarse step. Click the image once to give it "
|
||
"keyboard focus. Switch to per-angle colours to see which layer "
|
||
"you are moving.", _CSS_HINT))
|
||
self.lbl_active_note = _wrap_label("", _CSS_WARN)
|
||
lay.addWidget(self.lbl_active_note)
|
||
return self.grp_nudge
|
||
|
||
def _build_table_group(self) -> QWidget:
|
||
grp, lay = _group("Fit per Angle")
|
||
self.table = QTableWidget(self._sras.n_angles, 5)
|
||
self.table.setHorizontalHeaderLabels(
|
||
["Angle", "Rot °", "Δ stage °", "Score", "On"])
|
||
self.table.verticalHeader().setVisible(False)
|
||
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||
self.table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
||
self.table.horizontalHeader().setSectionResizeMode(
|
||
QHeaderView.ResizeMode.ResizeToContents)
|
||
self.table.setMinimumHeight(150)
|
||
lay.addWidget(self.table)
|
||
return grp
|
||
|
||
def _connect(self):
|
||
self.combo_view.currentIndexChanged.connect(self._redraw)
|
||
self.combo_ref.currentIndexChanged.connect(self._on_ref_changed)
|
||
self.spin_threshold.editingFinished.connect(self._on_threshold_changed)
|
||
self.combo_prerotate.currentIndexChanged.connect(self._apply_prerotation)
|
||
self.chk_lock_rotation.toggled.connect(self._on_lock_toggled)
|
||
self.btn_correlate.clicked.connect(self._on_correlate)
|
||
self.btn_reset.clicked.connect(self._apply_prerotation)
|
||
self.combo_active.currentIndexChanged.connect(self._on_active_changed)
|
||
for spin in (self.spin_rot, self.spin_shift_x, self.spin_shift_y):
|
||
spin.editingFinished.connect(self._on_manual_edit)
|
||
self.canvas.nudge_translate.connect(self._on_nudge_translate)
|
||
self.canvas.nudge_rotate.connect(self._on_nudge_rotate)
|
||
|
||
# ---- QWizardPage contract ----------------------------------------
|
||
|
||
def initializePage(self):
|
||
if self._masks_ready or self._busy:
|
||
return
|
||
self._set_controls_enabled(False)
|
||
self._start_mask_prep()
|
||
|
||
def isComplete(self) -> bool:
|
||
"""Next is gated on masks being ready and nothing running.
|
||
|
||
Returning False while busy also disables Finish, which is what stops a
|
||
Back/Next/Finish race from reaching the writer with a half-built result.
|
||
"""
|
||
return self._masks_ready and not self._busy
|
||
|
||
def request_stop(self):
|
||
if self._worker is not None:
|
||
self._worker.stop()
|
||
|
||
# ---- mask preparation --------------------------------------------
|
||
|
||
def _start_mask_prep(self):
|
||
st = self._wiz.state
|
||
st.dc4_mv = dict(self._wiz._cached_dc4)
|
||
missing = [a for a in range(self._sras.n_angles) if a not in st.dc4_mv]
|
||
if not missing:
|
||
self._finish_mask_prep()
|
||
return
|
||
self._busy = True
|
||
self.completeChanged.emit()
|
||
self.lbl_status.setText(
|
||
f"Preparing masks: 0/{len(missing)} angle(s) needed…")
|
||
worker = Ch4MaskWorker(self._sras, missing)
|
||
started = self._wiz._parent._run_worker(
|
||
Jobs.ALIGN_MASKS, worker,
|
||
connect=(("angle_done", self._on_mask_done),
|
||
("error", lambda m: self.lbl_status.setText(
|
||
f"Mask preparation failed: {m}"))),
|
||
on_done=self._finish_mask_prep)
|
||
if not started:
|
||
self._busy = False
|
||
self.completeChanged.emit()
|
||
self.lbl_status.setText(
|
||
"Another alignment step is still running — close and reopen.")
|
||
return
|
||
self._worker = worker
|
||
|
||
def _on_mask_done(self, angle_idx: int, dc4_mv: np.ndarray):
|
||
st = self._wiz.state
|
||
st.dc4_mv[angle_idx] = dc4_mv
|
||
self.lbl_status.setText(
|
||
f"Preparing masks: {len(st.dc4_mv)}/{self._sras.n_angles} ready…")
|
||
|
||
def _finish_mask_prep(self):
|
||
st = self._wiz.state
|
||
self._worker = None
|
||
self._busy = False
|
||
if len(st.dc4_mv) < self._sras.n_angles:
|
||
self.completeChanged.emit()
|
||
self.lbl_status.setText(
|
||
"Mask preparation did not finish for every angle.")
|
||
self._wiz.maybe_close_after_job()
|
||
return
|
||
|
||
# Rows and columns get independent factors. A real scan is ~7500 frames
|
||
# wide but only ~750 rows tall, so one shared factor sized for the
|
||
# frames would throw away 10x more row detail than the preview needs.
|
||
max_rows = max(img.shape[0] for img in st.dc4_mv.values())
|
||
max_cols = max(img.shape[1] for img in st.dc4_mv.values())
|
||
st.downsample = (max(1, -(-max_rows // _MAX_PREVIEW_DIM)),
|
||
max(1, -(-max_cols // _MAX_PREVIEW_DIM)))
|
||
|
||
st.params = self._wiz.seed_params()
|
||
self._recompute_masks()
|
||
self._masks_ready = True
|
||
self._set_controls_enabled(True)
|
||
self._apply_prerotation()
|
||
self.lbl_status.setText("Ready.")
|
||
self.completeChanged.emit()
|
||
self._wiz.maybe_close_after_job()
|
||
|
||
def _recompute_masks(self):
|
||
"""Threshold + downsample each angle's in-memory CH4 image. Cheap (a
|
||
compare and a block-mean), so a threshold change re-runs it in full
|
||
rather than re-fetching anything."""
|
||
st = self._wiz.state
|
||
fy, fx = st.downsample
|
||
st.masks_small = {
|
||
a: compute.block_mean_2d((img >= st.threshold_mv).astype(np.float32),
|
||
fy, fx)
|
||
for a, img in st.dc4_mv.items()
|
||
}
|
||
|
||
# ---- parameter changes -------------------------------------------
|
||
|
||
def _on_ref_changed(self, idx: int):
|
||
st = self._wiz.state
|
||
st.ref_angle_idx = idx
|
||
st.fits = {}
|
||
self._apply_prerotation()
|
||
|
||
def _on_threshold_changed(self):
|
||
st = self._wiz.state
|
||
value = self.spin_threshold.value()
|
||
if value == st.threshold_mv:
|
||
return
|
||
st.threshold_mv = value
|
||
if self._masks_ready:
|
||
self._recompute_masks()
|
||
self._refresh()
|
||
|
||
def _on_lock_toggled(self, locked: bool):
|
||
self.spin_search_deg.setEnabled(not locked)
|
||
self.spin_coarse_step.setEnabled(not locked)
|
||
|
||
def _apply_prerotation(self):
|
||
"""Seed every non-reference angle's rotation from the stage angles in
|
||
the file, and redraw — before any correlation has run.
|
||
|
||
This is the cheapest useful thing the page can show: the stage angles
|
||
are usually within a degree or two of the truth, so the stack already
|
||
looks close to right, and how close is a fair first read on whether the
|
||
scan's own metadata can be trusted.
|
||
"""
|
||
if not self._masks_ready:
|
||
return
|
||
st = self._wiz.state
|
||
_seed, _signs, disp_sign = self.combo_prerotate.currentData()
|
||
for a in range(self._sras.n_angles):
|
||
rot = 0.0 if a == st.ref_angle_idx else (
|
||
disp_sign * compute.nominal_delta_deg(self._sras, a,
|
||
st.ref_angle_idx))
|
||
st.params[a] = ManualAngleParams(rot, (0.0, 0.0))
|
||
st.fits = {}
|
||
self._refresh()
|
||
|
||
def _refresh(self):
|
||
"""Rebuild result, stack and every readout from the current params."""
|
||
st = self._wiz.state
|
||
self._wiz.rebuild_result()
|
||
st.geometry_generation += 1
|
||
self._wiz.rebuild_stack()
|
||
self._sync_spins()
|
||
self._update_table()
|
||
self._redraw()
|
||
|
||
# ---- correlation --------------------------------------------------
|
||
|
||
def _reg_kwargs(self) -> dict:
|
||
seed, signs, _disp = self.combo_prerotate.currentData()
|
||
kwargs = {
|
||
"seed_deg": seed,
|
||
"seed_signs": signs,
|
||
"coarse_step_deg": self.spin_coarse_step.value(),
|
||
"fine_dim": self.combo_fine_dim.currentData(),
|
||
}
|
||
if self.chk_lock_rotation.isChecked():
|
||
# Exactly one candidate, no hill-climb: rotation is the seed and
|
||
# only the translation is searched.
|
||
kwargs["refine"] = False
|
||
if len(signs) > 1:
|
||
kwargs["seed_signs"] = (1,)
|
||
return kwargs
|
||
|
||
def _on_correlate(self):
|
||
if not self._masks_ready or self._busy:
|
||
return
|
||
st = self._wiz.state
|
||
angles = [a for a in range(self._sras.n_angles) if a != st.ref_angle_idx]
|
||
if not angles:
|
||
self.lbl_status.setText("Only one angle — nothing to correlate.")
|
||
return
|
||
|
||
search = 0.0 if self.chk_lock_rotation.isChecked() \
|
||
else self.spin_search_deg.value()
|
||
worker = CrossCorrelateWorker(
|
||
self._sras, st.ref_angle_idx, angles, st.dc4_mv,
|
||
sources=self.combo_source.currentData(),
|
||
dc_threshold_mv=st.threshold_mv, search_deg=search,
|
||
reg_kwargs=self._reg_kwargs())
|
||
|
||
# Claim busy and disable the trigger *before* _run_worker, never after:
|
||
# anything that pumps the event loop in between could deliver a second
|
||
# click that starts a thread the first assignment then drops.
|
||
self._busy = True
|
||
self._done_count, self._total = 0, len(angles)
|
||
st.fits = {}
|
||
self.completeChanged.emit()
|
||
self._set_controls_enabled(False)
|
||
self.progress.setValue(0)
|
||
self.lbl_status.setText(f"Cross-correlating: 0/{self._total} angle(s)…")
|
||
|
||
started = self._wiz._parent._run_worker(
|
||
Jobs.ALIGN_CORRELATE, worker,
|
||
connect=(("angle_done", self._on_angle_done),
|
||
("error", lambda m: self.lbl_status.setText(
|
||
f"Cross-correlation failed: {m}"))),
|
||
on_done=self._finish_correlate)
|
||
if not started:
|
||
self._busy = False
|
||
self.completeChanged.emit()
|
||
self._set_controls_enabled(True)
|
||
self.lbl_status.setText(
|
||
"Another alignment step is still running — try again shortly.")
|
||
return
|
||
self._worker = worker
|
||
|
||
def _on_angle_done(self, angle_idx: int, rot: float, sx: float, sy: float,
|
||
score: float, source: str):
|
||
st = self._wiz.state
|
||
st.params[angle_idx] = ManualAngleParams(rot, (sx, sy))
|
||
st.fits[angle_idx] = (score, source)
|
||
self._done_count += 1
|
||
self.progress.setValue(int(self._done_count / max(1, self._total) * 100))
|
||
self.lbl_status.setText(
|
||
f"Cross-correlating: {self._done_count}/{self._total} angle(s)…")
|
||
|
||
def _finish_correlate(self):
|
||
self._worker = None
|
||
self._busy = False
|
||
self.progress.setValue(100)
|
||
self._refresh()
|
||
self._set_controls_enabled(True)
|
||
self.completeChanged.emit()
|
||
self.lbl_status.setText(
|
||
f"Correlated {self._done_count} angle(s) against angle "
|
||
f"{self._wiz.state.ref_angle_idx}. " + self._fit_summary())
|
||
self._wiz.maybe_close_after_job()
|
||
|
||
def _fit_summary(self) -> str:
|
||
"""Worst fit and any angle whose rotation disagrees with its stage angle.
|
||
|
||
Surfaced rather than buried because a single bad acquisition (stage
|
||
glitch, laser dropout) registers poorly and would otherwise be fused in
|
||
silently — knowing *which* angle is what makes dropping it with
|
||
sras_edit_scans.py actionable.
|
||
"""
|
||
st = self._wiz.state
|
||
if not st.fits:
|
||
return ""
|
||
rows = sorted(st.fits.items(), key=lambda kv: kv[1][0])
|
||
worst_a, (worst_score, worst_src) = rows[0]
|
||
parts = [f"Worst fit: angle {worst_a} (score {worst_score:.3f}, "
|
||
f"{worst_src or 'n/a'})."]
|
||
failed = [str(a) for a, (score, src) in rows if score < 0 or src == "none"]
|
||
if failed:
|
||
parts.append(
|
||
"Angle(s) " + ", ".join(failed) + " did not register at all and "
|
||
"are being treated as unrotated — lower the DC threshold, try "
|
||
"Raw signal, nudge them by hand, or drop them with "
|
||
"sras_edit_scans.py.")
|
||
drifted = []
|
||
for a, _ in rows:
|
||
nominal = compute.nominal_delta_deg(self._sras, a, st.ref_angle_idx)
|
||
got = st.params[a].rotation_deg
|
||
dev = min(abs(got - nominal), abs(got + nominal))
|
||
if dev > 1.0:
|
||
drifted.append(f"{a} ({dev:.2f}°)")
|
||
if drifted:
|
||
parts.append("Rotation differs from the stage angle by >1° for "
|
||
"angle(s) " + ", ".join(drifted) + ".")
|
||
return " ".join(parts)
|
||
|
||
# ---- manual nudging ----------------------------------------------
|
||
|
||
def _on_active_changed(self, idx: int):
|
||
self._active_angle = idx
|
||
is_ref = idx == self._wiz.state.ref_angle_idx
|
||
for spin in (self.spin_rot, self.spin_shift_x, self.spin_shift_y):
|
||
spin.setEnabled(self._masks_ready and not is_ref)
|
||
self.lbl_active_note.setText(
|
||
"Reference angle — defines the shared origin, not adjustable."
|
||
if is_ref else "")
|
||
self._sync_spins()
|
||
self._redraw()
|
||
|
||
def _sync_spins(self):
|
||
p = self._wiz.state.params.get(self._active_angle, ManualAngleParams())
|
||
for spin, val in ((self.spin_rot, p.rotation_deg),
|
||
(self.spin_shift_x, p.shift_mm[0]),
|
||
(self.spin_shift_y, p.shift_mm[1])):
|
||
with QSignalBlocker(spin):
|
||
spin.setValue(val)
|
||
|
||
def _on_manual_edit(self):
|
||
if not self._masks_ready or self._active_angle == self._wiz.state.ref_angle_idx:
|
||
return
|
||
self._wiz.state.params[self._active_angle] = ManualAngleParams(
|
||
self.spin_rot.value(),
|
||
(self.spin_shift_x.value(), self.spin_shift_y.value()))
|
||
self._refresh()
|
||
|
||
def _on_nudge_translate(self, dir_x: int, dir_y: int, coarse: bool):
|
||
if not self._masks_ready or self._active_angle == self._wiz.state.ref_angle_idx:
|
||
return
|
||
step = self.spin_step_translate.value()
|
||
if coarse:
|
||
step *= self.spin_step_mult.value()
|
||
p = self._wiz.state.params[self._active_angle]
|
||
self._wiz.state.params[self._active_angle] = ManualAngleParams(
|
||
p.rotation_deg,
|
||
(p.shift_mm[0] + dir_x * step, p.shift_mm[1] + dir_y * step))
|
||
self._refresh()
|
||
|
||
def _on_nudge_rotate(self, direction: int, coarse: bool):
|
||
if not self._masks_ready or self._active_angle == self._wiz.state.ref_angle_idx:
|
||
return
|
||
step = self.spin_step_rotate.value()
|
||
if coarse:
|
||
step *= self.spin_step_mult.value()
|
||
p = self._wiz.state.params[self._active_angle]
|
||
self._wiz.state.params[self._active_angle] = ManualAngleParams(
|
||
p.rotation_deg + direction * step, p.shift_mm)
|
||
self._refresh()
|
||
|
||
# ---- drawing ------------------------------------------------------
|
||
|
||
def _set_controls_enabled(self, enabled: bool):
|
||
for w in (self.combo_ref, self.spin_threshold, self.combo_source,
|
||
self.combo_prerotate, self.chk_lock_rotation,
|
||
self.spin_search_deg, self.spin_coarse_step,
|
||
self.combo_fine_dim, self.btn_correlate, self.btn_reset,
|
||
self.grp_nudge):
|
||
w.setEnabled(enabled)
|
||
if enabled:
|
||
self._on_lock_toggled(self.chk_lock_rotation.isChecked())
|
||
self._on_active_changed(self._active_angle)
|
||
|
||
def _redraw(self):
|
||
st = self._wiz.state
|
||
if st.counts is None or st.result is None:
|
||
return
|
||
extent = self._wiz.preview_extent()
|
||
if self.combo_view.currentIndex() == 0:
|
||
self.canvas.show_counts(
|
||
st.counts, self._sras.n_angles, extent,
|
||
f"Mask stack — {self._sras.n_angles} angles, "
|
||
f"ref angle {st.ref_angle_idx}")
|
||
else:
|
||
self.canvas.show_overlay(
|
||
self._rgba(), extent,
|
||
f"Angle {self._active_angle} active "
|
||
f"({self._sras.angles_deg[self._active_angle]:.1f}°)")
|
||
self._update_overlap_text()
|
||
|
||
def _rgba(self) -> np.ndarray:
|
||
"""Alpha-composite each angle's mask in its own colour, active on top."""
|
||
st = self._wiz.state
|
||
n_rows, n_cols = st.preview_shape
|
||
rgba = np.zeros((n_rows, n_cols, 4), dtype=np.float32)
|
||
order = sorted(range(self._sras.n_angles),
|
||
key=lambda a: a == self._active_angle)
|
||
for a in order:
|
||
layer = st.layers.get(a)
|
||
if layer is None:
|
||
continue
|
||
alpha = _ACTIVE_ALPHA if a == self._active_angle else _BASE_ALPHA
|
||
color = self._wiz.angle_colors[a]
|
||
fg = layer * alpha
|
||
for c in range(3):
|
||
rgba[..., c] = color[c] * fg + rgba[..., c] * rgba[..., 3] * (1 - fg)
|
||
rgba[..., 3] = fg + rgba[..., 3] * (1 - fg)
|
||
return rgba
|
||
|
||
def _update_overlap_text(self):
|
||
st = self._wiz.state
|
||
n = self._sras.n_angles
|
||
stats = compute.overlap_stats(st.counts, n)
|
||
px = st.preview_pitch_mm
|
||
area = abs(px[0] * px[1])
|
||
self.lbl_overlap.setText(
|
||
f"Covered by any angle: {stats['union_px']:,} px "
|
||
f"({stats['union_px'] * area:.2f} mm²) · "
|
||
f"by all {n}: {stats['full_px']:,} px "
|
||
f"({stats['full_frac'] * 100:.1f}% of that) · "
|
||
f"mean overlap {stats['mean_count']:.2f} angles")
|
||
if stats["empty"]:
|
||
self.lbl_warn.setText(
|
||
"No angle covers any pixel — check the DC threshold.")
|
||
elif stats["max_count"] < n:
|
||
self.lbl_warn.setText(
|
||
f"No pixel is covered by all {n} angles (best is "
|
||
f"{stats['max_count']}). Either the alignment is wrong or these "
|
||
f"scans genuinely do not all overlap. You can still export.")
|
||
else:
|
||
self.lbl_warn.setText("")
|
||
|
||
def _update_table(self):
|
||
st = self._wiz.state
|
||
for a in range(self._sras.n_angles):
|
||
score, source = st.fits.get(a, (float("nan"), ""))
|
||
nominal = compute.nominal_delta_deg(self._sras, a, st.ref_angle_idx)
|
||
got = st.params[a].rotation_deg
|
||
dev = min(abs(got - nominal), abs(got + nominal))
|
||
is_ref = a == st.ref_angle_idx
|
||
cells = [
|
||
f"{a}" + (" [ref]" if is_ref else ""),
|
||
f"{got:.3f}",
|
||
"—" if is_ref else f"{dev:.2f}",
|
||
"—" if is_ref or np.isnan(score) else f"{score:.3f}",
|
||
"ref" if is_ref else (source or "—"),
|
||
]
|
||
bad = (not is_ref) and (score < 0 or source == "none")
|
||
weak = (not is_ref) and (not np.isnan(score)) and 0 <= score < 0.3
|
||
for col, text in enumerate(cells):
|
||
item = QTableWidgetItem(text)
|
||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
if bad:
|
||
item.setForeground(Qt.GlobalColor.red)
|
||
elif weak or (not is_ref and dev > 1.0):
|
||
item.setForeground(Qt.GlobalColor.darkYellow)
|
||
self.table.setItem(a, col, item)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Page 2 — ROI crop
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class RoiPage(QWizardPage):
|
||
"""Pick the rectangle of the shared canvas to keep.
|
||
|
||
Axis-aligned, because v6 geometry is (x_start, x_delta, n_frames, n_rows)
|
||
plus a Y row table — a rectangle on the canvas grid is the only crop the
|
||
file can express, and drawing something else would have to be squared off
|
||
without saying so.
|
||
"""
|
||
|
||
def __init__(self, wizard: AlignmentWizard):
|
||
super().__init__(wizard)
|
||
self._wiz = wizard
|
||
self._sras = wizard.sras
|
||
self._syncing = False
|
||
self._generation = -1
|
||
|
||
self.setTitle("Step 2 — Choose the region to keep")
|
||
self.setSubTitle(
|
||
"Drag a rectangle on the stack, or type canvas pixels directly. "
|
||
"Cropping to the region the angles actually share is usually a big "
|
||
"size saving over the full canvas.")
|
||
self._build_ui()
|
||
|
||
def _build_ui(self):
|
||
root = QHBoxLayout(self)
|
||
|
||
self.canvas = ImageCanvas(rect_only=True)
|
||
left = QWidget()
|
||
ll = QVBoxLayout(left)
|
||
ll.setContentsMargins(0, 0, 0, 0)
|
||
ll.setSpacing(4)
|
||
ll.addWidget(NavigationToolbar2QT(self.canvas, left))
|
||
ll.addWidget(self.canvas, stretch=1)
|
||
root.addWidget(left, stretch=1)
|
||
|
||
panel = QWidget()
|
||
pl = QVBoxLayout(panel)
|
||
pl.setContentsMargins(0, 0, 0, 0)
|
||
pl.setSpacing(8)
|
||
|
||
grp_fit, fl = _group("Preset")
|
||
self.btn_fit_overlap = QPushButton("Fit to full overlap")
|
||
self.btn_fit_union = QPushButton("Fit to any coverage")
|
||
self.btn_whole = QPushButton("Whole canvas (no crop)")
|
||
for b in (self.btn_fit_overlap, self.btn_fit_union, self.btn_whole):
|
||
fl.addWidget(b)
|
||
fl.addWidget(_wrap_label(
|
||
"“Full overlap” finds the largest rectangle lying entirely inside "
|
||
"the region every angle covers — not its bounding box, which would "
|
||
"include corners no angle reaches.", _CSS_HINT))
|
||
pl.addWidget(grp_fit)
|
||
|
||
grp_rect, rl = _group("Crop (canvas pixels)")
|
||
form = _form()
|
||
self.spin_col0 = QSpinBox()
|
||
self.spin_row0 = QSpinBox()
|
||
self.spin_cols = QSpinBox()
|
||
self.spin_rows = QSpinBox()
|
||
for spin, label in ((self.spin_col0, "First column:"),
|
||
(self.spin_row0, "First row:"),
|
||
(self.spin_cols, "Columns:"),
|
||
(self.spin_rows, "Rows:")):
|
||
spin.setRange(0, 1)
|
||
spin.setMinimumWidth(96)
|
||
form.addRow(label, spin)
|
||
rl.addLayout(form)
|
||
self.btn_draw = QPushButton("Draw a new rectangle")
|
||
rl.addWidget(self.btn_draw)
|
||
pl.addWidget(grp_rect)
|
||
|
||
grp_info, il = _group("Result")
|
||
self.lbl_extent = _wrap_label("", _CSS_INFO)
|
||
self.lbl_size = _wrap_label("", _CSS_INFO)
|
||
self.lbl_coverage = _wrap_label("", _CSS_MUTED)
|
||
self.lbl_warn = _wrap_label("", _CSS_WARN)
|
||
for w in (self.lbl_extent, self.lbl_size, self.lbl_coverage, self.lbl_warn):
|
||
il.addWidget(w)
|
||
pl.addWidget(grp_info)
|
||
|
||
pl.addStretch()
|
||
root.addWidget(_scroll_panel(panel, _PANEL_W))
|
||
|
||
self.btn_fit_overlap.clicked.connect(self._on_fit_overlap)
|
||
self.btn_fit_union.clicked.connect(self._on_fit_union)
|
||
self.btn_whole.clicked.connect(self._on_whole)
|
||
self.btn_draw.clicked.connect(self.canvas.start_drawing)
|
||
self.canvas.roi_changed.connect(self._on_roi_changed)
|
||
for spin in (self.spin_col0, self.spin_row0, self.spin_cols, self.spin_rows):
|
||
spin.valueChanged.connect(self._on_spin_changed)
|
||
|
||
# ---- QWizardPage contract ----------------------------------------
|
||
|
||
def initializePage(self):
|
||
st = self._wiz.state
|
||
# A crop is indexed in canvas pixels, so it is meaningless against a
|
||
# canvas built from different rotations. Drop a stale one rather than
|
||
# silently reinterpreting its indices.
|
||
if st.crop is not None and self._generation != st.geometry_generation:
|
||
st.crop = None
|
||
self._generation = st.geometry_generation
|
||
|
||
n_rows, n_cols = st.result.canvas_shape
|
||
# Blocked: setRange clamps the current value into the new range and
|
||
# emits valueChanged, which would run _on_spin_changed and commit a
|
||
# crop built from three not-yet-set spin boxes — landing on 1x1 and
|
||
# making the crop look already-chosen, so the preset below is skipped.
|
||
for spin, lo, hi in ((self.spin_col0, 0, n_cols - 1),
|
||
(self.spin_row0, 0, n_rows - 1),
|
||
(self.spin_cols, 1, n_cols),
|
||
(self.spin_rows, 1, n_rows)):
|
||
with QSignalBlocker(spin):
|
||
spin.setRange(lo, max(lo, hi))
|
||
self._draw_counts()
|
||
self.btn_fit_overlap.setEnabled(
|
||
compute.largest_rect_at_least(st.counts, self._sras.n_angles)
|
||
is not None)
|
||
if st.crop is None:
|
||
self._on_fit_union()
|
||
else:
|
||
self._set_crop(*st.crop)
|
||
|
||
def isComplete(self) -> bool:
|
||
crop = self._wiz.state.crop
|
||
return crop is not None and crop[2] >= 1 and crop[3] >= 1
|
||
|
||
def validatePage(self) -> bool:
|
||
st = self._wiz.state
|
||
cropped = compute.crop_alignment_result(st.result, *st.crop)
|
||
plan = export.plan_export(self._sras, cropped)
|
||
|
||
empty = [a for a in range(self._sras.n_angles) if plan.valid_px[a] == 0]
|
||
if len(empty) == self._sras.n_angles:
|
||
QMessageBox.warning(
|
||
self, "Empty crop",
|
||
"This rectangle contains no data from any angle. Move or grow "
|
||
"it before continuing.")
|
||
return False
|
||
if empty:
|
||
answer = QMessageBox.question(
|
||
self, "Some angles are empty",
|
||
f"Angle(s) {', '.join(map(str, empty))} have no data inside "
|
||
f"this crop and would be written as padding.\n\nContinue anyway?")
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return False
|
||
|
||
st.cropped_result = cropped
|
||
return True
|
||
|
||
def cleanupPage(self):
|
||
"""Going back means the canvas geometry may change under this crop."""
|
||
self._wiz.state.crop = None
|
||
self._wiz.state.cropped_result = None
|
||
|
||
# ---- drawing / presets --------------------------------------------
|
||
|
||
def _draw_counts(self):
|
||
st = self._wiz.state
|
||
n = self._sras.n_angles
|
||
# Same colormap the previous page used, so a count keeps its colour
|
||
# across the two pages that show this image on different canvas classes.
|
||
cmap, norm, ticks = count_colormap(n)
|
||
self.canvas.show_image(
|
||
st.counts, self._wiz.preview_extent(), cmap, 0.0, 0.0,
|
||
"X (mm)", "Y (mm)",
|
||
f"Overlap count — drag the crop rectangle ({n} angles)",
|
||
colorbar_label="angles overlapping", cb_ticks=ticks, norm=norm)
|
||
|
||
def _coarse_rect_to_canvas(self, rect) -> tuple[int, int, int, int]:
|
||
"""A rectangle in coarse preview indices, as final canvas pixels.
|
||
|
||
Inset by one coarse block on each side. The coarse grid samples canvas
|
||
pixels 0, fx, 2fx, …, so a coarse pixel reported as fully covered
|
||
stands for a block whose far edge may not be; shrinking by a block keeps
|
||
a "fit to full overlap" rectangle honestly inside the overlap region.
|
||
"""
|
||
st = self._wiz.state
|
||
fy, fx = st.downsample
|
||
row0, col0, nr, nc = rect
|
||
n_rows, n_cols = st.result.canvas_shape
|
||
r0 = min(n_rows - 1, row0 * fy + fy)
|
||
c0 = min(n_cols - 1, col0 * fx + fx)
|
||
nr_c = max(1, min(n_rows - r0, nr * fy - 2 * fy))
|
||
nc_c = max(1, min(n_cols - c0, nc * fx - 2 * fx))
|
||
return r0, c0, nr_c, nc_c
|
||
|
||
def _on_fit_overlap(self):
|
||
st = self._wiz.state
|
||
rect = compute.largest_rect_at_least(st.counts, self._sras.n_angles)
|
||
if rect is None:
|
||
return
|
||
self._set_crop(*self._coarse_rect_to_canvas(rect))
|
||
|
||
def _on_fit_union(self):
|
||
st = self._wiz.state
|
||
rows, cols = np.nonzero(st.counts > 0)
|
||
if rows.size == 0:
|
||
self._on_whole()
|
||
return
|
||
fy, fx = st.downsample
|
||
n_rows, n_cols = st.result.canvas_shape
|
||
r0 = int(rows.min()) * fy
|
||
c0 = int(cols.min()) * fx
|
||
r1 = min(n_rows, (int(rows.max()) + 1) * fy)
|
||
c1 = min(n_cols, (int(cols.max()) + 1) * fx)
|
||
self._set_crop(r0, c0, max(1, r1 - r0), max(1, c1 - c0))
|
||
|
||
def _on_whole(self):
|
||
n_rows, n_cols = self._wiz.state.result.canvas_shape
|
||
self._set_crop(0, 0, n_rows, n_cols)
|
||
|
||
# ---- two-way sync -------------------------------------------------
|
||
|
||
def _set_crop(self, row0: int, col0: int, n_rows: int, n_cols: int):
|
||
st = self._wiz.state
|
||
cr, cc = st.result.canvas_shape
|
||
row0 = int(np.clip(row0, 0, cr - 1))
|
||
col0 = int(np.clip(col0, 0, cc - 1))
|
||
n_rows = int(np.clip(n_rows, 1, cr - row0))
|
||
n_cols = int(np.clip(n_cols, 1, cc - col0))
|
||
st.crop = (row0, col0, n_rows, n_cols)
|
||
|
||
self._syncing = True
|
||
try:
|
||
for spin, val in ((self.spin_row0, row0), (self.spin_col0, col0),
|
||
(self.spin_rows, n_rows), (self.spin_cols, n_cols)):
|
||
with QSignalBlocker(spin):
|
||
spin.setValue(val)
|
||
x0, y0 = self._wiz.canvas_to_mm(col0 - 0.5, row0 - 0.5)
|
||
x1, y1 = self._wiz.canvas_to_mm(col0 + n_cols - 0.5,
|
||
row0 + n_rows - 0.5)
|
||
self.canvas.set_roi(RoiQuad.from_bbox(min(x0, x1), min(y0, y1),
|
||
max(x0, x1), max(y0, y1)))
|
||
finally:
|
||
self._syncing = False
|
||
self._update_readout()
|
||
self.completeChanged.emit()
|
||
|
||
def _on_spin_changed(self):
|
||
if self._syncing:
|
||
return
|
||
self._set_crop(self.spin_row0.value(), self.spin_col0.value(),
|
||
self.spin_rows.value(), self.spin_cols.value())
|
||
|
||
def _on_roi_changed(self):
|
||
# set_roi itself emits roi_changed, so without the guard _set_crop
|
||
# would re-enter through its own canvas update.
|
||
if self._syncing:
|
||
return
|
||
roi = self.canvas.get_roi()
|
||
if roi is None:
|
||
return
|
||
pts = roi.corners()
|
||
(x0, y0), (x1, y1) = pts.min(axis=0), pts.max(axis=0)
|
||
c_a, r_a = self._wiz.mm_to_canvas(x0, y0)
|
||
c_b, r_b = self._wiz.mm_to_canvas(x1, y1)
|
||
col0, col1 = sorted((c_a, c_b))
|
||
row0, row1 = sorted((r_a, r_b))
|
||
# Both edges snap to the nearest pixel boundary with the same rule, so
|
||
# the numeric boxes -> rectangle -> numeric boxes round trip is exact.
|
||
# _SNAP_TOL absorbs the float noise of the mm round trip: a boundary
|
||
# that should land on 11.0 arrives as 11.000000000000002, and a bare
|
||
# ceil() turns that into 12 — one spurious column per edit.
|
||
r0, r1 = _snap_edge(row0), _snap_edge(row1)
|
||
c0, c1 = _snap_edge(col0), _snap_edge(col1)
|
||
self._set_crop(r0, c0, max(1, r1 - r0), max(1, c1 - c0))
|
||
|
||
def _update_readout(self):
|
||
st = self._wiz.state
|
||
row0, col0, n_rows, n_cols = st.crop
|
||
x0, y0 = self._wiz.canvas_to_mm(col0, row0)
|
||
x1, y1 = self._wiz.canvas_to_mm(col0 + n_cols - 1, row0 + n_rows - 1)
|
||
self.lbl_extent.setText(
|
||
f"Output: {n_cols:,} × {n_rows:,} px "
|
||
f"X {min(x0, x1):.3f} … {max(x0, x1):.3f} mm "
|
||
f"Y {min(y0, y1):.3f} … {max(y0, y1):.3f} mm")
|
||
|
||
cropped = compute.crop_alignment_result(st.result, *st.crop)
|
||
plan = export.plan_export(self._sras, cropped)
|
||
self.lbl_size.setText(f"Estimated file size: {_humanize(plan.total_bytes)}"
|
||
f" ({self._sras.n_angles} angles)")
|
||
self.lbl_coverage.setText("Real data per angle: " + ", ".join(
|
||
f"{a}: {plan.coverage_frac(a) * 100:.0f}%"
|
||
for a in range(self._sras.n_angles)))
|
||
empty = [a for a in range(self._sras.n_angles) if plan.valid_px[a] == 0]
|
||
self.lbl_warn.setText(
|
||
f"Angle(s) {', '.join(map(str, empty))} have no data here and would "
|
||
f"be written as padding." if empty else "")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Page 3 — save
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class SavePage(QWizardPage):
|
||
"""Choose a destination and write the aligned, cropped scan.
|
||
|
||
The write runs on a worker and is started by a button rather than from
|
||
validatePage, which must not block the GUI thread for what can be minutes of
|
||
I/O. Finish only becomes available once a file has actually been written, so
|
||
the wizard cannot be completed on a failed export.
|
||
"""
|
||
|
||
def __init__(self, wizard: AlignmentWizard):
|
||
super().__init__(wizard)
|
||
self._wiz = wizard
|
||
self._sras = wizard.sras
|
||
self._busy = False
|
||
self._worker = None
|
||
|
||
self.setTitle("Step 3 — Save the aligned scan")
|
||
self.setSubTitle(
|
||
"Writes a new v6 .sras in which every angle shares the cropped "
|
||
"grid, so it opens already aligned. The original is not modified.")
|
||
self._build_ui()
|
||
|
||
def _build_ui(self):
|
||
root = QVBoxLayout(self)
|
||
|
||
row = QHBoxLayout()
|
||
row.addWidget(QLabel("Save to:"))
|
||
self.edit_path = QLineEdit()
|
||
self.edit_path.setReadOnly(True)
|
||
row.addWidget(self.edit_path, stretch=1)
|
||
self.btn_browse = QPushButton("Browse…")
|
||
row.addWidget(self.btn_browse)
|
||
root.addLayout(row)
|
||
|
||
grp, gl = _group("What will be written")
|
||
self.lbl_summary = _wrap_label("", _CSS_INFO)
|
||
gl.addWidget(self.lbl_summary)
|
||
self.lbl_notes = _wrap_label("", _CSS_WARN)
|
||
gl.addWidget(self.lbl_notes)
|
||
root.addWidget(grp)
|
||
|
||
run = QHBoxLayout()
|
||
self.btn_export = QPushButton("Write .sras")
|
||
run.addWidget(self.btn_export)
|
||
self.btn_cancel = QPushButton("Cancel write")
|
||
self.btn_cancel.setEnabled(False)
|
||
run.addWidget(self.btn_cancel)
|
||
run.addStretch()
|
||
root.addLayout(run)
|
||
|
||
self.progress = QProgressBar()
|
||
self.progress.setRange(0, 100)
|
||
root.addWidget(self.progress)
|
||
|
||
self.lbl_status = _wrap_label("", _CSS_MUTED)
|
||
root.addWidget(self.lbl_status)
|
||
root.addStretch()
|
||
|
||
self.btn_browse.clicked.connect(self._on_browse)
|
||
self.btn_export.clicked.connect(self._on_export)
|
||
self.btn_cancel.clicked.connect(self.request_stop)
|
||
|
||
# ---- QWizardPage contract ----------------------------------------
|
||
|
||
def initializePage(self):
|
||
st = self._wiz.state
|
||
if not st.out_path:
|
||
src = Path(self._sras.path)
|
||
st.out_path = str(src.with_name(f"{src.stem}_aligned.sras"))
|
||
self.edit_path.setText(st.out_path)
|
||
st.exported_path = ""
|
||
self.progress.setValue(0)
|
||
self.lbl_status.setText("")
|
||
self._update_summary()
|
||
self._wiz.setButtonText(QWizard.WizardButton.FinishButton, "Done")
|
||
self.completeChanged.emit()
|
||
|
||
def isComplete(self) -> bool:
|
||
return bool(self._wiz.state.exported_path) and not self._busy
|
||
|
||
def request_stop(self):
|
||
if self._worker is not None:
|
||
self._worker.stop()
|
||
self.lbl_status.setText("Cancelling…")
|
||
|
||
# ---- summary ------------------------------------------------------
|
||
|
||
def _update_summary(self):
|
||
st = self._wiz.state
|
||
plan = export.plan_export(self._sras, st.cropped_result)
|
||
x0, y0 = st.cropped_result.canvas_origin_mm
|
||
self.lbl_summary.setText(
|
||
f"Format: .sras v6, no precomputed cache (the viewer recomputes "
|
||
f"DC/FFT on first open).\n"
|
||
f"Angles: {plan.n_angles}, all sharing one grid of "
|
||
f"{plan.n_frames:,} × {plan.n_rows:,} px.\n"
|
||
f"Origin: X {x0:.4f} mm, Y {y0:.4f} mm · "
|
||
f"pitch {st.cropped_result.canvas_dx_mm * 1000:.2f} × "
|
||
f"{abs(st.cropped_result.canvas_dy_mm) * 1000:.2f} µm.\n"
|
||
f"Stage angles, calibration preambles and the background waveform "
|
||
f"are carried over unchanged.\n"
|
||
f"Size: {_humanize(plan.bytes_per_angle)} per angle, "
|
||
f"{_humanize(plan.total_bytes)} total.\n"
|
||
f"Real data per angle: " + ", ".join(
|
||
f"{a}: {plan.coverage_frac(a) * 100:.0f}%"
|
||
for a in range(plan.n_angles)))
|
||
self.lbl_notes.setText("\n".join(plan.warnings))
|
||
|
||
# ---- export -------------------------------------------------------
|
||
|
||
def _on_browse(self):
|
||
st = self._wiz.state
|
||
path, _ = QFileDialog.getSaveFileName(
|
||
self, "Save aligned .sras", st.out_path,
|
||
"SRAS scans (*.sras);;All files (*)")
|
||
if not path:
|
||
return
|
||
if not path.lower().endswith(".sras"):
|
||
path += ".sras"
|
||
st.out_path = path
|
||
self.edit_path.setText(path)
|
||
st.exported_path = ""
|
||
self.completeChanged.emit()
|
||
|
||
def _on_export(self):
|
||
st = self._wiz.state
|
||
if self._busy or not st.out_path:
|
||
return
|
||
|
||
worker = AlignedExportWorker(self._sras, st.cropped_result, st.out_path)
|
||
self._busy = True
|
||
self.completeChanged.emit()
|
||
self.btn_export.setEnabled(False)
|
||
self.btn_browse.setEnabled(False)
|
||
self.progress.setValue(0)
|
||
self.lbl_status.setText(f"Writing {Path(st.out_path).name}…")
|
||
|
||
started = self._wiz._parent._run_worker(
|
||
Jobs.ALIGN_EXPORT, worker,
|
||
connect=(("progress", self.progress.setValue),
|
||
("finished", self._on_export_finished)))
|
||
if not started:
|
||
self._busy = False
|
||
self.completeChanged.emit()
|
||
self.btn_export.setEnabled(True)
|
||
self.btn_browse.setEnabled(True)
|
||
self.lbl_status.setText(
|
||
"Another alignment step is still running — try again shortly.")
|
||
return
|
||
self._worker = worker
|
||
self.btn_cancel.setEnabled(True)
|
||
|
||
def _on_export_finished(self, written: str, error: str):
|
||
st = self._wiz.state
|
||
self._worker = None
|
||
self._busy = False
|
||
self.btn_export.setEnabled(True)
|
||
self.btn_browse.setEnabled(True)
|
||
self.btn_cancel.setEnabled(False)
|
||
|
||
if error:
|
||
self.progress.setValue(0)
|
||
self.lbl_status.setText(f"Export failed: {error}")
|
||
elif not written:
|
||
self.progress.setValue(0)
|
||
self.lbl_status.setText("Export cancelled; no file was written.")
|
||
else:
|
||
st.exported_path = written
|
||
self.progress.setValue(100)
|
||
self.lbl_status.setText(
|
||
f"Wrote {Path(written).name}. Choose Done to apply this "
|
||
f"alignment to the open scan as well.")
|
||
self.completeChanged.emit()
|
||
self._wiz.maybe_close_after_job()
|
||
|
||
|
||
# Slack when snapping a rectangle edge, in canvas pixels. The mm round trip is
|
||
# two multiplications and a subtraction, so an exact boundary can come back a
|
||
# few ULPs either side of the integer.
|
||
_SNAP_TOL = 1e-6
|
||
|
||
|
||
def _combo(items) -> QComboBox:
|
||
"""A combo box whose size hint does not depend on its longest entry.
|
||
|
||
By default a QComboBox asks for enough width to show its widest item. These
|
||
hold descriptive phrases, and the panel lives in a fixed-width scroll area
|
||
with the horizontal scrollbar off (`_scroll_panel`) — so an unconstrained
|
||
hint pushes the inner widget past the panel and everything on the right,
|
||
including the hint text, is silently clipped instead of scrolling.
|
||
|
||
*items* is a sequence of (label, data) pairs, or of plain labels.
|
||
"""
|
||
combo = QComboBox()
|
||
combo.setSizeAdjustPolicy(
|
||
QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
|
||
combo.setMinimumContentsLength(10)
|
||
for item in items:
|
||
if isinstance(item, tuple):
|
||
combo.addItem(item[0], item[1])
|
||
else:
|
||
combo.addItem(item)
|
||
return combo
|
||
|
||
|
||
def _snap_edge(coord: float) -> int:
|
||
"""A fractional pixel-index edge, as the nearest pixel boundary index.
|
||
|
||
Pixel k spans [k - 0.5, k + 0.5), so a boundary at fractional coordinate
|
||
*coord* is boundary index coord + 0.5.
|
||
"""
|
||
return int(np.floor(coord + 0.5 + _SNAP_TOL))
|
||
|
||
|
||
def _humanize(n_bytes: int) -> str:
|
||
value = float(n_bytes)
|
||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||
if value < 1024 or unit == "TB":
|
||
return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B"
|
||
value /= 1024
|
||
return f"{value:.1f} TB"
|