toolsandogh 0.3.2__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.2 → toolsandogh-0.3.3}/.github/workflows/ci.yml +1 -0
  2. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/.github/workflows/release.yml +8 -0
  3. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/DESIGN.md +3 -2
  4. {toolsandogh-0.3.2/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.2 → toolsandogh-0.3.3}/src/toolsandogh/_locate.py +189 -146
  7. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_locate.py +92 -0
  8. {toolsandogh-0.3.2 → toolsandogh-0.3.3/src/toolsandogh.egg-info}/PKG-INFO +1 -1
  9. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh.egg-info/SOURCES.txt +1 -0
  10. {toolsandogh-0.3.2 → 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.2/src/toolsandogh.egg-info/scm_version.json +0 -8
  13. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/.gitignore +0 -0
  14. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/.pre-commit-config.yaml +0 -0
  15. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/LICENSE +0 -0
  16. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/README.md +0 -0
  17. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/demos/demo_iscat_50nm_bead.py +0 -0
  18. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/pyproject.toml +0 -0
  19. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/setup.cfg +0 -0
  20. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/__init__.py +0 -0
  21. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_canonicalize_video.py +0 -0
  22. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_generate_video.py +0 -0
  23. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_link.py +0 -0
  24. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_load_video.py +0 -0
  25. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_rolling.py +0 -0
  26. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_rvt.py +0 -0
  27. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_simulate_particles.py +0 -0
  28. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_simulate_psf.py +0 -0
  29. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_store_video.py +0 -0
  30. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_validate_video.py +0 -0
  31. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/__init__.py +0 -0
  32. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/conftest.py +0 -0
  33. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_io.py +0 -0
  34. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_link.py +0 -0
  35. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_rolling.py +0 -0
  36. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_simulate_particles.py +0 -0
  37. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_simulate_psf.py +0 -0
  38. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/testfile.tiff +0 -0
  39. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh.egg-info/dependency_links.txt +0 -0
  40. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh.egg-info/requires.txt +0 -0
  41. {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh.egg-info/top_level.txt +0 -0
  42. {toolsandogh-0.3.2 → 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:
@@ -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.2
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()
@@ -27,6 +27,14 @@ from ._canonicalize_video import canonicalize_video
27
27
  # Public API
28
28
  # ---------------------------------------------------------------------------
29
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
+
30
38
 
31
39
  def locate(
32
40
  video: xr.DataArray,
@@ -34,7 +42,7 @@ def locate(
34
42
  *,
35
43
  channel: int | str | float | None = None,
36
44
  starting_frame: int = 0,
37
- chunk_size: int = 1,
45
+ chunk_size: int | Literal["auto"] = "auto",
38
46
  min_distance: int = 3,
39
47
  min_contrast: float = 0.0,
40
48
  sign: Literal["both", "positive", "negative"] = "both",
@@ -67,8 +75,13 @@ def locate(
67
75
  Frame index of the first frame of the video. Used to populate
68
76
  the ``t`` column when concatenating results from multiple
69
77
  videos.
70
- chunk_size : int
71
- 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.
72
85
  min_distance : int
73
86
  Minimum separation (in pixels) between two detected peaks.
74
87
  Pixels closer than ``min_distance`` to a stronger peak are
@@ -126,13 +139,21 @@ def locate(
126
139
 
127
140
  # Restrict to the chosen channel and chunk along the time axis.
128
141
  n_frames = int(video.sizes["T"])
129
- 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})
130
149
 
131
- # 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.
132
153
  psf_jax = jnp.asarray(psf_arr, dtype=dtype)
133
154
  results: list[polars.DataFrame] = []
134
- for start in range(0, n_frames, chunk_size):
135
- 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)
136
157
  block: xr.DataArray = video.isel(T=slice(start, end))
137
158
  chunk = jnp.asarray(block.data.compute(), dtype=dtype)
138
159
  results.append(
@@ -219,40 +240,37 @@ def locate_in_chunk(
219
240
  if Pz > Z and Z > 1:
220
241
  raise ValueError(f"PSF Z size {Pz} must not exceed data Z size {Z}.")
221
242
 
222
- # 1. Matched-filter score for every frame.
223
- scores = _matched_filter_batch(psf, chunk) # (B, Z, Y, X)
224
-
225
- # 2. Per-frame peak detection (eager Python loop over frames).
226
- rows_t: list[int] = []
227
- rows_z: list[int] = []
228
- rows_y: list[int] = []
229
- rows_x: list[int] = []
230
- rows_score: list[float] = []
231
- for b in range(B):
232
- coords, peak_scores = _detect_peaks_one_frame(
233
- scores[b],
234
- min_distance=min_distance,
235
- min_contrast=min_contrast,
236
- sign=sign,
237
- )
238
- for (zi, yi, xi), score in zip(coords, peak_scores):
239
- rows_t.append(b)
240
- rows_z.append(int(zi))
241
- rows_y.append(int(yi))
242
- rows_x.append(int(xi))
243
- rows_score.append(float(score))
244
-
245
- 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:
246
263
  return _empty_result(channel=channel)
247
264
 
248
- # 3. Fit each emitter.
249
- n_emitters = len(rows_t)
250
- stamps = jnp.stack(
251
- [
252
- _extract_stamp(chunk[b], rows_z[i], rows_y[i], rows_x[i], (Pz, Py, Px))
253
- for i, b in enumerate(rows_t)
254
- ]
255
- )
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))
256
274
 
257
275
  fit = _fit_emitters_batch(
258
276
  stamps,
@@ -262,23 +280,34 @@ def locate_in_chunk(
262
280
  )
263
281
 
264
282
  # 4. Compose the output table.
265
- t_col = np.asarray(rows_t, dtype=np.int32) + np.int32(starting_frame)
283
+ t_col = b_idx + np.int32(starting_frame)
266
284
  c_scalar, c_dtype = _channel_scalar_and_dtype(channel)
267
285
 
268
- # Subpixel-refined positions: integer peak + subpixel offset.
269
- z_offsets = np.asarray(fit["z_offset"], dtype=np.float32)
270
- y_offsets = np.asarray(fit["y_offset"], dtype=np.float32)
271
- x_offsets = np.asarray(fit["x_offset"], dtype=np.float32)
272
- z_col = (np.asarray(rows_z, dtype=np.float32) + z_offsets).astype(np.float32)
273
- y_col = (np.asarray(rows_y, dtype=np.float32) + y_offsets).astype(np.float32)
274
- x_col = (np.asarray(rows_x, dtype=np.float32) + x_offsets).astype(np.float32)
275
-
276
- contrast_col = np.asarray(fit["contrast"], dtype=np.float32)
277
- mass_col = np.asarray(fit["mass"], dtype=np.float32)
278
- 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])
279
303
  n_iter_col = np.full(n_emitters, np.int32(iterations), dtype=np.int32)
280
304
  converged_col = np.asarray(fit["converged"], dtype=bool)
281
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
+
282
311
  snr_col: list[float | None] = [None] * n_emitters
283
312
 
284
313
  return polars.DataFrame(
@@ -312,130 +341,102 @@ def locate_in_chunk(
312
341
 
313
342
 
314
343
  # ---------------------------------------------------------------------------
315
- # Matched filter
344
+ # Matched filter + non-maximum suppression (fused, batched over frames)
316
345
  # ---------------------------------------------------------------------------
317
346
 
318
347
 
319
- @jax.jit
320
- def _matched_filter_batch(
321
- psf: Float[Array, "Z Y X"],
348
+ @jax.jit(static_argnames=["min_distance", "sign"])
349
+ def _peak_mask_batch(
322
350
  chunk: Float[Array, "B Z Y X"],
323
- ) -> Float[Array, "B Z Y X"]:
324
- """Compute the matched-filter score for every frame in ``chunk``."""
325
- return jax.vmap(lambda frame: jsignal.correlate(frame, psf, mode="same", method="fft"))(chunk)
326
-
327
-
328
- # ---------------------------------------------------------------------------
329
- # Peak detection (non-maximum suppression)
330
- # ---------------------------------------------------------------------------
331
-
332
-
333
- def _detect_peaks_one_frame(
334
- score: Float[Array, "Z Y X"],
351
+ psf: Float[Array, "Pz Py Px"],
335
352
  *,
336
353
  min_distance: int,
337
354
  min_contrast: float,
338
355
  sign: Literal["both", "positive", "negative"],
339
- ) -> tuple[Float[Array, "n 3"], Float[Array, " n"]]:
356
+ ) -> Float[Array, "B Z Y X"]:
340
357
  """
341
- 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.
342
360
 
343
- Returns the integer coordinates of each peak and the corresponding
344
- matched-filter score at that pixel.
345
-
346
- This function is eager: ``jnp.argwhere`` returns a variable-size
347
- output that is incompatible with tracing, and the per-frame work
348
- is dominated by the matched filter and the Levenberg--Marquardt
349
- 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.
350
364
  """
351
- 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)
352
369
 
353
370
  if sign == "positive":
354
- local_ext = jax.lax.reduce_window(
355
- score,
356
- -jnp.inf,
357
- jax.lax.max,
358
- (window, window, window),
359
- (1, 1, 1),
360
- "same",
361
- )
362
- is_peak = (score == local_ext) & (score > 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)
363
373
  elif sign == "negative":
364
- local_ext = jax.lax.reduce_window(
365
- score,
366
- jnp.inf,
367
- jax.lax.min,
368
- (window, window, window),
369
- (1, 1, 1),
370
- "same",
371
- )
372
- is_peak = (score == local_ext) & (score < -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)
373
376
  else: # "both"
374
- local_max = jax.lax.reduce_window(
375
- score,
376
- -jnp.inf,
377
- jax.lax.max,
378
- (window, window, window),
379
- (1, 1, 1),
380
- "same",
381
- )
382
- local_min = jax.lax.reduce_window(
383
- score,
384
- jnp.inf,
385
- jax.lax.min,
386
- (window, window, window),
387
- (1, 1, 1),
388
- "same",
389
- )
390
- is_pos = (score == local_max) & (score > min_contrast)
391
- is_neg = (score == local_min) & (score < -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)
392
381
  is_peak = is_pos | is_neg
393
382
 
394
- coords = jnp.argwhere(is_peak) # (n, 3)
395
- scores = score[coords[:, 0], coords[:, 1], coords[:, 2]]
396
- return coords, scores
383
+ return is_peak
397
384
 
398
385
 
399
386
  # ---------------------------------------------------------------------------
400
- # Stamp extraction
387
+ # Stamp extraction (batched)
401
388
  # ---------------------------------------------------------------------------
402
389
 
403
390
 
404
- def _extract_stamp(
405
- frame: Float[Array, "Z Y X"],
406
- z: int,
407
- y: int,
408
- 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],
409
397
  shape: tuple[int, int, int],
410
- ) -> Float[Array, "Pz Py Px"]:
398
+ ) -> Float[Array, "n Pz Py Px"]:
411
399
  """
412
- Extract a stamp of ``shape`` centered on ``(z, y, x)`` from ``frame``.
400
+ Extract every emitter stamp in a single batched gather.
413
401
 
414
- Out-of-bounds positions are filled with the edge value of the
415
- frame. The stamp's index ``(Pz//2, Py//2, Px//2)`` corresponds
416
- 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.
417
410
  """
418
411
  Pz, Py, Px = shape
419
412
  half_z, half_y, half_x = Pz // 2, Py // 2, Px // 2
413
+ n = b_idx.shape[0]
420
414
 
421
- # 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)``.
422
418
  padded = jnp.pad(
423
- frame,
424
- ((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
+ ),
425
426
  mode="edge",
426
427
  )
427
428
 
428
- # The stamp's centre (at index (Pz//2, Py//2, Px//2)) should land
429
- # on the padded position that corresponds to the frame position
430
- # (z, y, x). Since the frame starts at padded position
431
- # (half_z, half_y, half_x), the frame position (z, y, x) sits at
432
- # padded (z + half_z, y + half_y, x + half_x). The stamp's centre
433
- # is Pz//2 and Py//2 in from the top-left of the stamp, so the
434
- # top-left of the stamp is at:
435
- z_p = z + half_z - Pz // 2
436
- y_p = y + half_y - Py // 2
437
- x_p = x + half_x - Px // 2
438
- 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)]
439
440
 
440
441
 
441
442
  # ---------------------------------------------------------------------------
@@ -583,23 +584,34 @@ def _fit_one_emitter(
583
584
  shift0 = jnp.zeros(n_shift, dtype=stamp.dtype)
584
585
  params0 = jnp.concatenate([jnp.array([amp0, bg0], dtype=stamp.dtype), shift0])
585
586
 
587
+ def residual(params: Float[Array, " n_params"]) -> Float[Array, " m"]:
588
+ return (stamp - model(params)).ravel()
589
+
586
590
  def cost(params: Float[Array, " n_params"]) -> Float[Array, ""]:
587
- r = (stamp - model(params)).ravel()
591
+ r = residual(params)
588
592
  return 0.5 * jnp.sum(r * r)
589
593
 
590
594
  # Fixed-length Levenberg--Marquardt scan. Every emitter runs the
591
595
  # same number of iterations, which keeps the ``vmap`` over
592
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.
593
603
  lambda0 = jnp.array(1e-3, dtype=stamp.dtype)
594
604
 
595
605
  def lm_step(carry, _):
596
606
  params, lambda_, prev_cost, _prev_delta_max = carry
597
607
 
598
- _, grad = jax.value_and_grad(cost)(params)
599
- 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
600
612
 
601
- diag_h = jnp.diag(hess)
602
- damped = hess + lambda_ * jnp.diag(diag_h)
613
+ diag_h = jnp.diag(jtj)
614
+ damped = jtj + lambda_ * jnp.diag(diag_h)
603
615
  delta = jnp.linalg.solve(damped, -grad)
604
616
  new_params = params + delta
605
617
  new_cost = cost(new_params)
@@ -721,3 +733,34 @@ def _empty_result(channel: int | str | float = 0) -> polars.DataFrame:
721
733
  "converged": polars.Boolean,
722
734
  },
723
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.2
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.2",
3
- "distance": 0,
4
- "node": "gd9acb8d90441cb1fca0cc8c398a815769ffdc357",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-07-09"
8
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes