pymmcore-plus 0.9.3__py3-none-any.whl → 0.13.0__py3-none-any.whl
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.
- pymmcore_plus/__init__.py +7 -4
- pymmcore_plus/_benchmark.py +203 -0
- pymmcore_plus/_build.py +6 -1
- pymmcore_plus/_cli.py +131 -31
- pymmcore_plus/_logger.py +19 -10
- pymmcore_plus/_pymmcore.py +12 -0
- pymmcore_plus/_util.py +139 -32
- pymmcore_plus/core/__init__.py +5 -0
- pymmcore_plus/core/_config.py +6 -4
- pymmcore_plus/core/_config_group.py +4 -3
- pymmcore_plus/core/_constants.py +135 -10
- pymmcore_plus/core/_device.py +4 -4
- pymmcore_plus/core/_metadata.py +3 -3
- pymmcore_plus/core/_mmcore_plus.py +254 -170
- pymmcore_plus/core/_property.py +6 -6
- pymmcore_plus/core/_sequencing.py +370 -233
- pymmcore_plus/core/events/__init__.py +6 -6
- pymmcore_plus/core/events/_device_signal_view.py +8 -6
- pymmcore_plus/core/events/_norm_slot.py +2 -4
- pymmcore_plus/core/events/_prop_event_mixin.py +7 -4
- pymmcore_plus/core/events/_protocol.py +5 -2
- pymmcore_plus/core/events/_psygnal.py +2 -2
- pymmcore_plus/experimental/__init__.py +0 -0
- pymmcore_plus/experimental/unicore/__init__.py +14 -0
- pymmcore_plus/experimental/unicore/_device_manager.py +173 -0
- pymmcore_plus/experimental/unicore/_proxy.py +127 -0
- pymmcore_plus/experimental/unicore/_unicore.py +703 -0
- pymmcore_plus/experimental/unicore/devices/__init__.py +0 -0
- pymmcore_plus/experimental/unicore/devices/_device.py +269 -0
- pymmcore_plus/experimental/unicore/devices/_properties.py +400 -0
- pymmcore_plus/experimental/unicore/devices/_stage.py +221 -0
- pymmcore_plus/install.py +16 -11
- pymmcore_plus/mda/__init__.py +1 -1
- pymmcore_plus/mda/_engine.py +320 -148
- pymmcore_plus/mda/_protocol.py +6 -4
- pymmcore_plus/mda/_runner.py +62 -51
- pymmcore_plus/mda/_thread_relay.py +5 -3
- pymmcore_plus/mda/events/__init__.py +2 -2
- pymmcore_plus/mda/events/_protocol.py +10 -2
- pymmcore_plus/mda/events/_psygnal.py +2 -2
- pymmcore_plus/mda/handlers/_5d_writer_base.py +106 -15
- pymmcore_plus/mda/handlers/__init__.py +7 -1
- pymmcore_plus/mda/handlers/_img_sequence_writer.py +11 -6
- pymmcore_plus/mda/handlers/_ome_tiff_writer.py +8 -4
- pymmcore_plus/mda/handlers/_ome_zarr_writer.py +82 -9
- pymmcore_plus/mda/handlers/_tensorstore_handler.py +374 -0
- pymmcore_plus/mda/handlers/_util.py +1 -1
- pymmcore_plus/metadata/__init__.py +36 -0
- pymmcore_plus/metadata/functions.py +353 -0
- pymmcore_plus/metadata/schema.py +472 -0
- pymmcore_plus/metadata/serialize.py +120 -0
- pymmcore_plus/mocks.py +51 -0
- pymmcore_plus/model/_config_file.py +5 -6
- pymmcore_plus/model/_config_group.py +29 -2
- pymmcore_plus/model/_core_device.py +12 -1
- pymmcore_plus/model/_core_link.py +2 -1
- pymmcore_plus/model/_device.py +39 -8
- pymmcore_plus/model/_microscope.py +39 -3
- pymmcore_plus/model/_pixel_size_config.py +27 -4
- pymmcore_plus/model/_property.py +13 -3
- pymmcore_plus/seq_tester.py +1 -1
- {pymmcore_plus-0.9.3.dist-info → pymmcore_plus-0.13.0.dist-info}/METADATA +22 -12
- pymmcore_plus-0.13.0.dist-info/RECORD +71 -0
- {pymmcore_plus-0.9.3.dist-info → pymmcore_plus-0.13.0.dist-info}/WHEEL +1 -1
- pymmcore_plus/core/_state.py +0 -244
- pymmcore_plus-0.9.3.dist-info/RECORD +0 -55
- {pymmcore_plus-0.9.3.dist-info → pymmcore_plus-0.13.0.dist-info}/entry_points.txt +0 -0
- {pymmcore_plus-0.9.3.dist-info → pymmcore_plus-0.13.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -5,21 +5,28 @@ import json
|
|
|
5
5
|
import os.path
|
|
6
6
|
import shutil
|
|
7
7
|
import tempfile
|
|
8
|
-
from
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal, Protocol
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from pymmcore_plus.metadata.serialize import to_builtins
|
|
9
14
|
|
|
10
15
|
from ._5d_writer_base import _5DWriterBase
|
|
11
16
|
|
|
12
17
|
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import MutableMapping, Sequence
|
|
19
|
+
from contextlib import AbstractAsyncContextManager
|
|
13
20
|
from os import PathLike
|
|
14
|
-
from typing import
|
|
21
|
+
from typing import TypedDict
|
|
15
22
|
|
|
16
|
-
import
|
|
23
|
+
import xarray as xr
|
|
17
24
|
import zarr
|
|
18
25
|
from fsspec import FSMap
|
|
19
26
|
from numcodecs.abc import Codec
|
|
20
27
|
|
|
21
28
|
class ZarrSynchronizer(Protocol):
|
|
22
|
-
def __getitem__(self, key: str) ->
|
|
29
|
+
def __getitem__(self, key: str) -> AbstractAsyncContextManager: ...
|
|
23
30
|
|
|
24
31
|
class ArrayCreationKwargs(TypedDict, total=False):
|
|
25
32
|
compressor: str | Codec
|
|
@@ -67,7 +74,7 @@ class OMEZarrWriter(_5DWriterBase["zarr.Array"]):
|
|
|
67
74
|
│ └── y
|
|
68
75
|
│ └── x # chunks will be each XY plane
|
|
69
76
|
├── ...
|
|
70
|
-
├──
|
|
77
|
+
├── p<n>
|
|
71
78
|
│ ├── .zarray
|
|
72
79
|
│ ├── .zattrs
|
|
73
80
|
│ └── t...
|
|
@@ -127,8 +134,8 @@ class OMEZarrWriter(_5DWriterBase["zarr.Array"]):
|
|
|
127
134
|
|
|
128
135
|
# if we don't check this here, we'll get an error when creating the first array
|
|
129
136
|
if (
|
|
130
|
-
not overwrite and any(self._group.arrays())
|
|
131
|
-
): # pragma: no cover
|
|
137
|
+
not overwrite and any(self._group.arrays())
|
|
138
|
+
) or self._group.attrs: # pragma: no cover
|
|
132
139
|
path = self._group.store.path if hasattr(self._group.store, "path") else ""
|
|
133
140
|
raise ValueError(
|
|
134
141
|
f"There is already data in {path!r}. Use 'overwrite=True' to overwrite."
|
|
@@ -186,14 +193,74 @@ class OMEZarrWriter(_5DWriterBase["zarr.Array"]):
|
|
|
186
193
|
def finalize_metadata(self) -> None:
|
|
187
194
|
"""Called by superclass in sequenceFinished. Flush metadata to disk."""
|
|
188
195
|
# flush frame metadata to disk
|
|
196
|
+
self._populate_xarray_coords()
|
|
189
197
|
while self.frame_metadatas:
|
|
190
198
|
key, metas = self.frame_metadatas.popitem()
|
|
191
199
|
if key in self.position_arrays:
|
|
192
|
-
self.position_arrays[key].attrs["frame_meta"] = metas
|
|
200
|
+
self.position_arrays[key].attrs["frame_meta"] = to_builtins(metas)
|
|
193
201
|
|
|
194
202
|
if self._minify_metadata:
|
|
195
203
|
self._minify_zattrs_metadata()
|
|
196
204
|
|
|
205
|
+
def _populate_xarray_coords(self) -> None:
|
|
206
|
+
# FIXME:
|
|
207
|
+
# This provides support for xarray coordinates... but it's not obvious
|
|
208
|
+
# how we should deal with positions that have different shapes, etc...
|
|
209
|
+
# Also: this whole thing should be generalized to support any kind of
|
|
210
|
+
# dimension, and should be better about populating the coords as the experiment
|
|
211
|
+
# progresses. And it's rather ugly...
|
|
212
|
+
if not (seq := self.current_sequence):
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
sizes = {**seq.sizes}
|
|
216
|
+
px: float = 1.0
|
|
217
|
+
if self.frame_metadatas:
|
|
218
|
+
key, metas = next(iter(self.frame_metadatas.items()))
|
|
219
|
+
if key in self.position_arrays:
|
|
220
|
+
shape = self.position_arrays[key].shape
|
|
221
|
+
px = metas[-1].get("pixel_size_um", 1)
|
|
222
|
+
with suppress(IndexError):
|
|
223
|
+
sizes.update(y=shape[-2], x=shape[-1])
|
|
224
|
+
|
|
225
|
+
for dim, size in sizes.items():
|
|
226
|
+
if size == 0:
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
# TODO: this could be much cleaner
|
|
230
|
+
attrs: dict = {"_ARRAY_DIMENSIONS": [dim]}
|
|
231
|
+
if dim == "t":
|
|
232
|
+
if self._timestamps:
|
|
233
|
+
coords: Any = list(self._timestamps)
|
|
234
|
+
elif seq.time_plan:
|
|
235
|
+
coords = np.arange(seq.time_plan.num_timepoints(), dtype="float")
|
|
236
|
+
else:
|
|
237
|
+
continue
|
|
238
|
+
attrs["units"] = "ms"
|
|
239
|
+
elif dim == "p":
|
|
240
|
+
# coords = [(p.x, p.y, p.z) for p in seq.stage_positions]
|
|
241
|
+
coords = np.arange(size)
|
|
242
|
+
elif dim == "c":
|
|
243
|
+
coords = [c.config for c in seq.channels]
|
|
244
|
+
elif dim == "z":
|
|
245
|
+
coords = list(seq.z_plan) if seq.z_plan else [0]
|
|
246
|
+
attrs["units"] = "um"
|
|
247
|
+
elif dim in "yx":
|
|
248
|
+
coords = np.arange(size, dtype="float") * px
|
|
249
|
+
attrs["units"] = "um"
|
|
250
|
+
elif dim == "g":
|
|
251
|
+
coords = np.arange(size)
|
|
252
|
+
# TODO
|
|
253
|
+
else:
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
# fill_value=None is important to avoid nan where coords == 0
|
|
257
|
+
if dim in self._group:
|
|
258
|
+
ds = self._group[dim]
|
|
259
|
+
ds[:] = coords
|
|
260
|
+
else:
|
|
261
|
+
ds = self._group.create_dataset(dim, data=coords, fill_value=None)
|
|
262
|
+
ds.attrs.update(attrs)
|
|
263
|
+
|
|
197
264
|
def new_array(self, key: str, dtype: np.dtype, sizes: dict[str, int]) -> zarr.Array:
|
|
198
265
|
"""Create a new array in the group, under `key`."""
|
|
199
266
|
dims, shape = zip(*sizes.items())
|
|
@@ -211,7 +278,8 @@ class OMEZarrWriter(_5DWriterBase["zarr.Array"]):
|
|
|
211
278
|
self._group.attrs["multiscales"] = scales
|
|
212
279
|
ary.attrs["_ARRAY_DIMENSIONS"] = dims
|
|
213
280
|
if seq := self.current_sequence:
|
|
214
|
-
ary.attrs["useq_MDASequence"] =
|
|
281
|
+
ary.attrs["useq_MDASequence"] = to_builtins(seq)
|
|
282
|
+
|
|
215
283
|
return ary
|
|
216
284
|
|
|
217
285
|
# # the superclass implementation is all we need
|
|
@@ -250,6 +318,11 @@ class OMEZarrWriter(_5DWriterBase["zarr.Array"]):
|
|
|
250
318
|
# dump minified data back to disk
|
|
251
319
|
store[key] = json.dumps(data, separators=(",", ":")).encode("ascii")
|
|
252
320
|
|
|
321
|
+
def as_xarray(self) -> xr.Dataset:
|
|
322
|
+
import xarray as xr
|
|
323
|
+
|
|
324
|
+
return xr.open_zarr(self.group.store, consolidated=False)
|
|
325
|
+
|
|
253
326
|
|
|
254
327
|
# https://ngff.openmicroscopy.org/0.4/index.html#axes-md
|
|
255
328
|
AXTYPE = {
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
import warnings
|
|
8
|
+
from itertools import product
|
|
9
|
+
from os import PathLike
|
|
10
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from pymmcore_plus.metadata.serialize import json_dumps, json_loads
|
|
15
|
+
|
|
16
|
+
from ._util import position_sizes
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Mapping, Sequence
|
|
20
|
+
from typing import Literal, TypeAlias
|
|
21
|
+
|
|
22
|
+
import tensorstore as ts
|
|
23
|
+
import useq
|
|
24
|
+
from typing_extensions import Self # py311
|
|
25
|
+
|
|
26
|
+
from pymmcore_plus.metadata import FrameMetaV1, SummaryMetaV1
|
|
27
|
+
|
|
28
|
+
TsDriver: TypeAlias = Literal["zarr", "zarr3", "n5", "neuroglancer_precomputed"]
|
|
29
|
+
EventKey: TypeAlias = frozenset[tuple[str, int]]
|
|
30
|
+
|
|
31
|
+
# special dimension label used when _nd_storage is False
|
|
32
|
+
FRAME_DIM = "frame"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TensorStoreHandler:
|
|
36
|
+
"""Tensorstore handler for writing MDA sequences.
|
|
37
|
+
|
|
38
|
+
This is a performant and shape-agnostic handler for writing MDA sequences to
|
|
39
|
+
chunked storages like zarr, n5, backed by tensorstore:
|
|
40
|
+
<https://google.github.io/tensorstore/>
|
|
41
|
+
|
|
42
|
+
By default, the handler will store frames in a zarr array, with a shape of
|
|
43
|
+
(nframes, *frame_shape) and a chunk size of (1, *frame_shape), i.e. each frame
|
|
44
|
+
is stored in a separate chunk. To customize shape or chunking, override the
|
|
45
|
+
`get_full_shape`, `get_chunk_layout`, and `get_index_domain` methods (these
|
|
46
|
+
may change in the future as we learn to use tensorstore better).
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
driver : TsDriver, optional
|
|
51
|
+
The driver to use for the tensorstore, by default "zarr". Must be one of
|
|
52
|
+
"zarr", "zarr3", "n5", or "neuroglancer_precomputed".
|
|
53
|
+
kvstore : str | dict | None, optional
|
|
54
|
+
The key-value store to use for the tensorstore, by default "memory://".
|
|
55
|
+
A dict might look like {'driver': 'file', 'path': '/path/to/dataset.zarr'}
|
|
56
|
+
see <https://google.github.io/tensorstore/kvstore/index.html#json-KvStore>
|
|
57
|
+
for all options. If path is provided, the kvstore will be set to file://path
|
|
58
|
+
path : str | Path | None, optional
|
|
59
|
+
Convenience for specifying a local filepath. If provided, overrides the
|
|
60
|
+
kvstore option, to be `file://file_path`.
|
|
61
|
+
delete_existing : bool, optional
|
|
62
|
+
Whether to delete the existing dataset if it exists, by default False.
|
|
63
|
+
spec : Mapping, optional
|
|
64
|
+
A spec to use when opening the tensorstore, by default None. Values provided
|
|
65
|
+
in this object will override the default values provided by the handler.
|
|
66
|
+
This is a complex object that can completely define the tensorstore, see
|
|
67
|
+
<https://google.github.io/tensorstore/spec.html> for more information.
|
|
68
|
+
|
|
69
|
+
Examples
|
|
70
|
+
--------
|
|
71
|
+
```python
|
|
72
|
+
from pymmcore_plus import CMMCorePlus
|
|
73
|
+
from pymmcore_plus.mda.handlers import TensorStoreHandler
|
|
74
|
+
from useq import MDASequence
|
|
75
|
+
|
|
76
|
+
core = CMMCorePlus.instance()
|
|
77
|
+
core.loadSystemConfiguration()
|
|
78
|
+
|
|
79
|
+
sequence = MDASequence(
|
|
80
|
+
channels=["DAPI", {"config": "FITC", "exposure": 1}],
|
|
81
|
+
stage_positions=[{"x": 1, "y": 1, "name": "some position"}, {"x": 0, "y": 0}],
|
|
82
|
+
time_plan={"interval": 2, "loops": 3},
|
|
83
|
+
z_plan={"range": 4, "step": 0.5},
|
|
84
|
+
axis_order="tpcz",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
writer = TensorStoreHandler(path="example_ts.zarr", delete_existing=True)
|
|
88
|
+
core.mda.run(sequence, output=writer)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
driver: TsDriver = "zarr",
|
|
97
|
+
kvstore: str | dict | None = "memory://",
|
|
98
|
+
path: str | PathLike | None = None,
|
|
99
|
+
delete_existing: bool = False,
|
|
100
|
+
spec: Mapping | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
try:
|
|
103
|
+
import tensorstore
|
|
104
|
+
except ImportError as e:
|
|
105
|
+
raise ImportError("Tensorstore is required to use this handler.") from e
|
|
106
|
+
|
|
107
|
+
self._ts = tensorstore
|
|
108
|
+
|
|
109
|
+
self.ts_driver = driver
|
|
110
|
+
self.kvstore = f"file://{path}" if path is not None else kvstore
|
|
111
|
+
self.delete_existing = delete_existing
|
|
112
|
+
self.spec = spec
|
|
113
|
+
|
|
114
|
+
# storage of individual frame metadata
|
|
115
|
+
# maps position key to list of frame metadata
|
|
116
|
+
self.frame_metadatas: list[tuple[useq.MDAEvent, FrameMetaV1]] = []
|
|
117
|
+
|
|
118
|
+
self._size_increment = 300
|
|
119
|
+
|
|
120
|
+
self._store: ts.TensorStore | None = None
|
|
121
|
+
self._futures: list[ts.Future | ts.WriteFutures] = []
|
|
122
|
+
self._frame_indices: dict[EventKey, int | ts.DimExpression] = {}
|
|
123
|
+
|
|
124
|
+
# "_nd_storage" means we're greedily attempting to store the data in a
|
|
125
|
+
# multi-dimensional format based on the axes of the sequence.
|
|
126
|
+
# for non-deterministic experiments, this often won't work...
|
|
127
|
+
# _nd_storage False means we simply store data as a 3D array of shape
|
|
128
|
+
# (nframes, y, x). `_nd_storage` is set when a new_store is created.
|
|
129
|
+
self._nd_storage: bool = True
|
|
130
|
+
self._frame_index: int = 0
|
|
131
|
+
|
|
132
|
+
# the highest index seen for each axis
|
|
133
|
+
self._axis_max: dict[str, int] = {}
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def store(self) -> ts.TensorStore | None:
|
|
137
|
+
"""The current tensorstore."""
|
|
138
|
+
return self._store
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def in_tmpdir(
|
|
142
|
+
cls,
|
|
143
|
+
suffix: str | None = "",
|
|
144
|
+
prefix: str | None = "pymmcore_zarr_",
|
|
145
|
+
dir: str | PathLike[str] | None = None,
|
|
146
|
+
cleanup_atexit: bool = True,
|
|
147
|
+
**kwargs: Any,
|
|
148
|
+
) -> Self:
|
|
149
|
+
"""Create TensorStoreHandler that writes to a temporary directory.
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
suffix : str, optional
|
|
154
|
+
If suffix is specified, the file name will end with that suffix, otherwise
|
|
155
|
+
there will be no suffix.
|
|
156
|
+
prefix : str, optional
|
|
157
|
+
If prefix is specified, the file name will begin with that prefix, otherwise
|
|
158
|
+
a default prefix is used.
|
|
159
|
+
dir : str or PathLike, optional
|
|
160
|
+
If dir is specified, the file will be created in that directory, otherwise
|
|
161
|
+
a default directory is used (tempfile.gettempdir())
|
|
162
|
+
cleanup_atexit : bool, optional
|
|
163
|
+
Whether to automatically cleanup the temporary directory when the python
|
|
164
|
+
process exits. Default is True.
|
|
165
|
+
**kwargs
|
|
166
|
+
Remaining kwargs are passed to `TensorStoreHandler.__init__`
|
|
167
|
+
"""
|
|
168
|
+
# same as zarr.storage.TempStore, but with option not to cleanup
|
|
169
|
+
path = tempfile.mkdtemp(suffix=suffix, prefix=prefix, dir=dir)
|
|
170
|
+
if cleanup_atexit:
|
|
171
|
+
|
|
172
|
+
@atexit.register
|
|
173
|
+
def _atexit_rmtree(_path: str = path) -> None: # pragma: no cover
|
|
174
|
+
if os.path.isdir(_path):
|
|
175
|
+
shutil.rmtree(_path, ignore_errors=True)
|
|
176
|
+
|
|
177
|
+
return cls(path=path, **kwargs)
|
|
178
|
+
|
|
179
|
+
def sequenceStarted(self, seq: useq.MDASequence, meta: SummaryMetaV1) -> None:
|
|
180
|
+
"""On sequence started, simply store the sequence."""
|
|
181
|
+
self._frame_index = 0
|
|
182
|
+
self._store = None
|
|
183
|
+
self._futures.clear()
|
|
184
|
+
self.frame_metadatas.clear()
|
|
185
|
+
self.current_sequence = seq
|
|
186
|
+
|
|
187
|
+
def sequenceFinished(self, seq: useq.MDASequence) -> None:
|
|
188
|
+
"""On sequence finished, clear the current sequence."""
|
|
189
|
+
if self._store is None:
|
|
190
|
+
return # pragma: no cover
|
|
191
|
+
|
|
192
|
+
while self._futures:
|
|
193
|
+
self._futures.pop().result()
|
|
194
|
+
if not self._nd_storage:
|
|
195
|
+
self._store = self._store.resize(
|
|
196
|
+
exclusive_max=(self._frame_index, *self._store.shape[-2:])
|
|
197
|
+
).result()
|
|
198
|
+
if self.frame_metadatas:
|
|
199
|
+
self.finalize_metadata()
|
|
200
|
+
|
|
201
|
+
def frameReady(
|
|
202
|
+
self, frame: np.ndarray, event: useq.MDAEvent, meta: FrameMetaV1
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Write frame to the zarr array for the appropriate position."""
|
|
205
|
+
if self._store is None:
|
|
206
|
+
self._store = self.new_store(frame, event.sequence, meta).result()
|
|
207
|
+
|
|
208
|
+
ts_index: ts.DimExpression | int
|
|
209
|
+
if self._nd_storage:
|
|
210
|
+
ts_index = self._event_index_to_store_index(event.index)
|
|
211
|
+
else:
|
|
212
|
+
if self._frame_index >= self._store.shape[0]:
|
|
213
|
+
self._store = self._expand_store(self._store).result()
|
|
214
|
+
ts_index = self._frame_index
|
|
215
|
+
# store reverse lookup of event.index -> frame_index
|
|
216
|
+
self._frame_indices[frozenset(event.index.items())] = ts_index
|
|
217
|
+
|
|
218
|
+
# write the new frame asynchronously
|
|
219
|
+
self._futures.append(self._store[ts_index].write(frame))
|
|
220
|
+
|
|
221
|
+
# store, but do not process yet, the frame metadata
|
|
222
|
+
self.frame_metadatas.append((event, meta))
|
|
223
|
+
# update the frame counter
|
|
224
|
+
self._frame_index += 1
|
|
225
|
+
# remember the highest index seen for each axis
|
|
226
|
+
for k, v in event.index.items():
|
|
227
|
+
self._axis_max[k] = max(self._axis_max.get(k, 0), v)
|
|
228
|
+
|
|
229
|
+
def isel(
|
|
230
|
+
self,
|
|
231
|
+
indexers: Mapping[str, int | slice] | None = None,
|
|
232
|
+
**indexers_kwargs: int | slice,
|
|
233
|
+
) -> np.ndarray:
|
|
234
|
+
"""Select data from the array."""
|
|
235
|
+
# FIXME: will fail on slices
|
|
236
|
+
indexers = {**(indexers or {}), **indexers_kwargs}
|
|
237
|
+
ts_index = self._event_index_to_store_index(indexers)
|
|
238
|
+
if self._store is None: # pragma: no cover
|
|
239
|
+
warnings.warn("No data written.", stacklevel=2)
|
|
240
|
+
return np.empty([])
|
|
241
|
+
return self._store[ts_index].read().result().squeeze() # type: ignore [no-any-return]
|
|
242
|
+
|
|
243
|
+
def new_store(
|
|
244
|
+
self, frame: np.ndarray, seq: useq.MDASequence | None, meta: FrameMetaV1
|
|
245
|
+
) -> ts.Future[ts.TensorStore]:
|
|
246
|
+
shape, chunks, labels = self.get_shape_chunks_labels(frame.shape, seq)
|
|
247
|
+
self._nd_storage = FRAME_DIM not in labels
|
|
248
|
+
return self._ts.open(
|
|
249
|
+
self.get_spec(),
|
|
250
|
+
create=True,
|
|
251
|
+
delete_existing=self.delete_existing,
|
|
252
|
+
dtype=self._ts.dtype(frame.dtype),
|
|
253
|
+
shape=shape,
|
|
254
|
+
chunk_layout=self._ts.ChunkLayout(chunk_shape=chunks),
|
|
255
|
+
domain=self._ts.IndexDomain(labels=labels),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def get_shape_chunks_labels(
|
|
259
|
+
self, frame_shape: tuple[int, ...], seq: useq.MDASequence | None
|
|
260
|
+
) -> tuple[tuple[int, ...], tuple[int, ...], tuple[str, ...]]:
|
|
261
|
+
labels: tuple[str, ...]
|
|
262
|
+
if seq is not None and seq.sizes:
|
|
263
|
+
# expand the sizes to include the largest size we encounter for each axis
|
|
264
|
+
# in the case of positions with subsequences, we'll still end up with a
|
|
265
|
+
# jagged array, but it won't take extra space, and we won't get index errors
|
|
266
|
+
max_sizes = dict(seq.sizes)
|
|
267
|
+
for psize in position_sizes(seq):
|
|
268
|
+
for k, v in psize.items():
|
|
269
|
+
max_sizes[k] = max(max_sizes.get(k, 0), v)
|
|
270
|
+
|
|
271
|
+
# remove axes with length 0
|
|
272
|
+
labels, sizes = zip(*(x for x in max_sizes.items() if x[1]))
|
|
273
|
+
full_shape: tuple[int, ...] = (*sizes, *frame_shape)
|
|
274
|
+
else:
|
|
275
|
+
labels = (FRAME_DIM,)
|
|
276
|
+
full_shape = (self._size_increment, *frame_shape)
|
|
277
|
+
|
|
278
|
+
chunks = [1] * len(full_shape)
|
|
279
|
+
chunks[-len(frame_shape) :] = frame_shape
|
|
280
|
+
labels = (*labels, "y", "x")
|
|
281
|
+
return full_shape, tuple(chunks), labels
|
|
282
|
+
|
|
283
|
+
def get_spec(self) -> dict:
|
|
284
|
+
"""Construct the tensorstore spec."""
|
|
285
|
+
spec = {"driver": self.ts_driver, "kvstore": self.kvstore}
|
|
286
|
+
if self.spec:
|
|
287
|
+
_merge_nested_dicts(spec, self.spec)
|
|
288
|
+
|
|
289
|
+
# HACK
|
|
290
|
+
if self.ts_driver == "zarr":
|
|
291
|
+
meta = cast(dict, spec.setdefault("metadata", {}))
|
|
292
|
+
if "dimension_separator" not in meta:
|
|
293
|
+
meta["dimension_separator"] = "/"
|
|
294
|
+
return spec
|
|
295
|
+
|
|
296
|
+
def finalize_metadata(self) -> None:
|
|
297
|
+
"""Finalize and flush metadata to storage."""
|
|
298
|
+
if not (store := self._store) or not store.kvstore:
|
|
299
|
+
return # pragma: no cover
|
|
300
|
+
|
|
301
|
+
metadata = {"frame_metadatas": [m[1] for m in self.frame_metadatas]}
|
|
302
|
+
if not self._nd_storage:
|
|
303
|
+
metadata["frame_indices"] = [
|
|
304
|
+
(tuple(dict(k).items()), v) # type: ignore
|
|
305
|
+
for k, v in self._frame_indices.items()
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
if self.ts_driver.startswith("zarr"):
|
|
309
|
+
store.kvstore.write(".zattrs", json_dumps(metadata).decode("utf-8"))
|
|
310
|
+
elif self.ts_driver == "n5": # pragma: no cover
|
|
311
|
+
attrs = json_loads(store.kvstore.read("attributes.json").result().value)
|
|
312
|
+
attrs.update(metadata)
|
|
313
|
+
store.kvstore.write("attributes.json", json_dumps(attrs).decode("utf-8"))
|
|
314
|
+
|
|
315
|
+
def _expand_store(self, store: ts.TensorStore) -> ts.Future[ts.TensorStore]:
|
|
316
|
+
"""Grow the store by `self._size_increment` frames.
|
|
317
|
+
|
|
318
|
+
This is used when _nd_storage mode is False and we've run out of space.
|
|
319
|
+
"""
|
|
320
|
+
new_shape = [self._frame_index + self._size_increment, *store.shape[-2:]]
|
|
321
|
+
return store.resize(exclusive_max=new_shape, expand_only=True)
|
|
322
|
+
|
|
323
|
+
def _event_index_to_store_index(
|
|
324
|
+
self, index: Mapping[str, int | slice]
|
|
325
|
+
) -> ts.DimExpression:
|
|
326
|
+
"""Convert event index to store index.
|
|
327
|
+
|
|
328
|
+
The return value is safe to use as an index to self._store[...]
|
|
329
|
+
"""
|
|
330
|
+
if self._nd_storage:
|
|
331
|
+
keys, values = zip(*index.items())
|
|
332
|
+
return self._ts.d[keys][values]
|
|
333
|
+
|
|
334
|
+
if any(isinstance(v, slice) for v in index.values()):
|
|
335
|
+
idx: list | int | ts.DimExpression = self._get_frame_indices(index)
|
|
336
|
+
else:
|
|
337
|
+
try:
|
|
338
|
+
idx = self._frame_indices[frozenset(index.items())] # type: ignore
|
|
339
|
+
except KeyError as e:
|
|
340
|
+
raise KeyError(f"Index {index} not found in frame_indices.") from e
|
|
341
|
+
return self._ts.d[FRAME_DIM][idx]
|
|
342
|
+
|
|
343
|
+
def _get_frame_indices(self, indexers: Mapping[str, int | slice]) -> list[int]:
|
|
344
|
+
"""Convert indexers (with slices) to a list of frame indices."""
|
|
345
|
+
# converting slice objects to actual indices
|
|
346
|
+
axis_indices: dict[str, Sequence[int]] = {}
|
|
347
|
+
for k, v in indexers.items():
|
|
348
|
+
if isinstance(v, slice):
|
|
349
|
+
axis_indices[k] = tuple(range(*v.indices(self._axis_max.get(k, 0) + 1)))
|
|
350
|
+
else:
|
|
351
|
+
axis_indices[k] = (v,)
|
|
352
|
+
|
|
353
|
+
indices: list[int] = []
|
|
354
|
+
for p in product(*axis_indices.values()):
|
|
355
|
+
key = frozenset(dict(zip(axis_indices.keys(), p)).items())
|
|
356
|
+
try:
|
|
357
|
+
indices.append(self._frame_indices[key])
|
|
358
|
+
except KeyError: # pragma: no cover
|
|
359
|
+
warnings.warn(
|
|
360
|
+
f"Index {dict(key)} not found in frame_indices.", stacklevel=2
|
|
361
|
+
)
|
|
362
|
+
return indices
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _merge_nested_dicts(dict1: dict, dict2: Mapping) -> None:
|
|
366
|
+
"""Merge two nested dictionaries.
|
|
367
|
+
|
|
368
|
+
Values in dict2 will override values in dict1.
|
|
369
|
+
"""
|
|
370
|
+
for key, value in dict2.items():
|
|
371
|
+
if key in dict1 and isinstance(dict1[key], dict) and isinstance(value, dict):
|
|
372
|
+
_merge_nested_dicts(dict1[key], value)
|
|
373
|
+
else:
|
|
374
|
+
dict1[key] = value
|
|
@@ -29,7 +29,7 @@ def position_sizes(seq: useq.MDASequence) -> list[dict[str, int]]:
|
|
|
29
29
|
`{dim: size}` pairs for each dimension in the sequence. Dimensions with no size
|
|
30
30
|
will be omitted, though singletons will be included.
|
|
31
31
|
"""
|
|
32
|
-
main_sizes = seq.sizes
|
|
32
|
+
main_sizes = dict(seq.sizes)
|
|
33
33
|
main_sizes.pop("p", None) # remove position
|
|
34
34
|
|
|
35
35
|
if not seq.stage_positions:
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from .functions import frame_metadata, summary_metadata
|
|
2
|
+
from .schema import (
|
|
3
|
+
ConfigGroup,
|
|
4
|
+
ConfigPreset,
|
|
5
|
+
DeviceInfo,
|
|
6
|
+
FrameMetaV1,
|
|
7
|
+
ImageInfo,
|
|
8
|
+
PixelSizeConfigPreset,
|
|
9
|
+
Position,
|
|
10
|
+
PropertyInfo,
|
|
11
|
+
PropertyValue,
|
|
12
|
+
StagePosition,
|
|
13
|
+
SummaryMetaV1,
|
|
14
|
+
SystemInfo,
|
|
15
|
+
)
|
|
16
|
+
from .serialize import json_dumps, to_builtins
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ConfigGroup",
|
|
20
|
+
"ConfigPreset",
|
|
21
|
+
"ConfigPreset",
|
|
22
|
+
"DeviceInfo",
|
|
23
|
+
"FrameMetaV1",
|
|
24
|
+
"ImageInfo",
|
|
25
|
+
"PixelSizeConfigPreset",
|
|
26
|
+
"Position",
|
|
27
|
+
"PropertyInfo",
|
|
28
|
+
"PropertyValue",
|
|
29
|
+
"StagePosition",
|
|
30
|
+
"SummaryMetaV1",
|
|
31
|
+
"SystemInfo",
|
|
32
|
+
"frame_metadata",
|
|
33
|
+
"json_dumps",
|
|
34
|
+
"summary_metadata",
|
|
35
|
+
"to_builtins",
|
|
36
|
+
]
|