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:
+134
@@ -171,6 +171,140 @@ for the same reason, and every affine maps shared-grid index → mm → undo
|
||||
rotation/shift → that angle's own local mm → that angle's own raw index,
|
||||
matching the output→input convention `scipy.ndimage.affine_transform` wants.
|
||||
|
||||
### Cropping the canvas is index translation, not a second transform
|
||||
|
||||
`crop_alignment_result` restricts an `AlignmentResult` to a rectangular window
|
||||
of its canvas by folding the crop into each angle's existing affine rather than
|
||||
composing a new one. From `_affine_out_to_src`, `matrix = D @ Rinv @ A_out`
|
||||
depends only on the pitches and the rotation, and `A_out @ [row0, col0]` is
|
||||
exactly the mm displacement of the new origin, so
|
||||
|
||||
```
|
||||
matrix @ [r', c'] + (offset + matrix @ [row0, col0])
|
||||
== matrix @ [r' + row0, c' + col0] + offset
|
||||
```
|
||||
|
||||
identically. `matrix` is untouched and `offset` — which already absorbs the
|
||||
origin — absorbs the crop too.
|
||||
|
||||
Two things follow, and both are relied on. `apply_alignment`, `reproject_mask`
|
||||
and the aligned exporter all work on a cropped result with no special-casing:
|
||||
resampling a cropped result is *exactly* a slice of resampling the full one
|
||||
(`tests/test_align_export.py::test_crop_is_a_window_of_the_full_canvas` asserts
|
||||
bit equality). And because the crop offset is a whole number of canvas pixels,
|
||||
`canvas_for_params`' snap invariant — the reference angle lands on integer
|
||||
canvas pixels — survives the crop, which is what keeps the reference exportable
|
||||
as a verbatim block.
|
||||
|
||||
## Aligned export (`sras_align_export.py`)
|
||||
|
||||
`write_aligned_sras` bakes an alignment into a new v6 file: every angle
|
||||
resampled onto the cropped shared canvas, so all of them end up with identical
|
||||
geometry and the file opens already aligned. It is the only place in the
|
||||
codebase that *resamples* waveform data — `sras_edit_scans` and `sras_average`
|
||||
copy waveform bytes verbatim — which is why it is its own top-level module
|
||||
rather than part of `sras_format` (scoped to the versioned binary spec, per the
|
||||
sidecar section's own rule) or `sras_compute` (imported by every
|
||||
multiprocessing child).
|
||||
|
||||
**Nearest neighbour, never interpolation.** Each output pixel gets exactly one
|
||||
source pixel's three waveforms, verbatim. Averaging two neighbouring CH1
|
||||
packets would synthesise a waveform the instrument never measured, whose FFT
|
||||
peak is the peak of neither — meaningless for a technique whose entire output is
|
||||
that peak frequency. The cost is that some source pixels are duplicated and
|
||||
others dropped, which is the same trade `apply_alignment`'s `order=0` already
|
||||
makes for the display.
|
||||
|
||||
**The rounding rule is `floor(x + 0.5)`, not `np.rint`.** `scipy.ndimage`'s
|
||||
`order=0` rounds halves away from zero while `np.rint` rounds them to even. The
|
||||
canvas is snapped to the reference's own pixel grid, so an angle whose row pitch
|
||||
differs from the reference's lands on exact half-integers across whole rows —
|
||||
this is the common case, not a corner case. Getting it wrong shifts those rows
|
||||
by one source pixel relative to what the Aligned View drew.
|
||||
|
||||
**Out-of-bounds is tested on the fractional coordinate, not the rounded index.**
|
||||
`scipy`'s `mode="constant"` writes `cval` wherever the coordinate leaves the
|
||||
range of sample *centres*, `[0, n-1]` — a coordinate of −0.4 rounds to a
|
||||
perfectly valid index 0 and is still padding. Testing the rounded index instead
|
||||
puts a one-pixel rim of real data everywhere the preview shows padding.
|
||||
|
||||
**...but with a tolerance (`_EDGE_TOL`).** The affine is built from a chain of
|
||||
mm-space multiplications, so an exactly-integer transform comes out a few times
|
||||
1e-13 off: the reference angle's offset is `-20 - 7e-15`, not `-20`. A bare
|
||||
`>= 0.0` therefore rejects that angle's entire first row, and `<= n-1` its last
|
||||
column — for the *reference* angle, whose whole job is to pass through as an
|
||||
exact integer crop. The tolerance is ~7 orders of magnitude above that noise and
|
||||
~7 below the half-pixel scale at which a rounding decision means anything, so it
|
||||
can only ever change pixels whose scipy answer was itself decided by noise.
|
||||
|
||||
**Padding is the per-channel ADC code nearest 0 mV, not 0.** Zero ADC decodes to
|
||||
`(0 - yoff) * ymult + yzero`, which on real calibration is around +100 mV —
|
||||
above any sensible CH4 mask threshold, so a zero fill would paint a solid
|
||||
rectangle of "valid" pixels around the sample and corrupt every DC image and ROI
|
||||
statistic downstream.
|
||||
|
||||
**Source rows are served from sliding in-RAM bands** (`_SourceReader`). A
|
||||
rotated angle maps one output row to a *diagonal* across the source array, so
|
||||
the pixels of a single output row come from hundreds of different source rows —
|
||||
~1.4 MB each on a full-size scan. Indexing a memmap pixel-by-pixel in output
|
||||
order re-faults nearly the whole angle per output row: terabytes of paging for a
|
||||
gigabyte of data. Reading a contiguous band per output chunk, with the band
|
||||
advancing monotonically, costs roughly 2× the source size in total reads.
|
||||
|
||||
**Writes go to `.part` and are `os.replace`d into position.** Not politeness: a
|
||||
truncated .sras is not detectably broken, because `_parse_v6` drops incomplete
|
||||
trailing angle blocks and opens what is left as an aborted scan. A half-written
|
||||
export left in place would silently look like a real file with fewer angles.
|
||||
|
||||
**The Angle Table is carried over unchanged.** Alignment removes the *spatial*
|
||||
rotation of the sample; it does not change which acoustic propagation direction
|
||||
each angle measured, and that direction is the scientific content of a
|
||||
multi-angle scan. Zeroing the table would make the export self-consistent for
|
||||
re-registration and useless for anisotropy work. The consequence is that
|
||||
re-registering an export needs `seed_deg=0.0` to put 0° inside the coarse sweep,
|
||||
since `nominal_delta_deg` is still non-zero — which is exactly what the seed
|
||||
parameter exists for.
|
||||
|
||||
## Alignment wizard (`sras_viewer/align_wizard.py`)
|
||||
|
||||
A `QWizard` rather than another dialog because the three steps are genuinely
|
||||
sequential and the last one is destructive: correlate, choose a crop, write a
|
||||
file. It replaces both former Fusion actions, so it also absorbs the old
|
||||
`ManualAlignmentDialog`'s by-eye nudge editor — otherwise a scan the search
|
||||
cannot fit would have no fallback at all.
|
||||
|
||||
Shared state lives on the wizard object, not in `registerField`: the pages pass
|
||||
numpy arrays, `ManualAngleParams` and an `AlignmentResult` between them, none of
|
||||
which are scalar widget properties.
|
||||
|
||||
`IndependentPages` is deliberately left **off**. With it set Qt never calls
|
||||
`cleanupPage`, and `cleanupPage` is how the ROI page discards a crop when the
|
||||
user goes back to re-correlate — a crop is indexed in canvas pixels, and a new
|
||||
rotation means a different canvas, so stale indices would silently be
|
||||
reinterpreted against the wrong grid. `geometry_generation` is the belt-and-
|
||||
braces check for the same hazard.
|
||||
|
||||
The mask-stack preview shares the **final** canvas's origin and uses a pitch
|
||||
that is an integer multiple of it, unlike the old manual dialog's padded,
|
||||
unsnapped preview canvas. That is what lets the crop page convert a rectangle
|
||||
drawn in millimetres into an exact integer window of the real canvas, with no
|
||||
second coordinate frame to reconcile.
|
||||
|
||||
"Fit to full overlap" uses `largest_rect_at_least`, a largest-rectangle sweep,
|
||||
not a bounding box of the fully-covered pixels. The full-overlap region of
|
||||
several rotated scans is roughly a disc, and its bounding box has corners no
|
||||
angle covers — offering that as the crop would hand the user the padding they
|
||||
were trying to avoid.
|
||||
|
||||
Every background launch follows the two rules `_run_worker`'s docstring
|
||||
establishes: disable the trigger *before* the call (so a re-entrant click cannot
|
||||
start a second thread over the first), and never ignore the returned bool.
|
||||
Progress is an inline `QProgressBar` on the page rather than a `QProgressDialog`
|
||||
— a window-modal popup over a wizard both looks wrong and reintroduces the
|
||||
event-loop pumping hazard that ordering exists to avoid. `reject()` refuses to
|
||||
close while a job is in flight, since the running worker's signals are connected
|
||||
to bound methods of the pages Qt would be deleting.
|
||||
|
||||
## Manual-alignment sidecar (`sras_compute.py`)
|
||||
|
||||
`<name>.sras.align.json` lives next to the scan file. The code lives in
|
||||
|
||||
@@ -31,6 +31,7 @@ py-modules = [
|
||||
"sras_format",
|
||||
"sras_compute",
|
||||
"sras_workers",
|
||||
"sras_align_export",
|
||||
"sras_average",
|
||||
"sras_edit_scans",
|
||||
]
|
||||
|
||||
@@ -173,6 +173,41 @@ sum over angles a of: n_rows[a] × 3 × n_frames[a] × samples_per_frame × byte
|
||||
> Table and check `file_size` against the running total before reshaping —
|
||||
> a fixed `(n_angles, n_rows, ...)` reshape (as in pre-v6 readers) will not
|
||||
> work since row/frame counts are no longer uniform across angles.
|
||||
>
|
||||
> A consequence worth stating explicitly: because a short file opens
|
||||
> *successfully* as a scan with fewer angles, a truncated file is not
|
||||
> detectably broken. Anything that writes a .sras must therefore stage to a
|
||||
> temporary name and rename on success — the viewer's aligned export writes
|
||||
> `<name>.part` and `os.replace`s it — or an interrupted write leaves behind
|
||||
> something that loads without complaint and silently has the wrong angle count.
|
||||
|
||||
---
|
||||
|
||||
## Files written by the viewer's Alignment Wizard
|
||||
|
||||
The acquisition app is not the only producer of this format. The viewer's
|
||||
`Fusion → Alignment Wizard…` writes a **v6** file holding the aligned, cropped
|
||||
stack, with these properties:
|
||||
|
||||
* Every angle shares one grid — the cropped alignment canvas — so the
|
||||
Per-Angle Geometry Table is `n_angles` identical records and the ragged Row
|
||||
Table is `n_angles` identical spans. The raggedness v6 exists for is still
|
||||
*expressible*, just unused, so any v6 reader works unchanged.
|
||||
* `x_delta` is the reference angle's own pitch, which is exactly
|
||||
`velocity_mm_s / laser_freq_hz`, so the derived X axis stays consistent with
|
||||
the header.
|
||||
* The `*_nominal` header fields describe the crop. Uniquely for these files they
|
||||
coincide with the actual per-angle geometry, since after alignment every angle
|
||||
really does scan the same box.
|
||||
* The **Angle Table is unchanged**. Alignment removes the sample's spatial
|
||||
rotation, not the acoustic propagation direction each angle measured — that
|
||||
direction is the point of a multi-angle scan, so it is preserved.
|
||||
* Output pixels with no corresponding source pixel (the canvas corners a rotated
|
||||
scan cannot reach) hold the per-channel ADC code nearest **0 mV**, not zero.
|
||||
Zero ADC decodes to roughly +100 mV on real calibration and would read as
|
||||
signal.
|
||||
* No Cache Tail is written: any cached DC/FFT is indexed by the source's grid
|
||||
and would be meaningless on the new one.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -292,6 +292,14 @@ class _SourceReader:
|
||||
|
||||
Small angles skip the machinery — if the whole block fits the budget it is
|
||||
materialized once and every band is a view of it.
|
||||
|
||||
One honest caveat: a chunk whose diagonal spans more source rows than the
|
||||
budget allows still gets the band it asked for, so the budget can be
|
||||
overshot. The overshoot is bounded by the span of _ROW_CHUNK output rows,
|
||||
and in the worst case (an extreme rotation on a huge scan) that is the whole
|
||||
angle — i.e. no worse than the in-RAM path above. Accepted deliberately:
|
||||
correctness of the gather is not negotiable, and the alternative is the
|
||||
memmap thrashing this class exists to avoid.
|
||||
"""
|
||||
|
||||
def __init__(self, sras: SrasFile, angle_idx: int, budget: int):
|
||||
|
||||
+6
-7
@@ -1466,7 +1466,7 @@ def canvas_for_params(sras: SrasFile, ref_angle_idx: int,
|
||||
pitch; the manual-alignment preview passes a coarser pitch and snap=False.
|
||||
|
||||
margin_frac pads the box on every side: 0 for a final canvas, nonzero for
|
||||
ManualAlignmentDialog's preview canvas, which needs headroom so an ordinary
|
||||
the wizard's preview canvas, which needs headroom so an ordinary
|
||||
translation nudge never has to trigger a full canvas resize (an extreme
|
||||
nudge can still push content past this padding; accepted, and cheap to
|
||||
recover from by re-opening the dialog).
|
||||
@@ -1694,7 +1694,7 @@ def compute_angle_alignment(sras: SrasFile, ref_angle_idx: int,
|
||||
"""Top-level alignment driver: register every angle onto *ref_angle_idx* by
|
||||
content, then lay them all out on that angle's own coordinate grid.
|
||||
|
||||
Runs on a background thread (see AngleAlignmentWorker) — deliberately
|
||||
Meant for a background thread — deliberately
|
||||
recomputes CH4 DC images from scratch rather than reading the GUI-thread
|
||||
_dc_cache dict, since background-thread workers must not touch
|
||||
GUI-thread-owned caches.
|
||||
@@ -1757,7 +1757,7 @@ def compute_angle_alignment(sras: SrasFile, ref_angle_idx: int,
|
||||
# with no per-pixel image work at all, so build_manual_alignment is cheap enough
|
||||
# to call synchronously on the GUI thread on every edit. The only genuinely
|
||||
# expensive per-pixel operation anywhere in this flow is reproject_mask, and
|
||||
# only ManualAlignmentDialog's own downsampled preview calls that per keystroke
|
||||
# only the wizard's own downsampled mask stack calls that per keystroke
|
||||
# — see that class's docstring for how it limits each nudge to reprojecting only
|
||||
# the actively-edited angle.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1770,9 +1770,8 @@ def reproject_mask(sras: SrasFile, angle_idx: int, ref_angle_idx: int,
|
||||
canvas_shape: tuple[int, int],
|
||||
*, src_downsample: tuple[int, int] = (1, 1)) -> np.ndarray:
|
||||
"""Resample one angle's binary/float mask onto an arbitrary canvas via an
|
||||
explicit rotation+shift — the single building block ManualAlignmentDialog's
|
||||
live preview repeatedly calls (once per keystroke, for only the
|
||||
actively-nudged angle). *src_downsample* must match the (rows, cols)
|
||||
explicit rotation+shift — the single building block the alignment wizard's
|
||||
live mask stack repeatedly calls (once per angle per edit). *src_downsample* must match the (rows, cols)
|
||||
block-mean factor already applied to *mask*, or the reprojection lands at
|
||||
the wrong scale. order=0 (nearest) matches apply_alignment's own reasoning:
|
||||
a binary mask must never be blended with zero-padding."""
|
||||
@@ -1885,7 +1884,7 @@ def delete_manual_alignment(sras: SrasFile) -> bool:
|
||||
"""Delete the sidecar if present. Returns whether a file actually existed
|
||||
to delete, so Clear Alignment's status message can say so. Genuine I/O
|
||||
errors (permission denied, read-only share) propagate — the caller
|
||||
(ManualAlignmentDialog._on_clear) surfaces them rather than silently
|
||||
(the wizard's Clear path) surfaces them rather than silently
|
||||
pretending the destructive action succeeded."""
|
||||
path = sidecar_path(sras.path)
|
||||
try:
|
||||
|
||||
@@ -14,6 +14,11 @@ Handles v2-v7. Any precomputed FFT/DC cache (v5 PREC tail, v7 CACH tail) is
|
||||
dropped on write, since it's indexed by angle and would be stale/misaligned
|
||||
after renumbering; the viewer just recomputes it next time the file opens.
|
||||
|
||||
This tool only ever *drops* angles — every kept angle's waveform bytes and
|
||||
geometry are carried across verbatim. To write a file whose angles have been
|
||||
resampled onto one shared aligned grid and cropped, use the viewer's
|
||||
Fusion -> Alignment Wizard (sras_align_export.py) instead.
|
||||
|
||||
Usage:
|
||||
python sras_edit_scans.py input.sras --list
|
||||
python sras_edit_scans.py input.sras output.sras --drop 2,5
|
||||
|
||||
@@ -18,9 +18,10 @@ import faulthandler
|
||||
|
||||
faulthandler.enable() # print a native stack trace on SIGSEGV/SIGABRT/etc.
|
||||
|
||||
from .canvases import ImageCanvas, RoiQuad, WaveformCanvas # noqa: E402,F401
|
||||
from .common import CH_LABELS, CMAPS, VELOCITY_MODE_IDX # noqa: E402,F401
|
||||
from .dialogs import ( # noqa: E402,F401
|
||||
FftOptionsDialog, ManualAlignmentDialog, RowAverageFftOptionsDialog,
|
||||
from .align_wizard import AlignmentWizard # noqa: E402,F401
|
||||
from .canvases import ( # noqa: E402,F401
|
||||
AlignOverlayCanvas, ImageCanvas, RoiQuad, WaveformCanvas,
|
||||
)
|
||||
from .common import CH_LABELS, CMAPS, VELOCITY_MODE_IDX # noqa: E402,F401
|
||||
from .dialogs import FftOptionsDialog, RowAverageFftOptionsDialog # noqa: E402,F401
|
||||
from .main_window import SrasViewerWindow, main # noqa: E402,F401
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+32
-13
@@ -13,6 +13,27 @@ from PyQt6.QtWidgets import QSizePolicy
|
||||
|
||||
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES, SrasFile, adc_to_mv
|
||||
|
||||
def count_colormap(n_angles: int):
|
||||
"""(cmap, norm, ticks) for an integer "how many angles cover this pixel"
|
||||
image, 0..n_angles.
|
||||
|
||||
Discrete, not continuous: the judgement the wizard's stack view exists for
|
||||
is "is this a plateau at N, or a fan of partial overlaps", so a region
|
||||
covered by one angle too few has to read as its own band rather than a
|
||||
slightly darker shade. Count 0 is fully transparent so uncovered canvas
|
||||
cannot be mistaken for a low count.
|
||||
|
||||
Shared by both wizard pages that draw this image — they use different canvas
|
||||
classes, and the same number must not change colour between them.
|
||||
"""
|
||||
n = max(1, int(n_angles))
|
||||
base = mpl.colormaps["viridis"].resampled(n)
|
||||
colors = [(0.0, 0.0, 0.0, 0.0)] + [base(i) for i in range(n)]
|
||||
return (ListedColormap(colors),
|
||||
BoundaryNorm(np.arange(-0.5, n + 1), len(colors)),
|
||||
np.arange(0, n + 1))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ROI (free quadrilateral in data coordinates)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -145,10 +166,11 @@ class ImageCanvas(FigureCanvasQTAgg):
|
||||
|
||||
def show_image(self, img: np.ndarray, extent: list[float], cmap,
|
||||
vmin: float, vmax: float, xlabel: str, ylabel: str, title: str,
|
||||
colorbar_label: str = "", cb_ticks=None):
|
||||
"""*cmap* may be a name or a Colormap instance; *cb_ticks* pins the
|
||||
colorbar's ticks, which the wizard's integer overlap-count view needs so
|
||||
each band reads as a whole number of angles rather than a shade."""
|
||||
colorbar_label: str = "", cb_ticks=None, norm=None):
|
||||
"""*cmap* may be a name or a Colormap instance. *norm* (which overrides
|
||||
vmin/vmax) and *cb_ticks* let a caller draw a discrete integer image —
|
||||
the wizard's overlap-count view — with whole-number colorbar bands
|
||||
instead of a continuous shade."""
|
||||
self.figure.clf()
|
||||
self.ax = self.figure.add_subplot(111)
|
||||
# Patches and lines are destroyed by figure.clf(); drop stale refs.
|
||||
@@ -157,10 +179,11 @@ class ImageCanvas(FigureCanvasQTAgg):
|
||||
self._extent = extent
|
||||
self._img_shape = img.shape
|
||||
|
||||
kw = ({"norm": norm} if norm is not None
|
||||
else {"vmin": vmin, "vmax": vmax})
|
||||
im = self.ax.imshow(
|
||||
img, aspect="auto", origin="upper",
|
||||
extent=extent, cmap=cmap, vmin=vmin, vmax=vmax,
|
||||
interpolation="nearest",
|
||||
extent=extent, cmap=cmap, interpolation="nearest", **kw,
|
||||
)
|
||||
cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04,
|
||||
ticks=cb_ticks)
|
||||
@@ -576,16 +599,12 @@ class AlignOverlayCanvas(FigureCanvasQTAgg):
|
||||
"""
|
||||
self.figure.clf()
|
||||
self.ax = self.figure.add_subplot(111)
|
||||
n = max(1, int(n_angles))
|
||||
colors = [(0.0, 0.0, 0.0, 0.0)] # count 0: nothing
|
||||
base = mpl.colormaps["viridis"].resampled(n)
|
||||
colors += [base(i) for i in range(n)]
|
||||
cmap, norm, ticks = count_colormap(n_angles)
|
||||
im = self.ax.imshow(
|
||||
np.asarray(counts), extent=extent, origin="upper", aspect="auto",
|
||||
interpolation="nearest", cmap=ListedColormap(colors),
|
||||
norm=BoundaryNorm(np.arange(-0.5, n + 1), len(colors)))
|
||||
interpolation="nearest", cmap=cmap, norm=norm)
|
||||
cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04,
|
||||
ticks=np.arange(0, n + 1))
|
||||
ticks=ticks)
|
||||
cb.set_label("angles overlapping")
|
||||
self._finish(title)
|
||||
|
||||
|
||||
+9
-609
@@ -1,33 +1,18 @@
|
||||
"""FFT Options and Manual Alignment dialogs."""
|
||||
"""FFT option dialogs.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
Angle alignment used to live here too, as ManualAlignmentDialog; it is now the
|
||||
alignment wizard's first page (see align_wizard.py), which needed the same
|
||||
mask-overlay editor plus the crop and export steps.
|
||||
"""
|
||||
|
||||
import matplotlib as mpl
|
||||
import numpy as np
|
||||
from matplotlib.backends.backend_qtagg import NavigationToolbar2QT
|
||||
from PyQt6.QtCore import QSignalBlocker, pyqtSignal
|
||||
from PyQt6.QtWidgets import (
|
||||
QButtonGroup, QComboBox, QDialog, QDialogButtonBox, QGroupBox,
|
||||
QHBoxLayout, QLabel, QMessageBox, QPushButton, QRadioButton, QSpinBox,
|
||||
QVBoxLayout, QWidget,
|
||||
QButtonGroup, QDialog, QDialogButtonBox, QGroupBox, QHBoxLayout, QLabel,
|
||||
QRadioButton, QSpinBox, QVBoxLayout,
|
||||
)
|
||||
|
||||
import sras_compute as compute
|
||||
from sras_compute import (
|
||||
PYFFTW_AVAILABLE, ManualAngleParams, build_manual_alignment,
|
||||
delete_manual_alignment, save_manual_alignment,
|
||||
)
|
||||
from sras_format import SrasFile
|
||||
from sras_workers import Ch4MaskWorker, CrossCorrelateWorker
|
||||
from sras_compute import PYFFTW_AVAILABLE
|
||||
|
||||
from .canvases import AlignOverlayCanvas
|
||||
from .common import (
|
||||
_CSS_HINT, _CSS_MUTED, _CSS_WARN, Jobs, _axes_extent, _form, _group, _make_dspin,
|
||||
_scroll_panel, _wrap_label,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .main_window import SrasViewerWindow
|
||||
from .common import _CSS_HINT, _make_dspin
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -250,588 +235,3 @@ class RowAverageFftOptionsDialog(QDialog):
|
||||
def get_threshold_mv(self) -> float:
|
||||
return self._spin_threshold.value()
|
||||
|
||||
|
||||
|
||||
class ManualAlignmentDialog(QDialog):
|
||||
"""Non-modal manual angle-alignment editor (Fusion -> Manual Alignment...).
|
||||
|
||||
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. 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 Cross-Correlate finds every non-reference angle's
|
||||
rotation *and* translation by registering its image against the
|
||||
reference's (see compute.register_angle_to_reference) — meant to get every
|
||||
angle stacked on top of each other so keyboard nudging only has to make
|
||||
small corrections, not find an alignment from scratch; Auto De-rotate is
|
||||
the weaker fallback that just seeds rotation from the stage's reported
|
||||
angle, leaving translation alone. 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 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)
|
||||
alignment_cleared = pyqtSignal()
|
||||
|
||||
_PREVIEW_MARGIN_FRAC = 0.15
|
||||
_BASE_ALPHA = 0.42
|
||||
_ACTIVE_ALPHA = 0.75
|
||||
_MAX_PREVIEW_DIM = 1024
|
||||
|
||||
# (label, sources passed to compute.register_angle_to_reference). "Both"
|
||||
# registers on each and keeps whichever scores higher per angle, which
|
||||
# costs roughly double but removes the failure mode where the single
|
||||
# chosen source is the one that happens to be uninformative for one angle.
|
||||
_CORRELATE_SOURCES = (
|
||||
("Both, keep best (recommended)", ("signal", "mask")),
|
||||
("Raw signal", ("signal",)),
|
||||
("Thresholded mask", ("mask",)),
|
||||
)
|
||||
|
||||
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._ref_angle_idx = ref_angle_idx
|
||||
self._downsample = (1, 1) # (rows, cols) block-mean factors
|
||||
self._dc4_mv: dict[int, np.ndarray] = {}
|
||||
self._masks_small: dict[int, np.ndarray] = {}
|
||||
self._preview_layers: dict[int, np.ndarray] = {}
|
||||
self._preview_origin_mm = (0.0, 0.0)
|
||||
self._preview_shape = (1, 1)
|
||||
self._preview_pitch_mm = (1.0, 1.0)
|
||||
self._masks_ready = False
|
||||
self._fit_notes: dict[int, tuple[float, str]] = {}
|
||||
self._derotate_sign_flipped = False
|
||||
|
||||
self.setWindowTitle(f"Manual Alignment — {sras.path.name}")
|
||||
self.resize(1150, 760)
|
||||
|
||||
self._seed_initial_params(seed_per_angle)
|
||||
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._active_angle = 1 if ref_angle_idx == 0 and n > 1 else 0
|
||||
|
||||
self._build_ui(dc_threshold_mv)
|
||||
self._set_controls_enabled(False) # re-enabled once masks are ready
|
||||
self._start_mask_prep(cached_dc4_mv)
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
self.canvas.setFocus()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Construction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _seed_initial_params(self, seed_per_angle: dict[int, ManualAngleParams] | None):
|
||||
seed = seed_per_angle or {}
|
||||
self._angle_params: dict[int, ManualAngleParams] = {
|
||||
a: (ManualAngleParams(seed[a].rotation_deg, seed[a].shift_mm)
|
||||
if a in seed else ManualAngleParams())
|
||||
for a in range(self._sras.n_angles)
|
||||
}
|
||||
self._angle_params[self._ref_angle_idx] = ManualAngleParams()
|
||||
|
||||
def _build_ui(self, dc_threshold_mv: float):
|
||||
root = QHBoxLayout(self)
|
||||
|
||||
self.canvas = AlignOverlayCanvas()
|
||||
left = QWidget()
|
||||
left_l = QVBoxLayout(left)
|
||||
left_l.setContentsMargins(0, 0, 0, 0)
|
||||
left_l.setSpacing(4)
|
||||
left_l.addWidget(NavigationToolbar2QT(self.canvas, left))
|
||||
left_l.addWidget(self.canvas)
|
||||
root.addWidget(left, stretch=1)
|
||||
|
||||
panel = QWidget()
|
||||
panel_l = QVBoxLayout(panel)
|
||||
panel_l.setContentsMargins(0, 0, 0, 0)
|
||||
panel_l.setSpacing(8)
|
||||
panel_l.addWidget(self._build_angle_group())
|
||||
panel_l.addWidget(self._build_adjust_group())
|
||||
panel_l.addWidget(self._build_step_group())
|
||||
panel_l.addWidget(self._build_threshold_group(dc_threshold_mv))
|
||||
panel_l.addWidget(self._build_correlate_group())
|
||||
panel_l.addWidget(self._build_actions_group())
|
||||
self.lbl_status = _wrap_label("", _CSS_MUTED)
|
||||
panel_l.addWidget(self.lbl_status)
|
||||
panel_l.addStretch()
|
||||
|
||||
root.addWidget(_scroll_panel(panel, 320))
|
||||
self._connect_controls()
|
||||
|
||||
def _build_angle_group(self) -> QWidget:
|
||||
grp_angle, al = _group("Active Angle")
|
||||
self.combo_active_angle = QComboBox()
|
||||
for a in range(self._sras.n_angles):
|
||||
label = f"Angle {a} ({self._sras.angles_deg[a]:.1f}°)"
|
||||
if a == self._ref_angle_idx:
|
||||
label += " [reference]"
|
||||
self.combo_active_angle.addItem(label)
|
||||
al.addWidget(self.combo_active_angle)
|
||||
self.lbl_active_note = _wrap_label("", _CSS_WARN)
|
||||
al.addWidget(self.lbl_active_note)
|
||||
return grp_angle
|
||||
|
||||
def _build_adjust_group(self) -> QWidget:
|
||||
self.grp_manual_adjust, mform_box = _group("Manual Adjustment")
|
||||
mform = _form()
|
||||
self.spin_active_rotation_deg = _make_dspin(-3600.0, 3600.0, 3, suffix=" °")
|
||||
mform.addRow("Rotation:", self.spin_active_rotation_deg)
|
||||
|
||||
self.spin_active_shift_x_mm = _make_dspin(-1e5, 1e5, 4, suffix=" mm")
|
||||
mform.addRow("Shift X:", self.spin_active_shift_x_mm)
|
||||
|
||||
self.spin_active_shift_y_mm = _make_dspin(-1e5, 1e5, 4, suffix=" mm")
|
||||
mform.addRow("Shift Y:", self.spin_active_shift_y_mm)
|
||||
mform_box.addLayout(mform)
|
||||
return self.grp_manual_adjust
|
||||
|
||||
def _build_step_group(self) -> QWidget:
|
||||
self.grp_step_sizes, sl = _group("Nudge Step Sizes")
|
||||
sform = _form()
|
||||
self.spin_step_translate_mm = _make_dspin(0.0001, 1000.0, 4,
|
||||
suffix=" mm", value=0.01)
|
||||
sform.addRow("Translate step:", self.spin_step_translate_mm)
|
||||
|
||||
self.spin_step_rotate_deg = _make_dspin(0.001, 90.0, 3,
|
||||
suffix=" °", value=0.1)
|
||||
sform.addRow("Rotate step:", self.spin_step_rotate_deg)
|
||||
|
||||
self.spin_step_multiplier = _make_dspin(1.0, 1000.0, 1, value=10.0)
|
||||
sform.addRow("Coarse × (Shift):", self.spin_step_multiplier)
|
||||
sl.addLayout(sform)
|
||||
sl.addWidget(_wrap_label(
|
||||
"Arrow keys nudge X/Y translation; Q/E nudge rotation (CCW/CW). "
|
||||
"Hold Shift for the coarse step. Click the image once so it has "
|
||||
"keyboard focus.", _CSS_HINT))
|
||||
return self.grp_step_sizes
|
||||
|
||||
def _build_threshold_group(self, dc_threshold_mv: float) -> QWidget:
|
||||
self.grp_mask_threshold, tl = _group("Mask Threshold")
|
||||
tform = _form()
|
||||
self.spin_mask_threshold_mv = _make_dspin(-500.0, 500.0, 3,
|
||||
suffix=" mV", value=dc_threshold_mv)
|
||||
tform.addRow("DC threshold:", self.spin_mask_threshold_mv)
|
||||
tl.addLayout(tform)
|
||||
return self.grp_mask_threshold
|
||||
|
||||
def _build_correlate_group(self) -> QWidget:
|
||||
self.grp_correlate, cl = _group("Cross-Correlate (FFT)")
|
||||
cform = _form()
|
||||
self.combo_correlate_source = QComboBox()
|
||||
for label, sources in self._CORRELATE_SOURCES:
|
||||
self.combo_correlate_source.addItem(label, sources)
|
||||
cform.addRow("Correlate on:", self.combo_correlate_source)
|
||||
|
||||
self.spin_correlate_search_deg = _make_dspin(0.0, 180.0, 1, suffix=" °",
|
||||
value=6.0, step=1.0)
|
||||
cform.addRow("Rotation search (±):", self.spin_correlate_search_deg)
|
||||
cl.addLayout(cform)
|
||||
self.btn_auto_correlate = QPushButton("Auto Cross-Correlate (vs Reference)")
|
||||
cl.addWidget(self.btn_auto_correlate)
|
||||
cl.addWidget(_wrap_label(
|
||||
"Finds each non-reference angle's rotation *and* translation by "
|
||||
"cross-correlating its image against the reference's — the stage's "
|
||||
"reported angle is only the starting point of the search, and both "
|
||||
"of its signs are tried. Run this first, then nudge only for small "
|
||||
"corrections.", _CSS_HINT))
|
||||
return self.grp_correlate
|
||||
|
||||
def _build_actions_group(self) -> QWidget:
|
||||
grp_actions, acl = _group("Actions")
|
||||
self.btn_auto_derotate = QPushButton("Auto De-rotate (use known angles)")
|
||||
self.btn_save = QPushButton("Save Alignment")
|
||||
self.btn_clear = QPushButton("Clear Alignment…")
|
||||
self.btn_close = QPushButton("Close")
|
||||
for btn in (self.btn_auto_derotate, self.btn_save, self.btn_clear, self.btn_close):
|
||||
acl.addWidget(btn)
|
||||
return grp_actions
|
||||
|
||||
def _connect_controls(self):
|
||||
self.combo_active_angle.currentIndexChanged.connect(self._on_active_angle_changed)
|
||||
self.spin_active_rotation_deg.editingFinished.connect(self._on_rotation_spin_edited)
|
||||
self.spin_active_shift_x_mm.editingFinished.connect(self._on_shift_spin_edited)
|
||||
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)
|
||||
self.canvas.nudge_translate.connect(self._on_nudge_translate)
|
||||
self.canvas.nudge_rotate.connect(self._on_nudge_rotate)
|
||||
|
||||
with QSignalBlocker(self.combo_active_angle):
|
||||
self.combo_active_angle.setCurrentIndex(self._active_angle)
|
||||
self._on_active_angle_changed(self._active_angle)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Mask preparation (initial CH4 fetch + threshold + downsample)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_mask_prep(self, cached_dc4_mv: dict[int, np.ndarray]):
|
||||
self._dc4_mv = dict(cached_dc4_mv)
|
||||
missing = [a for a in range(self._sras.n_angles) if a not in self._dc4_mv]
|
||||
if not missing:
|
||||
self._finish_mask_prep()
|
||||
return
|
||||
self.lbl_status.setText(f"Preparing masks: 0/{len(missing)} angle(s) needed…")
|
||||
started = self._parent._run_worker(
|
||||
Jobs.MANUAL_ALIGN_MASKS, Ch4MaskWorker(self._sras, missing),
|
||||
connect=(
|
||||
("angle_done", self._on_mask_angle_done),
|
||||
("error", lambda msg: self.lbl_status.setText(f"Mask prep error: {msg}")),
|
||||
),
|
||||
on_done=self._finish_mask_prep)
|
||||
if not started:
|
||||
self.lbl_status.setText(
|
||||
"Could not start mask preparation (busy) — close and reopen.")
|
||||
|
||||
def _on_mask_angle_done(self, angle_idx: int, dc4_mv: np.ndarray):
|
||||
self._dc4_mv[angle_idx] = dc4_mv
|
||||
self.lbl_status.setText(
|
||||
f"Preparing masks: {len(self._dc4_mv)}/{self._sras.n_angles} ready…")
|
||||
|
||||
def _finish_mask_prep(self):
|
||||
if len(self._dc4_mv) < self._sras.n_angles:
|
||||
return # a mask-worker error left some angles unfetched
|
||||
# Rows and columns get their own factor. A real scan is ~7500 frames
|
||||
# wide but only ~750 rows tall, so one shared factor sized for the
|
||||
# frames would throw away 8x more row detail than the preview needs and
|
||||
# leave the overlay too coarse in y to judge alignment by eye.
|
||||
max_rows = max(img.shape[0] for img in self._dc4_mv.values())
|
||||
max_cols = max(img.shape[1] for img in self._dc4_mv.values())
|
||||
self._downsample = (
|
||||
max(1, int(np.ceil(max_rows / self._MAX_PREVIEW_DIM))),
|
||||
max(1, int(np.ceil(max_cols / self._MAX_PREVIEW_DIM))))
|
||||
self._recompute_masks_small()
|
||||
self._rebuild_preview_canvas()
|
||||
self._set_controls_enabled(True)
|
||||
self.lbl_status.setText("Ready.")
|
||||
|
||||
def _recompute_masks_small(self):
|
||||
"""Threshold + downsample every angle's already-in-memory full-res
|
||||
CH4 mV image. Cheap (a compare + block-mean), so this re-runs in
|
||||
full whenever the mask-threshold spin box changes — no re-fetch.
|
||||
Purely for the overlay's visuals: no alignment geometry depends on this
|
||||
threshold, only which pixels the overlay paints."""
|
||||
threshold = self.spin_mask_threshold_mv.value()
|
||||
fy, fx = self._downsample
|
||||
self._masks_small = {
|
||||
a: compute.block_mean_2d((img >= threshold).astype(np.float32), fy, fx)
|
||||
for a, img in self._dc4_mv.items()
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Preview canvas: full rebuild vs. incremental single-layer refresh
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _rebuild_preview_canvas(self):
|
||||
"""Full geometry rebuild: recomputes the shared preview canvas's
|
||||
origin/shape (rotation can grow the union bbox — translation alone
|
||||
cannot, per the padding baked in via _PREVIEW_MARGIN_FRAC) and every
|
||||
angle's reprojected mask layer. Triggered by: dialog open,
|
||||
mask-threshold change, Auto De-rotate, a rotation nudge/edit of the
|
||||
active angle. NOT triggered by a translation-only nudge — see
|
||||
_refresh_active_preview_layer."""
|
||||
dx_ref, dy_ref = compute.pixel_pitch_mm(self._sras, self._ref_angle_idx)
|
||||
fy, fx = self._downsample
|
||||
pitch = (dx_ref * fx, dy_ref * fy)
|
||||
origin, shape = compute.canvas_for_params(
|
||||
self._sras, self._ref_angle_idx, pitch, self._angle_params,
|
||||
margin_frac=self._PREVIEW_MARGIN_FRAC, snap=False)
|
||||
self._preview_origin_mm, self._preview_shape = origin, shape
|
||||
self._preview_pitch_mm = pitch
|
||||
self._preview_layers = {
|
||||
a: self._reproject(a) for a in range(self._sras.n_angles)
|
||||
}
|
||||
self._redraw_overlay()
|
||||
|
||||
def _reproject(self, angle_idx: int) -> np.ndarray:
|
||||
"""One angle's downsampled mask on the current preview canvas.
|
||||
src_downsample must match _masks_small's block-mean factors, or the
|
||||
layer lands magnified and offset instead of where the alignment
|
||||
actually puts it."""
|
||||
p = self._angle_params[angle_idx]
|
||||
return compute.reproject_mask(
|
||||
self._sras, angle_idx, self._ref_angle_idx,
|
||||
self._masks_small[angle_idx], p.rotation_deg, p.shift_mm,
|
||||
self._preview_pitch_mm, self._preview_origin_mm, self._preview_shape,
|
||||
src_downsample=self._downsample)
|
||||
|
||||
def _refresh_active_preview_layer(self):
|
||||
"""Cheap path for a translation-only nudge/edit of the active angle:
|
||||
reproject just that one angle's downsampled mask onto the *existing*
|
||||
preview canvas — every other angle's cached layer is untouched."""
|
||||
self._preview_layers[self._active_angle] = self._reproject(self._active_angle)
|
||||
self._redraw_overlay()
|
||||
|
||||
def _redraw_overlay(self):
|
||||
"""Alpha-composite every angle's colored mask layer into one RGBA
|
||||
image ("all thresholds overlaid with varying opacity"). Each angle
|
||||
keeps a fixed, distinct color regardless of which is active; the
|
||||
active angle is drawn last (on top) at a visibly higher alpha so
|
||||
it's easy to track while nudging."""
|
||||
if not self._preview_layers:
|
||||
return # mask prep hasn't finished yet — nothing to draw
|
||||
n_rows, n_cols = self._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 = self._preview_layers.get(a)
|
||||
if layer is None:
|
||||
continue
|
||||
alpha = self._ACTIVE_ALPHA if a == self._active_angle else self._BASE_ALPHA
|
||||
color = self._angle_colors[a]
|
||||
fg_a = layer * alpha
|
||||
for c in range(3):
|
||||
rgba[..., c] = color[c] * fg_a + rgba[..., c] * rgba[..., 3] * (1 - fg_a)
|
||||
rgba[..., 3] = fg_a + rgba[..., 3] * (1 - fg_a)
|
||||
|
||||
x0, y0 = self._preview_origin_mm
|
||||
dx, dy = self._preview_pitch_mm
|
||||
x_axis = x0 + np.arange(n_cols) * dx
|
||||
y_axis = y0 + np.arange(n_rows) * dy
|
||||
extent = _axes_extent(x_axis, y_axis, dx, dy)
|
||||
title = (f"Angle {self._active_angle} active "
|
||||
f"({self._sras.angles_deg[self._active_angle]:.1f}°)")
|
||||
self.canvas.show_overlay(rgba, extent, title)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Angle selection / nudge / edit handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_active_angle_changed(self, angle_idx: int):
|
||||
self._active_angle = angle_idx
|
||||
is_ref = angle_idx == self._ref_angle_idx
|
||||
self.grp_manual_adjust.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_active_spinboxes()
|
||||
self._redraw_overlay()
|
||||
|
||||
def _sync_active_spinboxes(self):
|
||||
p = self._angle_params[self._active_angle]
|
||||
for spin, val in ((self.spin_active_rotation_deg, p.rotation_deg),
|
||||
(self.spin_active_shift_x_mm, p.shift_mm[0]),
|
||||
(self.spin_active_shift_y_mm, p.shift_mm[1])):
|
||||
with QSignalBlocker(spin):
|
||||
spin.setValue(val)
|
||||
|
||||
def _on_nudge_translate(self, dir_x: int, dir_y: int, coarse: bool):
|
||||
if not self._masks_ready or self._active_angle == self._ref_angle_idx:
|
||||
return
|
||||
step = self.spin_step_translate_mm.value()
|
||||
if coarse:
|
||||
step *= self.spin_step_multiplier.value()
|
||||
p = self._angle_params[self._active_angle]
|
||||
p.shift_mm = (p.shift_mm[0] + dir_x * step, p.shift_mm[1] + dir_y * step)
|
||||
self._sync_active_spinboxes()
|
||||
self._refresh_active_preview_layer()
|
||||
|
||||
def _on_nudge_rotate(self, direction: int, coarse: bool):
|
||||
if not self._masks_ready or self._active_angle == self._ref_angle_idx:
|
||||
return
|
||||
step = self.spin_step_rotate_deg.value()
|
||||
if coarse:
|
||||
step *= self.spin_step_multiplier.value()
|
||||
self._angle_params[self._active_angle].rotation_deg += direction * step
|
||||
self._sync_active_spinboxes()
|
||||
self._rebuild_preview_canvas()
|
||||
|
||||
def _on_rotation_spin_edited(self):
|
||||
if self._active_angle == self._ref_angle_idx:
|
||||
return
|
||||
self._angle_params[self._active_angle].rotation_deg = self.spin_active_rotation_deg.value()
|
||||
self._rebuild_preview_canvas()
|
||||
|
||||
def _on_shift_spin_edited(self):
|
||||
if self._active_angle == self._ref_angle_idx:
|
||||
return
|
||||
p = self._angle_params[self._active_angle]
|
||||
p.shift_mm = (self.spin_active_shift_x_mm.value(), self.spin_active_shift_y_mm.value())
|
||||
self._refresh_active_preview_layer()
|
||||
|
||||
def _on_mask_threshold_edited(self):
|
||||
if not self._masks_ready:
|
||||
return
|
||||
self._recompute_masks_small()
|
||||
self._rebuild_preview_canvas()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_auto_derotate(self):
|
||||
"""Seed every angle's rotation from the stage's reported angle.
|
||||
|
||||
A starting point for nudging by eye, not an alignment: the stage's
|
||||
sign convention relative to this module's is not knowable from the
|
||||
file, so the sign that lines the scans up is whichever of the two looks
|
||||
right in the overlay. Auto Cross-Correlate decides that from the images
|
||||
instead, and is the button to reach for first.
|
||||
"""
|
||||
sign = -1.0 if self._derotate_sign_flipped else 1.0
|
||||
self._derotate_sign_flipped = not self._derotate_sign_flipped
|
||||
n_changed = 0
|
||||
for a in range(self._sras.n_angles):
|
||||
if a == self._ref_angle_idx:
|
||||
continue
|
||||
self._angle_params[a].rotation_deg = sign * compute.nominal_delta_deg(
|
||||
self._sras, a, self._ref_angle_idx)
|
||||
n_changed += 1
|
||||
self._sync_active_spinboxes()
|
||||
self._rebuild_preview_canvas()
|
||||
self.lbl_status.setText(
|
||||
f"Rotation set to the stage angle ({'−' if sign < 0 else '+'}delta) "
|
||||
f"for {n_changed} angle(s); translation untouched. Click again to "
|
||||
"try the opposite sign.")
|
||||
|
||||
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
|
||||
worker = CrossCorrelateWorker(
|
||||
self._sras, self._ref_angle_idx, angles, self._dc4_mv,
|
||||
sources=self.combo_correlate_source.currentData(),
|
||||
dc_threshold_mv=self.spin_mask_threshold_mv.value(),
|
||||
search_deg=self.spin_correlate_search_deg.value())
|
||||
self._correlate_done_count = 0
|
||||
self._correlate_total = len(angles)
|
||||
self._fit_notes = {}
|
||||
self._set_controls_enabled(False)
|
||||
self.lbl_status.setText(f"Cross-correlating: 0/{self._correlate_total} angle(s)…")
|
||||
started = self._parent._run_worker(
|
||||
Jobs.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,
|
||||
score: float, source: str):
|
||||
self._angle_params[angle_idx] = ManualAngleParams(rotation_deg, (shift_x_mm, shift_y_mm))
|
||||
self._fit_notes[angle_idx] = (score, source)
|
||||
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)
|
||||
self.lbl_status.setText(
|
||||
f"Cross-correlated {self._correlate_done_count} angle(s) against "
|
||||
f"Angle {self._ref_angle_idx}.\n" + self._fit_report())
|
||||
|
||||
def _fit_report(self) -> str:
|
||||
"""Per-angle registration quality, worst first.
|
||||
|
||||
Surfaced rather than buried because a single bad acquisition (stage
|
||||
glitch, laser dropout) registers poorly and would otherwise be fused in
|
||||
silently — seeing which angle it is, is what makes dropping it with
|
||||
sras_edit_scans.py actionable. The deviation from the stage's own
|
||||
reported angle is shown alongside: a large one means the search and the
|
||||
stage disagree, which is either a genuine mechanical error or a sign
|
||||
that this angle's fit is not to be trusted.
|
||||
"""
|
||||
if not self._fit_notes:
|
||||
return ""
|
||||
rows = sorted(self._fit_notes.items(), key=lambda kv: kv[1][0])
|
||||
worst = rows[0]
|
||||
lines = [f"Worst fit: angle {worst[0]} (score {worst[1][0]:.3f}, "
|
||||
f"{worst[1][1]})."]
|
||||
drifted = []
|
||||
for a, _note in rows:
|
||||
nominal = compute.nominal_delta_deg(self._sras, a, self._ref_angle_idx)
|
||||
got = self._angle_params[a].rotation_deg
|
||||
dev = min(abs(got - nominal), abs(got + nominal))
|
||||
if dev > 1.0:
|
||||
drifted.append(f"{a} ({dev:.2f}°)")
|
||||
if drifted:
|
||||
lines.append("Rotation differs from the stage angle by >1° for "
|
||||
"angle(s) " + ", ".join(drifted) + ".")
|
||||
lines.append("Nudge from here for any remaining fine correction.")
|
||||
return " ".join(lines)
|
||||
|
||||
def _on_save(self):
|
||||
threshold = self.spin_mask_threshold_mv.value()
|
||||
resolved = dict(self._angle_params) # already concrete floats
|
||||
try:
|
||||
path = save_manual_alignment(self._sras, self._ref_angle_idx, threshold, resolved)
|
||||
result = build_manual_alignment(self._sras, self._ref_angle_idx,
|
||||
threshold, resolved)
|
||||
except OSError as exc:
|
||||
QMessageBox.warning(self, "Save Alignment Failed", str(exc))
|
||||
return
|
||||
self.lbl_status.setText(f"Saved to {path.name}.")
|
||||
self.alignment_saved.emit(result, str(path))
|
||||
|
||||
def _on_clear(self):
|
||||
reply = QMessageBox.question(
|
||||
self, "Clear Alignment",
|
||||
"This resets every angle back to raw/unaligned (0° rotation, no "
|
||||
"shift) and deletes the saved alignment file for this scan, if "
|
||||
"any. This cannot be undone. Continue?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
existed = delete_manual_alignment(self._sras)
|
||||
except OSError as exc:
|
||||
QMessageBox.warning(self, "Clear Alignment Failed",
|
||||
f"Could not delete the saved alignment file: {exc}")
|
||||
return
|
||||
self._angle_params = {a: ManualAngleParams() for a in range(self._sras.n_angles)}
|
||||
self._fit_notes = {}
|
||||
self._sync_active_spinboxes()
|
||||
self._rebuild_preview_canvas()
|
||||
self.lbl_status.setText(
|
||||
"Alignment cleared; saved file removed." if existed
|
||||
else "Alignment cleared (there was no saved file).")
|
||||
self.alignment_cleared.emit()
|
||||
|
||||
def _set_controls_enabled(self, enabled: bool):
|
||||
self._masks_ready = enabled
|
||||
self.combo_active_angle.setEnabled(enabled)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
+67
-126
@@ -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.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
|
||||
|
||||
+6
-33
@@ -16,9 +16,7 @@ from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
import sras_compute as compute
|
||||
from sras_align_export import write_aligned_sras
|
||||
from sras_compute import (
|
||||
cache_file, compute_angle_alignment, compute_rf_image, dc_image_mv,
|
||||
)
|
||||
from sras_compute import cache_file, compute_rf_image, dc_image_mv
|
||||
from sras_format import CH3_IDX, CH4_IDX, SrasFile
|
||||
|
||||
# Concurrency caps. Batch conversion runs one process per file, and each of
|
||||
@@ -302,34 +300,9 @@ class BatchCacheWorker(QObject):
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
class AngleAlignmentWorker(QObject):
|
||||
"""Computes the rigid (rotation + translation, never scale) alignment for
|
||||
every angle in *sras* against *ref_angle_idx*, by cross-correlating each
|
||||
angle's CH4 image against the reference's. Both the rotation and the
|
||||
translation are found from image content — see compute_angle_alignment.
|
||||
"""
|
||||
progress = pyqtSignal(int) # 0–100
|
||||
finished = pyqtSignal(object, str) # AlignmentResult|None, error ("" = 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=self.progress.emit)
|
||||
self.finished.emit(result, "")
|
||||
except Exception as exc:
|
||||
self.finished.emit(None, str(exc))
|
||||
|
||||
|
||||
class Ch4MaskWorker(_PooledWorker):
|
||||
"""Fetches each requested angle's CH4 (Bias B) DC image in mV, for
|
||||
ManualAlignmentDialog's initial threshold-mask overlay.
|
||||
"""Fetches each requested angle's CH4 (Bias B) DC image in mV, for the
|
||||
alignment wizard's initial threshold-mask stack.
|
||||
|
||||
Reuses dc_image_mv, which prefers a stored v5/v7 cache over recomputing
|
||||
from raw waveforms, so this only does real work for a file that hasn't
|
||||
@@ -338,7 +311,7 @@ class Ch4MaskWorker(_PooledWorker):
|
||||
every file load) hasn't reached yet. In the common case — the user opens
|
||||
Fusion -> Manual Alignment after DC precompute has already finished —
|
||||
*angle_indices* is empty and this worker is never even constructed (see
|
||||
ManualAlignmentDialog._start_mask_prep).
|
||||
CorrelatePage._start_mask_prep).
|
||||
"""
|
||||
angle_done = pyqtSignal(int, np.ndarray) # angle_idx, dc4_mv
|
||||
|
||||
@@ -365,8 +338,8 @@ class Ch4MaskWorker(_PooledWorker):
|
||||
|
||||
class CrossCorrelateWorker(_PooledWorker):
|
||||
"""Rigid registration (rotation + translation, never scale) of each of
|
||||
*angle_indices* against *ref_angle_idx*, for ManualAlignmentDialog's Auto
|
||||
Cross-Correlate button.
|
||||
*angle_indices* against *ref_angle_idx*, for the alignment wizard's
|
||||
Run/Re-run Correlation button.
|
||||
|
||||
Runs on a background thread — registering a real many-angle,
|
||||
high-resolution scan takes long enough that doing it on the GUI thread
|
||||
|
||||
@@ -406,6 +406,40 @@ def test_largest_rect_at_least():
|
||||
assert compute.largest_rect_at_least(np.full((3, 4), 2), 2) == (0, 0, 3, 4)
|
||||
|
||||
|
||||
def test_largest_rect_matches_brute_force():
|
||||
"""Randomized check against an O(n^4) reference.
|
||||
|
||||
The histogram sweep is short and easy to get subtly wrong — an off-by-one in
|
||||
the stack unwind yields rectangles that are merely large, and "large but not
|
||||
maximal" is invisible by eye on real data.
|
||||
"""
|
||||
def brute(good):
|
||||
n_rows, n_cols = good.shape
|
||||
best = 0
|
||||
for r0 in range(n_rows):
|
||||
for r1 in range(r0 + 1, n_rows + 1):
|
||||
run = 0
|
||||
for g in good[r0:r1].all(axis=0):
|
||||
run = run + 1 if g else 0
|
||||
best = max(best, run * (r1 - r0))
|
||||
return best
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
for _ in range(200):
|
||||
counts = rng.integers(0, 3, size=(int(rng.integers(1, 9)),
|
||||
int(rng.integers(1, 9))))
|
||||
got = compute.largest_rect_at_least(counts, 2)
|
||||
expected = brute(counts >= 2)
|
||||
if got is None:
|
||||
assert expected == 0
|
||||
continue
|
||||
row0, col0, nr, nc = got
|
||||
assert (counts[row0:row0 + nr, col0:col0 + nc] >= 2).all(), \
|
||||
f"rectangle is not pure:\n{counts}\n{got}"
|
||||
assert nr * nc == expected, \
|
||||
f"not maximal ({nr * nc} < {expected}):\n{counts}\n{got}"
|
||||
|
||||
|
||||
def test_largest_rect_is_pure_on_the_real_fixture(rig):
|
||||
"""On real overlap counts the returned rectangle must contain only
|
||||
full-overlap pixels — the property a bounding box would violate."""
|
||||
|
||||
@@ -155,7 +155,7 @@ def test_all_angles_stack(rig):
|
||||
|
||||
|
||||
def test_downsampled_preview_lands_with_full_res(rig):
|
||||
# ManualAlignmentDialog reprojects block-mean-downsampled masks, so the
|
||||
# The wizard reprojects block-mean-downsampled masks, so the
|
||||
# affine has to account for the factor. When it did not, every preview
|
||||
# layer came out magnified by that factor and offset — the overlay showed a
|
||||
# blown-up crop of each mask, which is not something you can align by eye.
|
||||
|
||||
+282
-170
@@ -3,8 +3,8 @@ signals and worker threads under the offscreen platform plugin.
|
||||
|
||||
Covers the interactions a manual smoke test would: load, switch angles and
|
||||
channels, background DC precompute, lazy FFT compute, threshold and bg-sub
|
||||
changes, angle alignment, manual angle alignment, aligned view, ROI
|
||||
draw/move, and CSV export.
|
||||
changes, the alignment wizard end to end (pre-rotation, correlation, manual
|
||||
nudging, crop, export), aligned view, ROI draw/move, and CSV export.
|
||||
|
||||
NOTE: this module is one ordered integration sequence over a single shared
|
||||
window — the tests build on each other's state and must run in definition
|
||||
@@ -218,44 +218,16 @@ def test_roi_survives_switches(ctx):
|
||||
"ROI still present after channel switch"
|
||||
|
||||
|
||||
def test_angle_alignment(ctx):
|
||||
win, s = ctx.win, ctx.s
|
||||
win.spin_angle.setValue(0)
|
||||
win._on_view_changed()
|
||||
wait_until(lambda: not win._job_running("compute"))
|
||||
assert win._alignment_act.isEnabled(), "alignment action enabled"
|
||||
win._on_angle_alignment()
|
||||
assert wait_until(
|
||||
lambda: win._alignment_result is not None and not win._job_running("align"),
|
||||
timeout_ms=60000), "alignment completed"
|
||||
|
||||
r = win._alignment_result
|
||||
assert len(r.per_angle) == s.n_angles, "transform for every angle"
|
||||
assert all(r.canvas_shape[0] >= int(s.n_rows[a])
|
||||
and r.canvas_shape[1] >= int(s.n_frames[a])
|
||||
for a in range(s.n_angles)), \
|
||||
f"canvas is at least as large as any single angle: {r.canvas_shape}"
|
||||
assert r.per_angle[r.ref_angle_idx].shift_mm == (0.0, 0.0), \
|
||||
"reference angle has zero shift"
|
||||
assert win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked(), \
|
||||
"Aligned View auto-enabled and checked"
|
||||
pump(200)
|
||||
assert win.image_canvas._img_shape == r.canvas_shape, \
|
||||
f"{win.image_canvas._img_shape} vs {r.canvas_shape}"
|
||||
|
||||
win.chk_aligned_view.setChecked(False)
|
||||
pump(200)
|
||||
assert win.image_canvas._img_shape == s.image_shape(0), \
|
||||
f"unchecking returns to the raw per-angle grid: {win.image_canvas._img_shape}"
|
||||
|
||||
|
||||
def test_manual_alignment_geometry(ctx):
|
||||
def test_alignment_geometry_is_stage_independent(ctx):
|
||||
"""Local mm is anchored on each angle's array center, not its stage
|
||||
position: that is what makes a scan's placement independent of where its
|
||||
window happened to sit. (Registration accuracy itself is covered by
|
||||
tests/test_alignment.py, which has a synthetic sample to register.)"""
|
||||
win, s = ctx.win, ctx.s
|
||||
assert win._manual_align_act.isEnabled(), "manual alignment action enabled"
|
||||
win.spin_angle.setValue(0)
|
||||
win._on_view_changed()
|
||||
wait_until(lambda: not win._job_running("compute"))
|
||||
assert win._wizard_act.isEnabled(), "alignment wizard action enabled"
|
||||
|
||||
n_rows, n_frames = s.image_shape(0)
|
||||
assert np.allclose(compute._center_idx(s, 0),
|
||||
@@ -276,7 +248,7 @@ def test_manual_alignment_geometry(ctx):
|
||||
("moving every non-reference angle's scan window must leave the canvas "
|
||||
f"unchanged: {origin_a} {shape_a} vs {origin_b} {shape_b}")
|
||||
|
||||
# Both signs of the stage's reported angle are searched.
|
||||
# Both signs of the stage's reported angle are searched by default.
|
||||
cands = compute._rotation_candidates(30.0, 6.0, 2.0)
|
||||
assert min(cands) < -29.0 and max(cands) > 29.0, f"{min(cands)}..{max(cands)}"
|
||||
|
||||
@@ -289,128 +261,285 @@ def test_manual_alignment_geometry(ctx):
|
||||
"_shift_into moves content by exactly the requested offset"
|
||||
|
||||
|
||||
def test_manual_dialog_opens_at_identity(ctx):
|
||||
"""Open must NOT seed from the still-live automatic AlignmentResult.
|
||||
Manual mode exists to fix up whatever the automatic registration got
|
||||
wrong, so it must start from identity (every angle centered on the
|
||||
reference, no rotation) regardless of whatever the automatic run last
|
||||
computed. Only a previously *saved manual* alignment (sidecar) should
|
||||
ever seed this dialog."""
|
||||
def test_wizard_opens_prerotated(ctx):
|
||||
"""The wizard shows a mask stack before any correlation has run, built from
|
||||
the stage angles in the file — the "pre-rotate" step. Nothing may seed from
|
||||
the still-live automatic result; only a saved sidecar."""
|
||||
win, s = ctx.win, ctx.s
|
||||
win._on_manual_alignment()
|
||||
assert win._manual_align_dialog is not None, "dialog opened"
|
||||
ctx.dlg = dlg = win._manual_align_dialog
|
||||
assert not win._job_running("manual_align_masks"), \
|
||||
win._on_alignment_wizard()
|
||||
assert win._align_wizard is not None, "wizard opened"
|
||||
ctx.wiz = wiz = win._align_wizard
|
||||
ctx.p1 = p1 = wiz.page(wiz.PAGE_CORRELATE)
|
||||
|
||||
assert not win._job_running("align_masks"), \
|
||||
"mask prep needed no background worker (already DC-cached)"
|
||||
assert all(dlg._angle_params[a] == compute.ManualAngleParams()
|
||||
for a in range(s.n_angles)), \
|
||||
"no manual sidecar yet -> dialog starts at identity, not the automatic result"
|
||||
assert wait_until(lambda: p1.isComplete()), "masks ready, Next enabled"
|
||||
assert not win._wizard_act.isEnabled(), \
|
||||
"wizard action disabled while a wizard is open"
|
||||
|
||||
st = wiz.state
|
||||
assert st.result is not None, "an AlignmentResult exists from pre-rotation alone"
|
||||
assert st.counts is not None and st.counts.shape == st.preview_shape
|
||||
assert 0 <= st.counts.max() <= s.n_angles
|
||||
assert not st.fits, "no fits before a correlation has run"
|
||||
for a in range(s.n_angles):
|
||||
nominal = compute.nominal_delta_deg(s, a, st.ref_angle_idx)
|
||||
expected = 0.0 if a == st.ref_angle_idx else nominal
|
||||
assert abs(st.params[a].rotation_deg - expected) < 1e-9, \
|
||||
f"angle {a} not pre-rotated to its stage angle"
|
||||
assert st.params[a].shift_mm == (0.0, 0.0), \
|
||||
"pre-rotation must not invent a translation"
|
||||
|
||||
|
||||
def test_reference_angle_is_locked(ctx):
|
||||
dlg = ctx.dlg
|
||||
dlg.combo_active_angle.setCurrentIndex(dlg._ref_angle_idx)
|
||||
def test_wizard_reference_angle_is_locked(ctx):
|
||||
p1, wiz = ctx.p1, ctx.wiz
|
||||
p1.combo_active.setCurrentIndex(wiz.state.ref_angle_idx)
|
||||
pump(30)
|
||||
before_ref = dlg._angle_params[dlg._ref_angle_idx]
|
||||
dlg._on_nudge_translate(1, 0, False)
|
||||
dlg._on_nudge_rotate(1, False)
|
||||
assert not dlg.grp_manual_adjust.isEnabled(), "reference angle group disabled"
|
||||
assert dlg._angle_params[dlg._ref_angle_idx] == before_ref, \
|
||||
before = wiz.state.params[wiz.state.ref_angle_idx]
|
||||
p1._on_nudge_translate(1, 0, False)
|
||||
p1._on_nudge_rotate(1, False)
|
||||
assert wiz.state.params[wiz.state.ref_angle_idx] == before, \
|
||||
"reference angle untouched by nudge attempts"
|
||||
|
||||
|
||||
def test_nudges(ctx):
|
||||
"""Nudging a real angle (fine + coarse, translate + rotate)."""
|
||||
dlg, s = ctx.dlg, ctx.s
|
||||
def test_wizard_nudges(ctx):
|
||||
"""Manual correction, which the wizard absorbed from the old dialog."""
|
||||
p1, wiz, s = ctx.p1, ctx.wiz, ctx.s
|
||||
ctx.active = active = 1 if s.n_angles > 1 else 0
|
||||
dlg.combo_active_angle.setCurrentIndex(active)
|
||||
p1.combo_active.setCurrentIndex(active)
|
||||
pump(30)
|
||||
before = dlg._angle_params[active].shift_mm
|
||||
dlg._on_nudge_translate(1, 0, False) # fine +X
|
||||
fine_step = dlg.spin_step_translate_mm.value()
|
||||
assert abs(dlg._angle_params[active].shift_mm[0] - (before[0] + fine_step)) < 1e-9, \
|
||||
|
||||
before = wiz.state.params[active].shift_mm
|
||||
p1._on_nudge_translate(1, 0, False)
|
||||
fine = p1.spin_step_translate.value()
|
||||
assert abs(wiz.state.params[active].shift_mm[0] - (before[0] + fine)) < 1e-9, \
|
||||
"fine translate nudge moved shift_x by exactly one fine step"
|
||||
|
||||
before = dlg._angle_params[active].shift_mm
|
||||
dlg._on_nudge_translate(0, -1, True) # coarse -Y
|
||||
coarse_step = fine_step * dlg.spin_step_multiplier.value()
|
||||
assert abs(dlg._angle_params[active].shift_mm[1] - (before[1] - coarse_step)) < 1e-9, \
|
||||
before = wiz.state.params[active].shift_mm
|
||||
p1._on_nudge_translate(0, -1, True)
|
||||
coarse = fine * p1.spin_step_mult.value()
|
||||
assert abs(wiz.state.params[active].shift_mm[1] - (before[1] - coarse)) < 1e-9, \
|
||||
"coarse translate nudge uses the multiplier"
|
||||
|
||||
before_rot = dlg._angle_params[active].rotation_deg
|
||||
dlg._on_nudge_rotate(1, False)
|
||||
assert dlg._angle_params[active].rotation_deg != before_rot, \
|
||||
"rotate nudge changed rotation_deg"
|
||||
assert len(dlg._preview_layers) == s.n_angles, \
|
||||
"preview canvas rebuilt for every angle after a rotation nudge"
|
||||
before_rot = wiz.state.params[active].rotation_deg
|
||||
p1._on_nudge_rotate(1, False)
|
||||
assert wiz.state.params[active].rotation_deg != before_rot
|
||||
assert len(wiz.state.layers) == s.n_angles, \
|
||||
"stack rebuilt for every angle after a rotation nudge"
|
||||
|
||||
# Real key-event wiring (proves keyPressEvent -> signal -> slot).
|
||||
before = dlg._angle_params[active].shift_mm
|
||||
QTest.keyClick(dlg.canvas, Qt.Key.Key_Right)
|
||||
assert dlg._angle_params[active].shift_mm[0] > before[0], \
|
||||
# Real key-event wiring (keyPressEvent -> signal -> slot).
|
||||
before = wiz.state.params[active].shift_mm
|
||||
QTest.keyClick(p1.canvas, Qt.Key.Key_Right)
|
||||
assert wiz.state.params[active].shift_mm[0] > before[0], \
|
||||
"a real Right-arrow key event nudged shift_x"
|
||||
|
||||
|
||||
def test_auto_derotate(ctx):
|
||||
"""Auto De-rotate: seeds rotation from the stage angle, no translation."""
|
||||
dlg, s, active = ctx.dlg, ctx.s, ctx.active
|
||||
shift_before_derotate = dlg._angle_params[active].shift_mm
|
||||
dlg._on_auto_derotate()
|
||||
nominal = compute.nominal_delta_deg(s, active, dlg._ref_angle_idx)
|
||||
assert abs(dlg._angle_params[active].rotation_deg - nominal) < 1e-6, \
|
||||
"auto de-rotate seeded rotation from the stage's reported angle"
|
||||
assert dlg._angle_params[active].shift_mm == shift_before_derotate, \
|
||||
"auto de-rotate left translation untouched"
|
||||
assert dlg._angle_params[dlg._ref_angle_idx].rotation_deg == 0.0, \
|
||||
"reference angle stays identity after auto de-rotate"
|
||||
# Clicking again offers the other sign, since which one lines the scans up
|
||||
# is not knowable from the file.
|
||||
dlg._on_auto_derotate()
|
||||
assert abs(dlg._angle_params[active].rotation_deg + nominal) < 1e-6, \
|
||||
"auto de-rotate offers the opposite sign on a second click"
|
||||
# Both views render from the same reprojected layers.
|
||||
p1.combo_view.setCurrentIndex(1)
|
||||
pump(50)
|
||||
p1.combo_view.setCurrentIndex(0)
|
||||
pump(50)
|
||||
|
||||
|
||||
def test_auto_cross_correlate(ctx):
|
||||
"""Auto Cross-Correlate: searches rotation *and* translation."""
|
||||
win, dlg, s = ctx.win, ctx.dlg, ctx.s
|
||||
assert dlg.btn_auto_correlate.isEnabled(), \
|
||||
"cross-correlate action enabled once masks are ready"
|
||||
for label_idx, (label, _sources) in enumerate(dlg._CORRELATE_SOURCES):
|
||||
dlg.combo_correlate_source.setCurrentIndex(label_idx)
|
||||
dlg._on_auto_correlate()
|
||||
assert wait_until(
|
||||
lambda: not win._job_running("manual_align_correlate"),
|
||||
timeout_ms=60000), f"auto cross-correlate completed ({label})"
|
||||
assert all(a in dlg._fit_notes for a in range(s.n_angles)
|
||||
if a != dlg._ref_angle_idx), \
|
||||
def test_wizard_correlate(ctx):
|
||||
"""Cross-correlation, for every source option, retryable."""
|
||||
win, p1, wiz, s = ctx.win, ctx.p1, ctx.wiz, ctx.s
|
||||
from sras_viewer.align_wizard import _CORRELATE_SOURCES
|
||||
|
||||
for idx, (label, _sources) in enumerate(_CORRELATE_SOURCES):
|
||||
p1.combo_source.setCurrentIndex(idx)
|
||||
p1.btn_correlate.click()
|
||||
assert not p1.isComplete(), \
|
||||
f"Next must be disabled while correlating ({label})"
|
||||
assert wait_until(lambda: not win._job_running("align_correlate"),
|
||||
timeout_ms=60000), f"correlation finished ({label})"
|
||||
assert p1.isComplete(), f"Next re-enabled ({label})"
|
||||
assert all(a in wiz.state.fits for a in range(s.n_angles)
|
||||
if a != wiz.state.ref_angle_idx), \
|
||||
f"every non-reference angle got a fit ({label})"
|
||||
assert dlg._angle_params[dlg._ref_angle_idx] == compute.ManualAngleParams(), \
|
||||
"auto cross-correlate reference angle stays identity"
|
||||
assert dlg.grp_correlate.isEnabled() and dlg.btn_save.isEnabled(), \
|
||||
"auto cross-correlate re-enabled controls when done"
|
||||
assert len(dlg._preview_layers) == s.n_angles, \
|
||||
"preview canvas rebuilt after cross-correlate"
|
||||
assert dlg._fit_report(), "fit quality is reported per angle"
|
||||
|
||||
assert wiz.state.params[wiz.state.ref_angle_idx] == compute.ManualAngleParams(), \
|
||||
"reference angle stays identity after correlation"
|
||||
assert p1.btn_correlate.isEnabled(), "controls re-enabled when done"
|
||||
assert p1.table.rowCount() == s.n_angles and p1.table.item(0, 0) is not None, \
|
||||
"per-angle fit table populated"
|
||||
assert p1.lbl_overlap.text(), "overlap summary reported"
|
||||
|
||||
r = wiz.state.result
|
||||
assert len(r.per_angle) == s.n_angles, "transform for every angle"
|
||||
assert r.per_angle[r.ref_angle_idx].shift_mm == (0.0, 0.0), \
|
||||
"reference angle has zero shift"
|
||||
|
||||
|
||||
def test_save_sidecar(ctx):
|
||||
win, dlg, s, active = ctx.win, ctx.dlg, ctx.s, ctx.active
|
||||
dlg._on_save()
|
||||
sidecar = compute.sidecar_path(s.path)
|
||||
assert sidecar.exists(), "sidecar file written"
|
||||
def test_wizard_retry_changes_geometry(ctx):
|
||||
"""Editing a parameter and re-running is the retry path, and it must
|
||||
invalidate anything indexed against the old canvas."""
|
||||
p1, wiz = ctx.p1, ctx.wiz
|
||||
gen_before = wiz.state.geometry_generation
|
||||
p1.spin_threshold.setValue(p1.spin_threshold.value() + 5.0)
|
||||
p1.spin_threshold.editingFinished.emit()
|
||||
pump(60)
|
||||
assert wiz.state.geometry_generation > gen_before, \
|
||||
"a threshold change rebuilt the geometry"
|
||||
|
||||
# Reset drops the fits and returns to pre-rotation only.
|
||||
p1.btn_reset.click()
|
||||
pump(60)
|
||||
assert not wiz.state.fits, "reset cleared the fits"
|
||||
nominal = compute.nominal_delta_deg(ctx.s, ctx.active, wiz.state.ref_angle_idx)
|
||||
assert abs(wiz.state.params[ctx.active].rotation_deg - nominal) < 1e-9
|
||||
assert wiz.state.params[ctx.active].shift_mm == (0.0, 0.0), \
|
||||
"reset also drops nudged translation"
|
||||
|
||||
# Put a real correlation back for the pages that follow.
|
||||
p1.btn_correlate.click()
|
||||
assert wait_until(lambda: not ctx.win._job_running("align_correlate"),
|
||||
timeout_ms=60000)
|
||||
|
||||
|
||||
def test_wizard_roi_page(ctx):
|
||||
"""The crop page: presets, and two-way sync between the drawn rectangle and
|
||||
the numeric canvas-pixel boxes."""
|
||||
wiz = ctx.wiz
|
||||
wiz.next()
|
||||
pump(150)
|
||||
assert wiz.currentId() == wiz.PAGE_ROI, "advanced to the ROI page"
|
||||
ctx.p2 = p2 = wiz.page(wiz.PAGE_ROI)
|
||||
st = wiz.state
|
||||
|
||||
assert st.crop is not None and p2.isComplete(), \
|
||||
"a default crop is offered on entry"
|
||||
n_rows, n_cols = st.result.canvas_shape
|
||||
assert st.crop[2] > 1 or n_rows == 1, \
|
||||
f"default crop must not collapse to a single row: {st.crop}"
|
||||
|
||||
p2.btn_whole.click()
|
||||
pump(50)
|
||||
assert st.crop == (0, 0, n_rows, n_cols), "whole-canvas preset"
|
||||
|
||||
p2.btn_fit_union.click()
|
||||
pump(50)
|
||||
assert st.counts[st.crop[0]:st.crop[0] + st.crop[2],
|
||||
st.crop[1]:st.crop[1] + st.crop[3]].sum() == st.counts.sum(), \
|
||||
"fit-to-union must keep every covered pixel"
|
||||
|
||||
if p2.btn_fit_overlap.isEnabled():
|
||||
p2.btn_fit_overlap.click()
|
||||
pump(50)
|
||||
row0, col0, nr, nc = st.crop
|
||||
assert (st.counts[row0:row0 + nr, col0:col0 + nc] >= 1).all(), \
|
||||
"full-overlap crop must not include uncovered pixels"
|
||||
|
||||
# Numeric -> drawn rectangle.
|
||||
p2.btn_whole.click()
|
||||
pump(50)
|
||||
target = (0, 0, max(1, n_rows // 2), max(1, n_cols // 2))
|
||||
p2.spin_rows.setValue(target[2])
|
||||
p2.spin_cols.setValue(target[3])
|
||||
pump(50)
|
||||
assert st.crop == target, f"spin boxes drive the crop: {st.crop} vs {target}"
|
||||
|
||||
# Drawn rectangle -> numeric, round-tripping exactly.
|
||||
x0, y0 = wiz.canvas_to_mm(target[1] - 0.5, target[0] - 0.5)
|
||||
x1, y1 = wiz.canvas_to_mm(target[1] + target[3] - 0.5,
|
||||
target[0] + target[2] - 0.5)
|
||||
p2.canvas.set_roi(RoiQuad.from_bbox(min(x0, x1), min(y0, y1),
|
||||
max(x0, x1), max(y0, y1)))
|
||||
pump(80)
|
||||
assert st.crop == target, \
|
||||
f"drawn rectangle round-trips to the same crop: {st.crop} vs {target}"
|
||||
|
||||
# A degenerate crop blocks Next.
|
||||
st.crop = None
|
||||
p2.completeChanged.emit()
|
||||
assert not p2.isComplete(), "an absent crop blocks Next"
|
||||
p2._set_crop(*target)
|
||||
assert p2.isComplete()
|
||||
ctx.crop = target
|
||||
|
||||
|
||||
def test_wizard_crop_dropped_when_going_back(ctx):
|
||||
"""A crop is canvas-pixel indexed, so it cannot survive a re-correlation."""
|
||||
wiz, p2 = ctx.wiz, ctx.p2
|
||||
wiz.back()
|
||||
pump(120)
|
||||
assert wiz.currentId() == wiz.PAGE_CORRELATE
|
||||
assert wiz.state.crop is None, "cleanupPage discarded the stale crop"
|
||||
assert wiz.state.cropped_result is None
|
||||
wiz.next()
|
||||
pump(150)
|
||||
assert wiz.state.crop is not None, "a fresh default crop is offered again"
|
||||
p2._set_crop(*ctx.crop)
|
||||
|
||||
|
||||
def test_wizard_export(ctx):
|
||||
"""Writing the file: Finish stays unavailable until a write succeeds."""
|
||||
win, wiz = ctx.win, ctx.wiz
|
||||
out = ctx.tmpdir / "wizard_aligned.sras"
|
||||
with patch("sras_viewer.align_wizard.QMessageBox.question",
|
||||
return_value=QMessageBox.StandardButton.Yes):
|
||||
wiz.next()
|
||||
pump(150)
|
||||
assert wiz.currentId() == wiz.PAGE_SAVE, "advanced to the save page"
|
||||
ctx.p3 = p3 = wiz.page(wiz.PAGE_SAVE)
|
||||
assert wiz.state.cropped_result is not None, "crop applied on leaving page 2"
|
||||
assert wiz.state.cropped_result.canvas_shape == ctx.crop[2:], \
|
||||
"cropped result carries the chosen shape"
|
||||
assert not p3.isComplete(), "Finish unavailable before anything is written"
|
||||
assert p3.lbl_summary.text(), "a summary of what will be written is shown"
|
||||
|
||||
with patch("sras_viewer.align_wizard.QFileDialog.getSaveFileName",
|
||||
return_value=(str(out), "")):
|
||||
p3.btn_browse.click()
|
||||
assert wiz.state.out_path == str(out)
|
||||
|
||||
p3.btn_export.click()
|
||||
assert wait_until(lambda: not win._job_running("align_export"),
|
||||
timeout_ms=60000), "export finished"
|
||||
assert wiz.state.exported_path == str(out), p3.lbl_status.text()
|
||||
assert p3.isComplete(), "Finish available once the file exists"
|
||||
assert out.exists()
|
||||
|
||||
written = SrasFile(str(out))
|
||||
ctx.written = written
|
||||
assert written.version == 6, "export is a v6 file"
|
||||
assert written.n_angles == ctx.s.n_angles
|
||||
assert all(written.image_shape(a) == ctx.crop[2:]
|
||||
for a in range(written.n_angles)), \
|
||||
"every angle shares the cropped grid"
|
||||
assert not out.with_name(out.name + ".part").exists(), \
|
||||
"no staging file left behind"
|
||||
|
||||
|
||||
def test_wizard_finish_applies_and_persists(ctx):
|
||||
"""Finish makes the session match the file: Aligned View shows the exported
|
||||
extent, and the sidecar records it for the input scan."""
|
||||
win, wiz = ctx.win, ctx.wiz
|
||||
wiz.accept()
|
||||
pump(250)
|
||||
assert win._align_wizard is None, "wizard reference released"
|
||||
assert win._wizard_act.isEnabled(), "wizard action available again"
|
||||
assert win._alignment_result is not None
|
||||
assert win._alignment_result.canvas_shape == ctx.crop[2:], \
|
||||
"the *cropped* result is what the view now uses"
|
||||
assert win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked()
|
||||
pump(200)
|
||||
assert win.image_canvas._img_shape == ctx.crop[2:], \
|
||||
f"canvas shows the cropped extent: {win.image_canvas._img_shape}"
|
||||
|
||||
sidecar = compute.sidecar_path(ctx.s.path)
|
||||
assert sidecar.exists(), "sidecar written for the input scan"
|
||||
ctx.sidecar = sidecar
|
||||
ctx.sidecar_raw = raw = json.loads(sidecar.read_text())
|
||||
assert raw.get("schema_version") == compute._SIDECAR_SCHEMA_VERSION, \
|
||||
"sidecar schema_version is current"
|
||||
assert all(raw.get("per_angle", {}).get(str(a), {}).get("rotation_deg")
|
||||
== dlg._angle_params[a].rotation_deg for a in range(s.n_angles)), \
|
||||
"sidecar per_angle round-trips the dialog's resolved params"
|
||||
assert (win._alignment_result is not None
|
||||
and win._alignment_result.per_angle[active].rotation_deg
|
||||
== dlg._angle_params[active].rotation_deg), \
|
||||
"main window's alignment_result replaced by the manual build"
|
||||
assert win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked(), \
|
||||
"Aligned View auto-enabled after Save"
|
||||
assert raw.get("schema_version") == compute._SIDECAR_SCHEMA_VERSION
|
||||
assert all(raw["per_angle"][str(a)]["rotation_deg"]
|
||||
== win._alignment_result.per_angle[a].rotation_deg
|
||||
for a in range(ctx.s.n_angles)), \
|
||||
"sidecar round-trips the applied rotations"
|
||||
|
||||
win.chk_aligned_view.setChecked(False)
|
||||
pump(200)
|
||||
assert win.image_canvas._img_shape == ctx.s.image_shape(0), \
|
||||
f"unchecking returns to the raw per-angle grid: {win.image_canvas._img_shape}"
|
||||
|
||||
|
||||
def test_stale_schema_sidecar_ignored(ctx):
|
||||
@@ -424,56 +553,39 @@ def test_stale_schema_sidecar_ignored(ctx):
|
||||
sidecar.write_text(json.dumps(raw)) # restore for the rest of the sequence
|
||||
|
||||
|
||||
def test_clear_with_confirmation(ctx):
|
||||
win, dlg, s = ctx.win, ctx.dlg, ctx.s
|
||||
with patch("sras_viewer.dialogs.QMessageBox.question",
|
||||
return_value=QMessageBox.StandardButton.Yes):
|
||||
dlg._on_clear()
|
||||
assert not ctx.sidecar.exists(), "sidecar file deleted"
|
||||
assert all(dlg._angle_params[a] == compute.ManualAngleParams()
|
||||
for a in range(s.n_angles)), "dialog params reset to identity"
|
||||
assert win._alignment_result is None, "main window alignment_result cleared"
|
||||
assert (not win.chk_aligned_view.isEnabled()
|
||||
and not win.chk_aligned_view.isChecked()), \
|
||||
"Aligned View disabled after Clear"
|
||||
|
||||
dlg.close()
|
||||
pump(150)
|
||||
assert win._manual_align_dialog is None, "dialog reference released on close"
|
||||
|
||||
|
||||
def test_sidecar_restored_on_reload(ctx):
|
||||
win, active = ctx.win, ctx.active
|
||||
win._on_manual_alignment()
|
||||
dlg = win._manual_align_dialog
|
||||
dlg.combo_active_angle.setCurrentIndex(active)
|
||||
pump(30)
|
||||
dlg._on_auto_derotate()
|
||||
dlg._on_nudge_translate(1, 1, True)
|
||||
saved_rotation = dlg._angle_params[active].rotation_deg
|
||||
saved_shift = dlg._angle_params[active].shift_mm
|
||||
dlg._on_save()
|
||||
dlg.close()
|
||||
pump(150)
|
||||
|
||||
saved = json.loads(ctx.sidecar.read_text())["per_angle"][str(active)]
|
||||
old_sras_id = id(win._sras)
|
||||
win._load_file(str(ctx.path)) # reload the same file fresh
|
||||
assert wait_until(
|
||||
lambda: win._sras is not None and id(win._sras) != old_sras_id), \
|
||||
"file reloaded"
|
||||
ctx.s = win._sras
|
||||
assert win._manual_align_dialog is None, \
|
||||
"manual dialog force-closed by a reload"
|
||||
assert win._align_wizard is None, "no wizard left open across a reload"
|
||||
assert win._alignment_result is not None, \
|
||||
"reload restores the saved manual alignment automatically"
|
||||
"reload restores the saved alignment automatically"
|
||||
assert abs(win._alignment_result.per_angle[active].rotation_deg
|
||||
- saved_rotation) < 1e-9, "restored rotation matches what was saved"
|
||||
assert win._alignment_result.per_angle[active].shift_mm == saved_shift, \
|
||||
"restored shift matches what was saved"
|
||||
- saved["rotation_deg"]) < 1e-9, \
|
||||
"restored rotation matches what was saved"
|
||||
assert win.chk_aligned_view.isChecked(), \
|
||||
"Aligned View auto-checked after restoring a saved alignment"
|
||||
|
||||
|
||||
def test_wizard_closes_with_a_reload(ctx):
|
||||
"""An open wizard belongs to the file it was opened on."""
|
||||
win = ctx.win
|
||||
win._on_alignment_wizard()
|
||||
assert win._align_wizard is not None
|
||||
assert wait_until(
|
||||
lambda: win._align_wizard.page(win._align_wizard.PAGE_CORRELATE).isComplete())
|
||||
win._load_file(str(ctx.path))
|
||||
assert wait_until(lambda: not win._job_running("load"))
|
||||
pump(200)
|
||||
assert win._align_wizard is None, "wizard force-closed by a reload"
|
||||
ctx.s = win._sras
|
||||
|
||||
|
||||
def test_pixel_inspector(ctx):
|
||||
win = ctx.win
|
||||
win.chk_aligned_view.setChecked(False)
|
||||
|
||||
Reference in New Issue
Block a user