Skip to content

Commit 00562d9

Browse files
committed
Normalize PSF in locate so min_contrast thresholds the fitted contrast
The ``min_contrast`` parameter and the ``contrast`` output column were not on the same scale: ``min_contrast`` thresholded the raw matched-filter score (which mixes contrast, PSF energy, and background), while ``contrast`` was the fitted amplitude of the PSF template. A user setting ``min_contrast=1.0`` could not predict which emitters would survive the threshold without knowing the PSF's L2 norm and DC offset. Fix: normalize the PSF in ``_canonicalize_psf`` (mean-subtract to kill the background contribution, L2-normalize so a unit-contrast emitter scores 1.0). After normalization, the peak matched-filter score of an emitter with true contrast C is exactly C, so ``min_contrast`` becomes a direct threshold on the fitted ``contrast`` column. The fitted ``contrast`` is now relative to the normalized PSF; the fitted ``background`` absorbs the DC component removed by mean subtraction (for an all-positive PSF it is biased high by ``contrast * mean(psf)``). The normalization is a canonicalization step, so it lives in ``_canonicalize_psf`` (called only by ``locate``). ``_locate_in_chunk`` remains a pure primitive whose docstring now notes that the PSF must be pre-normalized. A precision-relative floor on the L2 norm guards against divide-by-zero for constant PSFs. Test changes: - Migrate result-checking tests from ``_locate_in_chunk`` to ``locate`` (the public API that normalizes the PSF). ``_locate_in_chunk`` is now only tested for its own contracts (validation, padding, noise estimation). - Recalibrate value-asserting tests for the new contrast scale (fitted contrast = true_contrast * L2_norm). The background test moves to a 20x20 frame and checks the nearest detection, since the mean-zero PSF produces edge artifacts on constant-background frames. - Add ``sign="positive"`` to tests with positive-only emitters to avoid Mexican-hat ring detections. - Add ``test_canonicalize_psf_normalizes`` to verify mean-zero and unit-L2 properties, plus the constant-PSF floor. - Fix ``bench_locate.py``: the dense case used ``min_contrast=0.0``, which is now rejected (must be strictly positive).
1 parent e2cdc73 commit 00562d9

5 files changed

Lines changed: 186 additions & 162 deletions

File tree

benchmarks/bench_locate.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ def main() -> None:
103103
print("auto chunk_size (default):")
104104
for hw in (64, 128):
105105
bench(64, hw, "auto", min_contrast=0.2)
106-
print("Dense case (min_contrast=0):")
107-
bench(64, 64, 8, min_contrast=0.0)
106+
print("Dense case (min_contrast=0.1):")
107+
bench(64, 64, 8, min_contrast=0.1)
108108

109109

110110
if __name__ == "__main__":

