ngio 0.4.8__py3-none-any.whl → 0.5.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.
Files changed (56) hide show
  1. ngio/__init__.py +5 -2
  2. ngio/common/__init__.py +11 -6
  3. ngio/common/_masking_roi.py +34 -54
  4. ngio/common/_pyramid.py +322 -75
  5. ngio/common/_roi.py +258 -330
  6. ngio/experimental/iterators/_feature.py +3 -3
  7. ngio/experimental/iterators/_rois_utils.py +10 -11
  8. ngio/hcs/_plate.py +192 -136
  9. ngio/images/_abstract_image.py +539 -35
  10. ngio/images/_create_synt_container.py +45 -47
  11. ngio/images/_create_utils.py +406 -0
  12. ngio/images/_image.py +524 -248
  13. ngio/images/_label.py +257 -180
  14. ngio/images/_masked_image.py +2 -2
  15. ngio/images/_ome_zarr_container.py +658 -255
  16. ngio/io_pipes/_io_pipes.py +9 -9
  17. ngio/io_pipes/_io_pipes_masked.py +7 -7
  18. ngio/io_pipes/_io_pipes_roi.py +6 -6
  19. ngio/io_pipes/_io_pipes_types.py +3 -3
  20. ngio/io_pipes/_match_shape.py +6 -8
  21. ngio/io_pipes/_ops_slices_utils.py +8 -5
  22. ngio/ome_zarr_meta/__init__.py +29 -18
  23. ngio/ome_zarr_meta/_meta_handlers.py +402 -689
  24. ngio/ome_zarr_meta/ngio_specs/__init__.py +4 -0
  25. ngio/ome_zarr_meta/ngio_specs/_axes.py +152 -51
  26. ngio/ome_zarr_meta/ngio_specs/_dataset.py +13 -22
  27. ngio/ome_zarr_meta/ngio_specs/_ngio_hcs.py +129 -91
  28. ngio/ome_zarr_meta/ngio_specs/_ngio_image.py +69 -69
  29. ngio/ome_zarr_meta/v04/__init__.py +5 -1
  30. ngio/ome_zarr_meta/v04/{_v04_spec_utils.py → _v04_spec.py} +55 -86
  31. ngio/ome_zarr_meta/v05/__init__.py +27 -0
  32. ngio/ome_zarr_meta/v05/_custom_models.py +18 -0
  33. ngio/ome_zarr_meta/v05/_v05_spec.py +495 -0
  34. ngio/resources/__init__.py +1 -1
  35. ngio/resources/resource_model.py +1 -1
  36. ngio/tables/_tables_container.py +82 -24
  37. ngio/tables/backends/_abstract_backend.py +7 -0
  38. ngio/tables/backends/_anndata.py +60 -7
  39. ngio/tables/backends/_anndata_utils.py +2 -4
  40. ngio/tables/backends/_csv.py +3 -19
  41. ngio/tables/backends/_json.py +10 -13
  42. ngio/tables/backends/_parquet.py +3 -31
  43. ngio/tables/backends/_py_arrow_backends.py +222 -0
  44. ngio/tables/backends/_utils.py +1 -1
  45. ngio/tables/v1/_roi_table.py +41 -24
  46. ngio/utils/__init__.py +8 -12
  47. ngio/utils/_cache.py +48 -0
  48. ngio/utils/_zarr_utils.py +354 -236
  49. {ngio-0.4.8.dist-info → ngio-0.5.0.dist-info}/METADATA +12 -5
  50. ngio-0.5.0.dist-info/RECORD +88 -0
  51. ngio/images/_create.py +0 -276
  52. ngio/tables/backends/_non_zarr_backends.py +0 -196
  53. ngio/utils/_logger.py +0 -50
  54. ngio-0.4.8.dist-info/RECORD +0 -85
  55. {ngio-0.4.8.dist-info → ngio-0.5.0.dist-info}/WHEEL +0 -0
  56. {ngio-0.4.8.dist-info → ngio-0.5.0.dist-info}/licenses/LICENSE +0 -0
ngio/images/_image.py CHANGED
@@ -1,21 +1,21 @@
1
1
  """Generic class to handle Image-like data in a OME-NGFF file."""
2
2
 
3
- from collections.abc import Sequence
4
- from typing import Literal
3
+ import warnings
4
+ from collections.abc import Mapping, Sequence
5
+ from typing import Any, Literal
5
6
 
6
7
  import dask.array as da
7
8
  import numpy as np
8
9
  from pydantic import BaseModel, model_validator
9
- from zarr.types import DIMENSION_SEPARATOR
10
+ from zarr.core.array import CompressorLike
10
11
 
11
12
  from ngio.common import (
12
13
  Dimensions,
13
14
  InterpolationOrder,
14
15
  Roi,
15
- RoiPixels,
16
16
  )
