toolsandogh 0.3.1__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/.github/workflows/ci.yml +1 -0
  2. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/.github/workflows/release.yml +10 -2
  3. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/DESIGN.md +3 -2
  4. {toolsandogh-0.3.1/src/toolsandogh.egg-info → toolsandogh-0.3.3}/PKG-INFO +1 -1
  5. toolsandogh-0.3.3/benchmarks/bench_locate.py +103 -0
  6. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/_locate.py +190 -148
  7. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_locate.py +92 -0
  8. {toolsandogh-0.3.1 → toolsandogh-0.3.3/src/toolsandogh.egg-info}/PKG-INFO +1 -1
  9. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh.egg-info/SOURCES.txt +1 -0
  10. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh.egg-info/scm_file_list.json +1 -0
  11. toolsandogh-0.3.3/src/toolsandogh.egg-info/scm_version.json +8 -0
  12. toolsandogh-0.3.1/src/toolsandogh.egg-info/scm_version.json +0 -8
  13. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/.gitignore +0 -0
  14. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/.pre-commit-config.yaml +0 -0
  15. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/LICENSE +0 -0
  16. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/README.md +0 -0
  17. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/demos/demo_iscat_50nm_bead.py +0 -0
  18. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/pyproject.toml +0 -0
  19. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/setup.cfg +0 -0
  20. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/__init__.py +0 -0
  21. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/_canonicalize_video.py +0 -0
  22. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/_generate_video.py +0 -0
  23. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/_link.py +0 -0
  24. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/_load_video.py +0 -0
  25. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/_rolling.py +0 -0
  26. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/_rvt.py +0 -0
  27. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/_simulate_particles.py +0 -0
  28. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/_simulate_psf.py +0 -0
  29. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/_store_video.py +0 -0
  30. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/_validate_video.py +0 -0
  31. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/tests/__init__.py +0 -0
  32. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/tests/conftest.py +0 -0
  33. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_io.py +0 -0
  34. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_link.py +0 -0
  35. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_rolling.py +0 -0
  36. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_simulate_particles.py +0 -0
  37. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_simulate_psf.py +0 -0
  38. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh/tests/testfile.tiff +0 -0
  39. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh.egg-info/dependency_links.txt +0 -0
  40. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh.egg-info/requires.txt +0 -0
  41. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/src/toolsandogh.egg-info/top_level.txt +0 -0
  42. {toolsandogh-0.3.1 → toolsandogh-0.3.3}/uv.lock +0 -0
@@ -4,6 +4,7 @@ on:
4
4
  push:
5
5
  branches: [main]
6
6
  pull_request:
7
+ workflow_call: # reused by release.yml so every release is gated by CI
7
8
 
8
9
  concurrency:
9
10
  group: ci-${{ github.ref }}
@@ -14,9 +14,17 @@ permissions:
14
14
  contents: read
15
15
 
16
16
  jobs:
17
+ # Run the full CI suite (pre-commit + portability test matrix) on the
18
+ # tagged commit before anything is built or published. This reuses ci.yml
19
+ # via workflow_call, so a release can never ship code that hasn't passed
20
+ # the same checks that gate pull requests.
21
+ ci:
22
+ uses: ./.github/workflows/ci.yml
23
+
17
24
  # Build the sdist and wheel from the tagged commit. setuptools-scm derives
18
25
  # the version from git, so a full history checkout is required.
19
26
  build:
27
+ needs: ci
20
28
  runs-on: ubuntu-latest
21
29
  timeout-minutes: 10
22
30
  steps:
@@ -28,7 +36,7 @@ jobs:
28
36
  enable-cache: true
29
37
  python-version: "3.12"
30
38
  - run: uv build -o dist
31
- - uses: actions/upload-artifact@v6
39
+ - uses: actions/upload-artifact@v7
32
40
  with:
33
41
  name: dist
34
42
  path: dist/
@@ -46,7 +54,7 @@ jobs:
46
54
  id-token: write # PyPI Trusted Publishing (OIDC)
47
55
  contents: write # create GitHub Release
48
56
  steps:
49
- - uses: actions/download-artifact@v6
57
+ - uses: actions/download-artifact@v8
50
58
  with:
51
59
  name: dist
52
60
  path: dist/
@@ -80,8 +80,8 @@ The following guidelines apply to all code in the toolsandogh package. They are
80
80
  #### 1.3 Inputs and outputs
81
81
 
82
82
  * Publicly visible functions should be liberal in what they accept and conservative in what they emit.
83
- * Accept the widest reasonable input: file paths, raw arrays, tuples, or plain lists where convenient, and let the function coerce them into the canonical representation.
84
- * Always emit stable, well-typed values (typically ``xarray.DataArray`` or ``polars.DataFrame``). Do not leak implementation types such as NumPy arrays or pandas DataFrames through the public API.
83
+ * Accept the widest reasonable input: file paths, raw arrays, tuples, or plain lists where convenient, and let the function coerce them into the canonical representation. We achieve this by defining, for each concept or data structure ``foo``, a function ``canonicalize_foo`` that turns a wide range of ``foo``-like objects into canonical ``foo`` objects.
84
+ * Always emit stable, well-typed values (typically ``xarray.DataArray`` or ``polars.DataFrame``).
85
85
 