src/toolsandogh/_locate.py

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,12 @@ def locate(
8383
psf : array-like
8484
The point-spread function model, shape ``(Py, Px)`` for a 2D
8585
(widefield) PSF or ``(Pz, Py, Px)`` for a 3D PSF. A 2D PSF is
86-
promoted to ``(1, Py, Px)`` internally.
86+
promoted to ``(1, Py, Px)`` internally. The PSF is normalized
87+
(mean-subtracted and L2-normalized) so that the matched-filter
88+
score of a unit-contrast emitter is exactly 1; this makes
89+
``min_contrast`` a direct threshold on the fitted ``contrast``
90+
column. The fitted ``contrast`` is therefore reported relative
91+
to the normalized PSF, not the supplied one.
8792
channel : int or str or float, optional
8893
The channel to localize, given as a coordinate label (anything
8994
xarray's ``.sel`` accepts). Required when the video has more
@@ -103,7 +108,10 @@ def locate(
103108
suppressed.
104109
min_contrast : float
105110
Minimum absolute value of the matched-filter score for a peak
106-
to be reported. Must be strictly positive; the default is
111+
to be reported. Because the PSF is normalized (mean-zero,
112+
unit L2 norm), the score at a true peak equals the emitter's
113+
fitted contrast, so this is a direct threshold on the
114+
``contrast`` column. Must be strictly positive; the default is
107115
``1.0``.
108116
sign : {"both", "positive", "negative"}
109117
Whether to detect only positive peaks, only negative peaks, or
@@ -148,6 +156,14 @@ def locate(
148156
149157
Notes
150158
-----
159+
The PSF is normalized (mean-subtracted, L2-normalized) internally,
160+
so the fitted ``contrast`` is relative to the normalized PSF: a
161+
unit-contrast emitter (one that matches the normalized PSF with
162+
amplitude 1) produces a peak matched-filter score of 1 and a fitted
163+
contrast of 1. The fitted ``background`` absorbs the DC component
164+
removed by mean-subtraction, so for an all-positive PSF it is biased
165+
high relative to the true additive offset by ``contrast * mean(psf)``.
166+
151167
The ``chi2`` column is the chi-squared statistic
152168
``sum(residual**2) / noise_sigma**2``, which follows a chi-squared
153169
distribution with ``dof = Pz*Py*Px - n_params`` degrees of freedom
@@ -302,7 +318,12 @@ def _locate_in_chunk(
302318
Both ``chunk`` and ``psf`` must already be well-formed JAX arrays:
303319
``chunk`` is a dense 4D ``(B, Z, Y, X)`` array and ``psf`` is a 3D
304320
``(Pz, Py, Px)`` array (a 2D PSF must have been promoted to ``(1, Py,
305-
Px)`` by the caller). Input canonicalization and validation are the
321+
Px)`` by the caller). The PSF must also be **normalized**
322+
(mean-subtracted and L2-normalized, as :func:`_canonicalize_psf`
323+
does) so that the matched-filter score and the fitted contrast share
324+
a scale; :func:`locate` guarantees this, but direct callers of
325+
:func:`_locate_in_chunk` are responsible for normalizing the PSF
326+
themselves. Input canonicalization and validation are the
306327
responsibility of :func:`locate`, not of this function.
307328
308329
The returned DataFrame carries only index-space coordinates:
@@ -318,6 +339,9 @@ def _locate_in_chunk(
318339
A dense ``(B, Z, Y, X)`` array of image data.
319340
psf : jax.Array
320341
The 3D point-spread function model, shape ``(Pz, Py, Px)``.
342+
Must be mean-zero and L2-normalized (see :func:`_canonicalize_psf`)
343+
so that the matched-filter score and the fitted contrast share
344+
a scale.
321345
n_active_frames : int, optional
322346
Number of frames at the start of ``chunk`` that contain real
323347
data. When ``None`` (the default), all ``B`` frames are
@@ -939,12 +963,27 @@ def _canonicalize_psf(
939963
dtype: npt.DTypeLike | None = None,
940964
) -> np.ndarray:
941965
"""
942-
Coerce a PSF model to the canonical 3D ``(Pz, Py, Px)`` form.
966+
Coerce a PSF model to the canonical normalized 3D ``(Pz, Py, Px)`` form.
943967
944968
A 2D ``(Py, Px)`` PSF (widefield) is promoted to ``(1, Py, Px)``.
945-
Any other rank is rejected. The result is cast to ``dtype`` when
969+
Any other rank is rejected. The PSF is then **normalized** so that
970+
the matched-filter score and the fitted contrast land on the same
971+
scale: the mean is subtracted (making the PSF mean-zero, so a flat
972+
background contributes nothing to the correlation score) and the
973+
result is divided by its L2 norm (so a unit-contrast emitter
974+
produces a unit peak score). The result is cast to ``dtype`` when
946975
supplied, otherwise the input dtype is preserved.
947976
977+
Normalization matters because :func:`locate` uses the PSF both as
978+
the matched-filter kernel (for detection) and as the fitting
979+
template (for refinement). With a mean-zero, L2-normalized PSF the
980+
peak matched-filter score of a unit-contrast emitter is exactly 1,
981+
so ``min_contrast`` becomes a direct threshold on the fitted
982+
``contrast`` column rather than on a PSF- and background-dependent
983+
score. A constant or all-zero PSF (L2 norm zero) is left unchanged
984+
after mean subtraction, with a precision-relative floor guarding the
985+
division.
986+
948987
Parameters
949988
----------
950989
psf : array-like
@@ -955,7 +994,7 @@ def _canonicalize_psf(
955994
Returns
956995
-------
957996
numpy.ndarray
958-
A 3D ``(Pz, Py, Px)`` array of the requested dtype.
997+
A normalized 3D ``(Pz, Py, Px)`` array of the requested dtype.
959998
960999
Raises
9611000
------
@@ -969,6 +1008,13 @@ def _canonicalize_psf(
9691008
raise ValueError(f"`psf` must be a 2D or 3D array, got an array of shape {arr.shape}.")
9701009
if dtype is not None:
9711010
arr = arr.astype(dtype)
1011+
# Mean-subtract (kills the background contribution to the matched-filter
1012+
# score) and L2-normalize (so a unit-contrast emitter scores 1.0). The
1013+
# floor on the norm avoids a divide-by-zero for a constant PSF.
1014+
arr = arr - arr.mean()
1015+
norm = np.linalg.norm(arr)
1016+
floor = np.sqrt(np.finfo(arr.dtype).eps) * max(float(np.max(np.abs(arr))), 1.0)
1017+
arr = arr / max(norm, floor)
9721018
return arr
9731019

9741020

src/toolsandogh/tests/test_link.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def _simulate_and_locate(
3333
psf,
3434
min_distance=3,
3535
min_contrast=0.1,
36+
sign="positive",
3637
iterations=10,
3738
atol=1e-3,
3839
**locate_kwargs,
@@ -209,6 +210,7 @@ def test_link_multi_channel_independent() -> None:
209210
channel=ch,
210211
min_distance=3,
211212
min_contrast=0.1,
213+
sign="positive",
212214
iterations=10,
213215
atol=1e-3,
214216
)

0 commit comments

Comments
 (0)