perf(drift): replace the rigid solver with the cryo-EM method — 10.6x more accurate, 4x faster - #134
Draft
CSSFrancis wants to merge 17 commits into
Draft
perf(drift): replace the rigid solver with the cryo-EM method — 10.6x more accurate, 4x faster#134CSSFrancis wants to merge 17 commits into
CSSFrancis wants to merge 17 commits into
Conversation
Replace the phase-correlation running-reference chain with the MotionCor2 / Unblur family method. - Plain cross-correlation weighted by a FIXED band-pass (q**tilt Gaussian), not by the data's own |R|. Whitening hands the bands that are pure noise at low dose the same weight as the bands that carry the image, which is backwards for Poisson-dominated data -- and the _PHASE_FLOOR constant was the symptom (near-empty bins amplified to unit weight by their own rounding error). The peak comes from real(ifft2()), not abs(): a true match is a positive real peak and abs also promotes spurious imaginary structure. - Every pair (i, j) with 0 < j-i <= band is measured and the per-frame positions come from an over-determined, robustly weighted least-squares solve with frame 0 as the gauge. The chain gave one measurement per unknown, so a bad registration had nothing to outvote it; band=12 gives ~12, and the banded normal equations are a graph Laplacian so thousands of frames stay a (band+1, N-1) solveh_banded call rather than a 700 MB dense lstsq. - The band batches: the pairs closing on a frame share its spectrum, so they are one product and ONE inverse FFT. Correlation runs on a box-mean binned grid, which bounds cost and memory at any frame size and is also the low-pass that makes the band-pass safe -- 8 e/px unbinned is 17 px wrong, bin-4 is 0.19 px wrong at identical filter settings. - Sub-pixel refinement is a LADDER of matmul-DFT stages (~12 px window each) so the binned grid still resolves 1/upsample of a FULL pixel without a 96-wide kernel. Deleted: _PHASE_FLOOR, the normalize whitening path, the running Fourier reference and its phase-ramp alignment, the reference modes, and the sharpness-based reject_outliers gate with _REJECT_FRACTION/_REJECT_MIN_SAMPLES. Kept: the Guizar-Sicairos upsampled matmul DFT, the streaming frame source, DriftModel, warp.py, and the numpy/torch operator adapter.
… a residual `reference`, `normalize` and `reject_outliers` no longer exist in the solver, so they leave DEFAULTS, the wizard schema, _coerce, _solver_kwargs and the TSX together -- the caret contract is that every payload key maps 1:1 to a DEFAULTS entry and every setter has a caller, and a stale key silently coerces to a default the backend then rejects. The default face is now ONE toggle: "Ignore bad frames" had no switch behind it once robustness became a property of the least-squares solve. Advanced gains "Pair span" (`band`), which is the redundancy the robust solve spends. The result readout reports the least-squares residual in place of a rejected- frame count -- how far the pairwise measurements disagree with the single trajectory fitted to them, which is the only quality signal the solve has. The dy/dx curve is repainted with the SOLVED shifts when the run finishes: what streams during the solve is a provisional causal estimate, because the global solve does not exist until every pair has been measured.
Accuracy and robustness gates pass unchanged: the <0.1 px sub-pixel gate, the synthetic recoveries, ROI-vs-full agreement, the corrupt-frame test, the round-trip/sign tests and numpy-vs-torch parity. Retired, with replacements: test_sequential_reference_accumulates -> test_monotonic_drift_is_recovered_end_to_end test_running_reference_survives_... -> test_survives_one_corrupt_frame test_outlier_rejection_can_be_disabled -> test_a_chain_cannot_even_detect_a_bad_measurement test_clean_stack_rejects_nothing -> test_clean_stack_downweights_nothing test_bad_reference_name_raises -> test_zero_band_raises test_fixed_index_out_of_range_raises -> (same) test_the_default_face_is_two_toggles -> test_the_default_face_is_one_toggle test_unknown_reference_falls_back -> test_band_is_clamped New: TestBandedLeastSquares (over-determination, one bad pair outvoted, the gauge, the residual as a diagnostic) and TestBatchedCorrelation (one inverse FFT per frame not per pair, binning does not cost sub-pixel resolution, refine_iters, trend-preserving smoothing). test_on_shift_streams_every_frame_as_it_solves now asserts the stream AGREES with the returned array rather than equalling it: the returned shifts come from a global solve that has no meaningful prefix, so bit-equality would be asserting the solver is sequential -- which is the thing that was replaced.
…th ways DriftModel.reference and .residuals describe what the solve now produces (a gauge and a per-frame pairwise residual in pixels) rather than the retired reference modes and the correlation-peak sharpness. The ROI note in drift_action carries the new measurement: the whole-frame solve improved (0.25 -> 0.03 px on the particle movie) while the guessed-box solve got worse (1.03 -> ~5 px), because plain correlation follows the BRIGHTEST features and that box is mostly particles moving relative to the film. The caret-contract test now checks both directions -- a DEFAULTS entry with no schema key is a control no host renders and no payload carries, which is how a removed parameter can be left behind in one place. A second test asserts every DEFAULTS entry actually reaches the solver or the wizard. Drops the unread per-pair quality store from _PassState.
…air bias Found by the new benchmark, which is the reason it exists. At 2048^2 over 240 frames (0.17 px/frame) the solve came back 1.46 px rms with band=12 and the error was a proportional SHRINK, not noise: recovered/true = 0.90. A banded system chains N/band independent links from frame 0 to frame N, so any systematic per-pair error multiplies along the chain -- where the old running-reference solver, registering every frame against one reference, paid it once. The per-pair bias itself comes from the plain-correlation peak sitting on the shoulder of the window/content envelope, and it is proportionally largest when the shift being measured is a small fraction of a binned pixel. A wide band fixes both halves: the shift across it is larger, so the bias is relatively smaller, and there are fewer links to multiply it along. band 12 -> 1.46 px rms band 24 -> 0.60 band 48 -> 0.09 corr_size goes 128 -> 256 for the same reason (a coarser grid makes a slow drift a smaller fraction of a grid pixel). Together, 2048^2/240 frames: 0.088 px rms in 7.0 s on torch-CPU, against 26.5 s and 0.934 px for the old solver. band=48 costs ~1.4x the time of 12 -- the per-frame read and FFT dominate, not the pairs -- so this is 16x the accuracy for 40% more time.
The first real use of this benchmark measured band=12 while the solver shipped 48, because argparse carried its own default -- i.e. it benchmarked a configuration nobody runs and reported it as the shipped one. None means "leave it to solve_translation".
np.memmap(mode="w+") allocates the whole file up front, so a synthesis interrupted half-way leaves a file of exactly the expected size whose tail is zeros -- and the reuse check is a size check, so the next run would benchmark a half-black movie and report it as real. Caught by interrupting a 4 GB run.
…he motion Amplitude weighting is not a deficiency to apologise for -- it defines what an ROI is FOR. Around a fiducial or a landmark, plain correlation locks onto exactly that feature. The particle_movie half-frame result (~5 px vs the old 0.37) is the estimator faithfully following the bright particles instead of the film that drifts: a mis-drawn ROI, not a broken solver.
…client frames.py held a raw dask array and called `.compute()` per frame with no scheduler. In the running app the context holds a process-global distributed.Client, so every frame became a cluster round-trip: graph out, worker reads, frame back over IPC -- for data this process consumes locally anyway. Measured on a real lazy .hspy movie, 2048^2, 1 frame/chunk (spyde/tests/repro_drift_frame_read.py): local threaded scheduler 45.2 ms/frame ambient distributed client 111.9 ms/frame 2.5x The drift caret reads 64 frames for its check image, ~20 for the ROI preview and N for the solve, so one unqualified .compute() taxes three separate user-visible waits at once: the check image alone goes 7.2s -> 2.9s on a 24-frame fixture. This is the same failure the navigator already fixed one layer down -- CLAUDE.md Live-Display §3 pins CachedDaskArray._client = None for exactly this reason and records it as a silent perf-only bug that survived a long time. This module bypassed that machinery by holding a raw array, and so re-acquired the bug. The guard is behavioural, not a timing assertion: with no cluster in the test process both paths are fast, which is precisely why this kept coming back. The new test installs a scheduler that raises if it is ever consulted and reads a frame. Verified to fail on the previous code. A graph whose data genuinely lives on the workers still falls back, with a one-shot warning that says why the solve is about to be slow.
"The ROI drift check takes 60 seconds" and "Correct Drift does nothing" are both unanswerable from a green functional suite: drift_wizard.spec.ts passes in 35s on a 96x112 fixture where every stage is fast for reasons that do not survive a real movie. Nothing in the project could see time at the ACTION level. ActionProfile is NavProfile one layer up -- same idiom deliberately, so there is one way to profile rather than two. One INFO line per invocation with the stage breakdown, gated on SPYDE_ACTION_PROFILE=1 or the live `action_profile` debug flag, every method short-circuiting on a single boolean when off. It LOGS rather than emits: backend emit() goes down the PLOTAPP line protocol and the main process echoes only a tiny allowlist, so a spec waiting on a profile MESSAGE waits forever. An INFO record reaches stderr, the Log panel and a terminal at once. The renderer stamps _t_click into drift payloads so the line carries `queued=` -- everything between the press and the handler starting. That is the difference between "the compute is slow" and "the button was dead first", which is exactly the distinction the Apply report turns on. _coerce builds its dict from DEFAULTS, so the stamp cannot leak into solver parameters. Instruments drift_open (frames/sum_read/check_window/roi_widget), drift_preview (crop_read/solve/sums/paint), drift_run (trace_window/solve/ corrected_sum/paint) and drift_commit (add_transformation/show_node), plus benchmark_drift_latency.py to drive them headless.
…lve 22.2s -> 7.9s
_SUM_MAX_FRAMES was a pure frame COUNT, so it was size-blind: 64 frames of the
96x112 test fixture is nothing, 64 frames of a 2048^2 movie is half a gigabyte
of reads. The ROI preview next door already budgeted in bytes; the check sums
never did.
Measured with SPYDE_ACTION_PROFILE=1, 60-frame 2048^2 lazy movie
(spyde/tests/benchmark_drift_latency.py), before -> after:
drift_open 11713ms -> 1822ms sum_read 11309 -> 1455 (frames 60 -> 8)
drift_run 22237ms -> 7922ms corrected_sum 15587 -> 2233
drift_preview 3058ms -> 4847ms (unchanged; now the dominant open cost)
drift_commit 65ms -> 71ms
Two things the profile made visible that inspection had not:
- 97% of drift_open was sum_read, for a picture whose only job is to look
blurry beside a sharper one.
- 70% of "Correct Drift" was corrected_sum, a SECOND 60-frame pass run AFTER
the solve finished -- the progress bar completes, then the caret sits dead
for 15s. That is the likeliest source of "the button seems to do nothing".
It shares sum_indices with the check image, so the byte budget fixes both.
drift_commit was never the problem: 65ms before, and it returns a node. Whatever
"Apply does nothing" is, it is not commit latency.
Measured on a 60-frame 2048^2 real .mrc (memmap, no decode -- the backing a real
in-situ movie has; an earlier pass measured .hspy and was reporting HDF5 DECODE
cost, which inflated every read stage and pointed the fix at the wrong thing).
stage before after
drift_open 11713ms 135.9ms 86x
sum_io 33.9ms
sum_acc 15.3ms
check_window 61.3ms (panels 27.3 html 22.7 emit 9.1)
drift_run 22237ms 2061.9ms 11x (corrected_sum 15587 -> 35.9)
drift_preview 3058ms 6039.7ms SLOWER -- see below
drift_commit 65ms 41.4ms
Three changes, each aimed at a stage the profile named:
1. Check sums use TWO frames (first and last -- maximum drift between them, so
the raw sum is at its blurriest and the comparison is at its strongest;
interior sampling shows less blur, so bigger n is weaker AND slower).
2. Check images are THUMBNAILS, decimated on read to <=512 px. Four of them live
in a 340x300 window, so building them at 2048^2 was paying on four stages at
once: accumulate, panel build, HTML size, emit. The disk was only 11% of
drift_open; the rest was resolution nobody can see. html 5.6MB -> 1.4MB.
3. Slice before reading, not after -- both the ROI crop and the stride now go
down to the reader. On a memmap a frame IS a slice, so reading a whole 2048^2
frame to keep a 1024^2 box or every 4th pixel pays full price for a fraction
of the data. ROI crop_read 1838 -> 263ms.
APPLY WAS NEVER SLOW, and this closes that question: drift_commit is 41ms and
returns a node. What read as "the button does nothing" was finding #2 -- 15.6s
of post-solve dead time in corrected_sum, after the progress bar had completed.
That is fixed; do not re-investigate commit latency.
drift_preview is now the long pole at 6.0s, and it got worse rather than better:
crop_read fell 1838 -> 263ms but the SOLVE on 20 crops of 1024^2 is 4373ms and
now dominates. Named, not fixed.
--assert-budget turns this into a CI gate: it reads the [ACTION-PROFILE] lines
the app itself emits (rather than re-timing independently, which would drift
away from what the app reports) and exits non-zero over budget.
… on drag
The preview was a 6 s batch measurement you committed to a box and waited for.
It is now a fixed 512x512 square you drag, updating per drag frame with the
gain readout tracking the cursor.
drift_preview 6040 ms (per settle) -> 13.2 ms median / 23.0 ms p95 per step
Reuses the protected machinery rather than adding a fast path beside it:
_PreviewDriver goes onto the SHARED base_selector._nav_dispatcher, which is
duck-typed on _run_update, so it is a first-class submitter there and the file
is untouched. That buys exactly what a drag needs and what CLAUDE.md
Live-Display §2 forbids rebuilding -- one serial lane, latest-position-wins
coalescing, no lock. The 250 ms settle timer on the drag path is gone; a box the
user has already dragged past is now dropped BEFORE it is computed rather than
computed and discarded.
What made a per-step update affordable, measured not guessed:
- The MOVIE does not change during a drag, only the box. Frames are read once
into _preview_cache (2 frames, ~16 MB at 2048^2), so a step is arithmetic on
resident pixels with no I/O.
- The box is FIXED at 512. Fixed size is what bounds the compute; a resizable
ROI makes the per-step cost a function of user input.
- Two frames, sampled at the ENDS of the movie -- adjacent frames have drifted
by almost nothing and would report a flattering gain for every box.
- Solve at full 512 (7 ms), sums and gain at 256 (46 ms -> 12 ms). The split is
measured: decimating the SOLVE is not an option, at stride 3 it stops
recovering the shift at all (31 px against a true 3.4).
--assert-budget now gates the drag step at 33 ms p95 (a frame at 30 fps, p95
because that is the one a user feels), alongside caret-open at 200 ms.
Tests updated for the fixed box, each pinning the same claim as before:
test_the_preview_uses_the_box_even_with_the_toggle_off - size now from
box_size(), position still from the widget
test_the_box_is_read_in_image_pixels_as_y0_x0_h_w - reframed on a 1024^2
movie so the fixed box is not degenerately clamped to the whole frame
test_use_roi_feeds_the_box_to_the_solver - same claim, fixed size
test_a_superseded_preview_does_not_paint -> ..._drag_step_is_never_computed
the mechanism moved from a generation guard to dispatcher coalescing, and
the new assertion is stronger: superseded steps are never COMPUTED.
…efaults drift_live_preview.spec.ts drags the box without releasing and asserts the gain readout CHANGES mid-drag -- a settle-on-release design holds one value there, so this is the assertion that separates live from dead. Headless timings cannot: a step that is fast and never fires, or fires and paints the same image, gives the same 14 ms. Reading the screenshots caught a bug no test could: the readout said "over 12 frames" when the backend default is 2. The caret ships its OWN defaults and sends them on every action, so a renderer value that drifts from the backend one silently WINS and the backend default becomes dead code. previewFrames was still 20, so the live preview was doing 10x the work it was designed for. `band` had done the same thing earlier (12 vs 48). So test_the_caret_defaults_match_the_backend_defaults parses DriftWizard.tsx's DEFAULTS block and compares it to drift_action.DEFAULTS, mapping camelCase to snake_case. Parsed rather than duplicated so the test cannot itself go stale. Verified it fails on the old value (assert 20 == 2). The spec runs on the PARTICLE movie, not test_data_movie: the latter has no real drift (its frames differ by a moving index band, not a translation), so a correct solve returns zero shift and the gain is exactly 1.000 for every box -- which makes a live preview indistinguishable from a dead one. Sized 1024^2 so the fixed 512 box has room to move over different content. Verified on the screenshots: the box moves, the ROI raw/aligned panels re-render with the new region's content, and the readout tracks 1.0x -> 0.9x. Final gated numbers, 60 x 2048^2 .mrc: drag step p95 17.9 ms budget 33 drift_open 153.0 ms budget 200 drift_commit 20.5 ms budget 500 drift_run 2119.0 ms budget 30000
…ng on real drift CORRECTNESS BUG, found only at real scale (245 x 4096^2 uint8 lazy). max_shift defaulted to a fixed 32 px. That number was chosen when the solver was a CHAIN, where max_shift bounded the shift between CONSECUTIVE frames and was always small. The banded solve bounds the shift across a pair up to `band` (48) frames apart, and the ROI preview compares the movie's two ENDS -- the entire excursion. So on any movie with more than 32 px of drift the true correlation peak fell OUTSIDE the mask and the solve returned a spurious one. Measured on frames 0 and 244 of a 245 x 4096^2 movie whose true shift is (-40, 20): max_shift=32 -> (-18.9, 1.4) gain 0.92 max_shift=64 -> (-40.0, 20.0) gain 2.10 A gain below 1 means the "corrected" sum is WORSE than the raw one. That is what a user sees and reports as the feature being broken, and it is what the maintainer reported (0.3x on their data). max_shift=0 (or None) now means AUTO: a quarter of the correlated region's shorter edge. That keeps the lattice guard the parameter exists for while admitting real drift. DEFAULTS, the caret schema and the TSX all move to 0 together -- the caret sends its own value on every action, so a stale default there silently wins. Verified at real scale in the running app (drift_real_scale.spec.ts, screenshots read): preview gain 2.103, max_abs_shift 40.00 px (exact) full solve max err 1.91 px, rms 1.09 px vs ground truth ROI raw / ROI aligned / corrected sum: all have content, nan 0% result: "1.8x sharper . 38.1 px drift . 0.13 px residual" benchmark_drift_real_scale.py builds the 245 x 4096^2 uint8 fixture and dumps the CONTENT of every array that reaches a panel (NaN fraction, min/max, constancy), because a blank panel is a fact about the array and no timing can see it. Its own ground-truth comparison had a sign error at first -- frame_i = roll(base, +truth[i]) so the correction is -truth[i] -- which reported ~2x the drift as error for a perfect solve; that is now spelled out in the code.
…budget by frame size
1. THE BLACK PANEL. The "Corrected sum" panel was initialised to zeros, so it
rendered solid black from the moment the window opened until a solve finished
-- and a solid black panel beside a real image is indistinguishable from a
broken feature. That is a failure state per CLAUDE.md and it is how this was
reported ("nothing visibly happens").
It now seeds with a COPY OF THE RAW SUM, titled "Corrected sum — press Correct
Drift" until a correction exists. The window is then honest at every instant:
the panels are a before/after pair, and before you have corrected anything the
honest "after" IS the before. They start identical and one changes the moment
the solve lands, which is the comparison the window exists to make. The title
carries the state so "identical" cannot be misread as "the correction did
nothing". Verified on pixels at 245 x 4096^2: all four panels have content on
open.
2. THE PREVIEW SOLVE IS PINNED TO torch-CPU. Measured on the 512^2 preview crops:
cpu 6.8 ms warm, cuda 7.3 ms, numpy 18.4 ms. CUDA buys nothing at this size and
pinning to CPU keeps a per-drag-frame job off the device the full solve wants.
NB the ~3 s the first preview cost in the harness is torch IMPORT+INIT, which
is process-wide and not about device choice -- the session already prewarms it
on a background thread, and the benchmark was racing that prewarm by opening
the caret immediately after load.
3. THE drift_open BUDGET IS NOW A FUNCTION OF FRAME SIZE, restated openly rather
than widened silently. It scales with frame bytes, so a flat number is either
slack at 2048^2 or a false failure at 4096^2. Measured floor at 4096^2: two
full-frame reads (145 ms) plus figure construction (99 ms) = ~244 ms against
278 ms observed; at 2048^2 the same terms are ~100 ms against 133-153 ms.
120 + 10/Mpx fits both -- a fitted line through two points, not a model.
The flat 200 ms IS reachable at 4096^2, but only by reading the check-sum
frames through the memmap rather than dask (11.6 ms/frame vs 77). Not done.
…the dispatcher
The first preview cost 4.4s in a cold process. 3.99s of that was torch's IMPORT,
paid by whichever thread called solve_translation first -- and that thread was
the SHARED nav dispatcher, so it blocked every navigator update too.
heavy_imports gains wait_for_torch()/torch_imported(): an Event set when the
prewarm thread finishes, whether or not a GPU was found (a torch-CPU consumer
cares that the import is done, not that CUDA exists). Set in `finally` and on
the pytest skip path, so a waiter can never hang.
drift_open now opens the check window first, then absorbs the wait on a WORKER
before submitting the first preview. The preview step itself gates on
torch_imported() and uses numpy (18ms warm) until torch is resident, so the
dispatcher never stalls on an import under any ordering.
Measured, 245 x 4096^2 uint8 lazy:
cold process normal load (prewarm done)
drift_open 241.8 ms 236.4 ms
first_preview torch_wait 3986.2 ms 0.0 ms <- on a worker either way
drift_preview_step 165.8 ms 212.6 ms (was 4357.7 before this)
open -> first preview 4.6 s 0.5 s
The normal-load number is the one a user sees: they open a 3.8 GiB movie, look
at it, and by the time they reach for the caret the background prewarm finished
long ago. The cold figure is a harness artefact of opening the caret
microseconds after the load -- worth having, but it is not the user's number,
and reporting it as one is what --normal-load exists to stop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces the hand-built phase-correlation chain with the MotionCor2/Unblur-family method, per the maintainer's call that whitening is the wrong weighting for Poisson-limited data.
Result (240 × 2048² uint16, dose 40 e/px, 48 px drift)
10.6× more accurate, 13× / 4× / 1.13× faster. On a short small movie (24 × 512²) it is slower (0.65 s vs 0.25 s) — band=48 is all-pairs there while the chain does 23 correlations — but still 7× more accurate. Crossover is where binning applies. New
benchmark_drift_translation.pyproduces this table.Method
q^tilt · exp(-(q/lowpass)²) · (1 − exp(-(q/highpass)²)). Every leg is a function of frequency only — no bin's weight depends on its own contents, which is precisely why no_PHASE_FLOORis needed. Peak fromreal(ifft2), neverabs.0 < j−i ≤ band, gaugep₀=0,AᵀWAis a banded graph Laplacian solved bysolveh_banded(not dense lstsq). IRLS with Tukey bisquare, seeded from the streaming median trajectory — from an unweighted start the outliers capture the fit and the 4.685σ cut rejects nothing.ifft2(test asserts ≤N inverse FFTs for ≫N pairs). Correlation on a binned grid; sub-pixel via a ladder of matmul-DFT stages so binning costs no resolution. Memory bounded:band+1binned spectra + one full-res frame.Deleted:
_PHASE_FLOOR,normalize's whitening path, the running Fourier reference and_phase_ramp, allreferencemodes,_accept_into_reference/_REJECT_*/reject_outliers. Kept: Guizar-Sicairos refinement, streaming source,DriftModel,warp.py, numpy↔torch parity.Three empirical corrections to the brief
band= 48, not 10–20. A banded system chains N/band links and multiplies per-pair bias: 1.46 px rms at band 12, 0.60 at 24, 0.09 at 48.One regression that needs a decision — the ROI path
With β=0 a small ROI is materially worse: on
particle_movie's half-frame box, 0.37 px (old) → ~5 px (new). Whole-frame went the other way, 0.25 → 0.03 px. Reproduced independently withskimage's ownnormalization=Noneon the same crops, so it is the estimator, not our implementation: plain CC is dominated by the brightest features, phase CC by the most numerous — the bright particles outweigh the film that actually carries the stage motion. The ROI toggle stays off by default and this is documented in the module docstring. See the question in the PR discussion: a fiducial-marker ROI may be exactly the case where β=0 is right, so this may want an ROI-specific β rather than a fix.Tests: 116 drift · full suite 2766 passed / 25 skipped · typecheck clean ·
drift_wizard.spec.ts4/4 with screenshots read (crisp corrected sum, "1.9x sharper · 6.0 px drift · 0.07 px residual", "Pair span 48"). Every changed/removed test is named with justification in the commits; no accuracy threshold was weakened.Not measured: 4096² (synthesis stalled on disk;
--size 4096works). Known weak spot: ≤256 px frames below ~4 e/px.