86
86
  #### 1.4 Data structures
87
87
 
@@ -98,6 +98,7 @@ The following guidelines apply to all code in the toolsandogh package. They are
98
98
 
99
99
  * Raise specific, informative exceptions that explain what went wrong and why. Include the offending value where helpful.
100
100
  * Never silently coerce types or swallow exceptions. A bare ``except`` or an unchecked type coercion hides bugs and should be avoided.
101
+ * Explicitly check invariants whenever sensible. In particular, whenever an object is expected to have some non-trivial property ``foo``, define a function ``validate_foo`` that raises appropriate errors unless its argument has that property.
101
102
  * Validate inputs at the boundary of the public API; prefer explicit checks over defensive programming scattered through the internals.
102
103
 
103
104
  ### 2. Documentation -- numpydoc
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: toolsandogh
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: A collection of Python scripts for iSCAT microscopy data analysis
5
5
  Author-email: Marco Heisig <marco.heisig@mpl.mpg.de>
6
6
  License-Expression: GPL-3.0-or-later
@@ -0,0 +1,103 @@
1
+ """Benchmark for `toolsandogh.locate` frames-per-second performance.
2
+
3
+ Run with:
4
+
5
+ .venv/bin/python benchmarks/bench_locate.py
6
+
7
+ Prints timing and frames-per-second for a representative synthetic video.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+
14
+ import jax
15
+ import jax.numpy as jnp
16
+ import numpy as np
17
+ import polars
18
+ import xarray as xr
19
+
20
+ import toolsandogh as tog
21
+
22
+
23
+ def make_psf() -> jnp.ndarray:
24
+ n = 9
25
+ sigma = 1.5
26
+ y, x = jnp.meshgrid(jnp.arange(n) - n // 2, jnp.arange(n) - n // 2, indexing="ij")
27
+ return jnp.exp(-(y * y + x * x) / (2 * sigma * sigma)).reshape(1, n, n)
28
+
29
+
30
+ def make_video(n_frames: int, hw: int, psf: jnp.ndarray) -> xr.DataArray:
31
+ cy, cx = hw / 2, hw / 2
32
+ radius = hw / 8
33
+ t = np.arange(n_frames)
34
+ theta = 2.0 * np.pi * t / max(n_frames, 1)
35
+ ys = cy + radius * np.cos(theta)
36
+ xs = cx + radius * np.sin(theta)
37
+ trajectories = polars.DataFrame(
38
+ {
39
+ "t": t.tolist(),
40
+ "c": [0] * n_frames,
41
+ "z": [0.0] * n_frames,
42
+ "y": ys.tolist(),
43
+ "x": xs.tolist(),
44
+ "contrast": [2.0] * n_frames,
45
+ "particle_id": [0] * n_frames,
46
+ }
47
+ )
48
+ return tog.simulate_particles(
49
+ trajectories,
50
+ psf,
51
+ shape=(n_frames, 1, 1, hw, hw),
52
+ noise_sigma=0.02,
53
+ seed=0,
54
+ dtype=np.float32,
55
+ )
56
+
57
+
58
+ def bench(
59
+ n_frames: int, hw: int, chunk_size, min_contrast: float = 0.2, warmup: int = 4, repeats: int = 6
60
+ ) -> float:
61
+ psf = make_psf()
62
+ video = make_video(n_frames, hw, psf)
63
+
64
+ # Warmup (JIT compilation, etc.).
65
+ for _ in range(warmup):
66
+ tog.locate(
67
+ video, psf, chunk_size=chunk_size, min_contrast=min_contrast, iterations=10, atol=1e-3
68
+ )
69
+
70
+ best = float("inf")
71
+ for _ in range(repeats):
72
+ t0 = time.perf_counter()
73
+ locs = tog.locate(
74
+ video, psf, chunk_size=chunk_size, min_contrast=min_contrast, iterations=10, atol=1e-3
75
+ )
76
+ # Force materialization.
77
+ _ = locs.height
78
+ t1 = time.perf_counter()
79
+ best = min(best, t1 - t0)
80
+ fps = n_frames / best
81
+ print(
82
+ f" n_frames={n_frames:4d} hw={hw:3d} chunk={str(chunk_size):>3} mc={min_contrast:.2f} "
83
+ f"best={best * 1e3:8.2f} ms fps={fps:8.2f} n_locs={locs.height}"
84
+ )
85
+ return fps
86
+
87
+
88
+ def main() -> None:
89
+ jax.config.update("jax_enable_x64", False)
90
+ print(f"jax devices: {jax.devices()} backend: {jax.default_backend()}")
91
+ print("Baseline locate() benchmark")
92
+ for hw in (64, 128):
93
+ for cs in (1, 8):
94
+ bench(64, hw, cs, min_contrast=0.2)
95
+ print("auto chunk_size (default):")
96
+ for hw in (64, 128):
97
+ bench(64, hw, "auto", min_contrast=0.2)
98
+ print("Dense case (min_contrast=0):")
99
+ bench(64, 64, 8, min_contrast=0.0)
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()
@@ -9,7 +9,6 @@ contrasts, and per-emitter statistics. The auxiliary function
9
9
  Y, X)``.
10
10
  """
