Use the real-input FFT for registration phase correlation - #1257
Open
bendichter wants to merge 1 commit into
Open
Use the real-input FFT for registration phase correlation#1257bendichter wants to merge 1 commit into
bendichter wants to merge 1 commit into
Conversation
The frames passed to convolve in registration/utils.py are real-valued, but the function ran a full complex fft2 and then discarded the imaginary half of the inverse transform with torch.real(). Because both the frames and the reference image are real, the phase-correlation spectrum is Hermitian, so the redundant half carries no information and rfft2/irfft2 produces the same result with half the transform work and half the spectrum memory. ref_smooth_fft now returns the non-redundant half of the spectrum, of shape (Ly, Lx // 2 + 1), to match. The callers in rigid.phasecorr and nonrigid.phasecorr keep their data real instead of casting to complex64. nonrigid.phasecorr had been storing real convolution output in a complex64 tensor, which doubled the memory traffic of the slicing that follows it. spatial_taper changes from float64 to float32. This is required rather than incidental: a float64 mask promotes the masked frames to float64 and the transform to complex128, which would be slower than the code being replaced. The float64 intermediate was discarded by the cast to complex64 on the following line in any case, and the MPS backend has always run this path in float32. Measured on 300 frames of 512x512 on CPU, registration goes from 1.08s to 0.52s rigid, and from 3.41s to 1.83s with nonrigid enabled. Rigid output is bit-identical: registered frames, mean image, and integer offsets all match exactly, and only the correlation peak values differ, at 1e-7. With nonrigid enabled, one block shift out of 10800 moved by a single subpixel step (0.1 px at the default subpixel=10), because float32 rounding in the transform flipped an argmax tie. The correlation values themselves agree to 1e-8. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bendichter
marked this pull request as ready for review
July 16, 2026 18:08
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.
How This Was Found
I used Claude Code to profile the registration path. It surfaced the
convolvehotspot and drafted the change. I am stating that at the top rather than leaving you to infer it, because I know AI-generated pull requests are frequently a net cost to the people maintaining a project, and you are entitled to not want them. If you would rather not take AI-assisted contributions regardless, say so and I will close this, no argument. If the change is welcome in principle but you want it structured differently, for instance the float32 taper split out as its own commit, I am glad to do that.Summary
The frames passed to
convolveinregistration/utils.pyare real-valued, but the function ran a full complexfft2and then discarded the imaginary half of the inverse transform withtorch.real(). Because both the frames and the reference image are real, the phase-correlation spectrum is Hermitian, so the redundant half carries no information. Usingrfft2/irfft2computes the same quantity with half the transform work and half the spectrum memory.The reason to look here at all is that
convolvedominates the stage. On a 500 frame, 512x512 synthetic movie it accounts for roughly two thirds of registration time (7.75s of 11.6s) across only ten calls.What Changed
ref_smooth_fftnow returns the non-redundant half of the spectrum, of shape(Ly, Lx // 2 + 1), so that the kernel matches the transform. The callers inrigid.phasecorrandnonrigid.phasecorrkeep their data real instead of casting tocomplex64.nonrigid.phasecorrhad been storing real convolution output in acomplex64tensor, which doubled the memory traffic of the slicing that follows it.spatial_taperchanges from float64 to float32. This is required because a float64 mask promotes the masked frames to float64 and the transform tocomplex128, which would make this change slower than the code it replaces. The float64 intermediate was discarded by the cast tocomplex64on the following line in any case, and the MPS backend has always run this path in float32, so float32 masks are not new behavior.nonrigid.phasecorrpreviously derived the block spatial size fromcfRefImg.shape, which is now half width. It reads frommaskMulinstead, which retains the full dimensions.One caller outside the package,
paper/figures.py, unpackedref_smooth_fftwithifft2and is updated to useirfft2.Reproducing
The script I used to verify this, along with its reference output, is here:
https://gist.github.com/bendichter/6d2ecc2e01e6c03dfe42c6e499c054fd
It runs
register_framesfrom two checkouts on byte-identical input and compares every output: the registered movie, the mean image, and all offsets. To use it against an unmodified tree:Performance
Measured on 300 frames of 512x512 on CPU, median of four runs. I do not have a CUDA device available, so these numbers are CPU only. I would expect the float64 removal in particular to matter more on CUDA, where fp64 throughput is a fraction of fp32 on consumer cards, but I have not measured that and am not claiming it.
Run-to-run variance on this machine is a few percent, mostly in the nonrigid numbers.
Numerical Equivalence
Rigid output is bit-identical. The registered frames, the mean image, and the integer offsets all match exactly. Only the correlation peak values differ, at 1e-7, which is float32 rounding.
With nonrigid enabled the output is not bit-identical. One block shift out of 10800 moved by a single subpixel step, 0.1 px at the default
subpixel=10, which affected 1 frame out of 300. The correlation values themselves agree to 1e-8. This is float32 rounding in the transform flipping an argmax tie between two nearly equal peaks, not a systematic change in the shifts. The knock-on effect is that the nonrigid mean image moves by at most 0.13 on a movie whose values run to roughly 12000. Reviewers who want exact reproduction of existing nonrigid outputs on a pinned dataset should know about it.Tests
This numerical path had no direct test coverage, so I added four tests to
tests/test_registration.py:test_convolve_matches_full_spectrum_referencechecksconvolveagainst a full complex-FFT reference implementation, pinning the Hermitian half-spectrum equivalence that this change rests on.test_ref_smooth_fft_returns_half_spectrumpins the returned shape and dtype.test_rigid_phasecorr_recovers_known_shiftschecks exact recovery of known integer shifts.test_spatial_taper_is_float32guards thecomplex128regression described above, which is otherwise silent and would only show up as a slowdown.All 7 tests in
tests/test_registration.pypass. The twotest_io.pyNWB round-trip failures also fail on unmodifiedmainand are unrelated to this change.🤖 Generated with Claude Code