ngio 0.3.4__py3-none-any.whl → 0.4.0a1__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.
Files changed (61) hide show
  1. ngio/__init__.py +6 -0
  2. ngio/common/__init__.py +50 -48
  3. ngio/common/_array_io_pipes.py +549 -0
  4. ngio/common/_array_io_utils.py +508 -0
  5. ngio/common/_dimensions.py +63 -27
  6. ngio/common/_masking_roi.py +38 -10
  7. ngio/common/_pyramid.py +9 -7
  8. ngio/common/_roi.py +571 -72
  9. ngio/common/_synt_images_utils.py +101 -0
  10. ngio/common/_zoom.py +17 -12
  11. ngio/common/transforms/__init__.py +5 -0
  12. ngio/common/transforms/_label.py +12 -0
  13. ngio/common/transforms/_zoom.py +109 -0
  14. ngio/experimental/__init__.py +5 -0
  15. ngio/experimental/iterators/__init__.py +17 -0
  16. ngio/experimental/iterators/_abstract_iterator.py +170 -0
  17. ngio/experimental/iterators/_feature.py +151 -0
  18. ngio/experimental/iterators/_image_processing.py +169 -0
  19. ngio/experimental/iterators/_rois_utils.py +127 -0
  20. ngio/experimental/iterators/_segmentation.py +278 -0
  21. ngio/hcs/_plate.py +41 -36
  22. ngio/images/__init__.py +22 -1
  23. ngio/images/_abstract_image.py +247 -117
  24. ngio/images/_create.py +15 -15
  25. ngio/images/_create_synt_container.py +128 -0
  26. ngio/images/_image.py +425 -62
  27. ngio/images/_label.py +33 -30
  28. ngio/images/_masked_image.py +396 -122
  29. ngio/images/_ome_zarr_container.py +203 -66
  30. ngio/{common → images}/_table_ops.py +41 -41
  31. ngio/ome_zarr_meta/ngio_specs/__init__.py +2 -8
  32. ngio/ome_zarr_meta/ngio_specs/_axes.py +151 -128
  33. ngio/ome_zarr_meta/ngio_specs/_channels.py +55 -18
  34. ngio/ome_zarr_meta/ngio_specs/_dataset.py +7 -7
  35. ngio/ome_zarr_meta/ngio_specs/_ngio_hcs.py +6 -15
  36. ngio/ome_zarr_meta/ngio_specs/_ngio_image.py +11 -68
  37. ngio/ome_zarr_meta/v04/_v04_spec_utils.py +1 -1
  38. ngio/resources/20200812-CardiomyocyteDifferentiation14-Cycle1_B03/mask.png +0 -0
  39. ngio/resources/20200812-CardiomyocyteDifferentiation14-Cycle1_B03/nuclei.png +0 -0
  40. ngio/resources/20200812-CardiomyocyteDifferentiation14-Cycle1_B03/raw.jpg +0 -0
  41. ngio/resources/__init__.py +54 -0
  42. ngio/resources/resource_model.py +35 -0
  43. ngio/tables/backends/_abstract_backend.py +5 -6
  44. ngio/tables/backends/_anndata.py +1 -2
  45. ngio/tables/backends/_anndata_utils.py +3 -3
  46. ngio/tables/backends/_non_zarr_backends.py +1 -1
  47. ngio/tables/backends/_table_backends.py +0 -1
  48. ngio/tables/backends/_utils.py +3 -3
  49. ngio/tables/v1/_roi_table.py +156 -69
  50. ngio/utils/__init__.py +2 -3
  51. ngio/utils/_logger.py +19 -0
  52. ngio/utils/_zarr_utils.py +1 -5
  53. {ngio-0.3.4.dist-info → ngio-0.4.0a1.dist-info}/METADATA +12 -10
  54. ngio-0.4.0a1.dist-info/RECORD +76 -0
  55. ngio/common/_array_pipe.py +0 -288
  56. ngio/common/_axes_transforms.py +0 -64
  57. ngio/common/_common_types.py +0 -5
  58. ngio/common/_slicer.py +0 -96
  59. ngio-0.3.4.dist-info/RECORD +0 -61
  60. {ngio-0.3.4.dist-info → ngio-0.4.0a1.dist-info}/WHEEL +0 -0
  61. {ngio-0.3.4.dist-info → ngio-0.4.0a1.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
@@ -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 collections.abc import Collection
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.utils import NgioValidationError, NgioValueError
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.on_disk_axes):
32
- raise NgioValidationError(
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.on_disk_axes_names} but got shape "
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.on_disk_axes, self._shape, strict=True)
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
- def get(self, axis_name: str, strict: bool = True) -> int:
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
- strict: If True, raise an error if the axis does not exist.
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 and strict:
55
- raise NgioValueError(f"Axis {axis_name} does not exist.")
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 on_disk_shape(self) -> tuple[int, ...]:
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", strict=False) == 1:
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", strict=False) != 1:
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", strict=False) == 1:
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