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
@@ -1,15 +1,16 @@
1
1
  """Fractal internal module for axes handling."""
2
2
 
3
- from collections.abc import Collection
3
+ from collections.abc import Sequence
4
4
  from enum import Enum
5
- from typing import Literal, TypeVar
5
+ from typing import Literal, TypeAlias, TypeVar
6
6
 
7
7
  import numpy as np
8
8
  from pydantic import BaseModel, ConfigDict, Field
9
9
 
10
- from ngio.utils import NgioValidationError, NgioValueError, ngio_logger
10
+ from ngio.utils import NgioValidationError, NgioValueError
11
11
 
12
12
  T = TypeVar("T")
13
+ SlicingType: TypeAlias = slice | tuple[int, ...] | int
13
14
 
14
15
  ################################################################################################
15
16
  #
@@ -98,24 +99,10 @@ class Axis(BaseModel):
98
99
 
99
100
  def implicit_type_cast(self, cast_type: AxisType) -> "Axis":
100
101
  unit = self.unit
101
- if self.axis_type != cast_type:
102
- ngio_logger.warning(
103
- f"Axis {self.on_disk_name} has type {self.axis_type}. "
104
- f"Casting to {cast_type}."
105
- )
106
-
107
102
  if cast_type == AxisType.time and unit is None:
108
- ngio_logger.warning(
109
- f"Time axis {self.on_disk_name} has unit {self.unit}. "
110
- f"Casting to {DefaultSpaceUnit}."
111
- )
112
103
  unit = DefaultTimeUnit
113
104
 
114
105
  if cast_type == AxisType.space and unit is None:
115
- ngio_logger.warning(
116
- f"Space axis {self.on_disk_name} has unit {unit}. "
117
- f"Casting to {DefaultSpaceUnit}."
118
- )
119
106
  unit = DefaultSpaceUnit
120
107
 
121
108
  return Axis(on_disk_name=self.on_disk_name, axis_type=cast_type, unit=unit)
@@ -170,8 +157,28 @@ class AxesSetup(BaseModel):
170
157
 
171
158
  model_config = ConfigDict(extra="forbid", frozen=True)
172
159
 
173
-
174
- def _check_unique_names(axes: Collection[Axis]):
160
+ def canonical_map(self) -> dict[str, str]:
161
+ """Get the canonical map of axes."""
162
+ return {
163
+ "t": self.t,
164
+ "c": self.c,
165
+ "z": self.z,
166
+ "y": self.y,
167
+ "x": self.x,
168
+ }
169
+
170
+ def inverse_canonical_map(self) -> dict[str, str]:
171
+ """Get the on disk map of axes."""
172
+ return {
173
+ self.t: "t",
174
+ self.c: "c",
175
+ self.z: "z",
176
+ self.y: "y",
177
+ self.x: "x",
178
+ }
179
+
180
+
181
+ def _check_unique_names(axes: Sequence[Axis]):
175
182
  """Check if all axes on disk have unique names."""
176
183
  names = [ax.on_disk_name for ax in axes]
177
184
  if len(set(names)) != len(names):
@@ -190,7 +197,7 @@ def _check_non_canonical_axes(axes_setup: AxesSetup, allow_non_canonical_axes: b
190
197
  )
191
198
 
192
199
 
193
- def _check_axes_validity(axes: Collection[Axis], axes_setup: AxesSetup):
200
+ def _check_axes_validity(axes: Sequence[Axis], axes_setup: AxesSetup):
194
201
  """Check if all axes are valid."""
195
202
  _axes_setup = axes_setup.model_dump(exclude={"others"})
196
203
  _all_known_axes = [*_axes_setup.values(), *axes_setup.others]
@@ -204,7 +211,7 @@ def _check_axes_validity(axes: Collection[Axis], axes_setup: AxesSetup):
204
211
 
205
212
 
206
213
  def _check_canonical_order(
207
- axes: Collection[Axis], axes_setup: AxesSetup, strict_canonical_order: bool
214
+ axes: Sequence[Axis], axes_setup: AxesSetup, strict_canonical_order: bool
208
215
  ):
209
216
  """Check if the axes are in the canonical order."""
210
217
  if not strict_canonical_order:
