pymmcore-plus 0.9.4__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 +133 -30
- 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.4.dist-info → pymmcore_plus-0.13.0.dist-info}/METADATA +22 -11
- pymmcore_plus-0.13.0.dist-info/RECORD +71 -0
- {pymmcore_plus-0.9.4.dist-info → pymmcore_plus-0.13.0.dist-info}/WHEEL +1 -1
- pymmcore_plus/core/_state.py +0 -244
- pymmcore_plus-0.9.4.dist-info/RECORD +0 -55
- {pymmcore_plus-0.9.4.dist-info → pymmcore_plus-0.13.0.dist-info}/entry_points.txt +0 -0
- {pymmcore_plus-0.9.4.dist-info → pymmcore_plus-0.13.0.dist-info}/licenses/LICENSE +0 -0
pymmcore_plus/mda/_protocol.py
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from abc import abstractmethod
|
|
4
|
-
from typing import TYPE_CHECKING,
|
|
4
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
5
5
|
|
|
6
6
|
if TYPE_CHECKING:
|
|
7
|
-
from
|
|
7
|
+
from collections.abc import Iterable, Iterator
|
|
8
8
|
|
|
9
9
|
from numpy.typing import NDArray
|
|
10
10
|
from useq import MDAEvent, MDASequence
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
from pymmcore_plus.metadata.schema import FrameMetaV1, SummaryMetaV1
|
|
13
|
+
|
|
14
|
+
PImagePayload = tuple[NDArray, MDAEvent, FrameMetaV1]
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
# NOTE: This whole thing could potentially go in useq-schema
|
|
@@ -21,7 +23,7 @@ class PMDAEngine(Protocol):
|
|
|
21
23
|
"""Protocol that all MDA engines must implement."""
|
|
22
24
|
|
|
23
25
|
@abstractmethod
|
|
24
|
-
def setup_sequence(self, sequence: MDASequence) ->
|
|
26
|
+
def setup_sequence(self, sequence: MDASequence) -> SummaryMetaV1 | None:
|
|
25
27
|
"""Setup state of system (hardware, etc.) before an MDA is run.
|
|
26
28
|
|
|
27
29
|
This method is called once at the beginning of a sequence.
|
pymmcore_plus/mda/_runner.py
CHANGED
|
@@ -2,17 +2,10 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
4
|
import warnings
|
|
5
|
-
from
|
|
5
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
6
|
+
from contextlib import AbstractContextManager, nullcontext
|
|
6
7
|
from pathlib import Path
|
|
7
|
-
from typing import
|
|
8
|
-
TYPE_CHECKING,
|
|
9
|
-
Any,
|
|
10
|
-
ContextManager,
|
|
11
|
-
Iterable,
|
|
12
|
-
Iterator,
|
|
13
|
-
Sequence,
|
|
14
|
-
Tuple,
|
|
15
|
-
)
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
16
9
|
from unittest.mock import MagicMock
|
|
17
10
|
|
|
18
11
|
from useq import MDASequence
|
|
@@ -39,7 +32,7 @@ MSG = (
|
|
|
39
32
|
|
|
40
33
|
|
|
41
34
|
class GeneratorMDASequence(MDASequence):
|
|
42
|
-
axis_order:
|
|
35
|
+
axis_order: tuple[str, ...] = ()
|
|
43
36
|
|
|
44
37
|
@property
|
|
45
38
|
def sizes(self) -> dict[str, int]: # pragma: no cover
|
|
@@ -77,7 +70,10 @@ class MDARunner:
|
|
|
77
70
|
|
|
78
71
|
self._canceled = False
|
|
79
72
|
self._sequence: MDASequence | None = None
|
|
80
|
-
|
|
73
|
+
# timer for the full sequence, reset only once at the beginning of the sequence
|
|
74
|
+
self._sequence_t0: float = 0.0
|
|
75
|
+
# event clock, reset whenever `event.reset_event_timer` is True
|
|
76
|
+
self._t0: float = 0.0
|
|
81
77
|
|
|
82
78
|
def set_engine(self, engine: PMDAEngine) -> PMDAEngine | None:
|
|
83
79
|
"""Set the [`PMDAEngine`][pymmcore_plus.mda.PMDAEngine] to use for the MDA run.""" # noqa: E501
|
|
@@ -89,7 +85,7 @@ class MDARunner:
|
|
|
89
85
|
if self.is_running(): # pragma: no cover
|
|
90
86
|
raise RuntimeError(
|
|
91
87
|
"Cannot register a new engine when the current engine is running "
|
|
92
|
-
"an
|
|
88
|
+
"an acquisition. Please cancel the current engine's acquisition "
|
|
93
89
|
"before registering"
|
|
94
90
|
)
|
|
95
91
|
|
|
@@ -115,7 +111,7 @@ class MDARunner:
|
|
|
115
111
|
return self._signals
|
|
116
112
|
|
|
117
113
|
def is_running(self) -> bool:
|
|
118
|
-
"""Return True if an
|
|
114
|
+
"""Return True if an acquisition is currently underway.
|
|
119
115
|
|
|
120
116
|
This will return True at any point between the emission of the
|
|
121
117
|
[`sequenceStarted`][pymmcore_plus.mda.PMDASignaler.sequenceStarted] and
|
|
@@ -125,19 +121,19 @@ class MDARunner:
|
|
|
125
121
|
Returns
|
|
126
122
|
-------
|
|
127
123
|
bool
|
|
128
|
-
Whether an
|
|
124
|
+
Whether an acquisition is underway.
|
|
129
125
|
"""
|
|
130
126
|
return self._running
|
|
131
127
|
|
|
132
128
|
def is_paused(self) -> bool:
|
|
133
|
-
"""Return True if the
|
|
129
|
+
"""Return True if the acquisition is currently paused.
|
|
134
130
|
|
|
135
131
|
Use `toggle_pause` to change the paused state.
|
|
136
132
|
|
|
137
133
|
Returns
|
|
138
134
|
-------
|
|
139
135
|
bool
|
|
140
|
-
Whether the current
|
|
136
|
+
Whether the current acquisition is paused.
|
|
141
137
|
"""
|
|
142
138
|
return self._paused
|
|
143
139
|
|
|
@@ -145,7 +141,7 @@ class MDARunner:
|
|
|
145
141
|
"""Cancel the currently running acquisition.
|
|
146
142
|
|
|
147
143
|
This is a no-op if no acquisition is currently running.
|
|
148
|
-
If an acquisition is running then this will cancel the
|
|
144
|
+
If an acquisition is running then this will cancel the acquisition and
|
|
149
145
|
a sequenceCanceled signal, followed by a sequenceFinished signal will
|
|
150
146
|
be emitted.
|
|
151
147
|
"""
|
|
@@ -157,7 +153,7 @@ class MDARunner:
|
|
|
157
153
|
|
|
158
154
|
To get whether the acquisition is currently paused use the
|
|
159
155
|
[`is_paused`][pymmcore_plus.mda.MDARunner.is_paused] method. This method is a
|
|
160
|
-
no-op if no
|
|
156
|
+
no-op if no acquisition is currently underway.
|
|
161
157
|
"""
|
|
162
158
|
if self.is_running():
|
|
163
159
|
self._paused = not self._paused
|
|
@@ -169,7 +165,7 @@ class MDARunner:
|
|
|
169
165
|
*,
|
|
170
166
|
output: SingleOutput | Sequence[SingleOutput] | None = None,
|
|
171
167
|
) -> None:
|
|
172
|
-
"""Run the multi-dimensional
|
|
168
|
+
"""Run the multi-dimensional acquisition defined by `sequence`.
|
|
173
169
|
|
|
174
170
|
Most users should not use this directly as it will block further
|
|
175
171
|
execution. Instead, use the
|
|
@@ -210,9 +206,21 @@ class MDARunner:
|
|
|
210
206
|
if error is not None:
|
|
211
207
|
raise error
|
|
212
208
|
|
|
209
|
+
def seconds_elapsed(self) -> float:
|
|
210
|
+
"""Return the number of seconds since the start of the acquisition."""
|
|
211
|
+
return time.perf_counter() - self._sequence_t0
|
|
212
|
+
|
|
213
|
+
def event_seconds_elapsed(self) -> float:
|
|
214
|
+
"""Return the number of seconds on the "event clock".
|
|
215
|
+
|
|
216
|
+
This is the time since either the start of the acquisition or the last
|
|
217
|
+
event with `reset_event_timer` set to `True`.
|
|
218
|
+
"""
|
|
219
|
+
return time.perf_counter() - self._t0
|
|
220
|
+
|
|
213
221
|
def _outputs_connected(
|
|
214
222
|
self, output: SingleOutput | Sequence[SingleOutput] | None
|
|
215
|
-
) ->
|
|
223
|
+
) -> AbstractContextManager:
|
|
216
224
|
"""Context in which output handlers are connected to the frameReady signal."""
|
|
217
225
|
if output is None:
|
|
218
226
|
return nullcontext()
|
|
@@ -266,10 +274,21 @@ class MDARunner:
|
|
|
266
274
|
def _run(self, engine: PMDAEngine, events: Iterable[MDAEvent]) -> None:
|
|
267
275
|
"""Main execution of events, inside the try/except block of `run`."""
|
|
268
276
|
teardown_event = getattr(engine, "teardown_event", lambda e: None)
|
|
269
|
-
|
|
277
|
+
if isinstance(events, Iterator):
|
|
278
|
+
# if an iterator is passed directly, then we use that iterator
|
|
279
|
+
# instead of the engine's event_iterator. Directly passing an iterator
|
|
280
|
+
# is an advanced use case, (for example, `iter(Queue(), None)` for event-
|
|
281
|
+
# driven acquisition) and we don't want the engine to interfere with it.
|
|
282
|
+
event_iterator = iter
|
|
283
|
+
else:
|
|
284
|
+
event_iterator = getattr(engine, "event_iterator", iter)
|
|
270
285
|
_events: Iterator[MDAEvent] = event_iterator(events)
|
|
286
|
+
self._reset_event_timer()
|
|
287
|
+
self._sequence_t0 = self._t0
|
|
271
288
|
|
|
272
289
|
for event in _events:
|
|
290
|
+
if event.reset_event_timer:
|
|
291
|
+
self._reset_event_timer()
|
|
273
292
|
# If cancelled break out of the loop
|
|
274
293
|
if self._wait_until_event(event) or not self._running:
|
|
275
294
|
break
|
|
@@ -278,17 +297,23 @@ class MDARunner:
|
|
|
278
297
|
logger.info("%s", event)
|
|
279
298
|
engine.setup_event(event)
|
|
280
299
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
300
|
+
try:
|
|
301
|
+
runner_time_ms = self.seconds_elapsed() * 1000
|
|
302
|
+
# this is a bit of a hack to pass the time into the engine
|
|
303
|
+
# it is used for intra-event time calculations inside the engine.
|
|
304
|
+
# we pop it off after the event is executed.
|
|
305
|
+
event.metadata["runner_t0"] = self._sequence_t0
|
|
306
|
+
output = engine.exec_event(event) or () # in case output is None
|
|
307
|
+
for payload in output:
|
|
308
|
+
img, event, meta = payload
|
|
309
|
+
event.metadata.pop("runner_t0", None)
|
|
310
|
+
# if the engine calculated its own time, don't overwrite it
|
|
311
|
+
if "runner_time_ms" not in meta:
|
|
312
|
+
meta["runner_time_ms"] = runner_time_ms
|
|
313
|
+
with exceptions_logged():
|
|
314
|
+
self._signals.frameReady.emit(img, event, meta)
|
|
315
|
+
finally:
|
|
316
|
+
teardown_event(event)
|
|
292
317
|
|
|
293
318
|
def _prepare_to_run(self, sequence: MDASequence) -> PMDAEngine:
|
|
294
319
|
"""Set up for the MDA run.
|
|
@@ -307,18 +332,13 @@ class MDARunner:
|
|
|
307
332
|
self._sequence = sequence
|
|
308
333
|
|
|
309
334
|
meta = self._engine.setup_sequence(sequence)
|
|
310
|
-
logger.info("MDA Started: %s", sequence)
|
|
311
|
-
|
|
312
335
|
self._signals.sequenceStarted.emit(sequence, meta or {})
|
|
313
|
-
|
|
336
|
+
logger.info("MDA Started: %s", sequence)
|
|
314
337
|
return self._engine
|
|
315
338
|
|
|
316
|
-
def
|
|
339
|
+
def _reset_event_timer(self) -> None:
|
|
317
340
|
self._t0 = time.perf_counter() # reference time, in seconds
|
|
318
341
|
|
|
319
|
-
def _time_elapsed(self) -> float:
|
|
320
|
-
return time.perf_counter() - self._t0
|
|
321
|
-
|
|
322
342
|
def _check_canceled(self) -> bool:
|
|
323
343
|
"""Return True if the cancel method has been called and emit relevant signals.
|
|
324
344
|
|
|
@@ -369,7 +389,7 @@ class MDARunner:
|
|
|
369
389
|
go_at = event.min_start_time + self._paused_time
|
|
370
390
|
# We need to enter a loop here checking paused and canceled.
|
|
371
391
|
# otherwise you'll potentially wait a long time to cancel
|
|
372
|
-
remaining_wait_time = go_at - self.
|
|
392
|
+
remaining_wait_time = go_at - self.event_seconds_elapsed()
|
|
373
393
|
while remaining_wait_time > 0:
|
|
374
394
|
self._signals.awaitingEvent.emit(event, remaining_wait_time)
|
|
375
395
|
while self._paused and not self._canceled:
|
|
@@ -380,7 +400,7 @@ class MDARunner:
|
|
|
380
400
|
if self._canceled:
|
|
381
401
|
break
|
|
382
402
|
time.sleep(min(remaining_wait_time, 0.5))
|
|
383
|
-
remaining_wait_time = go_at - self.
|
|
403
|
+
remaining_wait_time = go_at - self.event_seconds_elapsed()
|
|
384
404
|
|
|
385
405
|
# check canceled again in case it was canceled
|
|
386
406
|
# during the waiting loop
|
|
@@ -402,12 +422,3 @@ class MDARunner:
|
|
|
402
422
|
|
|
403
423
|
logger.info("MDA Finished: %s", sequence)
|
|
404
424
|
self._signals.sequenceFinished.emit(sequence)
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
def _assert_handler(handler: Any) -> None:
|
|
408
|
-
if (
|
|
409
|
-
not hasattr(handler, "start")
|
|
410
|
-
or not hasattr(handler, "finish")
|
|
411
|
-
or not hasattr(handler, "put")
|
|
412
|
-
):
|
|
413
|
-
raise TypeError("Handler must have start, finish, and put methods.")
|
|
@@ -11,7 +11,9 @@ from pymmcore_plus._util import listeners_connected
|
|
|
11
11
|
from .events import _get_auto_MDA_callback_class
|
|
12
12
|
|
|
13
13
|
if TYPE_CHECKING:
|
|
14
|
-
from
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from contextlib import AbstractContextManager
|
|
16
|
+
from typing import Any
|
|
15
17
|
|
|
16
18
|
from pymmcore_plus.core.events._protocol import PSignalInstance
|
|
17
19
|
from pymmcore_plus.mda import PMDASignaler
|
|
@@ -24,7 +26,7 @@ def mda_listeners_connected(
|
|
|
24
26
|
name_map: dict[str, str] | None = ...,
|
|
25
27
|
asynchronous: Literal[False],
|
|
26
28
|
wait_on_exit: bool = ...,
|
|
27
|
-
) ->
|
|
29
|
+
) -> AbstractContextManager[None]: ...
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
@overload
|
|
@@ -34,7 +36,7 @@ def mda_listeners_connected(
|
|
|
34
36
|
name_map: dict[str, str] | None = ...,
|
|
35
37
|
asynchronous: Literal[True] = ...,
|
|
36
38
|
wait_on_exit: bool = ...,
|
|
37
|
-
) ->
|
|
39
|
+
) -> AbstractContextManager[MDARelayThread]: ...
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
@contextmanager
|
|
@@ -8,12 +8,12 @@ from ._protocol import PMDASignaler
|
|
|
8
8
|
from ._psygnal import MDASignaler
|
|
9
9
|
|
|
10
10
|
if TYPE_CHECKING:
|
|
11
|
-
from ._qsignals import QMDASignaler # noqa:
|
|
11
|
+
from ._qsignals import QMDASignaler # noqa: TC004
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
__all__ = [
|
|
15
|
-
"PMDASignaler",
|
|
16
15
|
"MDASignaler",
|
|
16
|
+
"PMDASignaler",
|
|
17
17
|
"QMDASignaler",
|
|
18
18
|
"_get_auto_MDA_callback_class",
|
|
19
19
|
]
|
|
@@ -8,7 +8,11 @@ class PMDASignaler(Protocol):
|
|
|
8
8
|
"""Declares the protocol for all signals that will be emitted from [`pymmcore_plus.mda.MDARunner`][].""" # noqa: E501
|
|
9
9
|
|
|
10
10
|
sequenceStarted: PSignal
|
|
11
|
-
"""Emits `(sequence: MDASequence, metadata: dict)` when an acquisition sequence is started.
|
|
11
|
+
"""Emits `(sequence: MDASequence, metadata: dict)` when an acquisition sequence is started.
|
|
12
|
+
|
|
13
|
+
For the default [`MDAEngine`][pymmcore_plus.mda.MDAEngine], the metadata `dict` will
|
|
14
|
+
be of type [`SummaryMetaV1`][pymmcore_plus.metadata.schema.SummaryMetaV1].
|
|
15
|
+
""" # noqa: E501
|
|
12
16
|
sequencePauseToggled: PSignal
|
|
13
17
|
"""Emits `(paused: bool)` when an acquisition sequence is paused or unpaused."""
|
|
14
18
|
sequenceCanceled: PSignal
|
|
@@ -16,7 +20,11 @@ class PMDASignaler(Protocol):
|
|
|
16
20
|
sequenceFinished: PSignal
|
|
17
21
|
"""Emits `(sequence: MDASequence)` when an acquisition sequence is finished."""
|
|
18
22
|
frameReady: PSignal
|
|
19
|
-
"""Emits `(img: np.ndarray, event: MDAEvent, metadata: dict)` after an image is acquired during an acquisition sequence.
|
|
23
|
+
"""Emits `(img: np.ndarray, event: MDAEvent, metadata: dict)` after an image is acquired during an acquisition sequence.
|
|
24
|
+
|
|
25
|
+
For the default [`MDAEngine`][pymmcore_plus.mda.MDAEngine], the metadata `dict` will
|
|
26
|
+
be of type [`FrameMetaV1`][pymmcore_plus.metadata.schema.FrameMetaV1].
|
|
27
|
+
""" # noqa: E501
|
|
20
28
|
awaitingEvent: PSignal
|
|
21
29
|
"""Emits `(event: MDAEvent, remaining_sec: float)` when the runner is waiting to start an event.
|
|
22
30
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import numpy as np
|
|
2
|
-
from psygnal import Signal
|
|
2
|
+
from psygnal import Signal, SignalGroup
|
|
3
3
|
from useq import MDAEvent, MDASequence
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
class MDASignaler:
|
|
6
|
+
class MDASignaler(SignalGroup):
|
|
7
7
|
sequenceStarted = Signal(MDASequence, dict) # at the start of an MDA sequence
|
|
8
8
|
sequencePauseToggled = Signal(bool) # when MDA is paused/unpaused
|
|
9
9
|
sequenceCanceled = Signal(MDASequence) # when mda is canceled
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import warnings
|
|
4
4
|
from abc import abstractmethod
|
|
5
5
|
from collections import defaultdict
|
|
6
6
|
from typing import TYPE_CHECKING, Generic, Protocol, TypeVar
|
|
@@ -8,13 +8,18 @@ from typing import TYPE_CHECKING, Generic, Protocol, TypeVar
|
|
|
8
8
|
from ._util import position_sizes
|
|
9
9
|
|
|
10
10
|
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
11
13
|
import numpy as np
|
|
12
14
|
import useq
|
|
13
15
|
|
|
16
|
+
from pymmcore_plus.metadata import FrameMetaV1, SummaryMetaV1
|
|
17
|
+
|
|
14
18
|
class SupportsSetItem(Protocol):
|
|
15
19
|
def __setitem__(self, key: tuple[int, ...], value: np.ndarray) -> None: ...
|
|
16
20
|
|
|
17
21
|
|
|
22
|
+
_NULL = object()
|
|
18
23
|
POS_PREFIX = "p"
|
|
19
24
|
T = TypeVar("T", bound="SupportsSetItem")
|
|
20
25
|
|
|
@@ -66,36 +71,73 @@ class _5DWriterBase(Generic[T]):
|
|
|
66
71
|
|
|
67
72
|
# storage of individual frame metadata
|
|
68
73
|
# maps position key to list of frame metadata
|
|
69
|
-
self.frame_metadatas: defaultdict[str, list[
|
|
74
|
+
self.frame_metadatas: defaultdict[str, list[FrameMetaV1]] = defaultdict(list)
|
|
70
75
|
|
|
71
76
|
# set during sequenceStarted and cleared during sequenceFinished
|
|
72
77
|
self.current_sequence: useq.MDASequence | None = None
|
|
73
78
|
|
|
74
79
|
# list of {dim_name: size} map for each position in the sequence
|
|
75
|
-
self.
|
|
80
|
+
self._position_sizes: list[dict[str, int]] = []
|
|
81
|
+
|
|
82
|
+
# actual timestamps for each frame
|
|
83
|
+
self._timestamps: list[float] = []
|
|
76
84
|
|
|
77
85
|
# The next three methods - `sequenceStarted`, `sequenceFinished`, and `frameReady`
|
|
78
86
|
# are to be connected directly to the MDA's signals, perhaps via listener_connected
|
|
79
87
|
|
|
80
|
-
|
|
88
|
+
@property
|
|
89
|
+
def position_sizes(self) -> list[dict[str, int]]:
|
|
90
|
+
"""Return sizes of each dimension for each position in the sequence.
|
|
91
|
+
|
|
92
|
+
Will be a list of dicts, where each dict maps dimension names to sizes, and
|
|
93
|
+
the index in the list corresponds to the stage position index.
|
|
94
|
+
|
|
95
|
+
This is the preferred way to access both the dimensions present in each position
|
|
96
|
+
as well as the sizes of those dimensions.
|
|
97
|
+
|
|
98
|
+
The dict is ordered, and the order reflects the order of dimensions in the
|
|
99
|
+
shape of each position.
|
|
100
|
+
"""
|
|
101
|
+
return self._position_sizes
|
|
102
|
+
|
|
103
|
+
def sequenceStarted(
|
|
104
|
+
self, seq: useq.MDASequence, meta: SummaryMetaV1 | object = _NULL
|
|
105
|
+
) -> None:
|
|
81
106
|
"""On sequence started, simply store the sequence."""
|
|
107
|
+
# this is here for backwards compatibility with experimental viewer widget.
|
|
108
|
+
if meta is _NULL: # pragma: no cover
|
|
109
|
+
warnings.warn(
|
|
110
|
+
"calling `sequenceStarted` without metadata as the second argument is "
|
|
111
|
+
"deprecated and will raise an exception in the future. Please propagate"
|
|
112
|
+
" metadata from the event callback.",
|
|
113
|
+
UserWarning,
|
|
114
|
+
stacklevel=2,
|
|
115
|
+
)
|
|
82
116
|
self.frame_metadatas.clear()
|
|
83
117
|
self.current_sequence = seq
|
|
84
118
|
if seq:
|
|
85
|
-
self.
|
|
119
|
+
self._position_sizes = position_sizes(seq)
|
|
86
120
|
|
|
87
121
|
def sequenceFinished(self, seq: useq.MDASequence) -> None:
|
|
88
122
|
"""On sequence finished, clear the current sequence."""
|
|
89
123
|
self.finalize_metadata()
|
|
90
124
|
self.frame_metadatas.clear()
|
|
91
|
-
self.current_sequence = None
|
|
92
|
-
self.position_sizes = []
|
|
93
125
|
|
|
94
|
-
def
|
|
126
|
+
def get_position_key(self, position_index: int) -> str:
|
|
127
|
+
"""Get the position key for a specific position index.
|
|
128
|
+
|
|
129
|
+
This key will be used for subclasses like Zarr that need a directory structure
|
|
130
|
+
for each position. And may also be used to index into `self.position_arrays`.
|
|
131
|
+
"""
|
|
132
|
+
return f"{POS_PREFIX}{position_index}"
|
|
133
|
+
|
|
134
|
+
def frameReady(
|
|
135
|
+
self, frame: np.ndarray, event: useq.MDAEvent, meta: FrameMetaV1
|
|
136
|
+
) -> None:
|
|
95
137
|
"""Write frame to the zarr array for the appropriate position."""
|
|
96
138
|
# get the position key to store the array in the group
|
|
97
139
|
p_index = event.index.get("p", 0)
|
|
98
|
-
key =
|
|
140
|
+
key = self.get_position_key(p_index)
|
|
99
141
|
pos_sizes = self.position_sizes[p_index]
|
|
100
142
|
if key in self.position_arrays:
|
|
101
143
|
ary = self.position_arrays[key]
|
|
@@ -117,6 +159,9 @@ class _5DWriterBase(Generic[T]):
|
|
|
117
159
|
self.position_arrays[key] = ary = self.new_array(key, frame.dtype, sizes)
|
|
118
160
|
|
|
119
161
|
index = tuple(event.index[k] for k in pos_sizes)
|
|
162
|
+
t = event.index.get("t", 0)
|
|
163
|
+
if t >= len(self._timestamps) and "runner_time_ms" in meta:
|
|
164
|
+
self._timestamps.append(meta["runner_time_ms"])
|
|
120
165
|
self.write_frame(ary, index, frame)
|
|
121
166
|
self.store_frame_metadata(key, event, meta)
|
|
122
167
|
|
|
@@ -162,7 +207,9 @@ class _5DWriterBase(Generic[T]):
|
|
|
162
207
|
# WRITE DATA TO DISK
|
|
163
208
|
ary[index] = frame
|
|
164
209
|
|
|
165
|
-
def store_frame_metadata(
|
|
210
|
+
def store_frame_metadata(
|
|
211
|
+
self, key: str, event: useq.MDAEvent, meta: FrameMetaV1
|
|
212
|
+
) -> None:
|
|
166
213
|
"""Called during each frameReady event to store metadata for the frame.
|
|
167
214
|
|
|
168
215
|
Subclasses may override this method to customize how metadata is stored for each
|
|
@@ -181,11 +228,6 @@ class _5DWriterBase(Generic[T]):
|
|
|
181
228
|
# needn't be re-implemented in subclasses
|
|
182
229
|
# default implementation is to store the metadata in self._frame_metas
|
|
183
230
|
# use finalize_metadata to write to disk at the end of the sequence.
|
|
184
|
-
if meta:
|
|
185
|
-
# fix serialization MDAEvent
|
|
186
|
-
# XXX: There is already an Event object in meta, this overwrites it.
|
|
187
|
-
event_json = event.json(exclude={"sequence"}, exclude_defaults=True)
|
|
188
|
-
meta["Event"] = json.loads(event_json)
|
|
189
231
|
self.frame_metadatas[key].append(meta or {})
|
|
190
232
|
|
|
191
233
|
def finalize_metadata(self) -> None:
|
|
@@ -194,3 +236,52 @@ class _5DWriterBase(Generic[T]):
|
|
|
194
236
|
Subclasses may override this method to flush any accumulated frame metadata to
|
|
195
237
|
disk at the end of the sequence.
|
|
196
238
|
"""
|
|
239
|
+
|
|
240
|
+
# This syntax is intentionally the same as xarray's isel method. It's possible
|
|
241
|
+
# we will use xarray in the future, and this will make it easier to switch.
|
|
242
|
+
def isel(
|
|
243
|
+
self,
|
|
244
|
+
indexers: Mapping[str, int | slice] | None = None,
|
|
245
|
+
**indexers_kwargs: int | slice,
|
|
246
|
+
) -> np.ndarray:
|
|
247
|
+
"""Select data from the array.
|
|
248
|
+
|
|
249
|
+
This is a convenience method to select data from the array for a given position
|
|
250
|
+
key. It will call the appropriate `__getitem__` method on the array with the
|
|
251
|
+
given indexers.
|
|
252
|
+
|
|
253
|
+
Parameters
|
|
254
|
+
----------
|
|
255
|
+
indexers : Mapping[str, int | slice] | None
|
|
256
|
+
Mapping of dimension names to indices or slices. If None, will return the
|
|
257
|
+
full array.
|
|
258
|
+
**indexers_kwargs : int | slice
|
|
259
|
+
Keyword arguments of dimension names to indices or slices. These will be
|
|
260
|
+
merged with `indexers`, and allows for a nicer syntax.
|
|
261
|
+
|
|
262
|
+
Returns
|
|
263
|
+
-------
|
|
264
|
+
np.ndarray
|
|
265
|
+
The selected data from the array.
|
|
266
|
+
|
|
267
|
+
Examples
|
|
268
|
+
--------
|
|
269
|
+
>>> data = writer.isel(t=0, z=1, c=0, x=slice(128, 256))
|
|
270
|
+
"""
|
|
271
|
+
indexers = dict(indexers or {})
|
|
272
|
+
indexers.update(indexers_kwargs)
|
|
273
|
+
|
|
274
|
+
p_index = indexers.get("p", 0)
|
|
275
|
+
if isinstance(p_index, slice):
|
|
276
|
+
raise NotImplementedError("Cannot slice over position index") # TODO
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
sizes = [*list(self.position_sizes[p_index]), "y", "x"]
|
|
280
|
+
except IndexError as e:
|
|
281
|
+
raise IndexError(
|
|
282
|
+
f"Position index {p_index} out of range for {len(self.position_sizes)}"
|
|
283
|
+
) from e
|
|
284
|
+
data = self.position_arrays[self.get_position_key(p_index)]
|
|
285
|
+
full = slice(None, None)
|
|
286
|
+
index = tuple(indexers.get(k, full) for k in sizes)
|
|
287
|
+
return data[index] # type: ignore
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
from ._img_sequence_writer import ImageSequenceWriter
|
|
2
2
|
from ._ome_tiff_writer import OMETiffWriter
|
|
3
3
|
from ._ome_zarr_writer import OMEZarrWriter
|
|
4
|
+
from ._tensorstore_handler import TensorStoreHandler
|
|
4
5
|
|
|
5
|
-
__all__ = [
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ImageSequenceWriter",
|
|
8
|
+
"OMETiffWriter",
|
|
9
|
+
"OMEZarrWriter",
|
|
10
|
+
"TensorStoreHandler",
|
|
11
|
+
]
|
|
@@ -7,10 +7,12 @@ provided.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
import
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
11
|
from itertools import count
|
|
12
12
|
from pathlib import Path
|
|
13
|
-
from typing import TYPE_CHECKING, Any, Callable, ClassVar,
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Callable, ClassVar, cast
|
|
14
|
+
|
|
15
|
+
from pymmcore_plus.metadata.serialize import json_dumps
|
|
14
16
|
|
|
15
17
|
from ._util import get_full_sequence_axes
|
|
16
18
|
|
|
@@ -176,11 +178,13 @@ class ImageSequenceWriter:
|
|
|
176
178
|
include_frame_count=self._include_frame_count,
|
|
177
179
|
)
|
|
178
180
|
# make directory and write metadata
|
|
179
|
-
self._seq_meta_file.write_text(
|
|
181
|
+
self._seq_meta_file.write_text(
|
|
182
|
+
seq.model_dump_json(exclude_unset=True, indent=2)
|
|
183
|
+
)
|
|
180
184
|
|
|
181
185
|
def sequenceFinished(self, seq: useq.MDASequence) -> None:
|
|
182
186
|
# write final frame metadata to disk
|
|
183
|
-
self._frame_meta_file.
|
|
187
|
+
self._frame_meta_file.write_bytes(json_dumps(self._frame_metadata, indent=2))
|
|
184
188
|
|
|
185
189
|
def frameReady(self, frame: np.ndarray, event: useq.MDAEvent, meta: dict) -> None:
|
|
186
190
|
"""Write a frame to disk."""
|
|
@@ -199,11 +203,12 @@ class ImageSequenceWriter:
|
|
|
199
203
|
self._imwrite(str(self._directory / filename), frame, **self._imwrite_kwargs)
|
|
200
204
|
|
|
201
205
|
# store metadata
|
|
202
|
-
meta["Event"] = json.loads(event.json(exclude={"sequence"}, exclude_unset=True))
|
|
203
206
|
self._frame_metadata[filename] = meta
|
|
204
207
|
# write metadata to disk every 10 frames
|
|
205
208
|
if frame_idx % 10 == 0:
|
|
206
|
-
self._frame_meta_file.
|
|
209
|
+
self._frame_meta_file.write_bytes(
|
|
210
|
+
json_dumps(self._frame_metadata, indent=2)
|
|
211
|
+
)
|
|
207
212
|
|
|
208
213
|
@staticmethod
|
|
209
214
|
def fname_template(
|
|
@@ -41,13 +41,15 @@ from typing import TYPE_CHECKING, Any
|
|
|
41
41
|
|
|
42
42
|
import numpy as np
|
|
43
43
|
|
|
44
|
-
from ._5d_writer_base import _5DWriterBase
|
|
44
|
+
from ._5d_writer_base import _NULL, _5DWriterBase
|
|
45
45
|
|
|
46
46
|
if TYPE_CHECKING:
|
|
47
47
|
from pathlib import Path
|
|
48
48
|
|
|
49
49
|
import useq
|
|
50
50
|
|
|
51
|
+
from pymmcore_plus.metadata import SummaryMetaV1
|
|
52
|
+
|
|
51
53
|
IMAGEJ_AXIS_ORDER = "tzcyxs"
|
|
52
54
|
|
|
53
55
|
|
|
@@ -81,13 +83,15 @@ class OMETiffWriter(_5DWriterBase[np.memmap]):
|
|
|
81
83
|
|
|
82
84
|
super().__init__()
|
|
83
85
|
|
|
84
|
-
def sequenceStarted(
|
|
85
|
-
|
|
86
|
+
def sequenceStarted(
|
|
87
|
+
self, seq: useq.MDASequence, meta: SummaryMetaV1 | object = _NULL
|
|
88
|
+
) -> None:
|
|
89
|
+
super().sequenceStarted(seq, meta)
|
|
86
90
|
# Non-OME (ImageJ) hyperstack axes MUST be in TZCYXS order
|
|
87
91
|
# so we reorder the ordered position_sizes dicts. This will ensure
|
|
88
92
|
# that the array indices created from event.index are in the correct order.
|
|
89
93
|
if not self._is_ome:
|
|
90
|
-
self.
|
|
94
|
+
self._position_sizes = [
|
|
91
95
|
{k: x[k] for k in IMAGEJ_AXIS_ORDER if k.lower() in x}
|
|
92
96
|
for x in self.position_sizes
|
|
93
97
|
]
|