ngio 0.3.5__py3-none-any.whl → 0.4.0a2__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.
- ngio/__init__.py +6 -0
- ngio/common/__init__.py +50 -48
- ngio/common/_array_io_pipes.py +554 -0
- ngio/common/_array_io_utils.py +508 -0
- ngio/common/_dimensions.py +63 -27
- ngio/common/_masking_roi.py +38 -10
- ngio/common/_pyramid.py +9 -7
- ngio/common/_roi.py +583 -72
- ngio/common/_synt_images_utils.py +101 -0
- ngio/common/_zoom.py +17 -12
- ngio/common/transforms/__init__.py +5 -0
- ngio/common/transforms/_label.py +12 -0
- ngio/common/transforms/_zoom.py +109 -0
- ngio/experimental/__init__.py +5 -0
- ngio/experimental/iterators/__init__.py +17 -0
- ngio/experimental/iterators/_abstract_iterator.py +170 -0
- ngio/experimental/iterators/_feature.py +151 -0
- ngio/experimental/iterators/_image_processing.py +169 -0
- ngio/experimental/iterators/_rois_utils.py +127 -0
- ngio/experimental/iterators/_segmentation.py +282 -0
- ngio/hcs/_plate.py +41 -36
- ngio/images/__init__.py +22 -1
- ngio/images/_abstract_image.py +247 -117
- ngio/images/_create.py +15 -15
- ngio/images/_create_synt_container.py +128 -0
- ngio/images/_image.py +425 -62
- ngio/images/_label.py +33 -30
- ngio/images/_masked_image.py +396 -122
- ngio/images/_ome_zarr_container.py +203 -66
- ngio/{common → images}/_table_ops.py +41 -41
- ngio/ome_zarr_meta/ngio_specs/__init__.py +2 -8
- ngio/ome_zarr_meta/ngio_specs/_axes.py +151 -128
- ngio/ome_zarr_meta/ngio_specs/_channels.py +55 -18
- ngio/ome_zarr_meta/ngio_specs/_dataset.py +7 -7
- ngio/ome_zarr_meta/ngio_specs/_ngio_hcs.py +3 -3
- ngio/ome_zarr_meta/ngio_specs/_ngio_image.py +11 -68
- ngio/ome_zarr_meta/v04/_v04_spec_utils.py +1 -1
- ngio/resources/20200812-CardiomyocyteDifferentiation14-Cycle1_B03/mask.png +0 -0
- ngio/resources/20200812-CardiomyocyteDifferentiation14-Cycle1_B03/nuclei.png +0 -0
- ngio/resources/20200812-CardiomyocyteDifferentiation14-Cycle1_B03/raw.jpg +0 -0
- ngio/resources/__init__.py +54 -0
- ngio/resources/resource_model.py +35 -0
- ngio/tables/backends/_abstract_backend.py +5 -6
- ngio/tables/backends/_anndata.py +1 -1
- ngio/tables/backends/_anndata_utils.py +3 -3
- ngio/tables/backends/_non_zarr_backends.py +1 -1
- ngio/tables/backends/_table_backends.py +0 -1
- ngio/tables/backends/_utils.py +3 -3
- ngio/tables/v1/_roi_table.py +156 -69
- ngio/utils/__init__.py +2 -3
- ngio/utils/_logger.py +19 -0
- ngio/utils/_zarr_utils.py +1 -5
- {ngio-0.3.5.dist-info → ngio-0.4.0a2.dist-info}/METADATA +3 -1
- ngio-0.4.0a2.dist-info/RECORD +76 -0
- ngio/common/_array_pipe.py +0 -288
- ngio/common/_axes_transforms.py +0 -64
- ngio/common/_common_types.py +0 -5
- ngio/common/_slicer.py +0 -96
- ngio-0.3.5.dist-info/RECORD +0 -61
- {ngio-0.3.5.dist-info → ngio-0.4.0a2.dist-info}/WHEEL +0 -0
- {ngio-0.3.5.dist-info → ngio-0.4.0a2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
from typing import Protocol, TypeAlias, TypeVar
|
|
3
|
+
|
|
4
|
+
import dask.array as da
|
|
5
|
+
import numpy as np
|
|
6
|
+
import zarr
|
|
7
|
+
from dask.array import Array as DaskArray
|
|
8
|
+
|
|
9
|
+
from ngio.common._dimensions import Dimensions
|
|
10
|
+
from ngio.ome_zarr_meta.ngio_specs import Axis, SlicingOps
|
|
11
|
+
from ngio.utils import NgioValueError
|
|
12
|
+
|
|
13
|
+
SlicingInputType: TypeAlias = slice | Sequence[int] | int | None
|
|
14
|
+
SlicingType: TypeAlias = slice | tuple[int, ...] | int
|
|
15
|
+
ArrayLike: TypeAlias = np.ndarray | DaskArray
|
|
16
|
+
|
|
17
|
+
##############################################################
|
|
18
|
+
#
|
|
19
|
+
# Slicing Operations
|
|
20
|
+
#
|
|
21
|
+
##############################################################
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_int(value: int, shape: int) -> int:
|
|
25
|
+
"""Validate an integer value for slicing."""
|
|
26
|
+
if not isinstance(value, int):
|
|
27
|
+
raise NgioValueError(f"Invalid value {value} of type {type(value)}")
|
|
28
|
+
if value < 0 or value >= shape:
|
|
29
|
+
raise NgioValueError(
|
|
30
|
+
f"Invalid value {value}. Index out of bounds for axis of shape {shape}"
|
|
31
|
+
)
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _try_to_slice(input: Sequence[int]) -> slice | tuple[int, ...]:
|
|
36
|
+
"""Try to convert a list of integers into a slice if they are contiguous.
|
|
37
|
+
|
|
38
|
+
- If the input is empty, return an empty tuple.
|
|
39
|
+
- If the input is sorted, and contains contiguous integers,
|
|
40
|
+
return a slice from the minimum to the maximum integer.
|
|
41
|
+
- Otherwise, return the input as a tuple.
|
|
42
|
+
|
|
43
|
+
This is useful for optimizing array slicing operations
|
|
44
|
+
by allowing the use of slices when possible, which can be more efficient.
|
|
45
|
+
"""
|
|
46
|
+
if not input:
|
|
47
|
+
return ()
|
|
48
|
+
|
|
49
|
+
# If the input is not sorted, return it as a tuple
|
|
50
|
+
max_input = max(input)
|
|
51
|
+
min_input = min(input)
|
|
52
|
+
assert min_input >= 0, "Input must contain non-negative integers"
|
|
53
|
+
assert max_input >= 0, "Input must contain non-negative integers"
|
|
54
|
+
|
|
55
|
+
if sorted(input) == list(range(min_input, max_input + 1)):
|
|
56
|
+
return slice(min_input, max_input + 1)
|
|
57
|
+
|
|
58
|
+
return tuple(input)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _validate_iter_of_ints(value: Sequence, shape: int) -> slice | tuple[int, ...]:
|
|
62
|
+
value = [_validate_int(v, shape=shape) for v in value]
|
|
63
|
+
return _try_to_slice(value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _validate_slice(value: slice, shape: int) -> slice:
|
|
67
|
+
"""Validate a slice object and return it with adjusted start and stop."""
|
|
68
|
+
start = value.start if value.start is not None else 0
|
|
69
|
+
start = max(start, 0)
|
|
70
|
+
stop = value.stop if value.stop is not None else shape
|
|
71
|
+
return slice(start, stop)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _remove_channel_slicing(
|
|
75
|
+
slicing_dict: dict[str, SlicingInputType],
|
|
76
|
+
dimensions: Dimensions,
|
|
77
|
+
) -> dict[str, SlicingInputType]:
|
|
78
|
+
"""This utility function removes the channel selection from the slice kwargs.
|
|
79
|
+
|
|
80
|
+
if ignore_channel_selection is True, it will remove the channel selection
|
|
81
|
+
regardless of the dimensions. If the ignore_channel_selection is False
|
|
82
|
+
it will fail.
|
|
83
|
+
"""
|
|
84
|
+
if dimensions.is_multi_channels:
|
|
85
|
+
return slicing_dict
|
|
86
|
+
|
|
87
|
+
if "c" in slicing_dict:
|
|
88
|
+
slicing_dict.pop("c", None)
|
|
89
|
+
return slicing_dict
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check_slicing_virtual_axes(slice_: SlicingInputType) -> bool:
|
|
93
|
+
"""Check if the slice_ is compatible with virtual axes.
|
|
94
|
+
|
|
95
|
+
Virtual axes are axes that are not present in the actual data,
|
|
96
|
+
such as time or channel axes in some datasets.
|
|
97
|
+
So the only valid slices for virtual axes are:
|
|
98
|
+
- None: means all data along the axis
|
|
99
|
+
- 0: means the first element along the axis
|
|
100
|
+
- slice([0, None], [1, None])
|
|
101
|
+
"""
|
|
102
|
+
if slice_ is None or slice_ == 0:
|
|
103
|
+
return True
|
|
104
|
+
if isinstance(slice_, slice):
|
|
105
|
+
if slice_.start is None and slice_.stop is None:
|
|
106
|
+
return True
|
|
107
|
+
if slice_.start == 0 and slice_.stop is None:
|
|
108
|
+
return True
|
|
109
|
+
if slice_.start is None and slice_.stop == 0:
|
|
110
|
+
return True
|
|
111
|
+
if slice_.start == 0 and slice_.stop == 1:
|
|
112
|
+
return True
|
|
113
|
+
if isinstance(slice_, Sequence):
|
|
114
|
+
if len(slice_) == 1 and slice_[0] == 0:
|
|
115
|
+
return True
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _normalize_slicing_dict(
|
|
120
|
+
dimensions: Dimensions,
|
|
121
|
+
slicing_dict: Mapping[str, SlicingInputType],
|
|
122
|
+
remove_channel_selection: bool = False,
|
|
123
|
+
) -> dict[str, SlicingInputType]:
|
|
124
|
+
"""Convert slice kwargs to the on-disk axes names."""
|
|
125
|
+
normalized_slicing_dict: dict[str, SlicingInputType] = {}
|
|
126
|
+
for axis_name, slice_ in slicing_dict.items():
|
|
127
|
+
axis = dimensions.axes_mapper.get_axis(axis_name)
|
|
128
|
+
if axis is None:
|
|
129
|
+
# Virtual axes should be allowed to be selected
|
|
130
|
+
# Common use case is still allowing channel_selection
|
|
131
|
+
# When the zarr has not channel axis.
|
|
132
|
+
if not _check_slicing_virtual_axes(slice_):
|
|
133
|
+
raise NgioValueError(
|
|
134
|
+
f"Invalid axis selection:{axis_name}={slice_}. "
|
|
135
|
+
f"Not found on the on-disk axes {dimensions.axes}."
|
|
136
|
+
)
|
|
137
|
+
# Virtual axes can be safely ignored
|
|
138
|
+
continue
|
|
139
|
+
on_disk_name = axis.on_disk_name
|
|
140
|
+
if on_disk_name in normalized_slicing_dict:
|
|
141
|
+
raise NgioValueError(
|
|
142
|
+
f"Duplicate axis {on_disk_name} in slice kwargs. "
|
|
143
|
+
"Please provide unique axis names."
|
|
144
|
+
)
|
|
145
|
+
normalized_slicing_dict[axis.on_disk_name] = slice_
|
|
146
|
+
|
|
147
|
+
if remove_channel_selection:
|
|
148
|
+
normalized_slicing_dict = _remove_channel_slicing(
|
|
149
|
+
slicing_dict=normalized_slicing_dict, dimensions=dimensions
|
|
150
|
+
)
|
|
151
|
+
return normalized_slicing_dict
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _normalize_axes_order(
|
|
155
|
+
dimensions: Dimensions,
|
|
156
|
+
axes_order: Sequence[str],
|
|
157
|
+
) -> list[str]:
|
|
158
|
+
"""Convert axes order to the on-disk axes names.
|
|
159
|
+
|
|
160
|
+
In this way there is not unambiguity in the axes order.
|
|
161
|
+
"""
|
|
162
|
+
new_axes_order = []
|
|
163
|
+
for axis_name in axes_order:
|
|
164
|
+
axis = dimensions.axes_mapper.get_axis(axis_name)
|
|
165
|
+
if axis is None:
|
|
166
|
+
new_axes_order.append(axis_name)
|
|
167
|
+
else:
|
|
168
|
+
new_axes_order.append(axis.on_disk_name)
|
|
169
|
+
return new_axes_order
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _normalize_slice_input(
|
|
173
|
+
axis: Axis,
|
|
174
|
+
slicing_dict: dict[str, SlicingInputType],
|
|
175
|
+
dimensions: Dimensions,
|
|
176
|
+
requires_axes_ops: bool,
|
|
177
|
+
axes_order: list[str],
|
|
178
|
+
) -> SlicingType:
|
|
179
|
+
"""Normalize a slice input to a tuple of slices.
|
|
180
|
+
|
|
181
|
+
Make sure that the slice is valid for the given axis and dimensions.
|
|
182
|
+
And transform it to either a slice or a tuple of integers.
|
|
183
|
+
If the axis is not present in the slicing_dict, return a full slice.
|
|
184
|
+
"""
|
|
185
|
+
axis_name = axis.on_disk_name
|
|
186
|
+
if axis_name not in slicing_dict:
|
|
187
|
+
# If no slice is provided for the axis, use a full slice
|
|
188
|
+
return slice(None)
|
|
189
|
+
|
|
190
|
+
value = slicing_dict[axis_name]
|
|
191
|
+
if value is None:
|
|
192
|
+
return slice(None)
|
|
193
|
+
|
|
194
|
+
shape = dimensions.get(axis_name, default=None)
|
|
195
|
+
if shape is None:
|
|
196
|
+
raise NgioValueError(f"Unknown dimension {axis_name} for axis {axis}.")
|
|
197
|
+
|
|
198
|
+
if isinstance(value, int):
|
|
199
|
+
value = _validate_int(value, shape)
|
|
200
|
+
if requires_axes_ops or axis_name in axes_order:
|
|
201
|
+
# Axes ops require all dimensions to be preserved
|
|
202
|
+
value = slice(value, value + 1)
|
|
203
|
+
return value
|
|
204
|
+
elif isinstance(value, Sequence):
|
|
205
|
+
return _validate_iter_of_ints(value, shape)
|
|
206
|
+
elif isinstance(value, slice):
|
|
207
|
+
return _validate_slice(value, shape)
|
|
208
|
+
|
|
209
|
+
raise NgioValueError(f"Invalid slice definition {value} of type {type(value)}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _build_slicing_tuple(
|
|
213
|
+
*,
|
|
214
|
+
dimensions: Dimensions,
|
|
215
|
+
slicing_dict: dict[str, SlicingInputType],
|
|
216
|
+
axes_order: list[str] | None = None,
|
|
217
|
+
requires_axes_ops: bool = False,
|
|
218
|
+
remove_channel_selection: bool = False,
|
|
219
|
+
) -> tuple[SlicingType, ...] | None:
|
|
220
|
+
"""Assemble slices to be used to query the array."""
|
|
221
|
+
if len(slicing_dict) == 0:
|
|
222
|
+
# Skip unnecessary computation if no slicing is requested
|
|
223
|
+
return None
|
|
224
|
+
_axes_order = (
|
|
225
|
+
_normalize_axes_order(dimensions=dimensions, axes_order=axes_order)
|
|
226
|
+
if axes_order is not None
|
|
227
|
+
else []
|
|
228
|
+
)
|
|
229
|
+
_slicing_dict = _normalize_slicing_dict(
|
|
230
|
+
dimensions=dimensions,
|
|
231
|
+
slicing_dict=slicing_dict,
|
|
232
|
+
remove_channel_selection=remove_channel_selection,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
slicing_tuple = tuple(
|
|
236
|
+
_normalize_slice_input(
|
|
237
|
+
axis=axis,
|
|
238
|
+
slicing_dict=_slicing_dict,
|
|
239
|
+
dimensions=dimensions,
|
|
240
|
+
requires_axes_ops=requires_axes_ops,
|
|
241
|
+
axes_order=_axes_order,
|
|
242
|
+
)
|
|
243
|
+
for axis in dimensions.axes_mapper.axes
|
|
244
|
+
)
|
|
245
|
+
return slicing_tuple
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def get_slice_as_numpy(
|
|
249
|
+
zarr_array: zarr.Array, slice_tuple: tuple[SlicingType, ...] | None
|
|
250
|
+
) -> np.ndarray:
|
|
251
|
+
if slice_tuple is None:
|
|
252
|
+
return zarr_array[...]
|
|
253
|
+
|
|
254
|
+
if all(not isinstance(s, tuple) for s in slice_tuple):
|
|
255
|
+
return zarr_array[slice_tuple]
|
|
256
|
+
|
|
257
|
+
# If there are tuple[int, ...] we need to handle them separately
|
|
258
|
+
# this is a workaround for the fact that zarr does not support
|
|
259
|
+
# non-contiguous slicing with tuples/lists.
|
|
260
|
+
first_slice_tuple = []
|
|
261
|
+
for s in slice_tuple:
|
|
262
|
+
if isinstance(s, tuple):
|
|
263
|
+
first_slice_tuple.append(slice(None))
|
|
264
|
+
else:
|
|
265
|
+
first_slice_tuple.append(s)
|
|
266
|
+
second_slice_tuple = []
|
|
267
|
+
for s in slice_tuple:
|
|
268
|
+
if isinstance(s, tuple):
|
|
269
|
+
second_slice_tuple.append(s)
|
|
270
|
+
else:
|
|
271
|
+
second_slice_tuple.append(slice(None))
|
|
272
|
+
|
|
273
|
+
return zarr_array[tuple(first_slice_tuple)][tuple(second_slice_tuple)]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def get_slice_as_dask(
|
|
277
|
+
zarr_array: zarr.Array, slice_tuple: tuple[SlicingType, ...] | None
|
|
278
|
+
) -> da.Array:
|
|
279
|
+
da_array = da.from_zarr(zarr_array)
|
|
280
|
+
if slice_tuple is None:
|
|
281
|
+
return da_array
|
|
282
|
+
|
|
283
|
+
if any(isinstance(s, tuple) for s in slice_tuple):
|
|
284
|
+
raise NotImplementedError(
|
|
285
|
+
"Slicing with non-contiguous tuples/lists "
|
|
286
|
+
"is not supported yet for Dask arrays. Use the "
|
|
287
|
+
"numpy api to get the correct array slice."
|
|
288
|
+
)
|
|
289
|
+
return da_array[slice_tuple]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def set_numpy_patch(
|
|
293
|
+
zarr_array: zarr.Array,
|
|
294
|
+
patch: np.ndarray,
|
|
295
|
+
slice_tuple: tuple[SlicingType, ...] | None,
|
|
296
|
+
) -> None:
|
|
297
|
+
if slice_tuple is None:
|
|
298
|
+
zarr_array[...] = patch
|
|
299
|
+
return
|
|
300
|
+
zarr_array[slice_tuple] = patch
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def set_dask_patch(
|
|
304
|
+
zarr_array: zarr.Array, patch: da.Array, slice_tuple: tuple[SlicingType, ...] | None
|
|
305
|
+
) -> None:
|
|
306
|
+
da.to_zarr(arr=patch, url=zarr_array, region=slice_tuple)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
##############################################################
|
|
310
|
+
#
|
|
311
|
+
# Array Axes Operations
|
|
312
|
+
#
|
|
313
|
+
##############################################################
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def apply_numpy_axes_ops(
|
|
317
|
+
array: np.ndarray,
|
|
318
|
+
squeeze_axes: tuple[int, ...] | None = None,
|
|
319
|
+
transpose_axes: tuple[int, ...] | None = None,
|
|
320
|
+
expand_axes: tuple[int, ...] | None = None,
|
|
321
|
+
) -> np.ndarray:
|
|
322
|
+
"""Apply axes operations to a numpy array."""
|
|
323
|
+
if squeeze_axes is not None:
|
|
324
|
+
array = np.squeeze(array, axis=squeeze_axes)
|
|
325
|
+
if transpose_axes is not None:
|
|
326
|
+
array = np.transpose(array, axes=transpose_axes)
|
|
327
|
+
if expand_axes is not None:
|
|
328
|
+
array = np.expand_dims(array, axis=expand_axes)
|
|
329
|
+
return array
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def apply_dask_axes_ops(
|
|
333
|
+
array: da.Array,
|
|
334
|
+
squeeze_axes: tuple[int, ...] | None = None,
|
|
335
|
+
transpose_axes: tuple[int, ...] | None = None,
|
|
336
|
+
expand_axes: tuple[int, ...] | None = None,
|
|
337
|
+
) -> da.Array:
|
|
338
|
+
"""Apply axes operations to a dask array."""
|
|
339
|
+
if squeeze_axes is not None:
|
|
340
|
+
array = da.squeeze(array, axis=squeeze_axes)
|
|
341
|
+
if transpose_axes is not None:
|
|
342
|
+
array = da.transpose(array, axes=transpose_axes)
|
|
343
|
+
if expand_axes is not None:
|
|
344
|
+
array = da.expand_dims(array, axis=expand_axes)
|
|
345
|
+
return array
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
T = TypeVar("T")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def apply_sequence_axes_ops(
|
|
352
|
+
input_: Sequence[T],
|
|
353
|
+
default: T,
|
|
354
|
+
squeeze_axes: tuple[int, ...] | None = None,
|
|
355
|
+
transpose_axes: tuple[int, ...] | None = None,
|
|
356
|
+
expand_axes: tuple[int, ...] | None = None,
|
|
357
|
+
) -> list[T]:
|
|
358
|
+
input_list = list(input_)
|
|
359
|
+
if squeeze_axes is not None:
|
|
360
|
+
for offset, ax in enumerate(squeeze_axes):
|
|
361
|
+
input_list.pop(ax - offset)
|
|
362
|
+
|
|
363
|
+
if transpose_axes is not None:
|
|
364
|
+
input_list = [input_list[i] for i in transpose_axes]
|
|
365
|
+
|
|
366
|
+
if expand_axes is not None:
|
|
367
|
+
for ax in expand_axes:
|
|
368
|
+
input_list.insert(ax, default)
|
|
369
|
+
|
|
370
|
+
return input_list
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
#############################################################
|
|
374
|
+
#
|
|
375
|
+
# Transform Protocol
|
|
376
|
+
#
|
|
377
|
+
#############################################################
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class TransformProtocol(Protocol):
|
|
381
|
+
"""Protocol for a generic transform."""
|
|
382
|
+
|
|
383
|
+
def apply_numpy_transform(
|
|
384
|
+
self, array: np.ndarray, slicing_ops: SlicingOps
|
|
385
|
+
) -> np.ndarray:
|
|
386
|
+
"""A transformation to be applied after loading a numpy array."""
|
|
387
|
+
...
|
|
388
|
+
|
|
389
|
+
def apply_dask_transform(
|
|
390
|
+
self, array: da.Array, slicing_ops: SlicingOps
|
|
391
|
+
) -> da.Array:
|
|
392
|
+
"""A transformation to be applied after loading a dask array."""
|
|
393
|
+
...
|
|
394
|
+
|
|
395
|
+
def apply_inverse_numpy_transform(
|
|
396
|
+
self, array: np.ndarray, slicing_ops: SlicingOps
|
|
397
|
+
) -> np.ndarray:
|
|
398
|
+
"""A transformation to be applied before writing a numpy array."""
|
|
399
|
+
...
|
|
400
|
+
|
|
401
|
+
def apply_inverse_dask_transform(
|
|
402
|
+
self, array: da.Array, slicing_ops: SlicingOps
|
|
403
|
+
) -> da.Array:
|
|
404
|
+
"""A transformation to be applied before writing a dask array."""
|
|
405
|
+
...
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def apply_numpy_transforms(
|
|
409
|
+
array: np.ndarray,
|
|
410
|
+
slicing_ops: SlicingOps,
|
|
411
|
+
transforms: Sequence[TransformProtocol] | None = None,
|
|
412
|
+
) -> np.ndarray:
|
|
413
|
+
"""Apply a numpy transform to an array."""
|
|
414
|
+
if transforms is None:
|
|
415
|
+
return array
|
|
416
|
+
|
|
417
|
+
for transform in transforms:
|
|
418
|
+
array = transform.apply_numpy_transform(array, slicing_ops=slicing_ops)
|
|
419
|
+
return array
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def apply_dask_transforms(
|
|
423
|
+
array: da.Array,
|
|
424
|
+
slicing_ops: SlicingOps,
|
|
425
|
+
transforms: Sequence[TransformProtocol] | None = None,
|
|
426
|
+
) -> da.Array:
|
|
427
|
+
"""Apply a dask transform to an array."""
|
|
428
|
+
if transforms is None:
|
|
429
|
+
return array
|
|
430
|
+
|
|
431
|
+
for transform in transforms:
|
|
432
|
+
array = transform.apply_dask_transform(array, slicing_ops=slicing_ops)
|
|
433
|
+
return array
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def apply_inverse_numpy_transforms(
|
|
437
|
+
array: np.ndarray,
|
|
438
|
+
slicing_ops: SlicingOps,
|
|
439
|
+
transforms: Sequence[TransformProtocol] | None = None,
|
|
440
|
+
) -> np.ndarray:
|
|
441
|
+
"""Apply inverse numpy transforms to an array."""
|
|
442
|
+
if transforms is None:
|
|
443
|
+
return array
|
|
444
|
+
|
|
445
|
+
for transform in transforms:
|
|
446
|
+
array = transform.apply_inverse_numpy_transform(array, slicing_ops=slicing_ops)
|
|
447
|
+
return array
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def apply_inverse_dask_transforms(
|
|
451
|
+
array: da.Array,
|
|
452
|
+
slicing_ops: SlicingOps,
|
|
453
|
+
transforms: Sequence[TransformProtocol] | None = None,
|
|
454
|
+
) -> da.Array:
|
|
455
|
+
"""Apply inverse dask transforms to an array."""
|
|
456
|
+
if transforms is None:
|
|
457
|
+
return array
|
|
458
|
+
|
|
459
|
+
for transform in transforms:
|
|
460
|
+
array = transform.apply_inverse_dask_transform(array, slicing_ops=slicing_ops)
|
|
461
|
+
return array
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def setup_from_disk_pipe(
|
|
465
|
+
*,
|
|
466
|
+
dimensions: Dimensions,
|
|
467
|
+
slicing_dict: dict[str, SlicingInputType],
|
|
468
|
+
axes_order: Sequence[str] | None = None,
|
|
469
|
+
remove_channel_selection: bool = False,
|
|
470
|
+
) -> SlicingOps:
|
|
471
|
+
if axes_order is not None:
|
|
472
|
+
axes_order = _normalize_axes_order(dimensions=dimensions, axes_order=axes_order)
|
|
473
|
+
slicing_ops = dimensions.axes_mapper.to_order(axes_order)
|
|
474
|
+
else:
|
|
475
|
+
slicing_ops = SlicingOps()
|
|
476
|
+
|
|
477
|
+
slicing_tuple = _build_slicing_tuple(
|
|
478
|
+
dimensions=dimensions,
|
|
479
|
+
slicing_dict=slicing_dict,
|
|
480
|
+
axes_order=axes_order,
|
|
481
|
+
requires_axes_ops=slicing_ops.requires_axes_ops,
|
|
482
|
+
remove_channel_selection=remove_channel_selection,
|
|
483
|
+
)
|
|
484
|
+
slicing_ops.slice_tuple = slicing_tuple
|
|
485
|
+
return slicing_ops
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def setup_to_disk_pipe(
|
|
489
|
+
*,
|
|
490
|
+
dimensions: Dimensions,
|
|
491
|
+
slicing_dict: dict[str, SlicingInputType],
|
|
492
|
+
axes_order: Sequence[str] | None = None,
|
|
493
|
+
remove_channel_selection: bool = False,
|
|
494
|
+
) -> SlicingOps:
|
|
495
|
+
if axes_order is not None:
|
|
496
|
+
axes_order = _normalize_axes_order(dimensions=dimensions, axes_order=axes_order)
|
|
497
|
+
slicing_ops = dimensions.axes_mapper.from_order(axes_order)
|
|
498
|
+
else:
|
|
499
|
+
slicing_ops = SlicingOps()
|
|
500
|
+
|
|
501
|
+
slicing_tuple = _build_slicing_tuple(
|
|
502
|
+
dimensions=dimensions,
|
|
503
|
+
slicing_dict=slicing_dict,
|
|
504
|
+
requires_axes_ops=slicing_ops.requires_axes_ops,
|
|
505
|
+
remove_channel_selection=remove_channel_selection,
|
|
506
|
+
)
|
|
507
|
+
slicing_ops.slice_tuple = slicing_tuple
|
|
508
|
+
return slicing_ops
|
ngio/common/_dimensions.py
CHANGED
|
@@ -4,11 +4,11 @@ This is not related to the NGFF metadata,
|
|
|
4
4
|
but it is based on the actual metadata of the image data.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from
|
|
7
|
+
from typing import overload
|
|
8
8
|
|
|
9
|
-
from ngio.common._axes_transforms import transform_list
|
|
10
9
|
from ngio.ome_zarr_meta import AxesMapper
|
|
11
|
-
from ngio.
|
|
10
|
+
from ngio.ome_zarr_meta.ngio_specs import AxisType
|
|
11
|
+
from ngio.utils import NgioValueError
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class Dimensions:
|
|
@@ -28,10 +28,10 @@ class Dimensions:
|
|
|
28
28
|
self._shape = shape
|
|
29
29
|
self._axes_mapper = axes_mapper
|
|
30
30
|
|
|
31
|
-
if len(self._shape) != len(self._axes_mapper.
|
|
32
|
-
raise
|
|
31
|
+
if len(self._shape) != len(self._axes_mapper.axes):
|
|
32
|
+
raise NgioValueError(
|
|
33
33
|
"The number of dimensions must match the number of axes. "
|
|
34
|
-
f"Expected Axis {self._axes_mapper.
|
|
34
|
+
f"Expected Axis {self._axes_mapper.axes_names} but got shape "
|
|
35
35
|
f"{self._shape}."
|
|
36
36
|
)
|
|
37
37
|
|
|
@@ -39,24 +39,38 @@ class Dimensions:
|
|
|
39
39
|
"""Return the string representation of the object."""
|
|
40
40
|
dims = ", ".join(
|
|
41
41
|
f"{ax.on_disk_name}: {s}"
|
|
42
|
-
for ax, s in zip(self._axes_mapper.
|
|
42
|
+
for ax, s in zip(self._axes_mapper.axes, self._shape, strict=True)
|
|
43
43
|
)
|
|
44
44
|
return f"Dimensions({dims})"
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
@overload
|
|
47
|
+
def get(self, axis_name: str, default: None = None) -> int | None:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
@overload
|
|
51
|
+
def get(self, axis_name: str, default: int) -> int:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
def get(self, axis_name: str, default: int | None = None) -> int | None:
|
|
47
55
|
"""Return the dimension of the given axis name.
|
|
48
56
|
|
|
49
57
|
Args:
|
|
50
58
|
axis_name: The name of the axis (either canonical or non-canonical).
|
|
51
|
-
|
|
59
|
+
default: The default value to return if the axis does not exist.
|
|
52
60
|
"""
|
|
53
61
|
index = self._axes_mapper.get_index(axis_name)
|
|
54
|
-
if index is None
|
|
55
|
-
|
|
56
|
-
elif index is None:
|
|
57
|
-
return 1
|
|
62
|
+
if index is None:
|
|
63
|
+
return default
|
|
58
64
|
return self._shape[index]
|
|
59
65
|
|
|
66
|
+
def get_index(self, axis_name: str) -> int | None:
|
|
67
|
+
"""Return the index of the given axis name.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
axis_name: The name of the axis (either canonical or non-canonical).
|
|
71
|
+
"""
|
|
72
|
+
return self._axes_mapper.get_index(axis_name)
|
|
73
|
+
|
|
60
74
|
def has_axis(self, axis_name: str) -> bool:
|
|
61
75
|
"""Return whether the axis exists."""
|
|
62
76
|
index = self._axes_mapper.get_axis(axis_name)
|
|
@@ -64,36 +78,36 @@ class Dimensions:
|
|
|
64
78
|
return False
|
|
65
79
|
return True
|
|
66
80
|
|
|
67
|
-
def get_shape(self, axes_order: Collection[str]) -> tuple[int, ...]:
|
|
68
|
-
"""Return the shape in the given axes order."""
|
|
69
|
-
transforms = self._axes_mapper.to_order(axes_order)
|
|
70
|
-
return tuple(transform_list(list(self._shape), 1, transforms))
|
|
71
|
-
|
|
72
|
-
def get_canonical_shape(self) -> tuple[int, ...]:
|
|
73
|
-
"""Return the shape in the canonical order."""
|
|
74
|
-
transforms = self._axes_mapper.to_canonical()
|
|
75
|
-
return tuple(transform_list(list(self._shape), 1, transforms))
|
|
76
|
-
|
|
77
81
|
def __repr__(self) -> str:
|
|
78
82
|
"""Return the string representation of the object."""
|
|
79
83
|
return str(self)
|
|
80
84
|
|
|
81
85
|
@property
|
|
82
|
-
def
|
|
86
|
+
def axes_mapper(self) -> AxesMapper:
|
|
87
|
+
"""Return the axes mapper object."""
|
|
88
|
+
return self._axes_mapper
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def shape(self) -> tuple[int, ...]:
|
|
83
92
|
"""Return the shape as a tuple."""
|
|
84
93
|
return tuple(self._shape)
|
|
85
94
|
|
|
95
|
+
@property
|
|
96
|
+
def axes(self) -> tuple[str, ...]:
|
|
97
|
+
"""Return the axes as a tuple of strings."""
|
|
98
|
+
return self._axes_mapper.axes_names
|
|
99
|
+
|
|
86
100
|
@property
|
|
87
101
|
def is_time_series(self) -> bool:
|
|
88
102
|
"""Return whether the data is a time series."""
|
|
89
|
-
if self.get("t",
|
|
103
|
+
if self.get("t", default=1) == 1:
|
|
90
104
|
return False
|
|
91
105
|
return True
|
|
92
106
|
|
|
93
107
|
@property
|
|
94
108
|
def is_2d(self) -> bool:
|
|
95
109
|
"""Return whether the data is 2D."""
|
|
96
|
-
if self.get("z",
|
|
110
|
+
if self.get("z", default=1) != 1:
|
|
97
111
|
return False
|
|
98
112
|
return True
|
|
99
113
|
|
|
@@ -115,6 +129,28 @@ class Dimensions:
|
|
|
115
129
|
@property
|
|
116
130
|
def is_multi_channels(self) -> bool:
|
|
117
131
|
"""Return whether the data has multiple channels."""
|
|
118
|
-
if self.get("c",
|
|
132
|
+
if self.get("c", default=1) == 1:
|
|
133
|
+
return False
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
def is_compatible_with(self, other: "Dimensions") -> bool:
|
|
137
|
+
"""Check if the dimensions are compatible with another Dimensions object.
|
|
138
|
+
|
|
139
|
+
Two dimensions are compatible if:
|
|
140
|
+
- they have the same number of axes (excluding channels)
|
|
141
|
+
- the shape of each axis is the same
|
|
142
|
+
"""
|
|
143
|
+
if abs(len(self.shape) - len(other.shape)) > 1:
|
|
144
|
+
# Since channels are not considered in compatibility
|
|
145
|
+
# we allow a difference of 0, 1 n-dimension in the shapes.
|
|
119
146
|
return False
|
|
147
|
+
|
|
148
|
+
for ax in self._axes_mapper.axes:
|
|
149
|
+
if ax.axis_type == AxisType.channel:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
self_shape = self.get(ax.on_disk_name, default=None)
|
|
153
|
+
other_shape = other.get(ax.on_disk_name, default=None)
|
|
154
|
+
if self_shape != other_shape:
|
|
155
|
+
return False
|
|
120
156
|
return True
|