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.
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/.github/workflows/ci.yml +1 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/.github/workflows/release.yml +8 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/DESIGN.md +3 -2
- {toolsandogh-0.3.2/src/toolsandogh.egg-info → toolsandogh-0.3.3}/PKG-INFO +1 -1
- toolsandogh-0.3.3/benchmarks/bench_locate.py +103 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_locate.py +189 -146
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_locate.py +92 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3/src/toolsandogh.egg-info}/PKG-INFO +1 -1
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh.egg-info/SOURCES.txt +1 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh.egg-info/scm_file_list.json +1 -0
- toolsandogh-0.3.3/src/toolsandogh.egg-info/scm_version.json +8 -0
- toolsandogh-0.3.2/src/toolsandogh.egg-info/scm_version.json +0 -8
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/.gitignore +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/.pre-commit-config.yaml +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/LICENSE +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/README.md +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/demos/demo_iscat_50nm_bead.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/pyproject.toml +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/setup.cfg +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/__init__.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_canonicalize_video.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_generate_video.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_link.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_load_video.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_rolling.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_rvt.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_simulate_particles.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_simulate_psf.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_store_video.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/_validate_video.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/__init__.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/conftest.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_io.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_link.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_rolling.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_simulate_particles.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/test_simulate_psf.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh/tests/testfile.tiff +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh.egg-info/dependency_links.txt +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh.egg-info/requires.txt +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/src/toolsandogh.egg-info/top_level.txt +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.3.3}/uv.lock +0 -0
|
@@ -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``).
|
|
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
|
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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,
|
|
135
|
-
end = min(start +
|
|
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.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
#
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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 =
|
|
283
|
+
t_col = b_idx + np.int32(starting_frame)
|
|
266
284
|
c_scalar, c_dtype = _channel_scalar_and_dtype(channel)
|
|
267
285
|
|
|
268
|
-
#
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
) ->
|
|
356
|
+
) -> Float[Array, "B Z Y X"]:
|
|
340
357
|
"""
|
|
341
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
400
|
+
Extract every emitter stamp in a single batched gather.
|
|
413
401
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
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
|
-
|
|
424
|
-
(
|
|
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
|
-
#
|
|
429
|
-
#
|
|
430
|
-
#
|
|
431
|
-
#
|
|
432
|
-
#
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
return
|
|
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 = (
|
|
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
|
-
|
|
599
|
-
|
|
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(
|
|
602
|
-
damped =
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|