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.
Files changed (47) hide show
  1. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/.github/workflows/ci.yml +1 -0
  2. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/.github/workflows/release.yml +8 -0
  3. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/DESIGN.md +3 -2
  4. {toolsandogh-0.3.2/src/toolsandogh.egg-info → toolsandogh-0.4.0}/PKG-INFO +1 -1
  5. toolsandogh-0.4.0/benchmarks/bench_locate.py +111 -0
  6. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/__init__.py +1 -2
  7. toolsandogh-0.4.0/src/toolsandogh/_canonicalize_video.py +422 -0
  8. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/_generate_video.py +4 -0
  9. toolsandogh-0.4.0/src/toolsandogh/_link.py +314 -0
  10. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/_load_video.py +1 -1
  11. toolsandogh-0.4.0/src/toolsandogh/_locate.py +1217 -0
  12. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/_simulate_particles.py +4 -0
  13. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/_validate_video.py +17 -0
  14. toolsandogh-0.4.0/src/toolsandogh/tests/test_canonicalize_video.py +131 -0
  15. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/tests/test_link.py +240 -21
  16. toolsandogh-0.4.0/src/toolsandogh/tests/test_locate.py +1342 -0
  17. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/tests/test_simulate_particles.py +9 -8
  18. {toolsandogh-0.3.2 → toolsandogh-0.4.0/src/toolsandogh.egg-info}/PKG-INFO +1 -1
  19. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh.egg-info/SOURCES.txt +2 -0
  20. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh.egg-info/scm_file_list.json +21 -19
  21. toolsandogh-0.4.0/src/toolsandogh.egg-info/scm_version.json +8 -0
  22. toolsandogh-0.3.2/src/toolsandogh/_canonicalize_video.py +0 -282
  23. toolsandogh-0.3.2/src/toolsandogh/_link.py +0 -223
  24. toolsandogh-0.3.2/src/toolsandogh/_locate.py +0 -723
  25. toolsandogh-0.3.2/src/toolsandogh/tests/test_locate.py +0 -549
  26. toolsandogh-0.3.2/src/toolsandogh.egg-info/scm_version.json +0 -8
  27. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/.gitignore +0 -0
  28. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/.pre-commit-config.yaml +0 -0
  29. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/LICENSE +0 -0
  30. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/README.md +0 -0
  31. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/demos/demo_iscat_50nm_bead.py +0 -0
  32. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/pyproject.toml +0 -0
  33. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/setup.cfg +0 -0
  34. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/_rolling.py +0 -0
  35. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/_rvt.py +0 -0
  36. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/_simulate_psf.py +0 -0
  37. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/_store_video.py +0 -0
  38. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/tests/__init__.py +0 -0
  39. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/tests/conftest.py +0 -0
  40. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/tests/test_io.py +0 -0
  41. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/tests/test_rolling.py +0 -0
  42. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/tests/test_simulate_psf.py +0 -0
  43. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh/tests/testfile.tiff +0 -0
  44. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh.egg-info/dependency_links.txt +0 -0
  45. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh.egg-info/requires.txt +0 -0
  46. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/src/toolsandogh.egg-info/top_level.txt +0 -0
  47. {toolsandogh-0.3.2 → toolsandogh-0.4.0}/uv.lock +0 -0
@@ -4,6 +4,7 @@ on:
4
4
  push:
5
5
  branches: [main]
6
6
  pull_request:
7
+ workflow_call: # reused by release.yml so every release is gated by CI
7
8
 
8
9
  concurrency:
9
10
  group: ci-${{ github.ref }}
@@ -14,9 +14,17 @@ permissions:
14
14
  contents: read
15
15
 
16
16
  jobs:
17
+ # Run the full CI suite (pre-commit + portability test matrix) on the
18
+ # tagged commit before anything is built or published. This reuses ci.yml
19
+ # via workflow_call, so a release can never ship code that hasn't passed
20
+ # the same checks that gate pull requests.
21
+ ci:
22
+ uses: ./.github/workflows/ci.yml
23
+
17
24
  # Build the sdist and wheel from the tagged commit. setuptools-scm derives
18
25
  # the version from git, so a full history checkout is required.
19
26
  build:
27
+ needs: ci
20
28
  runs-on: ubuntu-latest
21
29
  timeout-minutes: 10
22
30
  steps:
@@ -80,8 +80,8 @@ The following guidelines apply to all code in the toolsandogh package. They are
80
80
  #### 1.3 Inputs and outputs
81
81
 
82
82
  * Publicly visible functions should be liberal in what they accept and conservative in what they emit.
83
- * Accept the widest reasonable input: file paths, raw arrays, tuples, or plain lists where convenient, and let the function coerce them into the canonical representation.
84
- * Always emit stable, well-typed values (typically ``xarray.DataArray`` or ``polars.DataFrame``). Do not leak implementation types such as NumPy arrays or pandas DataFrames through the public API.
83
+ * Accept the widest reasonable input: file paths, raw arrays, tuples, or plain lists where convenient, and let the function coerce them into the canonical representation. We achieve this by defining, for each concept or data structure ``foo``, a function ``canonicalize_foo`` that turns a wide range of ``foo``-like objects into canonical ``foo`` objects.
84
+ * Always emit stable, well-typed values (typically ``xarray.DataArray`` or ``polars.DataFrame``).
85
85
 
86
86
  #### 1.4 Data structures
87
87
 
@@ -98,6 +98,7 @@ The following guidelines apply to all code in the toolsandogh package. They are
98
98
 
99
99
  * Raise specific, informative exceptions that explain what went wrong and why. Include the offending value where helpful.
100
100
  * Never silently coerce types or swallow exceptions. A bare ``except`` or an unchecked type coercion hides bugs and should be avoided.
101
+ * Explicitly check invariants whenever sensible. In particular, whenever an object is expected to have some non-trivial property ``foo``, define a function ``validate_foo`` that raises appropriate errors unless its argument has that property.
101
102
  * Validate inputs at the boundary of the public API; prefer explicit checks over defensive programming scattered through the internals.
102
103
 
103
104
  ### 2. Documentation -- numpydoc
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: toolsandogh
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: A collection of Python scripts for iSCAT microscopy data analysis
5
5
  Author-email: Marco Heisig <marco.heisig@mpl.mpg.de>
6
6
  License-Expression: GPL-3.0-or-later
@@ -0,0 +1,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, locate_in_chunk
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}.")
@@ -55,6 +55,10 @@ def generate_video(
55
55
  size=(T, C, Z, Y, X),
56
56
  dtype=dtype, # type: ignore
57
57
  ),
58
+ dt=dt,
59
+ dz=dz,
60
+ dy=dy,
61
+ dx=dx,
58
62
  )
59
63
 
60
64
  # Check that the video matches the supplied parameters.