@@ -224,7 +231,7 @@ def _check_canonical_order(
224
231
 
225
232
 
226
233
  def validate_axes(
227
- axes: Collection[Axis],
234
+ axes: Sequence[Axis],
228
235
  axes_setup: AxesSetup,
229
236
  allow_non_canonical_axes: bool = False,
230
237
  strict_canonical_order: bool = False,
@@ -246,20 +253,19 @@ def validate_axes(
246
253
  )
247
254
 
248
255
 
249
- class AxesTransformation(BaseModel):
250
- model_config = ConfigDict(extra="forbid", frozen=True, arbitrary_types_allowed=True)
251
-
252
-
253
- class AxesTranspose(AxesTransformation):
254
- axes: tuple[int, ...]
255
-
256
-
257
- class AxesExpand(AxesTransformation):
258
- axes: tuple[int, ...]
256
+ class SlicingOps(BaseModel):
257
+ slice_tuple: tuple[SlicingType, ...] | None = None
258
+ transpose_axes: tuple[int, ...] | None = None
259
+ expand_axes: tuple[int, ...] | None = None
260
+ squeeze_axes: tuple[int, ...] | None = None
261
+ model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
259
262
 
260
-
261
- class AxesSqueeze(AxesTransformation):
262
- axes: tuple[int, ...]
263
+ @property
264
+ def requires_axes_ops(self) -> bool:
265
+ """Check if the slicing operations require axes operations."""
266
+ if self.expand_axes or self.transpose_axes or self.squeeze_axes:
267
+ return True
268
+ return False
263
269
 
264
270
 
265
271
  class AxesMapper:
@@ -272,7 +278,7 @@ class AxesMapper:
272
278
  def __init__(
273
279
  self,
274
280
  # spec dictated args
275
- on_disk_axes: Collection[Axis],
281
+ on_disk_axes: Sequence[Axis],
276
282
  # user defined args
277
283
  axes_setup: AxesSetup | None = None,
278
284
  allow_non_canonical_axes: bool = False,
@@ -300,56 +306,42 @@ class AxesMapper:
300
306
  self._strict_canonical_order = strict_canonical_order
301
307
 
302
308
  self._canonical_order = canonical_axes_order()
303
- self._extended_canonical_order = [*axes_setup.others, *self._canonical_order]
304
309
 
305
310
  self._on_disk_axes = on_disk_axes
306
311
  self._axes_setup = axes_setup
307
312
 
308
- self._name_mapping = self._compute_name_mapping()
309
313
  self._index_mapping = self._compute_index_mapping()
310
314
 
311
315
  # Validate the axes type and cast them if necessary
312
316
  # This needs to be done after the name mapping is computed
313
- self.validate_axex_type()
314
-
315
- def _compute_name_mapping(self):
316
- """Compute the name mapping.
317
-
318
- The name mapping is a dictionary with keys the canonical axes names
319
- and values the on disk axes names.
320
- """
321
- _name_mapping = {}
322
- axis_setup_dict = self._axes_setup.model_dump(exclude={"others"})
323
- _on_disk_names = self.on_disk_axes_names
324
- for canonical_key, on_disk_value in axis_setup_dict.items():
325
- if on_disk_value in _on_disk_names:
326
- _name_mapping[canonical_key] = on_disk_value
327
- else:
328
- _name_mapping[canonical_key] = None
329
-
330
- for on_disk_name in _on_disk_names:
331
- if on_disk_name not in _name_mapping.keys():
332
- _name_mapping[on_disk_name] = on_disk_name
333
-
334
- for other in self._axes_setup.others:
335
- if other not in _name_mapping.keys():
336
- _name_mapping[other] = None
337
- return _name_mapping
317
+ self.validate_axes_type()
338
318
 
339
319
  def _compute_index_mapping(self):
340
320
  """Compute the index mapping.
341
321
 
342
322
  The index mapping is a dictionary with keys the canonical axes names
343
323
  and values the on disk axes index.
324
+
325
+ Example:
326
+ If the on disk axes are ['channel', 't', 'z', 'y', 'x'],
327
+ the index mapping will be:
328
+ {
329
+ 'c': 0,
330
+ 'channel': 0,
331
+ 't': 1,
332
+ 'z': 2,
333
+ 'y': 3,
334
+ 'x': 4,
335
+ }
344
336
  """
345
337
  _index_mapping = {}
346
- for canonical_key, on_disk_value in self._name_mapping.items():
347
- if on_disk_value is not None:
348
- _index_mapping[canonical_key] = self.on_disk_axes_names.index(
349
- on_disk_value
350
- )
351
- else:
352
- _index_mapping[canonical_key] = None
338
+ for i, ax in enumerate(self.axes_names):
339
+ _index_mapping[ax] = i
340
+ # If the axis is not in the canonical order we also set it.
341
+ canonical_map = self._axes_setup.canonical_map()
342
+ for canonical_key, on_disk_value in canonical_map.items():
343
+ if on_disk_value in _index_mapping.keys():
344
+ _index_mapping[canonical_key] = _index_mapping[on_disk_value]
353
345
  return _index_mapping
354
346
 
355
347
  @property
@@ -358,12 +350,12 @@ class AxesMapper:
358
350
  return self._axes_setup
359
351
 
360
352
  @property
361
- def on_disk_axes(self) -> list[Axis]:
362
- return list(self._on_disk_axes)
353
+ def axes(self) -> tuple[Axis, ...]:
354
+ return tuple(self._on_disk_axes)
363
355
 
364
356
  @property
365
- def on_disk_axes_names(self) -> list[str]:
366
- return [ax.on_disk_name for ax in self._on_disk_axes]
357
+ def axes_names(self) -> tuple[str, ...]:
358
+ return tuple(ax.on_disk_name for ax in self._on_disk_axes)
367
359
 
368
360
  @property
369
361
  def allow_non_canonical_axes(self) -> bool:
@@ -377,28 +369,23 @@ class AxesMapper:
377
369
 
378
370
  def get_index(self, name: str) -> int | None:
379
371
  """Get the index of the axis by name."""
380
- if name not in self._index_mapping.keys():
381
- raise NgioValueError(
382
- f"Invalid axis name '{name}'. "
383
- f"Possible values are {self._index_mapping.keys()}"
384
- )
385
- return self._index_mapping[name]
372
+ return self._index_mapping.get(name, None)
386
373
 
387
374
  def get_axis(self, name: str) -> Axis | None:
388
375
  """Get the axis object by name."""
389
376
  index = self.get_index(name)
390
377
  if index is None:
391
378
  return None
392
- return self.on_disk_axes[index]
379
+ return self.axes[index]
393
380
 
394
- def validate_axex_type(self):
381
+ def validate_axes_type(self):
395
382
  """Validate the axes type.
396
383
 
397
384
  If the axes type is not correct, a warning is issued.
398
385
  and the axis is implicitly cast to the correct type.
399
386
  """
400
387
  new_axes = []
401
- for axes in self.on_disk_axes:
388
+ for axes in self.axes:
402
389
  for name in self._canonical_order:
403
390
  if axes == self.get_axis(name):
404
391
  new_axes.append(axes.canonical_axis_cast(name))
@@ -407,71 +394,107 @@ class AxesMapper:
407
394
  new_axes.append(axes)
408
395
  self._on_disk_axes = new_axes
409
396
 
410
- def _change_order(
411
- self, names: Collection[str]
412
- ) -> tuple[tuple[int, ...], tuple[int, ...]]:
413
- unique_names = set()
397
+ def _reorder_axes(
398
+ self, names: Sequence[str]
399
+ ) -> tuple[tuple[int, ...] | None, tuple[int, ...] | None, tuple[int, ...] | None]:
400
+ """Change the order of the axes."""
401
+ # Validate the names
402
+ unique_names = set(names)
403
+ if len(unique_names) != len(names):
404
+ raise NgioValueError(
405
+ "Duplicate axis names found. Please provide unique names for each axis."
406
+ )
414
407
  for name in names:
415
- if name not in self._index_mapping.keys():
408
+ if not isinstance(name, str):
416
409
  raise NgioValueError(
417
- f"Invalid axis name '{name}'. "
418
- f"Possible values are {self._index_mapping.keys()}"
410
+ f"Invalid axis name '{name}'. Axis names must be strings."
419
411
  )
420
- _unique_name = self._name_mapping.get(name)
421
- if _unique_name is None:
422
- continue
423
- if _unique_name in unique_names:
424
- raise NgioValueError(
425
- f"Duplicate axis name, two or more '{_unique_name}' were found. "
426
- f"Please provide unique names."
427
- )
428
- unique_names.add(_unique_name)
429
-
430
- if len(self.on_disk_axes_names) > len(unique_names):
431
- missing_names = set(self.on_disk_axes_names) - unique_names
432
- raise NgioValueError(
433
- f"Some axes where not queried. "
434
- f"Please provide the following missing axes {missing_names}"
435
- )
436
- _indices, _insert = [], []
412
+ inv_canonical_map = self.axes_setup.inverse_canonical_map()
413
+
414
+ # Step 1: Check find squeeze axes
415
+ _axes_to_squeeze: list[int] = []
416
+ axes_names_after_squeeze = []
417
+ for i, ax in enumerate(self.axes_names):
418
+ # If the axis is not in the names, it means we need to squeeze it
419
+ ax_canonical = inv_canonical_map.get(ax, None)
420
+ if ax not in names and ax_canonical not in names:
421
+ _axes_to_squeeze.append(i)
422
+ elif ax in names:
423
+ axes_names_after_squeeze.append(ax)
424
+ elif ax_canonical in names:
425
+ # If the axis is in the canonical map, we add it to the names
426
+ axes_names_after_squeeze.append(ax_canonical)
427
+
428
+ axes_to_squeeze = tuple(_axes_to_squeeze) if len(_axes_to_squeeze) > 0 else None
429
+
430
+ # Step 2: Find the transposition order
431
+ _transposition_order: list[int] = []
432
+ axes_names_after_transpose = []
433
+ for ax in names:
434
+ if ax in axes_names_after_squeeze:
435
+ _transposition_order.append(axes_names_after_squeeze.index(ax))
436
+ axes_names_after_transpose.append(ax)
437
+
438
+ if np.allclose(_transposition_order, range(len(_transposition_order))):
439
+ # If the transposition order is the identity, we don't need to transpose
440
+ transposition_order = None
441
+ else:
442
+ transposition_order = tuple(_transposition_order)
443
+
444
+ # Step 3: Find axes to expand
445
+ _axes_to_expand: list[int] = []
437
446
  for i, name in enumerate(names):
438
- _index = self._index_mapping[name]
439
- if _index is None:
440
- _insert.append(i)
441
- else:
442
- _indices.append(self._index_mapping[name])
443
- return tuple(_indices), tuple(_insert)
447
+ if name not in axes_names_after_transpose:
448
+ # If the axis is not in the mapping, it means we need to expand it
449
+ _axes_to_expand.append(i)
450
+
451
+ axes_to_expand = tuple(_axes_to_expand) if len(_axes_to_expand) > 0 else None
452
+ return axes_to_squeeze, transposition_order, axes_to_expand
444
453
 
445
- def to_order(self, names: Collection[str]) -> tuple[AxesTransformation, ...]:
454
+ def to_order(self, names: Sequence[str]) -> SlicingOps:
446
455
  """Get the new order of the axes."""
447
- _indices, _insert = self._change_order(names)
448
- return AxesTranspose(axes=_indices), AxesExpand(axes=_insert)
456
+ axes_to_squeeze, transposition_order, axes_to_expand = self._reorder_axes(names)
457
+ return SlicingOps(
458
+ transpose_axes=transposition_order,
459
+ expand_axes=axes_to_expand,
460
+ squeeze_axes=axes_to_squeeze,
461
+ )
449
462
 
450
- def from_order(self, names: Collection[str]) -> tuple[AxesTransformation, ...]:
463
+ def from_order(self, names: Sequence[str]) -> SlicingOps:
451
464
  """Get the new order of the axes."""
452
- _indices, _insert = self._change_order(names)
465
+ axes_to_squeeze, transposition_order, axes_to_expand = self._reorder_axes(names)
453
466
  # Inverse transpose is just the transpose with the inverse indices
454
- _reverse_indices = tuple(np.argsort(_indices))
455
- return AxesSqueeze(axes=_insert), AxesTranspose(axes=_reverse_indices)
467
+ if transposition_order is None:
468
+ _reverse_indices = None
469
+ else:
470
+ _reverse_indices = tuple(np.argsort(transposition_order))
471
+
472
+ return SlicingOps(
473
+ transpose_axes=_reverse_indices,
474
+ expand_axes=axes_to_squeeze,
475
+ squeeze_axes=axes_to_expand,
476
+ )
456
477
 
457
- def to_canonical(self) -> tuple[AxesTransformation, ...]:
478
+ def to_canonical(self) -> SlicingOps:
458
479
  """Get the new order of the axes."""
459
- return self.to_order(self._extended_canonical_order)
480
+ other = self._axes_setup.others
481
+ return self.to_order(other + list(self._canonical_order))
460
482
 
461
- def from_canonical(self) -> tuple[AxesTransformation, ...]:
483
+ def from_canonical(self) -> SlicingOps:
462
484
  """Get the new order of the axes."""
463
- return self.from_order(self._extended_canonical_order)
485
+ other = self._axes_setup.others
486
+ return self.from_order(other + list(self._canonical_order))
464
487
 
465
488
 
466
489
  def canonical_axes(
467
- axes_names: Collection[str],
468
- space_units: SpaceUnits | None = DefaultSpaceUnit,
469
- time_units: TimeUnits | None = DefaultTimeUnit,
490
+ axes_names: Sequence[str],
491
+ space_units: SpaceUnits | str | None = DefaultSpaceUnit,
492
+ time_units: TimeUnits | str | None = DefaultTimeUnit,
470
493
  ) -> list[Axis]:
471
494
  """Create a new canonical axes mapper.
472
495
 
473
496
  Args:
474
- axes_names (Collection[str] | int): The axes names on disk.
497
+ axes_names (Sequence[str] | int): The axes names on disk.
475
498
  - The axes should be in ['t', 'c', 'z', 'y', 'x']
476
499
  - The axes should be in strict canonical order.
477
500
  - If an integer is provided, the axes are created from the last axis
@@ -3,7 +3,7 @@
3
3
  Stores the same information as the Omero section of the ngff 0.4 metadata.
4
4
  """
5
5
 
6
- from collections.abc import Collection
6
+ from collections.abc import Sequence
7
7
  from difflib import SequenceMatcher
8
8
  from enum import Enum
9
9
  from typing import Any, TypeVar
@@ -77,7 +77,8 @@ class NgioColors(str, Enum):
77
77
  # try to match the color to the channel name
78
78
  similarity[color] = SequenceMatcher(None, channel_name, color).ratio()
79
79
  # Get the color with the highest similarity
80
- color_str = max(similarity, key=similarity.get) # type: ignore
80
+ color_str = max(similarity, key=similarity.get) # type: ignore (max type overload fails to infer type)
81
+ assert isinstance(color_str, str), "Color name must be a string."
81
82
  return NgioColors.__members__[color_str]
82
83
 
83
84
 
@@ -287,7 +288,7 @@ class Channel(BaseModel):
287
288
  T = TypeVar("T")
288
289
 
289
290
 
290
- def _check_elements(elements: Collection[T], expected_type: Any) -> Collection[T]:
291
+ def _check_elements(elements: Sequence[T], expected_type: Any) -> Sequence[T]:
291
292
  """Check that the elements are of the same type."""
292
293
  if len(elements) == 0:
293
294
  raise NgioValidationError("At least one element must be provided.")
@@ -301,7 +302,7 @@ def _check_elements(elements: Collection[T], expected_type: Any) -> Collection[T
301
302
  return elements
302
303
 
303
304
 
304
- def _check_unique(elements: Collection[T]) -> Collection[T]:
305
+ def _check_unique(elements: Sequence[T]) -> Sequence[T]:
305
306
  """Check that the elements are unique."""
306
307
  if len(set(elements)) != len(elements):
307
308
  raise NgioValidationError("All elements must be unique.")
@@ -329,35 +330,35 @@ class ChannelsMeta(BaseModel):
329
330
  @classmethod
330
331
  def default_init(
331
332
  cls,
332
- labels: Collection[str | None] | int,
333
- wavelength_id: Collection[str | None] | None = None,
334
- colors: Collection[str | NgioColors | None] | None = None,
335
- start: Collection[int | float | None] | int | float | None = None,
336
- end: Collection[int | float | None] | int | float | None = None,
337
- active: Collection[bool | None] | None = None,
333
+ labels: Sequence[str | None] | int,
334
+ wavelength_id: Sequence[str | None] | None = None,
335
+ colors: Sequence[str | NgioColors | None] | None = None,
336
+ start: Sequence[int | float | None] | int | float | None = None,
337
+ end: Sequence[int | float | None] | int | float | None = None,
338
+ active: Sequence[bool | None] | None = None,
338
339
  data_type: Any = np.uint16,
339
340
  **omero_kwargs: dict,
340
341
  ) -> "ChannelsMeta":
341
342
  """Create a ChannelsMeta object with the default unit.
342
343
 
343
344
  Args:
344
- labels(Collection[str | None] | int): The list of channels names
345
+ labels(Sequence[str | None] | int): The list of channels names
345
346
  in the image. If an integer is provided, the channels will be
346
347
  named "channel_i".
347
- wavelength_id(Collection[str | None] | None): The wavelength ID of the
348
+ wavelength_id(Sequence[str | None] | None): The wavelength ID of the
348
349
  channel. If None, the wavelength ID will be the same as the
349
350
  channel name.
350
- colors(Collection[str | NgioColors | None] | None): The list of
351
+ colors(Sequence[str | NgioColors | None] | None): The list of
351
352
  colors for the channels. If None, the colors will be random.
352
- start(Collection[int | float | None] | int | float | None): The start
353
+ start(Sequence[int | float | None] | int | float | None): The start
353
354
  value of the channel. If None, the start value will be the
354
355
  minimum value of the data type.
355
- end(Collection[int | float | None] | int | float | None): The end
356
+ end(Sequence[int | float | None] | int | float | None): The end
356
357
  value of the channel. If None, the end value will be the
357
358
  maximum value of the data type.
358
359
  data_type(Any): The data type of the channel. Will be used to set the
359
360
  min and max values of the channel.
360
- active (Collection[bool | None] | None): Whether the channel should
361
+ active (Sequence[bool | None] | None): Whether the channel should
361
362
  be shown by default.
362
363
  omero_kwargs(dict): Extra fields to store in the omero attributes.
363
364
  """
@@ -367,9 +368,9 @@ class ChannelsMeta(BaseModel):
367
368
  labels = _check_elements(labels, str)
368
369
  labels = _check_unique(labels)
369
370
 
370
- _wavelength_id: Collection[str | None] = [None] * len(labels)
371
+ _wavelength_id: Sequence[str | None] = [None] * len(labels)
371
372
  if wavelength_id is None:
372
- _wavelength_id: Collection[str | None] = [None] * len(labels)
373
+ _wavelength_id: Sequence[str | None] = [None] * len(labels)
373
374
  else:
374
375
  _wavelength_id = _check_elements(wavelength_id, str)
375
376
  _wavelength_id = _check_unique(wavelength_id)
@@ -425,3 +426,39 @@ class ChannelsMeta(BaseModel):
425
426
  )
426
427
  )
427
428
  return cls(channels=channels, **omero_kwargs)
429
+
430
+ @property
431
+ def channel_labels(self) -> list[str]:
432
+ """Get the labels of the channels in the image."""
433
+ return [channel.label for channel in self.channels]
434
+
435
+ @property
436
+ def channel_wavelength_ids(self) -> list[str | None]:
437
+ """Get the wavelength IDs of the channels in the image."""
438
+ return [channel.wavelength_id for channel in self.channels]
439
+
440
+ def get_channel_idx(
441
+ self, channel_label: str | None = None, wavelength_id: str | None = None
442
+ ) -> int:
443
+ """Get the index of a channel by its label or wavelength ID."""
444
+ # Only one of the arguments must be provided
445
+ if channel_label is not None and wavelength_id is not None:
446
+ raise NgioValueError(
447
+ "get_channel_idx must receive either label or wavelength_id, not both."
448
+ )
449
+
450
+ if channel_label is not None:
451
+ if channel_label not in self.channel_labels:
452
+ raise NgioValueError(f"Channel with label {channel_label} not found.")
453
+ return self.channel_labels.index(channel_label)
454
+
455
+ if wavelength_id is not None:
456
+ if wavelength_id not in self.channel_wavelength_ids:
457
+ raise NgioValueError(
458
+ f"Channel with wavelength ID {wavelength_id} not found."
459
+ )
460
+ return self.channel_wavelength_ids.index(wavelength_id)
461
+
462
+ raise NgioValueError(
463
+ "get_channel_idx must receive either label or wavelength_id"
464
+ )
@@ -1,6 +1,6 @@
1
1
  """Fractal internal module for dataset metadata handling."""
2
2
 
3
- from collections.abc import Collection
3
+ from collections.abc import Sequence
4
4
 
5
5
  from ngio.ome_zarr_meta.ngio_specs._axes import (
6
6
  AxesMapper,
@@ -28,9 +28,9 @@ class Dataset:
28
28
  *,
29
29
  # args coming from ngff specs
30
30
  path: str,
31
- on_disk_axes: Collection[Axis],
32
- on_disk_scale: Collection[float],
33
- on_disk_translation: Collection[float] | None = None,
31
+ on_disk_axes: Sequence[Axis],
32
+ on_disk_scale: Sequence[float],
33
+ on_disk_translation: Sequence[float] | None = None,
34
34
  # user defined args
35
35
  axes_setup: AxesSetup | None = None,
36
36
  allow_non_canonical_axes: bool = False,
@@ -123,8 +123,8 @@ class Dataset:
123
123
  y=self.get_scale("y"),
124
124
  z=self.get_scale("z"),
125
125
  t=self.get_scale("t"),
126
- space_unit=self.space_unit, # type: ignore
127
- time_unit=self.time_unit, # type: ignore
126
+ space_unit=self.space_unit,
127
+ time_unit=self.time_unit,
128
128
  )
129
129
 
130
130
  @property
@@ -145,7 +145,7 @@ class Dataset:
145
145
  time_unit(str): The time unit to convert to.
146
146
  """
147
147
  new_axes = []
148
- for ax in self.axes_mapper.on_disk_axes:
148
+ for ax in self.axes_mapper.axes:
149
149
  if ax.axis_type == AxisType.space:
150
150
  new_ax = Axis(
151
151
  on_disk_name=ax.on_disk_name,
@@ -21,21 +21,12 @@ from ngio.utils import NgioValueError, ngio_logger
21
21
 
22
22
  def path_in_well_validation(path: str) -> str:
23
23
  """Validate the path in the well."""
24
- if path.find("_") != -1:
25
- # Remove underscores from the path
26
- # This is a custom serialization step
27
- old_value = path
28
- path = path.replace("_", "")
29
- ngio_logger.warning(
30
- f"Underscores in well-paths are not allowed. "
31
- f"Path '{old_value}' was changed to '{path}'"
32
- f" to comply with the specification."
33
- )
34
24
  # Check if the value contains only alphanumeric characters
35
25
  if not path.isalnum():
36
- raise NgioValueError(
26
+ ngio_logger.warning(
37
27
  f"Path '{path}' contains non-alphanumeric characters. "
38
- f"Only alphanumeric characters are allowed."
28
+ "This may cause issues with some tools. "
29
+ "Consider using only alphanumeric characters in the path."
39
30
  )
40
31
  return path
41
32
 
@@ -60,11 +51,11 @@ class CustomWellImage(WellImage04):
60
51
 
61
52
 
62
53
  class CustomWellMeta(WellMeta04):
63
- images: list[CustomWellImage] # type: ignore[valid-type]
54
+ images: list[CustomWellImage] # type: ignore (override of WellMeta04.images)
64
55
 
65
56
 
66
57
  class CustomWellAttrs(WellAttrs04):
67
- well: CustomWellMeta # type: ignore[valid-type]
58
+ well: CustomWellMeta # type: ignore (override of WellAttrs04.well)
68
59
 
69
60
 
70
61
  class NgioWellMeta(CustomWellAttrs):
@@ -540,7 +531,7 @@ class NgioPlateMeta(HCSAttrs):
540
531
  acquisitions = None
541
532
 
542
533
  if version is None:
543
- version = self.plate.version # type: ignore[assignment]
534
+ version = self.plate.version # type: ignore (version is NgffVersions or None)
544
535
 
545
536
  return NgioPlateMeta(
546
537
  plate=Plate(