Repository navigation
Fix the 2D width clamp and the shared-background Jacobian - #71
Merged
HazenBabcock merged 2 commits intoOct 18, 2026
Merged
Conversation
daoUpdate2D() copied XWIDTH into YWIDTH before daoCheck2D() applied the
width clamp, and the clamp only touched XWIDTH. Whenever it fired, the
peak was drawn with the clamped width in x and the unclamped one in y,
while daoCalcJH2D() differentiates a round peak of width XWIDTH, so the
error that accepts or rejects the step belonged to a different model.
Multi-plane DAO fitting was worse off. mpDaoUpdate() updates XWIDTH and
calls daoCheck2D(), and nothing on that path ever wrote YWIDTH, so every
peak kept its starting y width for the whole fit.
daoCheck2D() now sets YWIDTH from XWIDTH after the clamp, which fixes
both. daoCheck3D() used to start by calling daoCheck2D(); it now does its
own margin check and clamps the two widths independently, so the 3D
model is unchanged.
Pre-specified 2D peaks ("text" / "hdf5") pass through the same check when
they are added, so a requested ysigma that differs from xsigma is now
replaced by xsigma straight away rather than after the first update.
test_mfit_8 and test_mfit_10 checked the old round trip; they now check
the new one.
On identical simulated data the multiplane_dao diagnostic goes from
recall 0.702 to 0.725 and XY RMSE 19.6 / 19.3 nm to 19.2 / 18.5 nm, at
the cost of twice the fitting iterations (mean 6.2 to 12.4 per
localization) now that the y width actually moves. The daostorm_3d,
sCMOS, spliner, pupilfn and psf_fft diagnostics are unchanged to within
run-to-run noise (~1e-13).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Overlapping peaks share their background. Each peak adds its background to bg_data and increments bg_counts over its ROI, and the model value at a pixel uses bg_data / bg_counts, the average background of the peaks whose ROIs cover it (see mFitCalcErr()). The Jacobian functions used df/db = rqe at every ROI pixel, which is only right where a single ROI covers the pixel. Where ROIs overlap the derivative is rqe / bg_counts. With the wrong column the per-peak update heads for a point that is not the maximum likelihood estimate of the model. More importantly, the background / height part of each step makes so little progress that the per-peak convergence test marks overlapping peaks converged early. All 19 Jacobian functions that use this model are changed: 3D-DAOSTORM / sCMOS (12, including the two Anscombe variants, whose column is t1 rather than rqei), Spliner (5), pupil function (1) and PSF FFT (1). Multi-plane fitting calls these per plane and picks the change up. Isolated peaks have bg_counts = 1 throughout, so their fits do not change at all (bit-identical on 400 single-peak fits). Measured on simulated clusters of 2 to 4 overlapping emitters (sigma 1.5 px, 2000 photons on a background of 50), against the joint maximum likelihood fit of the same model: - doFit() now stops a median 0.003 to 0.21 CRLB from the joint optimum at 2 to 3 sigma separation, was 0.08 to 0.64; at 1.5 sigma 0.03 to 0.92, was 0.11 to 1.65. - In the full find + fit pipeline, where the right number of peaks was found at 1.5 to 3 sigma, position and height errors against the truth now match the joint optimum: xy RMS ratio 0.96 to 1.05, was up to 1.49, and height RMS ratio 0.97 to 1.03, was up to 2.12. The exception is lines of three at 1.5 sigma and closer, where the joint optimum is itself near-degenerate and the pipeline still does better than it. Spurious extra localizations fall, e.g. pairs 2 sigma apart from 28% to 18% with 2dfixed and 56% to 42% with 2d. - The cost is more iterations in tight clusters, because peaks no longer stop early: 15 to 90% more LM iterations per cluster in those runs. The diagnostics, on identical data: daostorm_3d and sCMOS unchanged (their ROIs do not overlap); spliner summary statistics unchanged; pupilfn and psf_fft move by up to 1.5% in XYZ RMSE in both directions, within what one realisation resolves, and need 8 to 10% fewer iterations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Summary
Two defects in how the fitters update overlapping and width-clamped peaks.
2D width clamp (
dao_fit.c). The 2D model has one width, but the peak is drawn fromXWIDTHin x andYWIDTHin y:Before this change, a clamped 2D peak was drawn elliptical. Multi-plane DAO never wrote
YWIDTHat all, so every peak kept its starting y width.Shared-background Jacobian (19 functions in
dao_fit.c,cubic_fit.c,pupil_fit.c,fft_fit.c):Where ROIs overlap, the update was aimed at a point that is not the maximum likelihood fit. Its background/height steps also made so little progress that overlapping peaks were marked converged early. Isolated peaks have
bg_counts = 1, so their fits are unchanged.Evidence
Tests. 282 pass before and after.
test_mfit_8/test_mfit_10round-tripped a pre-specified 2D peak with ysigma ≠ xsigma. They now expect ysigma to come back as the clamped xsigma.Python replica of
mFitIterateLM(), compared with the C library sweep by sweep on 240 simulated fits of 1–4 overlapping peaks:After: 240 / 240 match (worst relative difference 1.4e-12) once the replica uses df/db = rqe / bg_counts. The replica with the old derivative now disagrees in exactly the 180 overlapping runs and agrees on the 60 single-peak runs.
Simulated clusters (σ 1.5 px, 2000 photons, background 50). Below is the median distance from the joint maximum likelihood fit when
doFit()stops, in CRLB units:End to end (3D-DAOSTORM find + fit on the same clusters), where the right number of peaks was found:
2dfixed), 56% → 42% (2d).Diagnostics, run on identical simulated data before and after:
daostorm_3d with random placement. This is the "Uniform background, STORM" block in
make_data.py:random_list.hdf5withSimpleSTORMblinking, about 117 emitters on per frame. It is switched off in the repo, so I reproduced it with 500 frames, giving about 58,600 emitter-frames per photon level. Scored by distance to the nearest emitter that is on in the same frame (1000 photons; 500 is similar):Unmatched localizations fall 412 → 319 (500 photons: 285 → 236). The gains are much smaller here than in the toy clusters. At these separations most of the error is detection and merging (recall below 0.5), and blinking emitters that overlap are usually very unequal in brightness.
Merge Danger
Door: two-way. This is a code-only change with no file format or parameter changes, and reverting restores the old behaviour exactly.
Blast Radius: numerical
text/hdf5) 2D peaks now report ysigma = xsigma from the moment they are added, including withno_fitting.🤖 Generated with Claude Code