toolsandogh 0.3.2__tar.gz → 0.4.0__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.4.0}/.github/workflows/ci.yml +1 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/.github/workflows/release.yml +8 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/DESIGN.md +3 -2
- {toolsandogh-0.3.2/src/toolsandogh.egg-info → toolsandogh-0.4.0}/PKG-INFO +1 -1
- toolsandogh-0.4.0/benchmarks/bench_locate.py +111 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/__init__.py +1 -2
- toolsandogh-0.4.0/src/toolsandogh/_canonicalize_video.py +422 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/_generate_video.py +4 -0
- toolsandogh-0.4.0/src/toolsandogh/_link.py +314 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/_load_video.py +1 -1
- toolsandogh-0.4.0/src/toolsandogh/_locate.py +1217 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/_simulate_particles.py +4 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/_validate_video.py +17 -0
- toolsandogh-0.4.0/src/toolsandogh/tests/test_canonicalize_video.py +131 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/tests/test_link.py +240 -21
- toolsandogh-0.4.0/src/toolsandogh/tests/test_locate.py +1342 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/tests/test_simulate_particles.py +9 -8
- {toolsandogh-0.3.2 → toolsandogh-0.4.0/src/toolsandogh.egg-info}/PKG-INFO +1 -1
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh.egg-info/SOURCES.txt +2 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh.egg-info/scm_file_list.json +21 -19
- toolsandogh-0.4.0/src/toolsandogh.egg-info/scm_version.json +8 -0
- toolsandogh-0.3.2/src/toolsandogh/_canonicalize_video.py +0 -282
- toolsandogh-0.3.2/src/toolsandogh/_link.py +0 -223
- toolsandogh-0.3.2/src/toolsandogh/_locate.py +0 -723
- toolsandogh-0.3.2/src/toolsandogh/tests/test_locate.py +0 -549
- toolsandogh-0.3.2/src/toolsandogh.egg-info/scm_version.json +0 -8
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/.gitignore +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/.pre-commit-config.yaml +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/LICENSE +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/README.md +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/demos/demo_iscat_50nm_bead.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/pyproject.toml +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/setup.cfg +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/_rolling.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/_rvt.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/_simulate_psf.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/_store_video.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/tests/__init__.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/tests/conftest.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/tests/test_io.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/tests/test_rolling.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/tests/test_simulate_psf.py +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/tests/testfile.tiff +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh.egg-info/dependency_links.txt +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh.egg-info/requires.txt +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh.egg-info/top_level.txt +0 -0
- {toolsandogh-0.3.2 → toolsandogh-0.4.0}/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,111 @@
|
|
|
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
|
+
# First call includes JIT compilation; record it as the cold-start time.
|
|
65
|
+
t0 = time.perf_counter()
|
|
66
|
+
tog.locate(
|
|
67
|
+
video, psf, chunk_size=chunk_size, min_contrast=min_contrast, iterations=10, atol=1e-3
|
|
68
|
+
)
|
|
69
|
+
cold = time.perf_counter() - t0
|
|
70
|
+
|
|
71
|
+
# Remaining warmup.
|
|
72
|
+
for _ in range(warmup - 1):
|
|
73
|
+
tog.locate(
|
|
74
|
+
video, psf, chunk_size=chunk_size, min_contrast=min_contrast, iterations=10, atol=1e-3
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
best = float("inf")
|
|
78
|
+
for _ in range(repeats):
|
|
79
|
+
t0 = time.perf_counter()
|
|
80
|
+
locs = tog.locate(
|
|
81
|
+
video, psf, chunk_size=chunk_size, min_contrast=min_contrast, iterations=10, atol=1e-3
|
|
82
|
+
)
|
|
83
|
+
# Force materialization.
|
|
84
|
+
_ = locs.height
|
|
85
|
+
t1 = time.perf_counter()
|
|
86
|
+
best = min(best, t1 - t0)
|
|
87
|
+
fps = n_frames / best
|
|
88
|
+
print(
|
|
89
|
+
f" n_frames={n_frames:4d} hw={hw:3d} chunk={str(chunk_size):>3} mc={min_contrast:.2f} "
|
|
90
|
+
f"cold={cold * 1e3:8.1f} ms warm={best * 1e3:8.2f} ms "
|
|
91
|
+
f"fps={fps:8.2f} n_locs={locs.height}"
|
|
92
|
+
)
|
|
93
|
+
return fps
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main() -> None:
|
|
97
|
+
jax.config.update("jax_enable_x64", False)
|
|
98
|
+
print(f"jax devices: {jax.devices()} backend: {jax.default_backend()}")
|
|
99
|
+
print("locate() benchmark (cold = first call incl. JIT compile, warm = best of repeats)")
|
|
100
|
+
for hw in (64, 128):
|
|
101
|
+
for cs in (1, 8):
|
|
102
|
+
bench(64, hw, cs, min_contrast=0.2)
|
|
103
|
+
print("auto chunk_size (default):")
|
|
104
|
+
for hw in (64, 128):
|
|
105
|
+
bench(64, hw, "auto", min_contrast=0.2)
|
|
106
|
+
print("Dense case (min_contrast=0):")
|
|
107
|
+
bench(64, 64, 8, min_contrast=0.0)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
main()
|
|
@@ -8,7 +8,7 @@ from ._canonicalize_video import canonicalize_video
|
|
|
8
8
|
from ._generate_video import generate_video
|
|
9
9
|
from ._link import link
|
|
10
10
|
from ._load_video import load_video
|
|
11
|
-
from ._locate import locate
|
|
11
|
+
from ._locate import locate
|
|
12
12
|
from ._rolling import differential_rolling_average, rolling_average, rolling_sum
|
|
13
13
|
from ._rvt import radial_variance_transform
|
|
14
14
|
from ._simulate_particles import simulate_particles
|
|
@@ -51,7 +51,6 @@ __all__: list[str] = [
|
|
|
51
51
|
"differential_rolling_average",
|
|
52
52
|
"link",
|
|
53
53
|
"locate",
|
|
54
|
-
"locate_in_chunk",
|
|
55
54
|
"focused_incident_field",
|
|
56
55
|
"generate_video",
|
|
57
56
|
"image_field_from_pupil",
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
import dask.array as da
|
|
5
|
+
import numpy as np
|
|
6
|
+
import numpy.typing as npt
|
|
7
|
+
import xarray as xr
|
|
8
|
+
from ome_types.model import (
|
|
9
|
+
OME,
|
|
10
|
+
Image,
|
|
11
|
+
Pixels,
|
|
12
|
+
Pixels_DimensionOrder,
|
|
13
|
+
PixelType,
|
|
14
|
+
UnitsLength,
|
|
15
|
+
UnitsTime,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from ._validate_video import validate_video
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def canonicalize_video(
|
|
22
|
+
video: npt.ArrayLike,
|
|
23
|
+
# optional video properties
|
|
24
|
+
T: int | None = None,
|
|
25
|
+
C: int | None = None,
|
|
26
|
+
Z: int | None = None,
|
|
27
|
+
Y: int | None = None,
|
|
28
|
+
X: int | None = None,
|
|
29
|
+
dt: float | None = None,
|
|
30
|
+
dz: float | None = None,
|
|
31
|
+
dy: float | None = None,
|
|
32
|
+
dx: float | None = None,
|
|
33
|
+
t0: float | None = None,
|
|
34
|
+
z0: float | None = None,
|
|
35
|
+
y0: float | None = None,
|
|
36
|
+
x0: float | None = None,
|
|
37
|
+
dtype: npt.DTypeLike | None = None,
|
|
38
|
+
# optional video metadata
|
|
39
|
+
acquisition_date: datetime | None = None,
|
|
40
|
+
creator: str | None = None,
|
|
41
|
+
) -> xr.DataArray:
|
|
42
|
+
"""
|
|
43
|
+
Turn the supplied data into its canonical TCZYX video representation.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
video : npt.ArrayLike
|
|
48
|
+
An array-like entity with up to five axes. If the axes are not labeled
|
|
49
|
+
already, a 1D array is treated as having an `X` axis only, a 2D array is
|
|
50
|
+
treated as having dimensions `XY`, a 3D array is treated as having
|
|
51
|
+
dimensions `TYX`, a 4D array is treated as having dimensions `TZYX`, and
|
|
52
|
+
a 5D array is treated as having dimensions `TCZYX`.
|
|
53
|
+
T : int
|
|
54
|
+
The expected T (time) extent of the video.
|
|
55
|
+
C : int
|
|
56
|
+
The expected C (channel) extent of the video.
|
|
57
|
+
Z : int
|
|
58
|
+
The expected Z (height) extent of the video.
|
|
59
|
+
Y : int
|
|
60
|
+
The expected Y (row) extent of the video.
|
|
61
|
+
X : int
|
|
62
|
+
The expected X (column) extent of the video.
|
|
63
|
+
dt : float, optional
|
|
64
|
+
The time interval in milliseconds between one video frame and the
|
|
65
|
+
next. When ``None`` (the default) the spacing is inferred from the
|
|
66
|
+
video's ``T`` coordinate when one is present, or else falls back to
|
|
67
|
+
``1000 / 60`` ms (60 fps). If supplied together with an existing
|
|
68
|
+
``T`` coordinate, the two must agree to within a small tolerance.
|
|
69
|
+
dz : float, optional
|
|
70
|
+
The spatial distance in micrometre between one Z slice and the next.
|
|
71
|
+
When ``None`` (the default) the spacing is inferred from the video's
|
|
72
|
+
``Z`` coordinate, or else defaults to 1 µm. If supplied together
|
|
73
|
+
with an existing ``Z`` coordinate, the two must agree.
|
|
74
|
+
dy : float, optional
|
|
75
|
+
The spatial distance in micrometre between one row of pixels and the
|
|
76
|
+
next. When ``None`` (the default) the spacing is inferred from the
|
|
77
|
+
video's ``Y`` coordinate, or else defaults to 1 µm. If supplied
|
|
78
|
+
together with an existing ``Y`` coordinate, the two must agree.
|
|
79
|
+
dx : float, optional
|
|
80
|
+
The spatial distance in micrometre between one column of pixels and
|
|
81
|
+
the next. When ``None`` (the default) the spacing is inferred from
|
|
82
|
+
the video's ``X`` coordinate, or else defaults to 1 µm. If supplied
|
|
83
|
+
together with an existing ``X`` coordinate, the two must agree.
|
|
84
|
+
t0 : float, optional
|
|
85
|
+
The time of the first frame, in milliseconds. When ``None`` (the
|
|
86
|
+
default) the origin is inferred from the video's ``T`` coordinate,
|
|
87
|
+
or else defaults to zero. If supplied together with an existing
|
|
88
|
+
``T`` coordinate, the two must agree.
|
|
89
|
+
z0 : float, optional
|
|
90
|
+
The position of the first Z slice, in micrometre. When ``None``
|
|
91
|
+
(the default) the origin is inferred from the video's ``Z``
|
|
92
|
+
coordinate, or else defaults to zero. If supplied together with an
|
|
93
|
+
existing ``Z`` coordinate, the two must agree.
|
|
94
|
+
y0 : float, optional
|
|
95
|
+
The position of the first row of pixels, in micrometre. When
|
|
96
|
+
``None`` (the default) the origin is inferred from the video's ``Y``
|
|
97
|
+
coordinate, or else defaults to zero. If supplied together with an
|
|
98
|
+
existing ``Y`` coordinate, the two must agree.
|
|
99
|
+
x0 : float, optional
|
|
100
|
+
The position of the first column of pixels, in micrometre. When
|
|
101
|
+
``None`` (the default) the origin is inferred from the video's ``X``
|
|
102
|
+
coordinate, or else defaults to zero. If supplied together with an
|
|
103
|
+
existing ``X`` coordinate, the two must agree.
|
|
104
|
+
dtype : npt.DTypeLike
|
|
105
|
+
The expected dtype of the video.
|
|
106
|
+
acquisition_date : datetime.datetime
|
|
107
|
+
A timestamp of when the video was created. Defaults to datetime.now().
|
|
108
|
+
creator : str
|
|
109
|
+
A string describing who created the video.
|
|
110
|
+
|
|
111
|
+
Returns
|
|
112
|
+
-------
|
|
113
|
+
xarray.DataArray
|
|
114
|
+
A TCZYX video with the supplied parameters that passes :py:func:`~toolsandogh.validate_video`.
|
|
115
|
+
|
|
116
|
+
Notes
|
|
117
|
+
-----
|
|
118
|
+
The ``T``, ``Z``, ``Y`` and ``X`` axes carry continuous, uniformly
|
|
119
|
+
spaced coordinates in milliseconds (time) and micrometres (space),
|
|
120
|
+
with the ``units`` attribute set on each. For each axis the scale
|
|
121
|
+
(``dt``, ``dz``, ``dy``, ``dx``) and origin (``t0``, ``z0``, ``y0``,
|
|
122
|
+
``x0``) are resolved by one of three routes: taken from an existing
|
|
123
|
+
coordinate on the video (e.g. physical coordinates supplied by a
|
|
124
|
+
reader), taken from the explicit parameter, or filled with a
|
|
125
|
+
default. When a parameter is supplied *and* the video already
|
|
126
|
+
carries a coordinate, the two must agree to within a small
|
|
127
|
+
tolerance; the coordinate values are always retained. The channel
|
|
128
|
+
(``C``) axis is categorical and carries no units.
|
|
129
|
+
"""
|
|
130
|
+
# Turn video into an xarray.
|
|
131
|
+
if not isinstance(video, xr.DataArray):
|
|
132
|
+
video = _coerce_to_xarray(video)
|
|
133
|
+
|
|
134
|
+
# Ensure the video's data is a Dask array.
|
|
135
|
+
if not isinstance(video.data, da.Array):
|
|
136
|
+
video = video.copy(data=da.from_array(video.data), deep=False)
|
|
137
|
+
|
|
138
|
+
# Ensure the TZYX axes exist. Missing axes are added as size-1
|
|
139
|
+
# dimensions so that the coordinate-resolution pass below treats
|
|
140
|
+
# them uniformly.
|
|
141
|
+
for dim in ("T", "Z", "Y", "X"):
|
|
142
|
+
if dim not in video.dims:
|
|
143
|
+
video = video.expand_dims({dim: 1})
|
|
144
|
+
|
|
145
|
+
# Resolve the per-axis scale, origin, and coordinate values. For
|
|
146
|
+
# each axis the scale and origin are either inferred from an
|
|
147
|
+
# existing coordinate (in which case any explicitly supplied value
|
|
148
|
+
# is validated against it), taken from the explicit parameter, or
|
|
149
|
+
# filled with a default. Time defaults to 60 fps (``1000 / 60`` ms);
|
|
150
|
+
# space defaults to 1 µm; origins default to zero.
|
|
151
|
+
axis_params = {
|
|
152
|
+
"T": (dt, t0, 1000.0 / 60.0, "ms"),
|
|
153
|
+
"Z": (dz, z0, 1.0, "µm"),
|
|
154
|
+
"Y": (dy, y0, 1.0, "µm"),
|
|
155
|
+
"X": (dx, x0, 1.0, "µm"),
|
|
156
|
+
}
|
|
157
|
+
resolved_scales: dict[str, float] = {}
|
|
158
|
+
for dim in ("T", "Z", "Y", "X"):
|
|
159
|
+
scale_param, origin_param, default_scale, units = axis_params[dim]
|
|
160
|
+
vals, scale = _resolve_axis(video, dim, scale_param, origin_param, default_scale)
|
|
161
|
+
coord = xr.DataArray(vals, dims=(dim,), attrs={"units": units})
|
|
162
|
+
video = video.assign_coords({dim: coord})
|
|
163
|
+
resolved_scales[dim] = scale
|
|
164
|
+
|
|
165
|
+
time_increment = resolved_scales["T"]
|
|
166
|
+
physical_size_z = resolved_scales["Z"]
|
|
167
|
+
physical_size_y = resolved_scales["Y"]
|
|
168
|
+
physical_size_x = resolved_scales["X"]
|
|
169
|
+
|
|
170
|
+
# Ensure the C axis exists. The channel axis is categorical and
|
|
171
|
+
# carries no physical units.
|
|
172
|
+
if "C" not in video.dims:
|
|
173
|
+
video = video.expand_dims({"C": 1})
|
|
174
|
+
|
|
175
|
+
# Derive the result dtype.
|
|
176
|
+
if dtype is None:
|
|
177
|
+
result_dtype = video.dtype
|
|
178
|
+
else:
|
|
179
|
+
result_dtype = np.dtype(dtype)
|
|
180
|
+
|
|
181
|
+
# If there is a S axis, merge its entries.
|
|
182
|
+
if "S" in video.dims:
|
|
183
|
+
video = video.mean("S", dtype=np.float32)
|
|
184
|
+
|
|
185
|
+
# Ensure the resulting video has the correct dtype.
|
|
186
|
+
if video.dtype != result_dtype:
|
|
187
|
+
video = video.astype(result_dtype)
|
|
188
|
+
|
|
189
|
+
# Ensure the correct ordering of axes.
|
|
190
|
+
axes = ("T", "C", "Z", "Y", "X")
|
|
191
|
+
video = video.transpose(*axes)
|
|
192
|
+
|
|
193
|
+
# Ensure each axis is as large as expected.
|
|
194
|
+
expected_shape = (T, C, Z, Y, X)
|
|
195
|
+
for axis, expected, actual in zip(axes, expected_shape, video.shape):
|
|
196
|
+
if not (expected is None or expected == actual):
|
|
197
|
+
raise RuntimeError(f"Expected {axis}-axis of size {expected}, but got {actual}")
|
|
198
|
+
|
|
199
|
+
# Ensure there is metadata attached.
|
|
200
|
+
if not hasattr(video, "processed"):
|
|
201
|
+
(size_t, size_c, size_z, size_y, size_x) = video.shape
|
|
202
|
+
pixels = Pixels(
|
|
203
|
+
type=_dtype_pixel_type(video.dtype),
|
|
204
|
+
big_endian=_dtype_is_big_endian(video.dtype),
|
|
205
|
+
dimension_order=Pixels_DimensionOrder.XYZCT,
|
|
206
|
+
size_t=size_t,
|
|
207
|
+
size_c=size_c,
|
|
208
|
+
size_z=size_z,
|
|
209
|
+
size_y=size_y,
|
|
210
|
+
size_x=size_x,
|
|
211
|
+
time_increment=time_increment,
|
|
212
|
+
physical_size_z=physical_size_z,
|
|
213
|
+
physical_size_y=physical_size_y,
|
|
214
|
+
physical_size_x=physical_size_x,
|
|
215
|
+
time_increment_unit=UnitsTime.MILLISECOND,
|
|
216
|
+
physical_size_z_unit=UnitsLength.MICROMETER,
|
|
217
|
+
physical_size_y_unit=UnitsLength.MICROMETER,
|
|
218
|
+
physical_size_x_unit=UnitsLength.MICROMETER,
|
|
219
|
+
)
|
|
220
|
+
image = Image(
|
|
221
|
+
acquisition_date=(acquisition_date or datetime.now(timezone.utc)),
|
|
222
|
+
description="Video with auto-generated metadata.",
|
|
223
|
+
pixels=pixels,
|
|
224
|
+
)
|
|
225
|
+
ome = OME(
|
|
226
|
+
images=[image],
|
|
227
|
+
creator=(creator or "MPL Erlangen, Sandoghdar Division, toolsandogh"),
|
|
228
|
+
)
|
|
229
|
+
video = video.assign_attrs({"processed": ome})
|
|
230
|
+
|
|
231
|
+
# Raise an exception if the video is still not in canonical form.
|
|
232
|
+
validate_video(video)
|
|
233
|
+
|
|
234
|
+
# Done.
|
|
235
|
+
return video
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _coerce_to_xarray(array: npt.ArrayLike) -> xr.DataArray:
|
|
239
|
+
"""
|
|
240
|
+
Turn a supplied array into an xarray.
|
|
241
|
+
|
|
242
|
+
Parameters
|
|
243
|
+
----------
|
|
244
|
+
array : npt.ArrayLike
|
|
245
|
+
An object designating an array.
|
|
246
|
+
|
|
247
|
+
Returns
|
|
248
|
+
-------
|
|
249
|
+
xr.DataArray
|
|
250
|
+
An xarray with the same content and dtype as the supplied array.
|
|
251
|
+
"""
|
|
252
|
+
# Determine the Dask array holding the video's data.
|
|
253
|
+
if isinstance(array, da.Array):
|
|
254
|
+
data = array
|
|
255
|
+
else:
|
|
256
|
+
data = da.from_array(array)
|
|
257
|
+
|
|
258
|
+
# Determine the appropriate dims
|
|
259
|
+
rank = len(data.shape)
|
|
260
|
+
match rank:
|
|
261
|
+
case 0:
|
|
262
|
+
dims = ()
|
|
263
|
+
case 1:
|
|
264
|
+
dims = ("X",)
|
|
265
|
+
case 2:
|
|
266
|
+
dims = ("Y", "X")
|
|
267
|
+
case 3:
|
|
268
|
+
dims = ("T", "Y", "X")
|
|
269
|
+
case 4:
|
|
270
|
+
dims = ("T", "Z", "Y", "X")
|
|
271
|
+
case 5:
|
|
272
|
+
dims = ("T", "C", "Z", "Y", "X")
|
|
273
|
+
case 6:
|
|
274
|
+
dims = ("T", "C", "Z", "Y", "X", "S")
|
|
275
|
+
case _:
|
|
276
|
+
raise RuntimeError(f"Cannot interpret {rank}-dimensional data as a video.")
|
|
277
|
+
|
|
278
|
+
# Create the xarray.
|
|
279
|
+
return xr.DataArray(data=data, dims=dims)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _axis_origin_and_step(
|
|
283
|
+
video: xr.DataArray,
|
|
284
|
+
dim: str,
|
|
285
|
+
*,
|
|
286
|
+
default_step: float = 1.0,
|
|
287
|
+
) -> tuple[float, float]:
|
|
288
|
+
"""
|
|
289
|
+
Return ``(origin, step)`` for a uniformly-spaced physical axis.
|
|
290
|
+
|
|
291
|
+
The values are read directly from the ``dim`` coordinate of
|
|
292
|
+
``video``. For a size-1 axis (no spacing to infer) the supplied
|
|
293
|
+
``default_step`` is returned. Uniform spacing is guaranteed by
|
|
294
|
+
:func:`validate_video` for canonical videos, so the step is read
|
|
295
|
+
from the first two coordinate values without averaging.
|
|
296
|
+
"""
|
|
297
|
+
coord = np.asarray(video[dim].values, dtype=np.float64)
|
|
298
|
+
origin = float(coord[0])
|
|
299
|
+
step = float(coord[1] - coord[0]) if coord.size >= 2 else default_step
|
|
300
|
+
return origin, step
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _resolve_axis(
|
|
304
|
+
video: xr.DataArray,
|
|
305
|
+
dim: str,
|
|
306
|
+
scale: float | None,
|
|
307
|
+
origin: float | None,
|
|
308
|
+
default_scale: float,
|
|
309
|
+
) -> tuple[np.ndarray, float]:
|
|
310
|
+
"""
|
|
311
|
+
Resolve the coordinate array and physical scale for one TZYX axis.
|
|
312
|
+
|
|
313
|
+
When the video already carries a coordinate for ``dim``, its values
|
|
314
|
+
are retained and the scale and origin are inferred from them; any
|
|
315
|
+
explicitly supplied ``scale`` or ``origin`` is checked against the
|
|
316
|
+
inferred values and must agree to within a small tolerance, otherwise
|
|
317
|
+
a :class:`ValueError` is raised.
|
|
318
|
+
|
|
319
|
+
When the video does not carry a coordinate, one is generated from the
|
|
320
|
+
supplied scale and origin (falling back to ``default_scale`` and
|
|
321
|
+
``0.0`` respectively).
|
|
322
|
+
|
|
323
|
+
Returns the coordinate values (as ``float64``) and the resolved
|
|
324
|
+
physical scale (for the OME metadata).
|
|
325
|
+
"""
|
|
326
|
+
size = int(video.sizes[dim])
|
|
327
|
+
if dim in video.coords:
|
|
328
|
+
vals = np.asarray(video[dim].values, dtype=np.float64)
|
|
329
|
+
axis_origin, axis_step = _axis_origin_and_step(video, dim, default_step=default_scale)
|
|
330
|
+
if origin is not None and not np.isclose(origin, axis_origin, rtol=1e-6, atol=1e-9):
|
|
331
|
+
raise ValueError(
|
|
332
|
+
f"The supplied {dim.lower()}0={origin!r} does not match the "
|
|
333
|
+
f"{dim} coordinate origin {axis_origin!r}."
|
|
334
|
+
)
|
|
335
|
+
if size >= 2:
|
|
336
|
+
if scale is not None and not np.isclose(scale, axis_step, rtol=1e-6, atol=1e-9):
|
|
337
|
+
raise ValueError(
|
|
338
|
+
f"The supplied d{dim.lower()}={scale!r} does not match the "
|
|
339
|
+
f"{dim} coordinate spacing {axis_step!r}."
|
|
340
|
+
)
|
|
341
|
+
return vals, axis_step
|
|
342
|
+
# A size-1 axis has no spacing to infer; respect an explicit scale.
|
|
343
|
+
return vals, (scale if scale is not None else default_scale)
|
|
344
|
+
# No existing coordinate: generate one.
|
|
345
|
+
resolved_scale = scale if scale is not None else default_scale
|
|
346
|
+
resolved_origin = origin if origin is not None else 0.0
|
|
347
|
+
vals = resolved_origin + np.arange(size, dtype=np.float64) * resolved_scale
|
|
348
|
+
return vals, resolved_scale
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _dtype_pixel_type(dtype: npt.DTypeLike) -> PixelType:
|
|
352
|
+
"""
|
|
353
|
+
Return the OME Pixel type corresponding to the supplied dtype.
|
|
354
|
+
|
|
355
|
+
Parameters
|
|
356
|
+
----------
|
|
357
|
+
dtype : npt.DTypeLike
|
|
358
|
+
The Numpy dtype to be used for representing pixel data.
|
|
359
|
+
|
|
360
|
+
Returns
|
|
361
|
+
-------
|
|
362
|
+
ome_types.model.PixelType
|
|
363
|
+
A suitable OME pixel type.
|
|
364
|
+
"""
|
|
365
|
+
match np.dtype(dtype):
|
|
366
|
+
case np.int8:
|
|
367
|
+
return PixelType.INT8
|
|
368
|
+
case np.int16:
|
|
369
|
+
return PixelType.INT16
|
|
370
|
+
case np.int32:
|
|
371
|
+
return PixelType.INT32
|
|
372
|
+
case np.uint8:
|
|
373
|
+
return PixelType.UINT8
|
|
374
|
+
case np.uint16:
|
|
375
|
+
return PixelType.UINT16
|
|
376
|
+
case np.uint32:
|
|
377
|
+
return PixelType.UINT32
|
|
378
|
+
case np.float32:
|
|
379
|
+
return PixelType.FLOAT
|
|
380
|
+
case np.float64:
|
|
381
|
+
return PixelType.DOUBLE
|
|
382
|
+
case np.complex64:
|
|
383
|
+
return PixelType.COMPLEXFLOAT
|
|
384
|
+
case np.complex128:
|
|
385
|
+
return PixelType.COMPLEXDOUBLE
|
|
386
|
+
# The dtypes np.int64 and np.uint64 have no OME equivalent.
|
|
387
|
+
case np.int64:
|
|
388
|
+
return PixelType.BIT
|
|
389
|
+
case np.uint64:
|
|
390
|
+
return PixelType.BIT
|
|
391
|
+
case _:
|
|
392
|
+
raise RuntimeError(f"Cannot interpret {dtype} as a OME pixel type.")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _dtype_is_big_endian(dtype: npt.DTypeLike) -> bool:
|
|
396
|
+
"""
|
|
397
|
+
Return whether the video's data is stored in big endian byte order.
|
|
398
|
+
|
|
399
|
+
Parameters
|
|
400
|
+
----------
|
|
401
|
+
dtype : npt.DTypeLike
|
|
402
|
+
The Numpy dtype to be used for representing pixel data.
|
|
403
|
+
|
|
404
|
+
Returns
|
|
405
|
+
-------
|
|
406
|
+
bool
|
|
407
|
+
True when the dtype is big endian, False otherwise.
|
|
408
|
+
"""
|
|
409
|
+
dtype = np.dtype(dtype)
|
|
410
|
+
|
|
411
|
+
if dtype.itemsize == 1:
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
match np.dtype(dtype).byteorder:
|
|
415
|
+
case ">":
|
|
416
|
+
return True
|
|
417
|
+
case "<":
|
|
418
|
+
return False
|
|
419
|
+
case "=":
|
|
420
|
+
return sys.byteorder == "big"
|
|
421
|
+
case _:
|
|
422
|
+
raise RuntimeError(f"Cannot determine endianness of {dtype}.")
|