17
- from ngio.images._abstract_image import AbstractImage
18
- from ngio.images._create import create_empty_image_container
17
+ from ngio.common._pyramid import ChunksLike, ShardsLike
18
+ from ngio.images._abstract_image import AbstractImage, abstract_derive
19
19
  from ngio.io_pipes import (
20
20
  SlicingInputType,
21
21
  TransformProtocol,
@@ -24,19 +24,19 @@ from ngio.ome_zarr_meta import (
24
24
  ImageMetaHandler,
25
25
  NgioImageMeta,
26
26
  PixelSize,
27
- find_image_meta_handler,
28
27
  )
29
28
  from ngio.ome_zarr_meta.ngio_specs import (
30
29
  Channel,
31
30
  ChannelsMeta,
32
- ChannelVisualisation,
33
31
  DefaultSpaceUnit,
34
32
  DefaultTimeUnit,
33
+ NgffVersions,
35
34
  SpaceUnits,
36
35
  TimeUnits,
37
36
  )
37
+ from ngio.ome_zarr_meta.ngio_specs._axes import AxesSetup
38
38
  from ngio.utils import (
39
- NgioValidationError,
39
+ NgioValueError,
40
40
  StoreOrGroup,
41
41
  ZarrGroupHandler,
42
42
  )
@@ -47,7 +47,7 @@ class ChannelSelectionModel(BaseModel):
47
47
 
48
48
  This model is used to select a channel by label, wavelength ID, or index.
49
49
 
50
- Args:
50
+ Properties:
51
51
  identifier (str): Unique identifier for the channel.
52
52
  This can be a channel label, wavelength ID, or index.
53
53
  mode (Literal["label", "wavelength_id", "index"]): Specifies how to
@@ -88,7 +88,7 @@ def _check_channel_meta(meta: NgioImageMeta, dimension: Dimensions) -> ChannelsM
88
88
  return ChannelsMeta.default_init(labels=c_dim)
89
89
 
90
90
  if len(meta.channels_meta.channels) != c_dim:
91
- raise NgioValidationError(
91
+ raise NgioValueError(
92
92
  "The number of channels does not match the image. "
93
93
  f"Expected {len(meta.channels_meta.channels)} channels, got {c_dim}."
94
94
  )
@@ -96,7 +96,7 @@ def _check_channel_meta(meta: NgioImageMeta, dimension: Dimensions) -> ChannelsM
96
96
  return meta.channels_meta
97
97
 
98
98
 
99
- class Image(AbstractImage[ImageMetaHandler]):
99
+ class Image(AbstractImage):
100
100
  """A class to handle a single image (or level) in an OME-Zarr image.
101
101
 
102
102
  This class is meant to be subclassed by specific image types.
@@ -106,7 +106,7 @@ class Image(AbstractImage[ImageMetaHandler]):
106
106
  self,
107
107
  group_handler: ZarrGroupHandler,
108
108
  path: str,
109
- meta_handler: ImageMetaHandler | None,
109
+ meta_handler: ImageMetaHandler,
110
110
  ) -> None:
111
111
  """Initialize the Image at a single level.
112
112
 
@@ -116,16 +116,22 @@ class Image(AbstractImage[ImageMetaHandler]):
116
116
  meta_handler: The image metadata handler.
117
117
 
118
118
  """
119
- if meta_handler is None:
120
- meta_handler = find_image_meta_handler(group_handler)
121
119
  super().__init__(
122
120
  group_handler=group_handler, path=path, meta_handler=meta_handler
123
121
  )
124
122
 
123
+ @property
124
+ def meta_handler(self) -> ImageMetaHandler:
125
+ """Return the metadata handler."""
126
+ assert isinstance(self._meta_handler, ImageMetaHandler)
127
+ return self._meta_handler
128
+
125
129
  @property
126
130
  def meta(self) -> NgioImageMeta:
127
131
  """Return the metadata."""
128
- return self._meta_handler.meta
132
+ meta = self.meta_handler.get_meta()
133
+ assert isinstance(meta, NgioImageMeta)
134
+ return meta
129
135
 
130
136
  @property
131
137
  def channels_meta(self) -> ChannelsMeta:
@@ -185,7 +191,7 @@ class Image(AbstractImage[ImageMetaHandler]):
185
191
 
186
192
  def get_roi_as_numpy(
187
193
  self,
188
- roi: Roi | RoiPixels,
194
+ roi: Roi,
189
195
  channel_selection: ChannelSlicingInputType = None,
190
196
  axes_order: Sequence[str] | None = None,
191
197
  transforms: Sequence[TransformProtocol] | None = None,
@@ -239,7 +245,7 @@ class Image(AbstractImage[ImageMetaHandler]):
239
245
 
240
246
  def get_roi_as_dask(
241
247
  self,
242
- roi: Roi | RoiPixels,
248
+ roi: Roi,
243
249
  channel_selection: ChannelSlicingInputType = None,
244
250
  axes_order: Sequence[str] | None = None,
245
251
  transforms: Sequence[TransformProtocol] | None = None,
@@ -296,7 +302,7 @@ class Image(AbstractImage[ImageMetaHandler]):
296
302
 
297
303
  def get_roi(
298
304
  self,
299
- roi: Roi | RoiPixels,
305
+ roi: Roi,
300
306
  channel_selection: ChannelSlicingInputType = None,
301
307
  axes_order: Sequence[str] | None = None,
302
308
  transforms: Sequence[TransformProtocol] | None = None,
@@ -356,7 +362,7 @@ class Image(AbstractImage[ImageMetaHandler]):
356
362
 
357
363
  def set_roi(
358
364
  self,
359
- roi: Roi | RoiPixels,
365
+ roi: Roi,
360
366
  patch: np.ndarray | da.Array,
361
367
  channel_selection: ChannelSlicingInputType = None,
362
368
  axes_order: Sequence[str] | None = None,
@@ -394,67 +400,135 @@ class Image(AbstractImage[ImageMetaHandler]):
394
400
 
395
401
 
396
402
  class ImagesContainer:
397
- """A class to handle the /labels group in an OME-NGFF file."""
403
+ """A class to handle the /images group in an OME-NGFF file."""
398
404
 
399
- def __init__(self, group_handler: ZarrGroupHandler) -> None:
400
- """Initialize the LabelGroupHandler."""
405
+ def __init__(
406
+ self,
407
+ group_handler: ZarrGroupHandler,
408
+ axes_setup: AxesSetup | None,
409
+ version: NgffVersions | None = None,
410
+ validate_paths: bool = True,
411
+ ) -> None:
412
+ """Initialize the ImagesContainer."""
401
413
  self._group_handler = group_handler
402
- self._meta_handler = find_image_meta_handler(group_handler)
414
+ self._meta_handler = ImageMetaHandler(
415
+ group_handler=group_handler, axes_setup=axes_setup, version=version
416
+ )
417
+ if validate_paths:
418
+ for level_path in self._meta_handler.get_meta().paths:
419
+ self.get(path=level_path)
403
420
 
404
421
  @property
405
422
  def meta(self) -> NgioImageMeta:
406
423
  """Return the metadata."""
407
- return self._meta_handler.meta
424
+ return self._meta_handler.get_meta()
425
+
426
+ @property
427
+ def channels_meta(self) -> ChannelsMeta:
428
+ """Return the channels metadata."""
429
+ return self.get().channels_meta
430
+
431
+ @property
432
+ def axes_setup(self) -> AxesSetup:
433
+ """Return the axes setup."""
434
+ return self.meta.axes_handler.axes_setup
435
+
436
+ @property
437
+ def level_paths(self) -> list[str]:
438
+ """Return the paths of the levels in the image."""
439
+ return self.meta.paths
440
+
441
+ @property
442
+ def levels_paths(self) -> list[str]:
443
+ """Deprecated: use 'level_paths' instead."""
444
+ warnings.warn(
445
+ "'levels_paths' is deprecated and will be removed in ngio=0.6. "
446
+ "Please use 'level_paths' instead.",
447
+ DeprecationWarning,
448
+ stacklevel=2,
449
+ )
450
+ return self.level_paths
408
451
 
409
452
  @property
410
453
  def levels(self) -> int:
411
454
  """Return the number of levels in the image."""
412
- return self._meta_handler.meta.levels
455
+ return self.meta.levels
413
456
 
414
457
  @property
415
- def levels_paths(self) -> list[str]:
416
- """Return the paths of the levels in the image."""
417
- return self._meta_handler.meta.paths
458
+ def is_3d(self) -> bool:
459
+ """Return True if the image is 3D."""
460
+ return self.get().is_3d
418
461
 
419
462
  @property
420
- def num_channels(self) -> int:
421
- """Return the number of channels."""
422
- image = self.get()
423
- return image.num_channels
463
+ def is_2d(self) -> bool:
464
+ """Return True if the image is 2D."""
465
+ return self.get().is_2d
466
+
467
+ @property
468
+ def is_time_series(self) -> bool:
469
+ """Return True if the image is a time series."""
470
+ return self.get().is_time_series
471
+
472
+ @property
473
+ def is_2d_time_series(self) -> bool:
474
+ """Return True if the image is a 2D time series."""
475
+ return self.get().is_2d_time_series
476
+
477
+ @property
478
+ def is_3d_time_series(self) -> bool:
479
+ """Return True if the image is a 3D time series."""
480
+ return self.get().is_3d_time_series
481
+
482
+ @property
483
+ def is_multi_channels(self) -> bool:
484
+ """Return True if the image is multichannel."""
485
+ return self.get().is_multi_channels
486
+
487
+ @property
488
+ def space_unit(self) -> str | None:
489
+ """Return the space unit of the image."""
490
+ return self.meta.space_unit
491
+
492
+ @property
493
+ def time_unit(self) -> str | None:
494
+ """Return the time unit of the image."""
495
+ return self.meta.time_unit
424
496
 
425
497
  @property
426
498
  def channel_labels(self) -> list[str]:
427
499
  """Return the channels of the image."""
428
- image = self.get()
429
- return image.channel_labels
500
+ return self.get().channel_labels
430
501
 
431
502
  @property
432
503
  def wavelength_ids(self) -> list[str | None]:
433
- """Return the wavelength of the image."""
434
- image = self.get()
435
- return image.wavelength_ids
504
+ """Return the list of wavelength of the image."""
505
+ return self.get().wavelength_ids
506
+
507
+ @property
508
+ def num_channels(self) -> int:
509
+ """Return the number of channels."""
510
+ return self.get().num_channels
436
511
 
437
512
  def get_channel_idx(
438
513
  self, channel_label: str | None = None, wavelength_id: str | None = None
439
514
  ) -> int:
440
- """Get the index of a channel by label or wavelength ID.
441
-
442
- Args:
443
- channel_label (str | None): The label of the channel.
444
- If None a wavelength ID must be provided.
445
- wavelength_id (str | None): The wavelength ID of the channel.
446
- If None a channel label must be provided.
447
-
448
- Returns:
449
- int: The index of the channel.
450
-
451
- """
452
- image = self.get()
453
- return image.get_channel_idx(
515
+ """Get the index of a channel by its label or wavelength ID."""
516
+ return self.channels_meta.get_channel_idx(
454
517
  channel_label=channel_label, wavelength_id=wavelength_id
455
518
  )
456
519
 
457
- def set_channel_meta(
520
+ def _set_channel_meta(
521
+ self,
522
+ channels_meta: ChannelsMeta | None = None,
523
+ ) -> None:
524
+ """Set the channels metadata."""
525
+ if channels_meta is None:
526
+ channels_meta = ChannelsMeta.default_init(labels=self.num_channels)
527
+ meta = self.meta
528
+ meta.set_channels_meta(channels_meta)
529
+ self._meta_handler.update_meta(meta)
530
+
531
+ def _set_channel_meta_legacy(
458
532
  self,
459
533
  labels: Sequence[str | None] | int | None = None,
460
534
  wavelength_id: Sequence[str | None] | None = None,
@@ -490,32 +564,22 @@ class ImagesContainer:
490
564
  ref_image = self.get(path=low_res_dataset.path)
491
565
 
492
566
  if start is not None and end is None:
493
- raise NgioValidationError(
494
- "If start is provided, end must be provided as well."
495
- )
567
+ raise NgioValueError("If start is provided, end must be provided as well.")
496
568
  if end is not None and start is None:
497
- raise NgioValidationError(
498
- "If end is provided, start must be provided as well."
499
- )
569
+ raise NgioValueError("If end is provided, start must be provided as well.")
500
570
 
501
571
  if start is not None and percentiles is not None:
502
- raise NgioValidationError(
572
+ raise NgioValueError(
503
573
  "If start and end are provided, percentiles must be None."
504
574
  )
505
575
 
506
- if percentiles is not None:
507
- start, end = compute_image_percentile(
508
- ref_image,
509
- start_percentile=percentiles[0],
510
- end_percentile=percentiles[1],
511
- )
512
576
  elif start is not None and end is not None:
513
577
  if len(start) != len(end):
514
- raise NgioValidationError(
578
+ raise NgioValueError(
515
579
  "The start and end lists must have the same length."
516
580
  )
517
581
  if len(start) != self.num_channels:
518
- raise NgioValidationError(
582
+ raise NgioValueError(
519
583
  "The start and end lists must have the same length as "
520
584
  "the number of channels."
521
585
  )
@@ -539,44 +603,212 @@ class ImagesContainer:
539
603
  data_type=ref_image.dtype,
540
604
  **omero_kwargs,
541
605
  )
606
+ self._set_channel_meta(channel_meta)
607
+ if percentiles is not None:
608
+ self.set_channel_windows_with_percentiles(percentiles=percentiles)
542
609
 
543
- meta = self.meta
544
- meta.set_channels_meta(channel_meta)
545
- self._meta_handler.write_meta(meta)
610
+ def set_channel_meta(
611
+ self,
612
+ channel_meta: ChannelsMeta | None = None,
613
+ labels: Sequence[str | None] | int | None = None,
614
+ wavelength_id: Sequence[str | None] | None = None,
615
+ start: Sequence[float | None] | None = None,
616
+ end: Sequence[float | None] | None = None,
617
+ percentiles: tuple[float, float] | None = None,
618
+ colors: Sequence[str | None] | None = None,
619
+ active: Sequence[bool | None] | None = None,
620
+ **omero_kwargs: dict,
621
+ ) -> None:
622
+ """Create a ChannelsMeta object with the default unit.
623
+
624
+ Args:
625
+ channel_meta (ChannelsMeta | None): The channels metadata to set.
626
+ If none, it will fall back to the deprecated parameters.
627
+ labels(Sequence[str | None] | int): Deprecated. The list of channels names
628
+ in the image. If an integer is provided, the channels will
629
+ be named "channel_i".
630
+ wavelength_id(Sequence[str | None]): Deprecated. The wavelength ID of the
631
+ channel. If None, the wavelength ID will be the same as
632
+ the channel name.
633
+ start(Sequence[float | None]): Deprecated. The start value for each channel.
634
+ If None, the start value will be computed from the image.
635
+ end(Sequence[float | None]): Deprecated. The end value for each channel.
636
+ If None, the end value will be computed from the image.
637
+ percentiles(tuple[float, float] | None): Deprecated. The start and end
638
+ percentiles for each channel. If None, the percentiles will
639
+ not be computed.
640
+ colors(Sequence[str | None]): Deprecated. The list of colors for the
641
+ channels. If None, the colors will be random.
642
+ active (Sequence[bool | None]): Deprecated. Whether the channel should
643
+ be shown by default.
644
+ omero_kwargs(dict): Deprecated. Extra fields to store in the omero
645
+ attributes.
646
+ """
647
+ _is_legacy = any(
648
+ param is not None
649
+ for param in [
650
+ labels,
651
+ wavelength_id,
652
+ start,
653
+ end,
654
+ percentiles,
655
+ colors,
656
+ active,
657
+ ]
658
+ )
659
+ if _is_legacy:
660
+ warnings.warn(
661
+ "The following parameters are deprecated and will be removed in "
662
+ "ngio=0.6: labels, wavelength_id, start, end, percentiles, "
663
+ "colors, active, omero_kwargs. Please use the "
664
+ "'channel_meta' parameter instead.",
665
+ DeprecationWarning,
666
+ stacklevel=2,
667
+ )
668
+ self._set_channel_meta_legacy(
669
+ labels=labels,
670
+ wavelength_id=wavelength_id,
671
+ start=start,
672
+ end=end,
673
+ percentiles=percentiles,
674
+ colors=colors,
675
+ active=active,
676
+ **omero_kwargs,
677
+ )
678
+ return None
679
+ self._set_channel_meta(channel_meta)
680
+
681
+ def set_channel_labels(
682
+ self,
683
+ labels: Sequence[str],
684
+ ) -> None:
685
+ """Update the labels of the channels.
686
+
687
+ Args:
688
+ labels (Sequence[str]): The new labels for the channels.
689
+ """
690
+ channels_meta = self.channels_meta
691
+ if len(labels) != len(channels_meta.channels):
692
+ raise NgioValueError(
693
+ "The number of labels must match the number of channels."
694
+ )
695
+ new_channels = []
696
+ for label, ch in zip(labels, channels_meta.channels, strict=True):
697
+ channel = ch.model_copy(update={"label": label})
698
+ new_channels.append(channel)
699
+ new_meta = channels_meta.model_copy(update={"channels": new_channels})
700
+ self._set_channel_meta(new_meta)
701
+
702
+ def set_channel_colors(
703
+ self,
704
+ colors: Sequence[str],
705
+ ) -> None:
706
+ """Update the colors of the channels.
707
+
708
+ Args:
709
+ colors (Sequence[str]): The new colors for the channels.
710
+ """
711
+ channel_meta = self.channels_meta
712
+ if len(colors) != len(channel_meta.channels):
713
+ raise NgioValueError(
714
+ "The number of colors must match the number of channels."
715
+ )
716
+ new_channels = []
717
+ for color, ch in zip(colors, channel_meta.channels, strict=True):
718
+ ch_visualisation = ch.channel_visualisation.model_copy(
719
+ update={"color": color}
720
+ )
721
+ channel = ch.model_copy(update={"channel_visualisation": ch_visualisation})
722
+ new_channels.append(channel)
723
+ new_meta = channel_meta.model_copy(update={"channels": new_channels})
724
+ self._set_channel_meta(new_meta)
546
725
 
547
726
  def set_channel_percentiles(
548
727
  self,
549
728
  start_percentile: float = 0.1,
550
729
  end_percentile: float = 99.9,
551
730
  ) -> None:
552
- """Update the percentiles of the channels."""
553
- if self.meta._channels_meta is None:
554
- raise NgioValidationError("The channels meta is not initialized.")
731
+ """Deprecated: Update the channel windows using percentiles.
555
732
 
556
- low_res_dataset = self.meta.get_lowest_resolution_dataset()
557
- ref_image = self.get(path=low_res_dataset.path)
558
- starts, ends = compute_image_percentile(
559
- ref_image, start_percentile=start_percentile, end_percentile=end_percentile
733
+ Args:
734
+ start_percentile (float): The start percentile.
735
+ end_percentile (float): The end percentile.
736
+ """
737
+ warnings.warn(
738
+ "The 'set_channel_percentiles' method is deprecated and will be removed in "
739
+ "ngio=0.6. Please use 'set_channel_windows_with_percentiles' instead.",
740
+ DeprecationWarning,
741
+ stacklevel=2,
742
+ )
743
+ self.set_channel_windows_with_percentiles(
744
+ percentiles=(start_percentile, end_percentile)
560
745
  )
561
746
 
562
- channels = []
563
- for c, channel in enumerate(self.meta._channels_meta.channels):
564
- new_v = ChannelVisualisation(
565
- start=starts[c],
566
- end=ends[c],
567
- **channel.channel_visualisation.model_dump(exclude={"start", "end"}),
747
+ def set_channel_windows(
748
+ self,
749
+ starts_ends: Sequence[tuple[float, float]],
750
+ min_max: Sequence[tuple[float, float]] | None = None,
751
+ ) -> None:
752
+ """Update the channel windows.
753
+
754
+ These values are used by viewers to set the display
755
+ range of each channel.
756
+
757
+ Args:
758
+ starts_ends (Sequence[tuple[float, float]]): The start and end values
759
+ for each channel.
760
+ min_max (Sequence[tuple[float, float]] | None): The min and max values
761
+ for each channel. If None, the min and max values will not be updated.
762
+ """
763
+ current_channels = self.channels_meta.channels
764
+ if len(starts_ends) != len(current_channels):
765
+ raise NgioValueError(
766
+ "The number of start-end pairs must match the number of channels."
568
767
  )
569
- new_c = Channel(
570
- channel_visualisation=new_v,
571
- **channel.model_dump(exclude={"channel_visualisation"}),
768
+ if min_max is not None and len(min_max) != len(current_channels):
769
+ raise NgioValueError(
770
+ "The number of min-max pairs must match the number of channels."
572
771
  )
573
- channels.append(new_c)
574
-
772
+ if min_max is None:
773
+ min_max_ = [None] * len(current_channels)
774
+ else:
775
+ min_max_ = list(min_max)
776
+ channels = []
777
+ for se, mm, ch in zip(
778
+ starts_ends, min_max_, self.channels_meta.channels, strict=True
779
+ ):
780
+ updates = {"start": se[0], "end": se[1]}
781
+ if mm is not None:
782
+ updates.update({"min": mm[0], "max": mm[1]})
783
+ channel_visualisation = ch.channel_visualisation.model_copy(update=updates)
784
+ channel = ch.model_copy(
785
+ update={"channel_visualisation": channel_visualisation}
786
+ )
787
+ channels.append(channel)
575
788
  new_meta = ChannelsMeta(channels=channels)
576
-
577
789
  meta = self.meta
578
790
  meta.set_channels_meta(new_meta)
579
- self._meta_handler.write_meta(meta)
791
+ self._meta_handler.update_meta(meta)
792
+
793
+ def set_channel_windows_with_percentiles(
794
+ self,
795
+ percentiles: tuple[float, float] | list[tuple[float, float]] = (0.1, 99.9),
796
+ ) -> None:
797
+ """Update the channel windows using percentiles.
798
+
799
+ Args:
800
+ percentiles (tuple[float, float] | list[tuple[float, float]]):
801
+ The start and end percentiles for each channel.
802
+ If a single tuple is provided,
803
+ the same percentiles will be used for all channels.
804
+ """
805
+ if self.meta._channels_meta is None:
806
+ raise NgioValueError("The channels meta is not initialized.")
807
+
808
+ low_res_dataset = self.meta.get_lowest_resolution_dataset()
809
+ ref_image = self.get(path=low_res_dataset.path)
810
+ starts_ends = compute_image_percentile(ref_image, percentiles=percentiles)
811
+ self.set_channel_windows(starts_ends=starts_ends)
580
812
 
581
813
  def set_axes_unit(
582
814
  self,
@@ -589,61 +821,127 @@ class ImagesContainer:
589
821
  space_unit (SpaceUnits): The space unit of the image.
590
822
  time_unit (TimeUnits): The time unit of the image.
591
823
  """
592
- meta = self.meta
593
- meta = meta.to_units(space_unit=space_unit, time_unit=time_unit)
594
- self._meta_handler.write_meta(meta)
824
+ self.get().set_axes_unit(space_unit=space_unit, time_unit=time_unit)
825
+
826
+ def set_axes_names(
827
+ self,
828
+ axes_names: Sequence[str],
829
+ ) -> None:
830
+ """Set the axes names of the image.
831
+
832
+ Args:
833
+ axes_names (Sequence[str]): The axes names of the image.
834
+ """
835
+ image = self.get()
836
+ image.set_axes_names(axes_names=axes_names)
837
+ self._meta_handler._axes_setup = image.meta.axes_handler.axes_setup
838
+
839
+ def set_name(
840
+ self,
841
+ name: str,
842
+ ) -> None:
843
+ """Set the name of the image in the metadata.
844
+
845
+ This does not change the group name or any paths.
846
+
847
+ Args:
848
+ name (str): The name of the image.
849
+ """
850
+ self.get().set_name(name=name)
595
851
 
596
852
  def derive(
597
853
  self,
598
854
  store: StoreOrGroup,
599
855
  ref_path: str | None = None,
856
+ # Metadata parameters
600
857
  shape: Sequence[int] | None = None,
601
- labels: Sequence[str] | None = None,
602
- pixel_size: PixelSize | None = None,
603
- axes_names: Sequence[str] | None = None,
858
+ pixelsize: float | tuple[float, float] | None = None,
859
+ z_spacing: float | None = None,
860
+ time_spacing: float | None = None,
604
861
  name: str | None = None,
605
- chunks: Sequence[int] | None = None,
606
- dtype: str | None = None,
607
- dimension_separator: DIMENSION_SEPARATOR | None = None,
608
- compressor: str | None = None,
862
+ translation: Sequence[float] | None = None,
863
+ channels_meta: Sequence[str | Channel] | None = None,
864
+ channels_policy: Literal["same", "squeeze", "singleton"] | int = "same",
865
+ ngff_version: NgffVersions | None = None,
866
+ # Zarr Array parameters
867
+ chunks: ChunksLike | None = None,
868
+ shards: ShardsLike | None = None,
869
+ dtype: str = "uint16",
870
+ dimension_separator: Literal[".", "/"] = "/",
871
+ compressors: CompressorLike = "auto",
872
+ extra_array_kwargs: Mapping[str, Any] | None = None,
609
873
  overwrite: bool = False,
874
+ # Deprecated arguments
875
+ labels: Sequence[str] | None = None,
876
+ pixel_size: PixelSize | None = None,
610
877
  ) -> "ImagesContainer":
611
878
  """Create an empty OME-Zarr image from an existing image.
612
879
 
880
+ If a kwarg is not provided, the value from the reference image will be used.
881
+
613
882
  Args:
614
883
  store (StoreOrGroup): The Zarr store or group to create the image in.
615
- ref_path (str | None): The path to the reference image in
616
- the image container.
884
+ ref_path (str | None): The path to the reference image in the image
885
+ container.
617
886
  shape (Sequence[int] | None): The shape of the new image.
618
- labels (Sequence[str] | None): The labels of the new image.
619
- pixel_size (PixelSize | None): The pixel size of the new image.
620
- axes_names (Sequence[str] | None): The axes names of the new image.
887
+ pixelsize (float | tuple[float, float] | None): The pixel size of the new
888
+ image.
889
+ z_spacing (float | None): The z spacing of the new image.
890
+ time_spacing (float | None): The time spacing of the new image.
621
891
  name (str | None): The name of the new image.
622
- chunks (Sequence[int] | None): The chunk shape of the new image.
623
- dimension_separator (DIMENSION_SEPARATOR | None): The separator to use for
624
- dimensions. If None it will use the same as the reference image.
625
- compressor (str | None): The compressor to use. If None it will use
626
- the same as the reference image.
892
+ translation (Sequence[float] | None): The translation for each axis
893
+ at the highest resolution level. Defaults to None.
894
+ channels_meta (Sequence[str | Channel] | None): The channels metadata
895
+ of the new image.
896
+ channels_policy (Literal["same", "squeeze", "singleton"] | int):
897
+ Possible policies:
898
+ - If "squeeze", the channels axis will be removed (no matter its size).
899
+ - If "same", the channels axis will be kept as is (if it exists).
900
+ - If "singleton", the channels axis will be set to size 1.
901
+ - If an integer is provided, the channels axis will be changed to have
902
+ that size.
903
+ ngff_version (NgffVersions | None): The NGFF version to use.
904
+ chunks (ChunksLike | None): The chunk shape of the new image.
905
+ shards (ShardsLike | None): The shard shape of the new image.
627
906
  dtype (str | None): The data type of the new image.
907
+ dimension_separator (Literal[".", "/"] | None): The separator to use for
908
+ dimensions.
909
+ compressors (CompressorLike | None): The compressors to use.
910
+ extra_array_kwargs (Mapping[str, Any] | None): Extra arguments to pass to
911
+ the zarr array creation.
628
912
  overwrite (bool): Whether to overwrite an existing image.
913
+ labels (Sequence[str] | None): The labels of the new image.
914
+ This argument is deprecated please use channels_meta instead.
915
+ pixel_size (PixelSize | None): The pixel size of the new image.
916
+ This argument is deprecated please use pixelsize, z_spacing,
917
+ and time_spacing instead.
629
918
 
630
919
  Returns:
631
- ImagesContainer: The new image
920
+ ImagesContainer: The new derived image.
921
+
632
922
  """
633
923
  return derive_image_container(
634
924
  image_container=self,
635
925
  store=store,
636
926
  ref_path=ref_path,
637
927
  shape=shape,
638
- labels=labels,
639
- pixel_size=pixel_size,
640
- axes_names=axes_names,
928
+ pixelsize=pixelsize,
929
+ z_spacing=z_spacing,
930
+ time_spacing=time_spacing,
641
931
  name=name,
932
+ translation=translation,
933
+ channels_meta=channels_meta,
934
+ channels_policy=channels_policy,
935
+ ngff_version=ngff_version,
642
936
  chunks=chunks,
937
+ shards=shards,
643
938
  dtype=dtype,
644
939
  dimension_separator=dimension_separator,
645
- compressor=compressor,
940
+ compressors=compressors,
941
+ extra_array_kwargs=extra_array_kwargs,
646
942
  overwrite=overwrite,
943
+ labels=labels,
944
+ pixel_size=pixel_size,
647
945
  )
648
946
 
649
947
  def get(
@@ -662,7 +960,7 @@ class ImagesContainer:
662
960
  closest pixel size level will be returned.
663
961
 
664
962
  """
665
- dataset = self._meta_handler.meta.get_dataset(
963
+ dataset = self._meta_handler.get_meta().get_dataset(
666
964
  path=path, pixel_size=pixel_size, strict=strict
667
965
  )
668
966
  return Image(
@@ -674,34 +972,53 @@ class ImagesContainer:
674
972
 
675
973
  def compute_image_percentile(
676
974
  image: Image,
677
- start_percentile: float = 0.1,
678
- end_percentile: float = 99.9,
679
- ) -> tuple[list[float], list[float]]:
975
+ percentiles: tuple[float, float] | list[tuple[float, float]] = (0.1, 99.9),
976
+ ) -> list[tuple[float, float]]:
680
977
  """Compute the start and end percentiles for each channel of an image.
681
978
 
682
979
  Args:
683
980
  image: The image to compute the percentiles for.
684
- start_percentile: The start percentile to compute.
685
- end_percentile: The end percentile to compute.
981
+ percentiles: The start and end percentiles for each channel.
982
+ If a single tuple is provided, the same percentiles will be used
983
+ for all channels.
686
984
 
687
985
  Returns:
688
986
  A tuple containing the start and end percentiles for each channel.
689
987
  """
690
- starts, ends = [], []
691
- for c in range(image.num_channels):
692
- if image.num_channels == 1:
693
- data = image.get_as_dask()
694
- else:
695
- data = image.get_as_dask(c=c)
988
+ num_channels = image.num_channels
989
+ # handle the case where a single tuple is provided
990
+ if isinstance(percentiles, tuple):
991
+ if len(percentiles) != 2:
992
+ raise NgioValueError(
993
+ "Percentiles must be a tuple of two floats: "
994
+ "(start_percentile, end_percentile) or "
995
+ "a list of such tuples with length equal to the number of channels."
996
+ )
997
+ if not isinstance(percentiles[0], float) or not isinstance(
998
+ percentiles[1], float
999
+ ):
1000
+ raise NgioValueError(
1001
+ "Percentiles must be a tuple of two floats: "
1002
+ "(start_percentile, end_percentile) or "
1003
+ "a list of such tuples with length equal to the number of channels."
1004
+ )
1005
+ percentiles = [percentiles] * num_channels
696
1006
 
1007
+ if len(percentiles) != num_channels:
1008
+ raise NgioValueError(
1009
+ "If a list of percentiles is provided, its length must be equal "
1010
+ "to the number of channels."
1011
+ )
1012
+ starts_and_ends = []
1013
+ for c_idx, (start_percentile, end_percentile) in enumerate(percentiles):
1014
+ data = image.get_as_dask(c=c_idx)
697
1015
  data = da.ravel(data)
698
1016
  # remove all the zeros
699
1017
  mask = data > 1e-16
700
1018
  data = data[mask]
701
1019
  _data = data.compute()
702
1020
  if _data.size == 0:
703
- starts.append(0.0)
704
- ends.append(0.0)
1021
+ starts_and_ends.append((0.0, 0.0))
705
1022
  continue
706
1023
 
707
1024
  # compute the percentiles
@@ -709,148 +1026,107 @@ def compute_image_percentile(
709
1026
  data, [start_percentile, end_percentile], method="nearest"
710
1027
  ).compute() # type: ignore (return type is a tuple of floats)
711
1028
 
712
- starts.append(float(_s_perc))
713
- ends.append(float(_e_perc))
714
- return starts, ends
1029
+ starts_and_ends.append((float(_s_perc), float(_e_perc)))
1030
+ return starts_and_ends
715
1031
 
716
1032
 
717
1033
  def derive_image_container(
1034
+ *,
718
1035
  image_container: ImagesContainer,
719
1036
  store: StoreOrGroup,
720
1037
  ref_path: str | None = None,
1038
+ # Metadata parameters
721
1039
  shape: Sequence[int] | None = None,
722
- labels: Sequence[str] | None = None,
723
- pixel_size: PixelSize | None = None,
724
- axes_names: Sequence[str] | None = None,
1040
+ pixelsize: float | tuple[float, float] | None = None,
1041
+ z_spacing: float | None = None,
1042
+ time_spacing: float | None = None,
725
1043
  name: str | None = None,
726
- chunks: Sequence[int] | None = None,
1044
+ translation: Sequence[float] | None = None,
1045
+ channels_policy: Literal["same", "squeeze", "singleton"] | int = "same",
1046
+ channels_meta: Sequence[str | Channel] | None = None,
1047
+ ngff_version: NgffVersions | None = None,
1048
+ # Zarr Array parameters
1049
+ chunks: ChunksLike | None = None,
1050
+ shards: ShardsLike | None = None,
727
1051
  dtype: str | None = None,
728
- dimension_separator: DIMENSION_SEPARATOR | None = None,
729
- compressor=None,
1052
+ dimension_separator: Literal[".", "/"] | None = None,
1053
+ compressors: CompressorLike | None = None,
1054
+ extra_array_kwargs: Mapping[str, Any] | None = None,
730
1055
  overwrite: bool = False,
1056
+ # Deprecated arguments
1057
+ labels: Sequence[str] | None = None,
1058
+ pixel_size: PixelSize | None = None,
731
1059
  ) -> ImagesContainer:
732
- """Create an empty OME-Zarr image from an existing image.
1060
+ """Derive a new OME-Zarr image container from an existing image.
1061
+
1062
+ If a kwarg is not provided, the value from the reference image will be used.
733
1063
 
734
1064
  Args:
735
- image_container (ImagesContainer): The image container to derive the new image.
1065
+ image_container (ImagesContainer): The image container to derive the new image
1066
+ from.
736
1067
  store (StoreOrGroup): The Zarr store or group to create the image in.
737
1068
  ref_path (str | None): The path to the reference image in the image container.
738
1069
  shape (Sequence[int] | None): The shape of the new image.
739
- labels (Sequence[str] | None): The labels of the new image.
740
- pixel_size (PixelSize | None): The pixel size of the new image.
741
- axes_names (Sequence[str] | None): The axes names of the new image.
1070
+ pixelsize (float | tuple[float, float] | None): The pixel size of the new image.
1071
+ z_spacing (float | None): The z spacing of the new image.
1072
+ time_spacing (float | None): The time spacing of the new image.
742
1073
  name (str | None): The name of the new image.
743
- chunks (Sequence[int] | None): The chunk shape of the new image.
744
- dimension_separator (DIMENSION_SEPARATOR | None): The separator to use for
745
- dimensions. If None it will use the same as the reference image.
746
- compressor: The compressor to use. If None it will use
747
- the same as the reference image.
1074
+ translation (Sequence[float] | None): The translation for each axis
1075
+ at the highest resolution level. Defaults to None.
1076
+ channels_policy (Literal["squeeze", "same", "singleton"] | int): Possible
1077
+ policies:
1078
+ - If "squeeze", the channels axis will be removed (no matter its size).
1079
+ - If "same", the channels axis will be kept as is (if it exists).
1080
+ - If "singleton", the channels axis will be set to size 1.
1081
+ - If an integer is provided, the channels axis will be changed to have
1082
+ that size.
1083
+ channels_meta (Sequence[str | Channel] | None): The channels metadata
1084
+ of the new image.
1085
+ ngff_version (NgffVersions | None): The NGFF version to use.
1086
+ chunks (ChunksLike | None): The chunk shape of the new image.
1087
+ shards (ShardsLike | None): The shard shape of the new image.
748
1088
  dtype (str | None): The data type of the new image.
749
- overwrite (bool): Whether to overwrite an existing image.
1089
+ dimension_separator (Literal[".", "/"] | None): The separator to use for
1090
+ dimensions.
1091
+ compressors (CompressorLike | None): The compressors to use.
1092
+ extra_array_kwargs (Mapping[str, Any] | None): Extra arguments to pass to
1093
+ the zarr array creation.
1094
+ overwrite (bool): Whether to overwrite an existing image. Defaults to False.
1095
+ labels (Sequence[str] | None): Deprecated. This argument is deprecated,
1096
+ please use channels_meta instead.
1097
+ pixel_size (PixelSize | None): Deprecated. The pixel size of the new image.
1098
+ This argument is deprecated, please use pixelsize, z_spacing,
1099
+ and time_spacing instead.
750
1100
 
751
1101
  Returns:
752
- ImagesContainer: The new image
1102
+ ImagesContainer: The new derived image container.
753
1103
 
754
1104
  """
755
- if ref_path is None:
756
- ref_image = image_container.get()
757
- else:
758
- ref_image = image_container.get(path=ref_path)
759
-
760
- ref_meta = ref_image.meta
761
-
762
- if shape is None:
763
- shape = ref_image.shape
764
-
765
- if pixel_size is None:
766
- pixel_size = ref_image.pixel_size
767
-
768
- if axes_names is None:
769
- axes_names = ref_meta.axes_handler.axes_names
770
-
771
- if len(axes_names) != len(shape):
772
- raise NgioValidationError(
773
- "The axes names of the new image does not match the reference image."
774
- f"Got {axes_names} for shape {shape}."
775
- )
776
-
777
- if chunks is None:
778
- chunks = ref_image.chunks
779
-
780
- if len(chunks) != len(shape):
781
- raise NgioValidationError(
782
- "The chunks of the new image does not match the reference image."
783
- f"Got {chunks} for shape {shape}."
784
- )
785
-
786
- if name is None:
787
- name = ref_meta.name
788
-
789
- if dtype is None:
790
- dtype = ref_image.dtype
791
-
792
- if dimension_separator is None:
793
- dimension_separator = ref_image.zarr_array._dimension_separator # type: ignore
794
-
795
- if compressor is None:
796
- compressor = ref_image.zarr_array.compressor # type: ignore
797
-
798
- handler = create_empty_image_container(
1105
+ ref_image = image_container.get(path=ref_path)
1106
+ group_handler, axes_setup = abstract_derive(
1107
+ ref_image=ref_image,
1108
+ meta_type=NgioImageMeta,
799
1109
  store=store,
800
1110
  shape=shape,
801
- pixelsize=pixel_size.x,
802
- z_spacing=pixel_size.z,
803
- time_spacing=pixel_size.t,
804
- levels=ref_meta.paths,
805
- yx_scaling_factor=ref_meta.yx_scaling(),
806
- z_scaling_factor=ref_meta.z_scaling(),
807
- time_unit=pixel_size.time_unit,
808
- space_unit=pixel_size.space_unit,
809
- axes_names=axes_names,
1111
+ pixelsize=pixelsize,
1112
+ z_spacing=z_spacing,
1113
+ time_spacing=time_spacing,
810
1114
  name=name,
1115
+ translation=translation,
1116
+ channels_meta=channels_meta,
1117
+ channels_policy=channels_policy,
1118
+ ngff_version=ngff_version,
811
1119
  chunks=chunks,
1120
+ shards=shards,
812
1121
  dtype=dtype,
813
- dimension_separator=dimension_separator, # type: ignore
814
- compressor=compressor, # type: ignore
1122
+ dimension_separator=dimension_separator,
1123
+ compressors=compressors,
1124
+ extra_array_kwargs=extra_array_kwargs,
815
1125
  overwrite=overwrite,
816
- version=ref_meta.version,
817
- )
818
- image_container = ImagesContainer(handler)
819
-
820
- if ref_image.num_channels == image_container.num_channels:
821
- _labels = ref_image.channel_labels
822
- wavelength_id = ref_image.wavelength_ids
823
-
824
- channel_meta = ref_image.channels_meta
825
- colors = [c.channel_visualisation.color for c in channel_meta.channels]
826
- active = [c.channel_visualisation.active for c in channel_meta.channels]
827
- start = [c.channel_visualisation.start for c in channel_meta.channels]
828
- end = [c.channel_visualisation.end for c in channel_meta.channels]
829
- else:
830
- _labels = None
831
- wavelength_id = None
832
- colors = None
833
- active = None
834
- start = None
835
- end = None
836
-
837
- if labels is not None:
838
- if len(labels) != image_container.num_channels:
839
- raise NgioValidationError(
840
- "The number of labels does not match the number of channels."
841
- )
842
- _labels = labels
843
-
844
- image_container.set_channel_meta(
845
- labels=_labels,
846
- wavelength_id=wavelength_id,
847
- percentiles=None,
848
- colors=colors,
849
- active=active,
850
- start=start,
851
- end=end,
1126
+ labels=labels,
1127
+ pixel_size=pixel_size,
852
1128
  )
853
- return image_container
1129
+ return ImagesContainer(group_handler=group_handler, axes_setup=axes_setup)
854
1130
 
855
1131
 
856
1132
  def _parse_str_or_model(
@@ -859,9 +1135,9 @@ def _parse_str_or_model(
859
1135
  """Parse a string or ChannelSelectionModel to an integer channel index."""
860
1136
  if isinstance(channel_selection, int):
861
1137
  if channel_selection < 0:
862
- raise NgioValidationError("Channel index must be a non-negative integer.")
1138
+ raise NgioValueError("Channel index must be a non-negative integer.")
863
1139
  if channel_selection >= image.num_channels:
864
- raise NgioValidationError(
1140
+ raise NgioValueError(
865
1141
  "Channel index must be less than the number "
866
1142
  f"of channels ({image.num_channels})."
867
1143
  )
@@ -879,7 +1155,7 @@ def _parse_str_or_model(
879
1155
  )
880
1156
  elif channel_selection.mode == "index":
881
1157
  return int(channel_selection.identifier)
882
- raise NgioValidationError(
1158
+ raise NgioValueError(
883
1159
  "Invalid channel selection type. "
884
1160
  f"{channel_selection} is of type {type(channel_selection)} ",
885
1161
  "supported types are str, ChannelSelectionModel, and int.",
@@ -898,7 +1174,7 @@ def _parse_channel_selection(
898
1174
  elif isinstance(channel_selection, Sequence):
899
1175
  _sequence = [_parse_str_or_model(image, cs) for cs in channel_selection]
900
1176
  return {"c": _sequence}
901
- raise NgioValidationError(
1177
+ raise NgioValueError(
902
1178
  f"Invalid channel selection type {type(channel_selection)}. "
903
1179
  "Supported types are int, str, ChannelSelectionModel, and Sequence."
904
1180
  )
@@ -912,7 +1188,7 @@ def add_channel_selection_to_slicing_dict(
912
1188
  """Add channel selection information to the slicing dictionary."""
913
1189
  channel_info = _parse_channel_selection(image, channel_selection)
914
1190
  if "c" in slicing_dict and channel_info:
915
- raise NgioValidationError(
1191
+ raise NgioValueError(
916
1192
  "Both channel_selection and 'c' in slicing_kwargs are provided. "
917
1193
  "Which channel selection should be used is ambiguous. "
918
1194
  "Please provide only one."