pymmcore-plus 0.15.4__py3-none-any.whl → 0.17.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 +20 -1
- pymmcore_plus/_accumulator.py +23 -5
- pymmcore_plus/_cli.py +44 -26
- pymmcore_plus/_discovery.py +344 -0
- pymmcore_plus/_ipy_completion.py +1 -1
- pymmcore_plus/_logger.py +3 -3
- pymmcore_plus/_util.py +9 -245
- pymmcore_plus/core/_device.py +57 -13
- pymmcore_plus/core/_mmcore_plus.py +20 -23
- pymmcore_plus/core/_property.py +35 -29
- pymmcore_plus/core/_sequencing.py +2 -0
- pymmcore_plus/core/events/_device_signal_view.py +8 -1
- pymmcore_plus/experimental/simulate/__init__.py +88 -0
- pymmcore_plus/experimental/simulate/_objects.py +670 -0
- pymmcore_plus/experimental/simulate/_render.py +510 -0
- pymmcore_plus/experimental/simulate/_sample.py +156 -0
- pymmcore_plus/experimental/unicore/__init__.py +2 -0
- pymmcore_plus/experimental/unicore/_device_manager.py +46 -13
- pymmcore_plus/experimental/unicore/core/_config.py +706 -0
- pymmcore_plus/experimental/unicore/core/_unicore.py +834 -18
- pymmcore_plus/experimental/unicore/devices/_device_base.py +13 -0
- pymmcore_plus/experimental/unicore/devices/_hub.py +50 -0
- pymmcore_plus/experimental/unicore/devices/_stage.py +46 -1
- pymmcore_plus/experimental/unicore/devices/_state.py +6 -0
- pymmcore_plus/install.py +149 -18
- pymmcore_plus/mda/_engine.py +268 -73
- pymmcore_plus/mda/handlers/_5d_writer_base.py +16 -5
- pymmcore_plus/mda/handlers/_tensorstore_handler.py +7 -1
- pymmcore_plus/metadata/_ome.py +553 -0
- pymmcore_plus/metadata/functions.py +2 -1
- {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/METADATA +7 -4
- {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/RECORD +35 -27
- {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/WHEEL +1 -1
- {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/entry_points.txt +0 -0
- {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -79,6 +79,9 @@ class _5DWriterBase(Generic[T]):
|
|
|
79
79
|
# list of {dim_name: size} map for each position in the sequence
|
|
80
80
|
self._position_sizes: list[dict[str, int]] = []
|
|
81
81
|
|
|
82
|
+
# map of position index to position key
|
|
83
|
+
self._position_key_map: dict[int, str] = {}
|
|
84
|
+
|
|
82
85
|
# actual timestamps for each frame
|
|
83
86
|
self._timestamps: list[float] = []
|
|
84
87
|
|
|
@@ -123,13 +126,21 @@ class _5DWriterBase(Generic[T]):
|
|
|
123
126
|
self.finalize_metadata()
|
|
124
127
|
self.frame_metadatas.clear()
|
|
125
128
|
|
|
126
|
-
def get_position_key(self,
|
|
127
|
-
"""Get the position key for a specific
|
|
129
|
+
def get_position_key(self, event: useq.MDAEvent) -> str:
|
|
130
|
+
"""Get the position key for a specific MDA event.
|
|
128
131
|
|
|
129
132
|
This key will be used for subclasses like Zarr that need a directory structure
|
|
130
133
|
for each position. And may also be used to index into `self.position_arrays`.
|
|
131
134
|
"""
|
|
132
|
-
|
|
135
|
+
pos_index = event.index.get("p", 0)
|
|
136
|
+
if pos_index in self._position_key_map:
|
|
137
|
+
return self._position_key_map[pos_index]
|
|
138
|
+
|
|
139
|
+
pos_key = event.pos_name
|
|
140
|
+
if pos_key is None:
|
|
141
|
+
pos_key = f"{POS_PREFIX}{pos_index}"
|
|
142
|
+
self._position_key_map[pos_index] = pos_key
|
|
143
|
+
return pos_key
|
|
133
144
|
|
|
134
145
|
def frameReady(
|
|
135
146
|
self, frame: np.ndarray, event: useq.MDAEvent, meta: FrameMetaV1
|
|
@@ -137,7 +148,7 @@ class _5DWriterBase(Generic[T]):
|
|
|
137
148
|
"""Write frame to the zarr array for the appropriate position."""
|
|
138
149
|
# get the position key to store the array in the group
|
|
139
150
|
p_index = event.index.get("p", 0)
|
|
140
|
-
key = self.get_position_key(
|
|
151
|
+
key = self.get_position_key(event)
|
|
141
152
|
pos_sizes = self.position_sizes[p_index]
|
|
142
153
|
if key in self.position_arrays:
|
|
143
154
|
ary = self.position_arrays[key]
|
|
@@ -281,7 +292,7 @@ class _5DWriterBase(Generic[T]):
|
|
|
281
292
|
raise IndexError(
|
|
282
293
|
f"Position index {p_index} out of range for {len(self.position_sizes)}"
|
|
283
294
|
) from e
|
|
284
|
-
data = self.position_arrays[self.
|
|
295
|
+
data = self.position_arrays[self._position_key_map[p_index]]
|
|
285
296
|
full = slice(None, None)
|
|
286
297
|
index = tuple(indexers.get(k, full) for k in sizes)
|
|
287
298
|
return data[index] # type: ignore
|
|
@@ -107,7 +107,13 @@ class TensorStoreHandler:
|
|
|
107
107
|
self._ts = tensorstore
|
|
108
108
|
|
|
109
109
|
self.ts_driver = driver
|
|
110
|
-
|
|
110
|
+
if path is not None:
|
|
111
|
+
self.kvstore: dict | str = {"driver": "file", "path": str(path)}
|
|
112
|
+
elif kvstore is not None:
|
|
113
|
+
self.kvstore = kvstore
|
|
114
|
+
else:
|
|
115
|
+
raise ValueError("Either path or kvstore must be provided.")
|
|
116
|
+
|
|
111
117
|
self.delete_existing = delete_existing
|
|
112
118
|
self.spec = spec
|
|
113
119
|
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from contextlib import suppress
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
7
|
+
|
|
8
|
+
import useq
|
|
9
|
+
from ome_types.model import (
|
|
10
|
+
OME,
|
|
11
|
+
Channel,
|
|
12
|
+
Image,
|
|
13
|
+
ImageRef,
|
|
14
|
+
Instrument,
|
|
15
|
+
Pixels,
|
|
16
|
+
Pixels_DimensionOrder,
|
|
17
|
+
PixelType,
|
|
18
|
+
Plane,
|
|
19
|
+
Plate,
|
|
20
|
+
TiffData,
|
|
21
|
+
UnitsLength,
|
|
22
|
+
UnitsTime,
|
|
23
|
+
Well,
|
|
24
|
+
WellSample,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from pymmcore_plus.mda._runner import GeneratorMDASequence
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from pymmcore_plus.metadata.schema import ImageInfo
|
|
31
|
+
|
|
32
|
+
from .schema import FrameMetaV1, SummaryMetaV1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = ["create_ome_metadata"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_ome_metadata(
|
|
39
|
+
summary_metadata: SummaryMetaV1, frame_metadata_list: list[FrameMetaV1]
|
|
40
|
+
) -> OME:
|
|
41
|
+
"""Create OME metadata from metadata saved as json by the core engine.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
summary_metadata : SummaryMetaV1
|
|
46
|
+
Summary metadata containing acquisition information.
|
|
47
|
+
frame_metadata_list : list[FrameMetaV1]
|
|
48
|
+
List of frame metadata for each acquired image.
|
|
49
|
+
|
|
50
|
+
Returns
|
|
51
|
+
-------
|
|
52
|
+
OME
|
|
53
|
+
The OME metadata as an `ome_types.OME` object.
|
|
54
|
+
"""
|
|
55
|
+
_uuid = f"urn:uuid:{uuid.uuid4()}"
|
|
56
|
+
ome = OME(uuid=_uuid)
|
|
57
|
+
|
|
58
|
+
ome.instruments = instruments = _build_instrument_list(summary_metadata)
|
|
59
|
+
|
|
60
|
+
image_infos = summary_metadata.get("image_infos", ())
|
|
61
|
+
if not frame_metadata_list or not image_infos:
|
|
62
|
+
return ome
|
|
63
|
+
|
|
64
|
+
sequence = _extract_mda_sequence(summary_metadata, frame_metadata_list[0])
|
|
65
|
+
position_groups = _group_frames_by_position(frame_metadata_list)
|
|
66
|
+
images = _build_ome_images(
|
|
67
|
+
dimension_info=_extract_dimension_info(image_infos[0]),
|
|
68
|
+
sequence=sequence,
|
|
69
|
+
position_groups=position_groups,
|
|
70
|
+
acquisition_date=_extract_acquisition_date(summary_metadata),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
plates = []
|
|
74
|
+
if (plate_plan := _extract_plate_plan(sequence)) is not None:
|
|
75
|
+
position_to_image_mapping = _create_position_to_image_mapping(position_groups)
|
|
76
|
+
plates = [_build_ome_plate(plate_plan, position_to_image_mapping)]
|
|
77
|
+
|
|
78
|
+
return OME(
|
|
79
|
+
uuid=_uuid,
|
|
80
|
+
images=images,
|
|
81
|
+
instruments=instruments,
|
|
82
|
+
plates=plates,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# =============================================================================
|
|
87
|
+
# Data Structures
|
|
88
|
+
# =============================================================================
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _DimensionInfo(NamedTuple):
|
|
92
|
+
pixel_size_um: float
|
|
93
|
+
dtype: str | None
|
|
94
|
+
height: int
|
|
95
|
+
width: int
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _PositionKey(NamedTuple):
|
|
99
|
+
name: str | None
|
|
100
|
+
p_index: int
|
|
101
|
+
g_index: int | None = None
|
|
102
|
+
|
|
103
|
+
def __str__(self) -> str:
|
|
104
|
+
if self.g_index is not None:
|
|
105
|
+
# if it has a name, include it in the position string before grid
|
|
106
|
+
# (e.g. name_p0000_g0000)
|
|
107
|
+
if self.name:
|
|
108
|
+
return f"{self.name}_p{self.p_index:04d}_g{self.g_index:04d}"
|
|
109
|
+
# otherwise just use p and g indices (e.g. p0000_g0000)
|
|
110
|
+
return f"p{self.p_index:04d}_g{self.g_index:04d}"
|
|
111
|
+
else:
|
|
112
|
+
# if it has a name, include it in the position string (e.g. name_p0000)
|
|
113
|
+
if self.name:
|
|
114
|
+
return f"{self.name}_p{self.p_index:04d}"
|
|
115
|
+
# otherwise just use p index (e.g. p0000)
|
|
116
|
+
return f"p{self.p_index:04d}"
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def image_id(self) -> str:
|
|
120
|
+
if self.g_index is not None:
|
|
121
|
+
return f"{self.p_index}:{self.g_index}"
|
|
122
|
+
return f"{self.p_index}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# =============================================================================
|
|
126
|
+
# Metadata Extraction Functions
|
|
127
|
+
# =============================================================================
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _extract_dimension_info(
|
|
131
|
+
image_info: ImageInfo,
|
|
132
|
+
) -> _DimensionInfo:
|
|
133
|
+
"""Extract pixel size (µm), data type, width, and height from image_infos."""
|
|
134
|
+
return _DimensionInfo(
|
|
135
|
+
pixel_size_um=image_info.get("pixel_size_um", 1.0),
|
|
136
|
+
dtype=image_info.get("dtype", None),
|
|
137
|
+
width=image_info.get("width", 0),
|
|
138
|
+
height=image_info.get("height", 0),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _extract_acquisition_date(summary_metadata: SummaryMetaV1) -> datetime | None:
|
|
143
|
+
"""Extract acquisition date from summary metadata."""
|
|
144
|
+
if (acquisition_time := summary_metadata.get("datetime")) is not None:
|
|
145
|
+
with suppress(ValueError, AttributeError):
|
|
146
|
+
return datetime.fromisoformat(acquisition_time.replace("Z", "+00:00"))
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _extract_mda_sequence(
|
|
151
|
+
summary_metadata: SummaryMetaV1, single_frame_metadata: FrameMetaV1
|
|
152
|
+
) -> useq.MDASequence | None:
|
|
153
|
+
"""Extract the MDA sequence from summary metadata or frame metadata."""
|
|
154
|
+
if (sequence_data := summary_metadata.get("mda_sequence")) is not None:
|
|
155
|
+
return useq.MDASequence.model_validate(sequence_data)
|
|
156
|
+
if (mda_event := _extract_mda_event(single_frame_metadata)) is not None:
|
|
157
|
+
return mda_event.sequence
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _extract_mda_event(frame_metadata: FrameMetaV1) -> useq.MDAEvent | None:
|
|
162
|
+
"""Extract the useq.MDAEvent from frame metadata."""
|
|
163
|
+
if (mda_event_data := frame_metadata.get("mda_event")) is not None:
|
|
164
|
+
return useq.MDAEvent.model_validate(mda_event_data)
|
|
165
|
+
return None # pragma: no cover
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _extract_plate_plan(
|
|
169
|
+
sequence: useq.MDASequence | None,
|
|
170
|
+
) -> useq.WellPlatePlan | None:
|
|
171
|
+
"""Extract the plate plan from the MDA sequence if it exists."""
|
|
172
|
+
if sequence is None: # pragma: no cover
|
|
173
|
+
return None
|
|
174
|
+
stage_positions = sequence.stage_positions
|
|
175
|
+
if isinstance(stage_positions, useq.WellPlatePlan):
|
|
176
|
+
return stage_positions
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# =============================================================================
|
|
181
|
+
# Frame Grouping and Processing
|
|
182
|
+
# =============================================================================
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _group_frames_by_position(
|
|
186
|
+
frame_metadata_list: list[FrameMetaV1],
|
|
187
|
+
) -> dict[_PositionKey, list[FrameMetaV1]]:
|
|
188
|
+
"""Reorganize frame metadata by stage position index in a dictionary.
|
|
189
|
+
|
|
190
|
+
Handles the 'g' axis (grid) by converting it to separate positions,
|
|
191
|
+
since OME doesn't support the 'g' axis. Each grid position becomes
|
|
192
|
+
a separate OME Image with names like "Pos0000_Grid0000".
|
|
193
|
+
|
|
194
|
+
Returns
|
|
195
|
+
-------
|
|
196
|
+
dict[str, list[FrameMetaV1]]
|
|
197
|
+
mapping of position identifier (e.g. 'Pos0000_Grid0000')
|
|
198
|
+
to list of `FrameMetaV1`.
|
|
199
|
+
"""
|
|
200
|
+
frames_by_position: dict[_PositionKey, list[FrameMetaV1]] = {}
|
|
201
|
+
for frame_metadata in frame_metadata_list:
|
|
202
|
+
if (mda_event := _extract_mda_event(frame_metadata)) is None:
|
|
203
|
+
continue # pragma: no cover
|
|
204
|
+
|
|
205
|
+
p_index = mda_event.index.get(useq.Axis.POSITION, 0) or 0
|
|
206
|
+
g_index = mda_event.index.get(useq.Axis.GRID, None)
|
|
207
|
+
key = _PositionKey(mda_event.pos_name, p_index, g_index)
|
|
208
|
+
pos_list = frames_by_position.setdefault(key, [])
|
|
209
|
+
pos_list.append(frame_metadata)
|
|
210
|
+
return frames_by_position
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _create_position_to_image_mapping(
|
|
214
|
+
position_groups: dict[_PositionKey, list[FrameMetaV1]],
|
|
215
|
+
) -> dict[int, str]:
|
|
216
|
+
"""Create a mapping from position index to image ID."""
|
|
217
|
+
position_to_image_mapping: dict[int, str] = {}
|
|
218
|
+
|
|
219
|
+
for position_key, position_frames in position_groups.items():
|
|
220
|
+
if position_frames:
|
|
221
|
+
mda_event = _extract_mda_event(position_frames[0])
|
|
222
|
+
if mda_event is not None:
|
|
223
|
+
position_index = mda_event.index.get("p", 0)
|
|
224
|
+
position_to_image_mapping[position_index] = position_key.image_id
|
|
225
|
+
return position_to_image_mapping
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# =============================================================================
|
|
229
|
+
# Dimension Order and Pixel Information
|
|
230
|
+
# =============================================================================
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _determine_dimension_order(
|
|
234
|
+
sequence: useq.MDASequence | None,
|
|
235
|
+
) -> Pixels_DimensionOrder | None:
|
|
236
|
+
"""Determine the dimension order for pixels."""
|
|
237
|
+
if sequence is None or isinstance(sequence, GeneratorMDASequence):
|
|
238
|
+
return Pixels_DimensionOrder.XYTCZ
|
|
239
|
+
return _extract_dimension_order_from_sequence(sequence)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _extract_dimension_order_from_sequence(
|
|
243
|
+
sequence: useq.MDASequence,
|
|
244
|
+
) -> Pixels_DimensionOrder:
|
|
245
|
+
"""Extract axis order from a useq.MDASequence.
|
|
246
|
+
|
|
247
|
+
useq axis_order represents iteration order (outermost to innermost loop),
|
|
248
|
+
while OME DimensionOrder represents rasterization order (slowest to fastest
|
|
249
|
+
varying dimension). Since planes are stored in the order they're generated,
|
|
250
|
+
we need to reverse the useq axis order to get the OME dimension order.
|
|
251
|
+
|
|
252
|
+
For example, if useq axis_order="tpzc":
|
|
253
|
+
- Iteration: for t in times: for p in positions: for z in z_steps: for c in channels
|
|
254
|
+
- Plane storage: t0-z0-c0, t0-z0-c1, t0-z1-c0, t0-z1-c1, t1-z0-c0, ...
|
|
255
|
+
- This means C varies fastest, then Z, then T → OME order "XYCZT"
|
|
256
|
+
|
|
257
|
+
Returns
|
|
258
|
+
-------
|
|
259
|
+
A Pixels_DimensionOrder representing the dimension order compatible with OME
|
|
260
|
+
standards (e.g., "XYCZT").
|
|
261
|
+
"""
|
|
262
|
+
# Filter out 'p' and 'g' axes since they don't exist within a single OME Image
|
|
263
|
+
filtered_axes = [axis for axis in sequence.axis_order if axis not in {"p", "g"}]
|
|
264
|
+
|
|
265
|
+
# Reverse the order since useq is iteration order, OME is rasterization order
|
|
266
|
+
reversed_axes = filtered_axes[::-1]
|
|
267
|
+
dimension_order = "XY" + "".join(reversed_axes).upper()
|
|
268
|
+
|
|
269
|
+
# Ensure we have exactly 5 dimensions by adding missing ones
|
|
270
|
+
if len(dimension_order) != 5:
|
|
271
|
+
missing_axes = [axis for axis in "XYCZT" if axis not in dimension_order]
|
|
272
|
+
dimension_order += "".join(missing_axes)
|
|
273
|
+
|
|
274
|
+
return Pixels_DimensionOrder(dimension_order)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _extract_pixel_dimensions_and_channels(
|
|
278
|
+
sequence: useq.MDASequence | None,
|
|
279
|
+
position_frames: list[FrameMetaV1],
|
|
280
|
+
image_id: str,
|
|
281
|
+
) -> tuple[tuple[int, int, int], list[Channel]]:
|
|
282
|
+
"""Extract pixel dimensions and channels from sequence or frames."""
|
|
283
|
+
if sequence is None or isinstance(sequence, GeneratorMDASequence):
|
|
284
|
+
return _extract_pixel_info_from_frames(position_frames, image_id)
|
|
285
|
+
return _extract_pixel_info_from_sequence(sequence, image_id)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _extract_pixel_info_from_frames(
|
|
289
|
+
position_metadata: list[FrameMetaV1],
|
|
290
|
+
image_id: str,
|
|
291
|
+
) -> tuple[tuple[int, int, int], list[Channel]]:
|
|
292
|
+
"""Extract pixel dimensions and channel information from frame metadata.
|
|
293
|
+
|
|
294
|
+
Returns
|
|
295
|
+
-------
|
|
296
|
+
A tuple containing the maximum (t, z, c) dimensions, and a list of channels.
|
|
297
|
+
"""
|
|
298
|
+
max_t, max_z, max_c = 0, 0, 0
|
|
299
|
+
channels: dict[int, Channel] = {}
|
|
300
|
+
|
|
301
|
+
for frame_metadata in position_metadata:
|
|
302
|
+
mda_event = _extract_mda_event(frame_metadata)
|
|
303
|
+
if mda_event is None: # pragma: no cover
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
t_index = mda_event.index.get("t", 0)
|
|
307
|
+
z_index = mda_event.index.get("z", 0)
|
|
308
|
+
c_index = mda_event.index.get("c", 0)
|
|
309
|
+
|
|
310
|
+
max_t = max(max_t, t_index)
|
|
311
|
+
max_z = max(max_z, z_index)
|
|
312
|
+
max_c = max(max_c, c_index)
|
|
313
|
+
|
|
314
|
+
if c_index not in channels and mda_event.channel is not None:
|
|
315
|
+
channels[c_index] = Channel(
|
|
316
|
+
id=f"Channel:{image_id}:{c_index}",
|
|
317
|
+
name=mda_event.channel.config,
|
|
318
|
+
samples_per_pixel=1,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
sorted_channels = [channels[i] for i in sorted(channels.keys())]
|
|
322
|
+
return (max_t + 1, max_z + 1, max_c + 1), sorted_channels
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _extract_pixel_info_from_sequence(
|
|
326
|
+
sequence: useq.MDASequence,
|
|
327
|
+
image_id: str,
|
|
328
|
+
) -> tuple[tuple[int, int, int], list[Channel]]:
|
|
329
|
+
"""Extract pixel dimensions and channel information from MDA sequence."""
|
|
330
|
+
max_t = sequence.sizes.get("t", 1)
|
|
331
|
+
max_z = sequence.sizes.get("z", 1)
|
|
332
|
+
channels = [
|
|
333
|
+
Channel(
|
|
334
|
+
id=f"Channel:{image_id}:{index}",
|
|
335
|
+
name=channel.config,
|
|
336
|
+
samples_per_pixel=1,
|
|
337
|
+
)
|
|
338
|
+
for index, channel in enumerate(sequence.channels)
|
|
339
|
+
]
|
|
340
|
+
return (max_t, max_z, len(channels)), channels
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# =============================================================================
|
|
344
|
+
# OME Object Builders
|
|
345
|
+
# =============================================================================
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _build_ome_images(
|
|
349
|
+
dimension_info: _DimensionInfo,
|
|
350
|
+
sequence: useq.MDASequence | None,
|
|
351
|
+
position_groups: dict[_PositionKey, list[FrameMetaV1]],
|
|
352
|
+
acquisition_date: datetime | None,
|
|
353
|
+
) -> list[Image]:
|
|
354
|
+
"""Build OME Images from grouped frame metadata by position."""
|
|
355
|
+
images = []
|
|
356
|
+
for position_key, position_frames in position_groups.items():
|
|
357
|
+
image_id = position_key.image_id
|
|
358
|
+
position_name = str(position_key)
|
|
359
|
+
|
|
360
|
+
dimension_order = _determine_dimension_order(sequence)
|
|
361
|
+
if not dimension_order: # pragma: no cover
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
size_info, channels = _extract_pixel_dimensions_and_channels(
|
|
365
|
+
sequence, position_frames, image_id
|
|
366
|
+
)
|
|
367
|
+
max_t, max_z, max_c = size_info
|
|
368
|
+
|
|
369
|
+
pixels = _build_pixels_object(
|
|
370
|
+
image_id,
|
|
371
|
+
dimension_order,
|
|
372
|
+
dimension_info,
|
|
373
|
+
max_t,
|
|
374
|
+
max_z,
|
|
375
|
+
max_c,
|
|
376
|
+
channels,
|
|
377
|
+
position_frames,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
image = Image(
|
|
381
|
+
acquisition_date=acquisition_date,
|
|
382
|
+
id=f"Image:{image_id}",
|
|
383
|
+
name=position_name,
|
|
384
|
+
pixels=pixels,
|
|
385
|
+
)
|
|
386
|
+
images.append(image)
|
|
387
|
+
return images
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _build_pixels_object(
|
|
391
|
+
image_id: str,
|
|
392
|
+
dimension_order: Pixels_DimensionOrder,
|
|
393
|
+
dimension_info: _DimensionInfo,
|
|
394
|
+
max_t: int,
|
|
395
|
+
max_z: int,
|
|
396
|
+
max_c: int,
|
|
397
|
+
channels: list[Channel],
|
|
398
|
+
position_frames: list[FrameMetaV1],
|
|
399
|
+
) -> Pixels:
|
|
400
|
+
"""Build a Pixels object with the given parameters."""
|
|
401
|
+
return Pixels(
|
|
402
|
+
id=f"Pixels:{image_id}",
|
|
403
|
+
dimension_order=dimension_order,
|
|
404
|
+
size_x=dimension_info.width,
|
|
405
|
+
size_y=dimension_info.height,
|
|
406
|
+
size_z=max(max_z, 1),
|
|
407
|
+
size_c=max(max_c, 1),
|
|
408
|
+
size_t=max(max_t, 1),
|
|
409
|
+
type=PixelType(dimension_info.dtype),
|
|
410
|
+
physical_size_x=dimension_info.pixel_size_um,
|
|
411
|
+
physical_size_x_unit=UnitsLength.MICROMETER,
|
|
412
|
+
physical_size_y=dimension_info.pixel_size_um,
|
|
413
|
+
physical_size_y_unit=UnitsLength.MICROMETER,
|
|
414
|
+
channels=channels,
|
|
415
|
+
tiff_data_blocks=_build_tiff_data_list(position_frames),
|
|
416
|
+
planes=_build_plane_list(position_frames),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _build_tiff_data_list(position_frames: list[FrameMetaV1]) -> list[TiffData]:
|
|
421
|
+
"""Build TiffData objects for frame metadata at a specific position."""
|
|
422
|
+
tiff_data_blocks = []
|
|
423
|
+
for frame_metadata in position_frames:
|
|
424
|
+
mda_event = _extract_mda_event(frame_metadata)
|
|
425
|
+
if mda_event is None: # pragma: no cover
|
|
426
|
+
continue
|
|
427
|
+
|
|
428
|
+
event_index = mda_event.index
|
|
429
|
+
z_index = event_index.get("z", 0)
|
|
430
|
+
c_index = event_index.get("c", 0)
|
|
431
|
+
t_index = event_index.get("t", 0)
|
|
432
|
+
|
|
433
|
+
# Create a TiffData block for this plane
|
|
434
|
+
tiff_data = TiffData(
|
|
435
|
+
first_z=z_index,
|
|
436
|
+
first_c=c_index,
|
|
437
|
+
first_t=t_index,
|
|
438
|
+
plane_count=1,
|
|
439
|
+
)
|
|
440
|
+
tiff_data_blocks.append(tiff_data)
|
|
441
|
+
|
|
442
|
+
return tiff_data_blocks
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _build_plane_list(position_frames: list[FrameMetaV1]) -> list[Plane]:
|
|
446
|
+
"""Build Plane objects for frame metadata at a specific position."""
|
|
447
|
+
planes = []
|
|
448
|
+
for frame_metadata in position_frames:
|
|
449
|
+
mda_event = _extract_mda_event(frame_metadata)
|
|
450
|
+
if mda_event is None: # pragma: no cover
|
|
451
|
+
continue
|
|
452
|
+
|
|
453
|
+
event_index = mda_event.index
|
|
454
|
+
z_index = event_index.get("z", 0)
|
|
455
|
+
c_index = event_index.get("c", 0)
|
|
456
|
+
t_index = event_index.get("t", 0)
|
|
457
|
+
|
|
458
|
+
runner_time_ms = frame_metadata.get("runner_time_ms", 0.0)
|
|
459
|
+
delta_t = runner_time_ms / 1000.0 if runner_time_ms > 0 else None
|
|
460
|
+
exposure_ms = frame_metadata.get("exposure_ms", 0.0)
|
|
461
|
+
|
|
462
|
+
plane = Plane(
|
|
463
|
+
the_z=z_index,
|
|
464
|
+
the_c=c_index,
|
|
465
|
+
the_t=t_index,
|
|
466
|
+
position_x=mda_event.x_pos,
|
|
467
|
+
position_x_unit=UnitsLength.MICROMETER,
|
|
468
|
+
position_y=mda_event.y_pos,
|
|
469
|
+
position_y_unit=UnitsLength.MICROMETER,
|
|
470
|
+
position_z=mda_event.z_pos,
|
|
471
|
+
position_z_unit=UnitsLength.MICROMETER,
|
|
472
|
+
delta_t=delta_t,
|
|
473
|
+
delta_t_unit=UnitsTime.SECOND,
|
|
474
|
+
exposure_time=exposure_ms,
|
|
475
|
+
exposure_time_unit=UnitsTime.MILLISECOND,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
planes.append(plane)
|
|
479
|
+
return planes
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _build_ome_plate(
|
|
483
|
+
plate_plan: useq.WellPlatePlan, position_to_image_mapping: dict[int, str]
|
|
484
|
+
) -> Plate:
|
|
485
|
+
"""Create a Plate object from a useq.WellPlatePlan."""
|
|
486
|
+
wells: list[Well] = []
|
|
487
|
+
|
|
488
|
+
# create a mapping from well name to acquisition indices
|
|
489
|
+
well_acquisition_map: dict[str, list[int]] = {}
|
|
490
|
+
for acquisition_index, position in enumerate(plate_plan.image_positions):
|
|
491
|
+
if (position_name := position.name) is not None:
|
|
492
|
+
# Extract base well name by removing FOV suffix ("A1_0000" -> "A1")
|
|
493
|
+
# This handles cases where well_points_plan creates multiple FOVs per well
|
|
494
|
+
base_well_name = position_name.split("_")[0]
|
|
495
|
+
|
|
496
|
+
if base_well_name not in well_acquisition_map:
|
|
497
|
+
well_acquisition_map[base_well_name] = []
|
|
498
|
+
|
|
499
|
+
well_acquisition_map[base_well_name].append(acquisition_index)
|
|
500
|
+
|
|
501
|
+
for well_index, ((row, col), name, pos) in enumerate(
|
|
502
|
+
zip(
|
|
503
|
+
plate_plan.selected_well_indices,
|
|
504
|
+
plate_plan.selected_well_names,
|
|
505
|
+
plate_plan.selected_well_positions,
|
|
506
|
+
)
|
|
507
|
+
):
|
|
508
|
+
# get all acquisition indices for this well
|
|
509
|
+
acquisition_indices = well_acquisition_map.get(name, [])
|
|
510
|
+
|
|
511
|
+
# create WellSample objects for each acquisition in this well
|
|
512
|
+
well_samples = []
|
|
513
|
+
for acq_index in acquisition_indices:
|
|
514
|
+
# Use the actual image ID from the mapping
|
|
515
|
+
image_id = position_to_image_mapping.get(acq_index, str(acq_index))
|
|
516
|
+
well_samples.append(
|
|
517
|
+
WellSample(
|
|
518
|
+
id=f"WellSample:{acq_index}",
|
|
519
|
+
position_x=pos.x,
|
|
520
|
+
position_y=pos.y,
|
|
521
|
+
position_x_unit=UnitsLength.MICROMETER,
|
|
522
|
+
position_y_unit=UnitsLength.MICROMETER,
|
|
523
|
+
index=acq_index,
|
|
524
|
+
image_ref=ImageRef(id=f"Image:{image_id}"),
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
wells.append(
|
|
529
|
+
Well(
|
|
530
|
+
id=f"Well:{well_index}",
|
|
531
|
+
row=row,
|
|
532
|
+
column=col,
|
|
533
|
+
well_samples=well_samples,
|
|
534
|
+
)
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
return Plate(
|
|
538
|
+
id="Plate:0",
|
|
539
|
+
name=plate_plan.plate.name,
|
|
540
|
+
rows=plate_plan.plate.rows,
|
|
541
|
+
columns=plate_plan.plate.columns,
|
|
542
|
+
wells=wells,
|
|
543
|
+
well_origin_x=plate_plan.a1_center_xy[0],
|
|
544
|
+
well_origin_x_unit=UnitsLength.MICROMETER,
|
|
545
|
+
well_origin_y=plate_plan.a1_center_xy[1],
|
|
546
|
+
well_origin_y_unit=UnitsLength.MICROMETER,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _build_instrument_list(summary_metadata: SummaryMetaV1) -> list[Instrument]:
|
|
551
|
+
"""Build instrument list from summary metadata."""
|
|
552
|
+
# TODO
|
|
553
|
+
return []
|
|
@@ -209,7 +209,8 @@ def image_infos(core: CMMCorePlus) -> tuple[ImageInfo, ...]:
|
|
|
209
209
|
infos.append(image_info(core))
|
|
210
210
|
finally:
|
|
211
211
|
# set the camera back to the originally selected device
|
|
212
|
-
|
|
212
|
+
with suppress(RuntimeError):
|
|
213
|
+
core.setCameraDevice(selected)
|
|
213
214
|
return tuple(infos)
|
|
214
215
|
|
|
215
216
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pymmcore-plus
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.0
|
|
4
4
|
Summary: pymmcore superset providing improved APIs, event handling, and a pure python acquisition engine
|
|
5
5
|
Project-URL: Source, https://github.com/pymmcore-plus/pymmcore-plus
|
|
6
6
|
Project-URL: Tracker, https://github.com/pymmcore-plus/pymmcore-plus/issues
|
|
@@ -28,18 +28,19 @@ Requires-Python: >=3.9
|
|
|
28
28
|
Requires-Dist: numpy>=1.25.2
|
|
29
29
|
Requires-Dist: numpy>=1.26.0; python_version >= '3.12'
|
|
30
30
|
Requires-Dist: numpy>=2.1.0; python_version >= '3.13'
|
|
31
|
+
Requires-Dist: ome-types>=0.6.0
|
|
31
32
|
Requires-Dist: platformdirs>=3.0.0
|
|
32
33
|
Requires-Dist: psygnal>=0.10
|
|
33
|
-
Requires-Dist: pymmcore>=11.
|
|
34
|
+
Requires-Dist: pymmcore>=11.10.0.74.0
|
|
34
35
|
Requires-Dist: rich>=10.2.0
|
|
35
36
|
Requires-Dist: tensorstore!=0.1.72,>=0.1.67
|
|
36
37
|
Requires-Dist: tensorstore!=0.1.72,>=0.1.71; python_version >= '3.13'
|
|
37
|
-
Requires-Dist: typer>=0.
|
|
38
|
+
Requires-Dist: typer>=0.13.0
|
|
38
39
|
Requires-Dist: typing-extensions>=4
|
|
39
40
|
Requires-Dist: useq-schema>=0.7.2
|
|
40
41
|
Provides-Extra: cli
|
|
41
42
|
Requires-Dist: rich>=10.2.0; extra == 'cli'
|
|
42
|
-
Requires-Dist: typer>=0.
|
|
43
|
+
Requires-Dist: typer>=0.13.0; extra == 'cli'
|
|
43
44
|
Provides-Extra: io
|
|
44
45
|
Requires-Dist: tifffile>=2021.6.14; extra == 'io'
|
|
45
46
|
Requires-Dist: zarr<3,>=2.15; extra == 'io'
|
|
@@ -51,6 +52,8 @@ Provides-Extra: pyside2
|
|
|
51
52
|
Requires-Dist: pyside2>=5.15.2.1; extra == 'pyside2'
|
|
52
53
|
Provides-Extra: pyside6
|
|
53
54
|
Requires-Dist: pyside6==6.7.3; extra == 'pyside6'
|
|
55
|
+
Provides-Extra: simulate
|
|
56
|
+
Requires-Dist: pillow>=11.0; extra == 'simulate'
|
|
54
57
|
Description-Content-Type: text/markdown
|
|
55
58
|
|
|
56
59
|
# pymmcore-plus
|