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