11
11
 
12
- import functools
13
12
  from typing import Callable, Literal
14
13
 
15
14
  import jax
@@ -28,6 +27,14 @@ from ._canonicalize_video import canonicalize_video
28
27
  # Public API
29
28
  # ---------------------------------------------------------------------------
30
29
 
30
+ # Fixed, reproducible device-memory budget for a single chunk when
31
+ # ``chunk_size="auto"``. The per-chunk working set is approximated as three
32
+ # times the frame size (data plus matched-filter scores plus the boolean
33
+ # peak mask); the auto chunk size is ``budget // (3 * frame_bytes)``. The
34
+ # value is hard-coded (not probed from the device) so that runs are
35
+ # reproducible across machines.
36
+ _MAX_CHUNK_BYTES = 1 << 30 # 1 GiB
37
+
31
38
 
32
39
  def locate(
33
40
  video: xr.DataArray,
@@ -35,7 +42,7 @@ def locate(
35
42
  *,
36
43
  channel: int | str | float | None = None,
37
44
  starting_frame: int = 0,
38
- chunk_size: int = 1,
45
+ chunk_size: int | Literal["auto"] = "auto",
39
46
  min_distance: int = 3,
40
47
  min_contrast: float = 0.0,
41
48
  sign: Literal["both", "positive", "negative"] = "both",
@@ -68,8 +75,13 @@ def locate(
68
75
  Frame index of the first frame of the video. Used to populate
69
76
  the ``t`` column when concatenating results from multiple
70
77
  videos.
71
- chunk_size : int
72
- Number of frames per Dask block.
78
+ chunk_size : int or {"auto"}, optional
79
+ Number of frames processed per chunk. Larger chunks reduce
80
+ dispatch and synchronization overhead; smaller chunks bound peak
81
+ device memory. If ``"auto"`` (the default), the size is derived
82
+ from a fixed, reproducible device-memory budget so that the
83
+ per-chunk working set (data plus matched-filter scores and the
84
+ boolean peak mask) stays under roughly 1 GiB.
73
85
  min_distance : int
74
86
  Minimum separation (in pixels) between two detected peaks.
75
87
  Pixels closer than ``min_distance`` to a stronger peak are
@@ -127,13 +139,21 @@ def locate(
127
139
 
128
140
  # Restrict to the chosen channel and chunk along the time axis.
129
141
  n_frames = int(video.sizes["T"])
130
- video = video.chunk({"T": min(chunk_size, n_frames)})
142
+ frame_shape = (
143
+ int(video.sizes["Z"]),
144
+ int(video.sizes["Y"]),
145
+ int(video.sizes["X"]),
146
+ )
147
+ chunk_axis = _resolve_chunk_size(chunk_size, n_frames, frame_shape, np.dtype(dtype).itemsize)
148
+ video = video.chunk({"T": chunk_axis})
131
149
 
132
- # Walk the blocks and accumulate the per-chunk results.
150
+ # Walk the blocks and accumulate the per-chunk results. Each chunk is
151
+ # computed independently from the Dask array, so only one chunk's data
152
+ # is held in memory at a time.
133
153
  psf_jax = jnp.asarray(psf_arr, dtype=dtype)
134
154
  results: list[polars.DataFrame] = []
135
- for start in range(0, n_frames, chunk_size):
136
- end = min(start + chunk_size, n_frames)
155
+ for start in range(0, n_frames, chunk_axis):
156
+ end = min(start + chunk_axis, n_frames)
137
157
  block: xr.DataArray = video.isel(T=slice(start, end))
138
158
  chunk = jnp.asarray(block.data.compute(), dtype=dtype)
139
159
  results.append(
@@ -220,40 +240,37 @@ def locate_in_chunk(
220
240
  if Pz > Z and Z > 1:
221
241
  raise ValueError(f"PSF Z size {Pz} must not exceed data Z size {Z}.")
222
242
 
223
- # 1. Matched-filter score for every frame.
224
- scores = _matched_filter_batch(psf, chunk) # (B, Z, Y, X)
225
-
226
- # 2. Per-frame peak detection (eager Python loop over frames).
227
- rows_t: list[int] = []
228
- rows_z: list[int] = []
229
- rows_y: list[int] = []
230
- rows_x: list[int] = []
231
- rows_score: list[float] = []
232
- for b in range(B):
233
- coords, peak_scores = _detect_peaks_one_frame(
234
- scores[b],
235
- min_distance=min_distance,
236
- min_contrast=min_contrast,
237
- sign=sign,
238
- )
239
- for (zi, yi, xi), score in zip(coords, peak_scores):
240
- rows_t.append(b)
241
- rows_z.append(int(zi))
242
- rows_y.append(int(yi))
243
- rows_x.append(int(xi))
244
- rows_score.append(float(score))
245
-
246
- if not rows_t:
243
+ # 1+2. Fused matched-filter score and non-maximum-suppression peak
244
+ # mask. A single JIT kernel produces the boolean (B, Z, Y, X) peak
245
+ # mask for the whole chunk; one eager ``argwhere`` then yields the
246
+ # integer coordinates of every detection -- a single host sync per
247
+ # chunk instead of one per frame.
248
+ is_peak = _peak_mask_batch(
249
+ chunk,
250
+ psf,
251
+ min_distance=min_distance,
252
+ min_contrast=min_contrast,
253
+ sign=sign,
254
+ ) # (B, Z, Y, X)
255
+
256
+ coords = jnp.argwhere(is_peak) # (n, 4) -> [b, z, y, x]
257
+ # ``argwhere``'s output size is data-dependent, so reading the
258
+ # shape forces one host sync. Materialize the whole array at once
259
+ # and slice in NumPy from here on -- no further device round-trips.
260
+ coords_np = np.asarray(coords)
261
+ n_emitters = int(coords_np.shape[0])
262
+ if n_emitters == 0:
247
263
  return _empty_result(channel=channel)
248
264
 
249
- # 3. Fit each emitter.
250
- n_emitters = len(rows_t)
251
- stamps = jnp.stack(
252
- [
253
- _extract_stamp(chunk[b], rows_z[i], rows_y[i], rows_x[i], (Pz, Py, Px))
254
- for i, b in enumerate(rows_t)
255
- ]
256
- )
265
+ b_idx = np.ascontiguousarray(coords_np[:, 0], dtype=np.int32)
266
+ rows_z = np.ascontiguousarray(coords_np[:, 1], dtype=np.int32)
267
+ rows_y = np.ascontiguousarray(coords_np[:, 2], dtype=np.int32)
268
+ rows_x = np.ascontiguousarray(coords_np[:, 3], dtype=np.int32)
269
+
270
+ # 3. Batched stamp extraction: pad the whole chunk once, then read every
271
+ # stamp with a single advanced-indexing gather (one device dispatch for
272
+ # all emitters, instead of one eager pad+slice per emitter).
273
+ stamps = _extract_stamps_batch(chunk, b_idx, rows_z, rows_y, rows_x, (Pz, Py, Px))
257
274
 
258
275
  fit = _fit_emitters_batch(
259
276
  stamps,
@@ -263,23 +280,34 @@ def locate_in_chunk(
263
280
  )
264
281
 
265
282
  # 4. Compose the output table.
266
- t_col = np.asarray(rows_t, dtype=np.int32) + np.int32(starting_frame)
283
+ t_col = b_idx + np.int32(starting_frame)
267
284
  c_scalar, c_dtype = _channel_scalar_and_dtype(channel)
268
285
 
269
- # Subpixel-refined positions: integer peak + subpixel offset.
270
- z_offsets = np.asarray(fit["z_offset"], dtype=np.float32)
271
- y_offsets = np.asarray(fit["y_offset"], dtype=np.float32)
272
- x_offsets = np.asarray(fit["x_offset"], dtype=np.float32)
273
- z_col = (np.asarray(rows_z, dtype=np.float32) + z_offsets).astype(np.float32)
274
- y_col = (np.asarray(rows_y, dtype=np.float32) + y_offsets).astype(np.float32)
275
- x_col = (np.asarray(rows_x, dtype=np.float32) + x_offsets).astype(np.float32)
276
-
277
- contrast_col = np.asarray(fit["contrast"], dtype=np.float32)
278
- mass_col = np.asarray(fit["mass"], dtype=np.float32)
279
- chi2_col = np.asarray(fit["chi2"], dtype=np.float32)
286
+ # Pull every fitted scalar off the device in a single sync: stack the
287
+ # per-emitter float outputs into one (k, n) array and read it back once.
288
+ fit_stack = jnp.stack(
289
+ [
290
+ fit["z_offset"],
291
+ fit["y_offset"],
292
+ fit["x_offset"],
293
+ fit["contrast"],
294
+ fit["mass"],
295
+ fit["chi2"],
296
+ ]
297
+ ) # (6, n)
298
+ fit_np = np.asarray(fit_stack, dtype=np.float32)
299
+ z_offsets, y_offsets, x_offsets = fit_np[0], fit_np[1], fit_np[2]
300
+ contrast_col = np.ascontiguousarray(fit_np[3])
301
+ mass_col = np.ascontiguousarray(fit_np[4])
302
+ chi2_col = np.ascontiguousarray(fit_np[5])
280
303
  n_iter_col = np.full(n_emitters, np.int32(iterations), dtype=np.int32)
281
304
  converged_col = np.asarray(fit["converged"], dtype=bool)
282
305
 
306
+ # Subpixel-refined positions: integer peak + subpixel offset.
307
+ z_col = (rows_z.astype(np.float32) + z_offsets).astype(np.float32)
308
+ y_col = (rows_y.astype(np.float32) + y_offsets).astype(np.float32)
309
+ x_col = (rows_x.astype(np.float32) + x_offsets).astype(np.float32)
310
+
283
311
  snr_col: list[float | None] = [None] * n_emitters
284
312
 
285
313
  return polars.DataFrame(
@@ -313,130 +341,102 @@ def locate_in_chunk(
313
341
 
314
342
 
315
343
  # ---------------------------------------------------------------------------
316
- # Matched filter
344
+ # Matched filter + non-maximum suppression (fused, batched over frames)
317
345
  # ---------------------------------------------------------------------------
318
346
 
319
347
 
320
- @jax.jit
321
- def _matched_filter_batch(
322
- psf: Float[Array, "Z Y X"],
348
+ @jax.jit(static_argnames=["min_distance", "sign"])
349
+ def _peak_mask_batch(
323
350
  chunk: Float[Array, "B Z Y X"],
324
- ) -> Float[Array, "B Z Y X"]:
325
- """Compute the matched-filter score for every frame in ``chunk``."""
326
- return jax.vmap(lambda frame: jsignal.correlate(frame, psf, mode="same", method="fft"))(chunk)
327
-
328
-
329
- # ---------------------------------------------------------------------------
330
- # Peak detection (non-maximum suppression)
331
- # ---------------------------------------------------------------------------
332
-
333
-
334
- def _detect_peaks_one_frame(
335
- score: Float[Array, "Z Y X"],
351
+ psf: Float[Array, "Pz Py Px"],
336
352
  *,
337
353
  min_distance: int,
338
354
  min_contrast: float,
339
355
  sign: Literal["both", "positive", "negative"],
340
- ) -> tuple[Float[Array, "n 3"], Float[Array, " n"]]:
356
+ ) -> Float[Array, "B Z Y X"]:
341
357
  """
342
- Find local extrema in a single score map.
358
+ Compute the matched-filter score and the boolean peak mask for every
359
+ frame in ``chunk`` in a single JIT kernel.
343
360
 
344
- Returns the integer coordinates of each peak and the corresponding
345
- matched-filter score at that pixel.
346
-
347
- This function is eager: ``jnp.argwhere`` returns a variable-size
348
- output that is incompatible with tracing, and the per-frame work
349
- is dominated by the matched filter and the Levenberg--Marquardt
350
- fit.
361
+ The non-maximum-suppression window has extent ``1`` along the frame
362
+ axis so that detections never bleed across frames; the spatial window
363
+ is ``(2*min_distance+1)**3``, matching the previous per-frame logic.
351
364
  """
352
- window = 2 * int(min_distance) + 1
365
+ scores = jax.vmap(lambda frame: jsignal.correlate(frame, psf, mode="same", method="fft"))(chunk)
366
+
367
+ window = (1, 2 * min_distance + 1, 2 * min_distance + 1, 2 * min_distance + 1)
368
+ strides = (1, 1, 1, 1)
353
369
 
354
370
  if sign == "positive":
355
- local_ext = jax.lax.reduce_window(
356
- score,
357
- -jnp.inf,
358
- jax.lax.max,
359
- (window, window, window),
360
- (1, 1, 1),
361
- "same",
362
- )
363
- is_peak = (score == local_ext) & (score > float(min_contrast))
371
+ local_ext = jax.lax.reduce_window(scores, -jnp.inf, jax.lax.max, window, strides, "same")
372
+ is_peak = (scores == local_ext) & (scores > min_contrast)
364
373
  elif sign == "negative":
365
- local_ext = jax.lax.reduce_window(
366
- score,
367
- jnp.inf,
368
- jax.lax.min,
369
- (window, window, window),
370
- (1, 1, 1),
371
- "same",
372
- )
373
- is_peak = (score == local_ext) & (score < -float(min_contrast))
374
+ local_ext = jax.lax.reduce_window(scores, jnp.inf, jax.lax.min, window, strides, "same")
375
+ is_peak = (scores == local_ext) & (scores < -min_contrast)
374
376
  else: # "both"
375
- local_max = jax.lax.reduce_window(
376
- score,
377
- -jnp.inf,
378
- jax.lax.max,
379
- (window, window, window),
380
- (1, 1, 1),
381
- "same",
382
- )
383
- local_min = jax.lax.reduce_window(
384
- score,
385
- jnp.inf,
386
- jax.lax.min,
387
- (window, window, window),
388
- (1, 1, 1),
389
- "same",
390
- )
391
- is_pos = (score == local_max) & (score > float(min_contrast))
392
- is_neg = (score == local_min) & (score < -float(min_contrast))
377
+ local_max = jax.lax.reduce_window(scores, -jnp.inf, jax.lax.max, window, strides, "same")
378
+ local_min = jax.lax.reduce_window(scores, jnp.inf, jax.lax.min, window, strides, "same")
379
+ is_pos = (scores == local_max) & (scores > min_contrast)
380
+ is_neg = (scores == local_min) & (scores < -min_contrast)
393
381
  is_peak = is_pos | is_neg
394
382
 
395
- coords = jnp.argwhere(is_peak) # (n, 3)
396
- scores = score[coords[:, 0], coords[:, 1], coords[:, 2]]
397
- return coords, scores
383
+ return is_peak
398
384
 
399
385
 
400
386
  # ---------------------------------------------------------------------------
401
- # Stamp extraction
387
+ # Stamp extraction (batched)
402
388
  # ---------------------------------------------------------------------------
403
389
 
404
390
 
405
- def _extract_stamp(
406
- frame: Float[Array, "Z Y X"],
407
- z: int,
408
- y: int,
409
- x: int,
391
+ def _extract_stamps_batch(
392
+ chunk: Float[Array, "B Z Y X"],
393
+ b_idx: npt.NDArray[np.int32],
394
+ z_idx: npt.NDArray[np.int32],
395
+ y_idx: npt.NDArray[np.int32],
396
+ x_idx: npt.NDArray[np.int32],
410
397
  shape: tuple[int, int, int],
411
- ) -> Float[Array, "Pz Py Px"]:
398
+ ) -> Float[Array, "n Pz Py Px"]:
412
399
  """
413
- Extract a stamp of ``shape`` centered on ``(z, y, x)`` from ``frame``.
400
+ Extract every emitter stamp in a single batched gather.
414
401
 
415
- Out-of-bounds positions are filled with the edge value of the
416
- frame. The stamp's index ``(Pz//2, Py//2, Px//2)`` corresponds
417
- to the frame position ``(z, y, x)``.
402
+ The whole ``chunk`` is edge-padded once along the spatial axes; each
403
+ stamp of ``shape`` centered on ``(z, y, x)`` is then read out with a
404
+ single advanced-indexing gather. This replaces the previous Python
405
+ loop that re-padded a full frame per emitter.
406
+
407
+ The stamp's index ``(Pz//2, Py//2, Px//2)`` corresponds to the frame
408
+ position ``(z, y, x)``; out-of-bounds positions are filled with the
409
+ edge value of the frame.
418
410
  """
419
411
  Pz, Py, Px = shape
420
412
  half_z, half_y, half_x = Pz // 2, Py // 2, Px // 2
413
+ n = b_idx.shape[0]
421
414
 
422
- # Pad the frame with edge values.
415
+ # Pad the spatial axes of the whole chunk once. Because
416
+ # ``half_z == Pz // 2`` (etc.), a stamp centred on frame position
417
+ # ``(z, y, x)`` starts at padded position ``(z, y, x)``.
423
418
  padded = jnp.pad(
424
- frame,
425
- ((half_z, Pz - half_z - 1), (half_y, Py - half_y - 1), (half_x, Px - half_x - 1)),
419
+ chunk,
420
+ (
421
+ (0, 0),
422
+ (half_z, Pz - half_z - 1),
423
+ (half_y, Py - half_y - 1),
424
+ (half_x, Px - half_x - 1),
425
+ ),
426
426
  mode="edge",
427
427
  )
428
428
 
429
- # The stamp's centre (at index (Pz//2, Py//2, Px//2)) should land
430
- # on the padded position that corresponds to the frame position
431
- # (z, y, x). Since the frame starts at padded position
432
- # (half_z, half_y, half_x), the frame position (z, y, x) sits at
433
- # padded (z + half_z, y + half_y, x + half_x). The stamp's centre
434
- # is Pz//2 and Py//2 in from the top-left of the stamp, so the
435
- # top-left of the stamp is at:
436
- z_p = z + half_z - Pz // 2
437
- y_p = y + half_y - Py // 2
438
- x_p = x + half_x - Px // 2
439
- return jax.lax.dynamic_slice(padded, (z_p, y_p, x_p), (Pz, Py, Px))
429
+ # Read every stamp with a single advanced-indexing gather. The index
430
+ # arrays are built in NumPy (cheap) so the only JAX dispatch here is the
431
+ # gather itself -- no ``vmap`` tracing, which matters when a chunk holds
432
+ # only a handful of emitters. All indices stay in range because the
433
+ # padding above guarantees ``padded`` is large enough along every axis.
434
+ oz, oy, ox = np.mgrid[:Pz, :Py, :Px]
435
+ b_g = np.broadcast_to(b_idx[:, None, None, None], (n, Pz, Py, Px))
436
+ z_g = z_idx[:, None, None, None] + oz[None]
437
+ y_g = y_idx[:, None, None, None] + oy[None]
438
+ x_g = x_idx[:, None, None, None] + ox[None]
439
+ return padded[jnp.asarray(b_g), jnp.asarray(z_g), jnp.asarray(y_g), jnp.asarray(x_g)]
440
440
 
441
441
 
442
442
  # ---------------------------------------------------------------------------
@@ -444,6 +444,7 @@ def _extract_stamp(
444
444
  # ---------------------------------------------------------------------------
445
445
 
446
446
 
447
+ @jax.jit(static_argnames=["iterations"])
447
448
  def _fit_emitters_batch(
448
449
  stamps: Float[Array, "n Pz Py Px"],
449
450
  psf: Float[Array, "Pz Py Px"],
@@ -529,7 +530,6 @@ def _make_shifted_psf_2d(
529
530
  return shifted_psf, 2
530
531
 
531
532
 
532
- @functools.partial(jax.jit, static_argnames=("iterations", "is_3d"))
533
533
  def _fit_one_emitter(
534
534
  stamp: Float[Array, "Pz Py Px"],
535
535
  psf: Float[Array, "Pz Py Px"],
@@ -584,23 +584,34 @@ def _fit_one_emitter(
584
584
  shift0 = jnp.zeros(n_shift, dtype=stamp.dtype)
585
585
  params0 = jnp.concatenate([jnp.array([amp0, bg0], dtype=stamp.dtype), shift0])
586
586
 
587
+ def residual(params: Float[Array, " n_params"]) -> Float[Array, " m"]:
588
+ return (stamp - model(params)).ravel()
589
+
587
590
  def cost(params: Float[Array, " n_params"]) -> Float[Array, ""]:
588
- r = (stamp - model(params)).ravel()
591
+ r = residual(params)
589
592
  return 0.5 * jnp.sum(r * r)
590
593
 
591
594
  # Fixed-length Levenberg--Marquardt scan. Every emitter runs the
592
595
  # same number of iterations, which keeps the ``vmap`` over
593
596
  # emitters happy.
597
+ #
598
+ # The normal equations use the Gauss--Newton approximation
599
+ # ``J^T J`` of the Hessian, with ``J = jacfwd(residual)``. For only
600
+ # 4--5 parameters, forward-mode AD over the residual is far cheaper
601
+ # than ``jax.hessian``'s reverse-over-reverse pass, and Gauss--Newton
602
+ # is the standard model for least-squares refinement of a PSF.
594
603
  lambda0 = jnp.array(1e-3, dtype=stamp.dtype)
595
604
 
596
605
  def lm_step(carry, _):
597
606
  params, lambda_, prev_cost, _prev_delta_max = carry
598
607
 
599
- _, grad = jax.value_and_grad(cost)(params)
600
- hess = jax.hessian(cost)(params)
608
+ r = residual(params)
609
+ jac = jax.jacfwd(residual)(params) # (m, n_params)
610
+ jtj = jac.T @ jac # (n_params, n_params)
611
+ grad = jac.T @ r # gradient of 0.5 * ||r||^2
601
612
 
602
- diag_h = jnp.diag(hess)
603
- damped = hess + lambda_ * jnp.diag(diag_h)
613
+ diag_h = jnp.diag(jtj)
614
+ damped = jtj + lambda_ * jnp.diag(diag_h)
604
615
  delta = jnp.linalg.solve(damped, -grad)
605
616
  new_params = params + delta
606
617
  new_cost = cost(new_params)
@@ -722,3 +733,34 @@ def _empty_result(channel: int | str | float = 0) -> polars.DataFrame:
722
733
  "converged": polars.Boolean,
723
734
  },
724
735
  ).with_columns(polars.lit(c_scalar).alias("c"))
736
+
737
+
738
+ def _resolve_chunk_size(
739
+ chunk_size: int | Literal["auto"],
740
+ n_frames: int,
741
+ frame_shape: tuple[int, int, int],
742
+ itemsize: int,
743
+ ) -> int:
744
+ """
745
+ Resolve ``chunk_size`` into a concrete number of frames per chunk.
746
+
747
+ For ``"auto"`` the size is derived from the fixed, reproducible
748
+ device-memory budget :data:`_MAX_CHUNK_BYTES`: the per-chunk working
749
+ set is approximated as three times the frame size (data plus
750
+ matched-filter scores plus the boolean peak mask), and the budget is
751
+ divided by that cost. The result is floored at ``1`` (a single frame
752
+ is the smallest unit; ``Z`` is never split) and capped at
753
+ ``n_frames``.
754
+
755
+ Raises
756
+ ------
757
+ ValueError
758
+ If ``chunk_size`` is neither a positive integer nor ``"auto"``.
759
+ """
760
+ if chunk_size == "auto":
761
+ frame_bytes = int(np.prod(frame_shape)) * itemsize
762
+ per_frame_working = 3 * frame_bytes
763
+ return max(1, min(n_frames, _MAX_CHUNK_BYTES // per_frame_working))
764
+ if not isinstance(chunk_size, (int, np.integer)) or chunk_size < 1:
765
+ raise ValueError(f"`chunk_size` must be a positive int or 'auto', got {chunk_size!r}.")
766
+ return min(int(chunk_size), n_frames)
@@ -547,3 +547,95 @@ def test_locate_anisotropic_psf() -> None:
547
547
  # Sign of the contrasts must be preserved.
548
548
  assert a0 > 0
549
549
  assert a1 < 0
550
+
551
+
552
+ def test_locate_auto_chunk_is_default() -> None:
553
+ """The default ``chunk_size`` must be ``"auto"``."""
554
+ import inspect
555
+
556
+ sig = inspect.signature(tog.locate)
557
+ assert sig.parameters["chunk_size"].default == "auto"
558
+
559
+
560
+ def test_locate_auto_chunk_matches_explicit() -> None:
561
+ """``chunk_size="auto"`` must produce the same result as an explicit size.
562
+
563
+ Detection and fitting are per-frame and per-emitter, so the result is
564
+ independent of the chunking; the driver streams each chunk through the
565
+ same code path. Agreement holds to float32 precision (batched-FFT
566
+ reduction order differs slightly from per-frame).
567
+ """
568
+ psf = _gaussian_psf(1.5, 9).reshape(1, 9, 9)
569
+ n_frames = 24
570
+ cy, cx = 8.5, 8.5
571
+ radius = 2.5
572
+ t = np.arange(n_frames)
573
+ theta = 2.0 * np.pi * t / n_frames
574
+ trajectories = polars.DataFrame(
575
+ {
576
+ "t": t.tolist(),
577
+ "c": [0] * n_frames,
578
+ "z": [0.0] * n_frames,
579
+ "y": (cy + radius * np.cos(theta)).tolist(),
580
+ "x": (cx + radius * np.sin(theta)).tolist(),
581
+ "contrast": [2.0] * n_frames,
582
+ "particle_id": [0] * n_frames,
583
+ }
584
+ )
585
+ video = tog.simulate_particles(
586
+ trajectories,
587
+ psf,
588
+ shape=(n_frames, 1, 1, 18, 18),
589
+ noise_sigma=0.02,
590
+ seed=0,
591
+ dtype=np.float32,
592
+ )
593
+ common = {
594
+ "min_distance": 4,
595
+ "min_contrast": 0.2,
596
+ "iterations": 10,
597
+ "atol": 1e-3,
598
+ }
599
+ auto = tog.locate(video, psf, chunk_size="auto", **common).sort("t", "y", "x")
600
+ explicit = tog.locate(video, psf, chunk_size=1, **common).sort("t", "y", "x")
601
+ explicit4 = tog.locate(video, psf, chunk_size=4, **common).sort("t", "y", "x")
602
+ # Same number of detections and identical coordinates and fits.
603
+ assert auto.shape[0] == explicit.shape[0] == explicit4.shape[0] == n_frames
604
+ np.testing.assert_allclose(auto["y"].to_numpy(), explicit["y"].to_numpy(), rtol=1e-4)
605
+ np.testing.assert_allclose(auto["x"].to_numpy(), explicit["x"].to_numpy(), rtol=1e-4)
606
+ np.testing.assert_allclose(
607
+ auto["contrast"].to_numpy(), explicit["contrast"].to_numpy(), rtol=1e-4
608
+ )
609
+ np.testing.assert_allclose(auto["y"].to_numpy(), explicit4["y"].to_numpy(), rtol=1e-4)
610
+
611
+
612
+ def test_resolve_chunk_size_auto() -> None:
613
+ """``"auto"`` derives a memory-bounded chunk size from the frame shape."""
614
+ from toolsandogh._locate import _MAX_CHUNK_BYTES, _resolve_chunk_size
615
+
616
+ itemsize = np.dtype(np.float32).itemsize
617
+ # Tiny frame: the budget admits more frames than exist, so use them all.
618
+ assert _resolve_chunk_size("auto", 24, (1, 18, 18), itemsize) == 24
619
+ # Huge 3D frame (200 x 2048 x 2048): a single frame already exceeds the
620
+ # budget, so floor at one frame per chunk.
621
+ big = _resolve_chunk_size("auto", 50, (200, 2048, 2048), itemsize)
622
+ assert big == 1
623
+ # The budget term matches the documented 3x working-set approximation.
624
+ frame_bytes = 128 * 128 * itemsize
625
+ expected = _MAX_CHUNK_BYTES // (3 * frame_bytes)
626
+ assert _resolve_chunk_size("auto", 10**9, (1, 128, 128), itemsize) == expected
627
+
628
+
629
+ def test_resolve_chunk_size_explicit_and_invalid() -> None:
630
+ """Explicit sizes are capped at ``n_frames``; bad values raise."""
631
+ from toolsandogh._locate import _resolve_chunk_size
632
+
633
+ itemsize = np.dtype(np.float32).itemsize
634
+ assert _resolve_chunk_size(8, 5, (1, 10, 10), itemsize) == 5
635
+ assert _resolve_chunk_size(4, 20, (1, 10, 10), itemsize) == 4
636
+ with pytest.raises(ValueError, match="chunk_size"):
637
+ _resolve_chunk_size(0, 20, (1, 10, 10), itemsize)
638
+ with pytest.raises(ValueError, match="chunk_size"):
639
+ _resolve_chunk_size(-2, 20, (1, 10, 10), itemsize)
640
+ with pytest.raises(ValueError, match="chunk_size"):
641
+ _resolve_chunk_size("weird", 20, (1, 10, 10), itemsize) # type: ignore
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: toolsandogh
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: A collection of Python scripts for iSCAT microscopy data analysis
5
5
  Author-email: Marco Heisig <marco.heisig@mpl.mpg.de>
6
6
  License-Expression: GPL-3.0-or-later
@@ -7,6 +7,7 @@ pyproject.toml
7
7
  uv.lock
8
8
  .github/workflows/ci.yml
9
9
  .github/workflows/release.yml
10
+ benchmarks/bench_locate.py
10
11
  demos/demo_iscat_50nm_bead.py
11
12
  src/toolsandogh/__init__.py
12
13
  src/toolsandogh/_canonicalize_video.py
@@ -29,6 +29,7 @@
29
29
  "src/toolsandogh/tests/test_link.py",
30
30
  "src/toolsandogh/tests/test_io.py",
31
31
  "demos/demo_iscat_50nm_bead.py",
32
+ "benchmarks/bench_locate.py",
32
33
  ".github/workflows/release.yml",
33
34
  ".github/workflows/ci.yml"
34
35
  ]
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.3.3",
3
+ "distance": 0,
4
+ "node": "g69d8fcea38e9e404b38aadbfa782d01ea47d304b",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-07-10"
8
+ }
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.3.1",
3
- "distance": 0,
4
- "node": "ge3b2e72fa7b3c57374fd48568db415c492c50b06",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-07-08"
8
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes