mlarray 0.0.34__tar.gz → 0.0.36__tar.gz

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 (38) hide show
  1. {mlarray-0.0.34 → mlarray-0.0.36}/PKG-INFO +1 -1
  2. {mlarray-0.0.34 → mlarray-0.0.36}/docs/optimization.md +2 -4
  3. {mlarray-0.0.34 → mlarray-0.0.36}/docs/schema.md +25 -6
  4. {mlarray-0.0.34 → mlarray-0.0.36}/docs/usage.md +37 -0
  5. {mlarray-0.0.34 → mlarray-0.0.36}/examples/example_channel.py +3 -3
  6. mlarray-0.0.36/examples/example_non_spatial.py +42 -0
  7. {mlarray-0.0.34 → mlarray-0.0.36}/mlarray/__init__.py +3 -0
  8. {mlarray-0.0.34 → mlarray-0.0.36}/mlarray/meta.py +179 -40
  9. {mlarray-0.0.34 → mlarray-0.0.36}/mlarray/mlarray.py +57 -51
  10. {mlarray-0.0.34 → mlarray-0.0.36}/mlarray.egg-info/PKG-INFO +1 -1
  11. {mlarray-0.0.34 → mlarray-0.0.36}/mlarray.egg-info/SOURCES.txt +1 -0
  12. {mlarray-0.0.34 → mlarray-0.0.36}/.github/workflows/workflow.yml +0 -0
  13. {mlarray-0.0.34 → mlarray-0.0.36}/.gitignore +0 -0
  14. {mlarray-0.0.34 → mlarray-0.0.36}/LICENSE +0 -0
  15. {mlarray-0.0.34 → mlarray-0.0.36}/MANIFEST.in +0 -0
  16. {mlarray-0.0.34 → mlarray-0.0.36}/README.md +0 -0
  17. {mlarray-0.0.34 → mlarray-0.0.36}/assets/banner.png +0 -0
  18. {mlarray-0.0.34 → mlarray-0.0.36}/assets/banner.png~ +0 -0
  19. {mlarray-0.0.34 → mlarray-0.0.36}/docs/api.md +0 -0
  20. {mlarray-0.0.34 → mlarray-0.0.36}/docs/cli.md +0 -0
  21. {mlarray-0.0.34 → mlarray-0.0.36}/docs/index.md +0 -0
  22. {mlarray-0.0.34 → mlarray-0.0.36}/docs/why.md +0 -0
  23. {mlarray-0.0.34 → mlarray-0.0.36}/examples/example_metadata_only.py +0 -0
  24. {mlarray-0.0.34 → mlarray-0.0.36}/examples/example_open.py +0 -0
  25. {mlarray-0.0.34 → mlarray-0.0.36}/examples/example_save_load.py +0 -0
  26. {mlarray-0.0.34 → mlarray-0.0.36}/mkdocs.yml +0 -0
  27. {mlarray-0.0.34 → mlarray-0.0.36}/mlarray/cli.py +0 -0
  28. {mlarray-0.0.34 → mlarray-0.0.36}/mlarray/utils.py +0 -0
  29. {mlarray-0.0.34 → mlarray-0.0.36}/mlarray.egg-info/dependency_links.txt +0 -0
  30. {mlarray-0.0.34 → mlarray-0.0.36}/mlarray.egg-info/entry_points.txt +0 -0
  31. {mlarray-0.0.34 → mlarray-0.0.36}/mlarray.egg-info/requires.txt +0 -0
  32. {mlarray-0.0.34 → mlarray-0.0.36}/mlarray.egg-info/top_level.txt +0 -0
  33. {mlarray-0.0.34 → mlarray-0.0.36}/pyproject.toml +0 -0
  34. {mlarray-0.0.34 → mlarray-0.0.36}/setup.cfg +0 -0
  35. {mlarray-0.0.34 → mlarray-0.0.36}/tests/test_bboxes.py +0 -0
  36. {mlarray-0.0.34 → mlarray-0.0.36}/tests/test_metadata.py +0 -0
  37. {mlarray-0.0.34 → mlarray-0.0.36}/tests/test_optimization.py +0 -0
  38. {mlarray-0.0.34 → mlarray-0.0.36}/tests/test_usage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlarray
3
- Version: 0.0.34
3
+ Version: 0.0.36
4
4
  Summary: Array format specialized for Machine Learning with Blosc2 backend and standardized metadata.
5
5
  Author-email: Karol Gotkowski <karol.gotkowski@dkfz.de>
6
6
  License: MIT
@@ -25,7 +25,7 @@ Instead of requiring users to be storage experts, MLArray introduces a **patch s
25
25
  * element size (bytes per pixel),
26
26
  * CPU cache sizes (L1 / L3 per core),
27
27
  * your patch size (2D or 3D),
28
- * channel layout (via `channel_axis`),
28
+ * non-spatial axes layout (via `axis_labels`),
29
29
  * and then chooses block/chunk sizes that aim to keep decompression and reads cache-friendly.
30
30
 
31
31
  Practically: this means *reading a training patch should tend to require as few chunk/block touches as possible*, while keeping the decompressed working set aligned with CPU caches.
@@ -201,8 +201,6 @@ When to use:
201
201
 
202
202
  ## Notes and practical tips
203
203
 
204
- * **Patch optimization is currently implemented for 2D and 3D images** (and common channel handling). If your data falls outside that, you can still set `chunk_size`/`block_size` manually or let Blosc2 decide.
204
+ * **Patch optimization is currently implemented for 2D and 3D images** (with at most one further non-spatial axis). If your data falls outside that, you can still set `chunk_size`/`block_size` manually or let Blosc2 decide.
205
205
  * The best patch size to use is usually the **patch size your dataloader requests most often** (training patch, not necessarily inference tile size).
206
206
  * If you’re unsure: start with the default (`patch_size='default'`) and only tune if profiling shows I/O bottlenecks.
207
-
208
- If you want, I can also help you add a short “How to pick patch_size” subsection tailored to typical pipelines (nnU-Net, 2D slice training, multi-channel inputs).
@@ -58,15 +58,34 @@ Top-level metadata container.
58
58
  * **Description:** Spatial metadata for the image.
59
59
  * **Dataclass:** `MetaSpatial`.
60
60
 
61
- This section stores the information needed to interpret the array in physical space (e.g., voxel spacing, coordinate origin, and orientation). It also optionally captures array shape and channel layout to make downstream consumers more robust.
61
+ This section stores the information needed to interpret the array in physical space (e.g., voxel spacing, coordinate origin, and orientation). It also optionally captures array shape and axes layout to make downstream consumers more robust.
62
62
 
63
63
  | field | type | description |
64
64
  | ------------ | --------------------------- | ---------------------------------------------------------------------------------------- |
65
65
  | spacing | Optional[List[float]] | Voxel spacing per spatial axis, length = `ndims`. |
66
66
  | origin | Optional[List[float]] | Origin per spatial axis, length = `ndims`. |
67
67
  | direction | Optional[List[List[float]]] | Direction matrix, shape `[ndims][ndims]`. |
68
- | shape | Optional[List[int]] | Array shape. If `channel_axis` is set, length = `ndims + 1`, otherwise length = `ndims`. |
69
- | channel_axis | Optional[int] | Index of channel dimension in the full array, if present. |
68
+ | shape | Optional[List[int]] | Full array shape, length = spatial + non-spatial axes. |
69
+ | axis_labels | Optional[List[str,AxisLabel]] | Per-axis labels or roles, length = full array `ndims`. |
70
+ | axis_units | Optional[List[str]] | Per-axis units, length = full array `ndims`. |
71
+
72
+ ---
73
+
74
+ #### AxisLabel
75
+
76
+ Axis labels describe the semantic role of each axis. They may be provided as strings or enum values.
77
+
78
+ | value | description |
79
+ | ------------- | -------------------------------------------------------- |
80
+ | spatial | Generic spatial axis (used when no axis-specific label). |
81
+ | spatial_x | Spatial axis representing X. |
82
+ | spatial_y | Spatial axis representing Y. |
83
+ | spatial_z | Spatial axis representing Z. |
84
+ | non_spatial | Generic non-spatial axis. |
85
+ | channel | Channel axis (e.g., color channels or feature maps). |
86
+ | temporal | Time axis. |
87
+ | continuous | Continuous-valued axis (non-spatial). |
88
+ | components | Component axis (e.g., vector components). |
70
89
 
71
90
  ---
72
91
 
@@ -131,9 +150,9 @@ This section records how the array was laid out on disk (chunking, blocking, pat
131
150
 
132
151
  | field | type | description |
133
152
  | ---------- | --------------------- | ---------------------------------------------------------------------- |
134
- | chunk_size | Optional[List[float]] | Chunk size per axis, length = full array `ndims` (including channels). |
135
- | block_size | Optional[List[float]] | Block size per axis, length = full array `ndims` (including channels). |
136
- | patch_size | Optional[List[float]] | Patch size per spatial axis, length = `ndims` (channels excluded). |
153
+ | chunk_size | Optional[List[float]] | Chunk size per axis, length = full array `ndims` (including non-spatial axes). |
154
+ | block_size | Optional[List[float]] | Block size per axis, length = full array `ndims` (including non-spatial axes). |
155
+ | patch_size | Optional[List[float]] | Patch size per spatial axis, length = `ndims` (non-spatial axes excluded). |
137
156
 
138
157
  ---
139
158
 
@@ -128,6 +128,43 @@ image.save("with-metadata.mla")
128
128
 
129
129
  ---
130
130
 
131
+ ## Non-spatial data usage (Channels, temporal, ...)
132
+
133
+ Use `axis_labels` to mark which axes are spatial and which are non-spatial
134
+ (channels, temporal, components, etc.). Spatial metadata (`spacing`, `origin`,
135
+ `direction`) is specified only for the spatial axes, while the full array shape
136
+ includes both spatial and non-spatial axes.
137
+
138
+ ```python
139
+ import numpy as np
140
+ from mlarray import MLArray, MetaSpatial
141
+
142
+ # Example shape: (time, z, y, x, channels)
143
+ array = np.random.random((2, 6, 4, 4, 3, 2))
144
+
145
+ axis_labels = [
146
+ MetaSpatial.AxisLabel.temporal,
147
+ MetaSpatial.AxisLabel.spatial_z,
148
+ MetaSpatial.AxisLabel.spatial_y,
149
+ "spatial_x", # Possible to pass predefined labels as string as well
150
+ MetaSpatial.AxisLabel.channel,
151
+ "some-other-type" # Possible to pass arbitrary strings as well
152
+ ]
153
+
154
+ image = MLArray(
155
+ array,
156
+ spacing=(2.5, 0.7, 0.7), # spatial axes only (z, y, x)
157
+ origin=(0.0, 0.0, 0.0),
158
+ axis_labels=axis_labels,
159
+ )
160
+
161
+ # Optional per-axis units (length = full array ndims)
162
+ image.meta.spatial.axis_units = ["s", "mm", "mm", "mm", ""]
163
+ image.save("time-series.mla", patch_size=None)
164
+ ```
165
+
166
+
167
+
131
168
  ## Patch size variants
132
169
 
133
170
  MLArray stores arrays in a chunked layout to enable efficient partial reads. You can control how data is chunked using `patch_size` (recommended in most cases), or manually specify chunk and block sizes when you need full control.
@@ -1,14 +1,14 @@
1
1
  import numpy as np
2
2
  import os
3
3
  from pathlib import Path
4
- from mlarray import MLArray, Meta, MetaBbox
4
+ from mlarray import MLArray, Meta, MetaBbox, MetaSpatial
5
5
  import json
6
6
 
7
7
 
8
8
  if __name__ == '__main__':
9
9
  print("Creating array...")
10
10
  array = np.random.random((32, 64, 64, 3))
11
- channel_axis = 3
11
+ axis_labels = (MetaSpatial.AxisLabel.spatial_z, MetaSpatial.AxisLabel.spatial_y, MetaSpatial.AxisLabel.spatial_x, MetaSpatial.AxisLabel.channel)
12
12
  spacing = np.array((2, 2.5, 4))
13
13
  origin = (1, 1, 1)
14
14
  direction = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
@@ -20,7 +20,7 @@ if __name__ == '__main__':
20
20
  os.remove(filepath)
21
21
 
22
22
  print("Initializing image...")
23
- image = MLArray(array, spacing=spacing, origin=origin, direction=direction, channel_axis=channel_axis, meta=Meta(source=source_meta, bbox=MetaBbox(bboxes)))
23
+ image = MLArray(array, spacing=spacing, origin=origin, direction=direction, axis_labels=axis_labels, meta=Meta(source=source_meta, bbox=MetaBbox(bboxes)))
24
24
  print("Saving image...")
25
25
  image.save(filepath)
26
26
 
@@ -0,0 +1,42 @@
1
+ import numpy as np
2
+ import os
3
+ from pathlib import Path
4
+ from mlarray import MLArray, Meta, MetaBbox, MetaSpatial
5
+ import json
6
+
7
+
8
+ if __name__ == '__main__':
9
+ print("Creating array...")
10
+ array = np.random.random((2, 6, 4, 4, 3, 2))
11
+ axis_labels = [
12
+ MetaSpatial.AxisLabel.temporal,
13
+ MetaSpatial.AxisLabel.spatial_z,
14
+ MetaSpatial.AxisLabel.spatial_y,
15
+ "spatial_x", # Possible to pass predefined labels as string as well
16
+ MetaSpatial.AxisLabel.channel,
17
+ "some-other-type" # Possible to pass arbitrary strings as well
18
+ ]
19
+ spacing = np.array((2, 2.5, 4))
20
+ origin = (1, 1, 1)
21
+ direction = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
22
+ source_meta = {"tmp1": "This is an image", "tmp2": 5, "tmp3": {"test1": 16.4587, "test2": [1, 2, 3, 4, 5, 6]}}
23
+ bboxes = [[[0, 1], [0, 1], [0, 1]]]
24
+ filepath = "tmp.mla"
25
+
26
+ if Path(filepath).is_file():
27
+ os.remove(filepath)
28
+
29
+ print("Initializing image...")
30
+ image = MLArray(array, spacing=spacing, origin=origin, direction=direction, axis_labels=axis_labels, meta=Meta(source=source_meta, bbox=MetaBbox(bboxes)))
31
+ image.meta.spatial.axis_units = ["s", "mm", "mm", "mm", ""]
32
+ print("Saving image...")
33
+ image.save(filepath, patch_size=None)
34
+
35
+ print("Loading image...")
36
+ image = MLArray(filepath)
37
+ print(json.dumps(image.meta.to_mapping(), indent=2, sort_keys=True))
38
+ print("Image mean value: ", np.mean(image.to_numpy()))
39
+ print("Some array data: \n", image[:2, :2, 0])
40
+
41
+ if Path(filepath).is_file():
42
+ os.remove(filepath)
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
17
17
  MetaSpatial,
18
18
  MetaStatistics,
19
19
  MetaVersion,
20
+ AxisLabel
20
21
  )
21
22
  from mlarray.utils import is_serializable
22
23
  from mlarray.cli import cli_print_header, cli_convert_to_mlarray
@@ -36,6 +37,7 @@ __all__ = [
36
37
  "MetaSpatial",
37
38
  "MetaStatistics",
38
39
  "MetaVersion",
40
+ "AxisLabel",
39
41
  "is_serializable",
40
42
  "cli_print_header",
41
43
  "cli_convert_to_mlarray",
@@ -61,6 +63,7 @@ _LAZY_ATTRS = {
61
63
  "MetaSpatial": ("mlarray.meta", "MetaSpatial"),
62
64
  "MetaStatistics": ("mlarray.meta", "MetaStatistics"),
63
65
  "MetaVersion": ("mlarray.meta", "MetaVersion"),
66
+ "AxisLabel": ("mlarray.meta", "AxisLabel"),
64
67
  "is_serializable": ("mlarray.utils", "is_serializable"),
65
68
  "cli_print_header": ("mlarray.cli", "cli_print_header"),
66
69
  "cli_convert_to_mlarray": ("mlarray.cli", "cli_convert_to_mlarray"),
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import MISSING, dataclass, field, fields
4
- from typing import Any, Dict, List, Mapping, Optional, Type, TypeVar, Union
4
+ from typing import Any, Mapping, Optional, Type, TypeVar, Union, TypeAlias, Iterable
5
+ from enum import Enum
5
6
 
6
7
  import numpy as np
7
8
  from mlarray.utils import is_serializable
@@ -57,7 +58,7 @@ class BaseMeta:
57
58
  """Return a user-friendly string based on plain values."""
58
59
  return str(self.to_plain())
59
60
 
60
- def to_mapping(self, *, include_none: bool = True) -> Dict[str, Any]:
61
+ def to_mapping(self, *, include_none: bool = True) -> dict[str, Any]:
61
62
  """Serialize to a mapping, recursively expanding nested BaseMeta.
62
63
 
63
64
  Args:
@@ -66,7 +67,7 @@ class BaseMeta:
66
67
  Returns:
67
68
  A dict of field names to serialized values.
68
69
  """
69
- out: Dict[str, Any] = {}
70
+ out: dict[str, Any] = {}
70
71
  for f in fields(self):
71
72
  v = getattr(self, f.name)
72
73
  if v is None and not include_none:
@@ -125,7 +126,7 @@ class BaseMeta:
125
126
  A dict of field values, with nested BaseMeta expanded. SingleKeyBaseMeta
126
127
  overrides this to return its wrapped value.
127
128
  """
128
- out: Dict[str, Any] = {}
129
+ out: dict[str, Any] = {}
129
130
  for f in fields(self):
130
131
  v = getattr(self, f.name)
131
132
  if v is None and not include_none:
@@ -251,7 +252,7 @@ class SingleKeyBaseMeta(BaseMeta):
251
252
  """Set the wrapped value."""
252
253
  self.value = v
253
254
 
254
- def to_mapping(self, *, include_none: bool = True) -> Dict[str, Any]:
255
+ def to_mapping(self, *, include_none: bool = True) -> dict[str, Any]:
255
256
  """Serialize to a mapping with the single key.
256
257
 
257
258
  Args:
@@ -451,6 +452,109 @@ def _validate_float_int_matrix(value: Any, label: str, ndims: Optional[int] = No
451
452
  for v in row:
452
453
  if not isinstance(v, (float, int)):
453
454
  raise TypeError(f"{label} must contain only floats or ints")
455
+
456
+
457
+ def validate_and_cast_axis_labels(
458
+ value: Any,
459
+ label: str,
460
+ ndims: Optional[int] = None,
461
+ ) -> tuple[Optional[list[str]], int, int]:
462
+ """
463
+ Validate axis labels/roles, normalize to list[str], and count spatial/non-spatial axes.
464
+
465
+ Args:
466
+ value: None or list-like of axis labels/roles (enum members or strings).
467
+ label: Label used in error messages.
468
+ ndims: If provided, enforce list length == ndims.
469
+
470
+ Returns:
471
+ (labels, n_spatial, n_non_spatial)
472
+
473
+ Raises:
474
+ TypeError: If value is not None and not list-like, or contains invalid items.
475
+ ValueError: If ndims is provided and length mismatch occurs.
476
+ """
477
+ if value is None:
478
+ return None, 0, 0
479
+
480
+ # Cast list / tuple / ndarray -> list
481
+ v = _cast_to_list(value, label)
482
+
483
+ # Enforce 1D list
484
+ for i, item in enumerate(v):
485
+ if isinstance(item, list):
486
+ raise TypeError(
487
+ f"{label} must be a 1D list (got nested list at index {i})"
488
+ )
489
+
490
+ if ndims is not None and len(v) != ndims:
491
+ raise ValueError(f"{label} must have length {ndims}")
492
+
493
+ spatial_enum_roles = {
494
+ AxisLabelEnum.spatial,
495
+ AxisLabelEnum.spatial_x,
496
+ AxisLabelEnum.spatial_y,
497
+ AxisLabelEnum.spatial_z,
498
+ }
499
+ spatial_string_roles = {r.value for r in spatial_enum_roles}
500
+
501
+ out: list[str] = []
502
+ n_spatial = 0
503
+ n_non_spatial = 0
504
+
505
+ for i, x in enumerate(v):
506
+ if isinstance(x, AxisLabelEnum):
507
+ out.append(x.value)
508
+ if x in spatial_enum_roles:
509
+ n_spatial += 1
510
+ else:
511
+ n_non_spatial += 1
512
+ continue
513
+
514
+ if isinstance(x, str):
515
+ out.append(x)
516
+ if x in spatial_string_roles:
517
+ n_spatial += 1
518
+ else:
519
+ n_non_spatial += 1
520
+ continue
521
+
522
+ raise TypeError(
523
+ f"{label}[{i}] must be a str or AxisLabelEnum "
524
+ f"(got {type(x).__name__})"
525
+ )
526
+
527
+ return out, n_spatial, n_non_spatial
528
+
529
+
530
+ def _is_spatial_axis(label: Union[str, AxisLabelEnum]) -> bool:
531
+ """Return True if an axis label/role represents a spatial axis."""
532
+ if isinstance(label, AxisLabelEnum):
533
+ return label in {
534
+ AxisLabelEnum.spatial,
535
+ AxisLabelEnum.spatial_x,
536
+ AxisLabelEnum.spatial_y,
537
+ AxisLabelEnum.spatial_z,
538
+ }
539
+
540
+ if isinstance(label, str):
541
+ return label in {
542
+ AxisLabelEnum.spatial.value,
543
+ AxisLabelEnum.spatial_x.value,
544
+ AxisLabelEnum.spatial_y.value,
545
+ AxisLabelEnum.spatial_z.value,
546
+ }
547
+
548
+ return False
549
+
550
+
551
+ def _spatial_axis_mask(
552
+ labels: Iterable[Union[str, AxisLabelEnum]],
553
+ ) -> list[bool]:
554
+ """Return a boolean mask indicating which axes are spatial."""
555
+ if labels is None:
556
+ return None
557
+ return [_is_spatial_axis(label) for label in labels]
454
558
 
455
559
 
456
560
  @dataclass(slots=True)
@@ -460,19 +564,18 @@ class MetaBlosc2(BaseMeta):
460
564
  Attributes:
461
565
  chunk_size: List of per-dimension chunk sizes. Length must match ndims.
462
566
  block_size: List of per-dimension block sizes. Length must match ndims.
463
- patch_size: List of per-dimension patch sizes. Length must match ndims,
464
- or (ndims - 1) when a channel axis is present.
567
+ patch_size: List of per-dimension patch sizes. Length must match spatial ndims.
465
568
  """
466
569
  chunk_size: Optional[list] = None
467
570
  block_size: Optional[list] = None
468
571
  patch_size: Optional[list] = None
469
572
 
470
- def _validate_and_cast(self, *, ndims: Optional[int] = None, channel_axis: Optional[int] = None, **_: Any) -> None:
573
+ def _validate_and_cast(self, *, ndims: Optional[int] = None, spatial_ndims: Optional[int] = None, **_: Any) -> None:
471
574
  """Validate and normalize tiling sizes.
472
575
 
473
576
  Args:
474
- ndims: Number of spatial dimensions.
475
- channel_axis: Channel axis index when present.
577
+ ndims: Number of dimensions.
578
+ spatial_ndims: Number of spatial dimensions.
476
579
  **_: Unused extra context.
477
580
  """
478
581
  if self.chunk_size is not None:
@@ -484,9 +587,37 @@ class MetaBlosc2(BaseMeta):
484
587
  _validate_float_int_list(self.block_size, "meta._blosc2.block_size", ndims)
485
588
 
486
589
  if self.patch_size is not None:
487
- _ndims = ndims if (ndims is None or channel_axis is None) else ndims - 1
590
+ spatial_ndims = ndims if spatial_ndims is None else spatial_ndims
488
591
  self.patch_size = _cast_to_list(self.patch_size, "meta._blosc2.patch_size")
489
- _validate_float_int_list(self.patch_size, "meta._blosc2.patch_size", _ndims)
592
+ _validate_float_int_list(self.patch_size, "meta._blosc2.patch_size", spatial_ndims)
593
+
594
+
595
+ class AxisLabelEnum(str, Enum):
596
+ """Axis label/role identifiers used for spatial metadata.
597
+
598
+ Attributes:
599
+ spatial: Generic spatial axis (used when no axis-specific label applies).
600
+ spatial_x: Spatial axis representing X.
601
+ spatial_y: Spatial axis representing Y.
602
+ spatial_z: Spatial axis representing Z.
603
+ non_spatial: Generic non-spatial axis.
604
+ channel: Channel axis (e.g., color channels or feature maps).
605
+ temporal: Time axis.
606
+ continuous: Continuous-valued axis (non-spatial).
607
+ components: Component axis (e.g., vector components).
608
+ """
609
+ spatial = "spatial"
610
+ spatial_x = "spatial_x"
611
+ spatial_y = "spatial_y"
612
+ spatial_z = "spatial_z"
613
+ non_spatial = "non_spatial"
614
+ channel = "channel"
615
+ temporal = "temporal"
616
+ continuous = "continuous"
617
+ components = "components"
618
+
619
+
620
+ AxisLabel: TypeAlias = Union[str, AxisLabelEnum]
490
621
 
491
622
 
492
623
  @dataclass(slots=True)
@@ -497,42 +628,49 @@ class MetaSpatial(BaseMeta):
497
628
  spacing: Per-dimension spacing values. Length must match ndims.
498
629
  origin: Per-dimension origin values. Length must match ndims.
499
630
  direction: Direction cosine matrix of shape [ndims, ndims].
500
- shape: Array shape. Length must match ndims, or (ndims + 1) when
501
- channel_axis is set.
502
- channel_axis: Index of the channel dimension, if any.
631
+ shape: Array shape. Length must match (spatial + non-spatial) ndims.
632
+ axis_labels: Per-axis labels or roles. Length must match ndims.
633
+ axis_units: Per-axis units. Length must match ndims.
634
+ _num_spatial_axes: Cached count of spatial axes derived from axis_labels.
635
+ _num_non_spatial_axes: Cached count of non-spatial axes derived from axis_labels.
503
636
  """
504
- spacing: Optional[List] = None
505
- origin: Optional[List] = None
506
- direction: Optional[List[List]] = None
507
- shape: Optional[List] = None
508
- channel_axis: Optional[int] = None
509
-
510
- def _validate_and_cast(self, *, ndims: Optional[int] = None, **_: Any) -> None:
637
+ AxisLabel = AxisLabelEnum
638
+ spacing: Optional[list[Union[int,float]]] = None
639
+ origin: Optional[list[Union[int,float]]] = None
640
+ direction: Optional[list[list[Union[int,float]]]] = None
641
+ shape: Optional[list[int]] = None
642
+ axis_labels: Optional[list[Union[str,AxisLabel]]] = None
643
+ axis_units: Optional[list[str]] = None
644
+ _num_spatial_axes: Optional[int] = None
645
+ _num_non_spatial_axes: Optional[int] = None
646
+
647
+ def _validate_and_cast(self, *, ndims: Optional[int] = None, spatial_ndims: Optional[int] = None, **_: Any) -> None:
511
648
  """Validate and normalize spatial fields.
512
649
 
513
650
  Args:
514
- ndims: Number of spatial dimensions.
651
+ ndims: Number of dimensions.
652
+ spatial_ndims: Number of spatial dimensions.
515
653
  **_: Unused extra context.
516
654
  """
517
- if self.channel_axis is not None:
518
- _validate_int(self.channel_axis, "meta.spatial.channel_axis")
655
+ if self.axis_labels is not None:
656
+ self.axis_labels, self._num_spatial_axes, self._num_non_spatial_axes = validate_and_cast_axis_labels(self.axis_labels, "meta.spatial.axis_labels", ndims)
657
+ spatial_ndims = spatial_ndims if self._num_spatial_axes is None else self._num_spatial_axes
519
658
 
520
659
  if self.spacing is not None:
521
660
  self.spacing = _cast_to_list(self.spacing, "meta.spatial.spacing")
522
- _validate_float_int_list(self.spacing, "meta.spatial.spacing", ndims)
661
+ _validate_float_int_list(self.spacing, "meta.spatial.spacing", spatial_ndims)
523
662
 
524
663
  if self.origin is not None:
525
664
  self.origin = _cast_to_list(self.origin, "meta.spatial.origin")
526
- _validate_float_int_list(self.origin, "meta.spatial.origin", ndims)
665
+ _validate_float_int_list(self.origin, "meta.spatial.origin", spatial_ndims)
527
666
 
528
667
  if self.direction is not None:
529
668
  self.direction = _cast_to_list(self.direction, "meta.spatial.direction")
530
- _validate_float_int_matrix(self.direction, "meta.spatial.direction", ndims)
669
+ _validate_float_int_matrix(self.direction, "meta.spatial.direction", spatial_ndims)
531
670
 
532
671
  if self.shape is not None:
533
- _ndims = ndims if (ndims is None or self.channel_axis is None) else ndims + 1
534
672
  self.shape = _cast_to_list(self.shape, "meta.spatial.shape")
535
- _validate_float_int_list(self.shape, "meta.spatial.shape", _ndims)
673
+ _validate_float_int_list(self.shape, "meta.spatial.shape", ndims)
536
674
 
537
675
 
538
676
  @dataclass(slots=True)
@@ -586,9 +724,9 @@ class MetaBbox(BaseMeta):
586
724
  labels: Optional labels aligned with bboxes. Each label may be a string,
587
725
  int, or float.
588
726
  """
589
- bboxes: Optional[List[List[List[Union[int, float]]]]] = None
590
- scores: Optional[List[Union[int, float]]] = None
591
- labels: Optional[List[Union[str, int, float]]] = None
727
+ bboxes: Optional[list[list[list[Union[int, float]]]]] = None
728
+ scores: Optional[list[Union[int, float]]] = None
729
+ labels: Optional[list[Union[str, int, float]]] = None
592
730
 
593
731
  def _validate_and_cast(self, **_: Any) -> None:
594
732
  """Validate bounding box structure and related fields."""
@@ -635,7 +773,7 @@ class MetaSource(SingleKeyBaseMeta):
635
773
  Attributes:
636
774
  data: Arbitrary JSON-serializable metadata.
637
775
  """
638
- data: Dict[str, Any] = field(default_factory=dict)
776
+ data: dict[str, Any] = field(default_factory=dict)
639
777
 
640
778
  def _validate_and_cast(self, **_: Any) -> None:
641
779
  """Validate that data is a JSON-serializable dict."""
@@ -652,7 +790,7 @@ class MetaExtra(SingleKeyBaseMeta):
652
790
  Attributes:
653
791
  data: Arbitrary JSON-serializable metadata.
654
792
  """
655
- data: Dict[str, Any] = field(default_factory=dict)
793
+ data: dict[str, Any] = field(default_factory=dict)
656
794
 
657
795
  def _validate_and_cast(self, **_: Any) -> None:
658
796
  """Validate that data is a JSON-serializable dict."""
@@ -749,11 +887,12 @@ class Meta(BaseMeta):
749
887
  _image_meta_format: "MetaImageFormat" = field(default_factory=lambda: MetaImageFormat())
750
888
  _mlarray_version: "MetaVersion" = field(default_factory=lambda: MetaVersion())
751
889
 
752
- def _validate_and_cast(self, *, ndims: Optional[int] = None, **_: Any) -> None:
890
+ def _validate_and_cast(self, *, ndims: Optional[int] = None, spatial_ndims: Optional[int] = None, **_: Any) -> None:
753
891
  """Coerce child metas and validate with optional context.
754
892
 
755
893
  Args:
756
- ndims: Number of spatial dimensions for context-aware validation.
894
+ ndims: Number of dimensions for context-aware validation.
895
+ spatial_ndims: Number of spatial dimensions for context-aware validation.
757
896
  **_: Unused extra context.
758
897
  """
759
898
  self.source = MetaSource.ensure(self.source)
@@ -767,8 +906,8 @@ class Meta(BaseMeta):
767
906
  self._image_meta_format = MetaImageFormat.ensure(self._image_meta_format)
768
907
  self._mlarray_version = MetaVersion.ensure(self._mlarray_version)
769
908
 
770
- self.spatial._validate_and_cast(ndims=ndims)
771
- self._blosc2._validate_and_cast(ndims=ndims, channel_axis=getattr(self.spatial, "channel_axis", None))
909
+ self.spatial._validate_and_cast(ndims=ndims, spatial_ndims=spatial_ndims)
910
+ self._blosc2._validate_and_cast(ndims=ndims, spatial_ndims=spatial_ndims)
772
911
 
773
912
  def to_plain(self, *, include_none: bool = False) -> Any:
774
913
  """Convert to plain values, suppressing default sub-metas.
@@ -780,7 +919,7 @@ class Meta(BaseMeta):
780
919
  A dict of field values where default child metas are represented
781
920
  as None and optionally filtered out.
782
921
  """
783
- out: Dict[str, Any] = {}
922
+ out: dict[str, Any] = {}
784
923
  for f in fields(self):
785
924
  v = getattr(self, f.name)
786
925
 
@@ -5,7 +5,7 @@ import math
5
5
  from typing import Dict, Optional, Union, List, Tuple
6
6
  from pathlib import Path
7
7
  import os
8
- from mlarray.meta import Meta, MetaBlosc2
8
+ from mlarray.meta import Meta, MetaBlosc2, AxisLabel, _spatial_axis_mask
9
9
  from mlarray.utils import is_serializable
10
10
 
11
11
  MLARRAY_SUFFIX = "mla"
@@ -21,7 +21,7 @@ class MLArray:
21
21
  origin: Optional[Union[List, Tuple, np.ndarray]] = None,
22
22
  direction: Optional[Union[List, Tuple, np.ndarray]] = None,
23
23
  meta: Optional[Union[Dict, Meta]] = None,
24
- channel_axis: Optional[int] = None,
24
+ axis_labels: Optional[List[Union[str, AxisLabel]]] = None,
25
25
  num_threads: int = 1,
26
26
  copy: Optional['MLArray'] = None) -> None:
27
27
  """Initializes a MLArray instance.
@@ -46,8 +46,7 @@ class MLArray:
46
46
  meta (Optional[Dict | Meta]): Free-form metadata dictionary or Meta
47
47
  instance. Must be JSON-serializable when saving.
48
48
  If meta is passed as a Dict, it will internally be converted into a Meta object with the dict being interpreted as meta.image metadata.
49
- channel_axis (Optional[int]): Axis index that represents channels
50
- in the array (e.g., 0 for CHW or -1 for HWC). If None, the array
49
+ axis_labels (Optional[List[Union[str, AxisLabel]]]): Per-axis labels or roles. Length must match ndims. If None, the array
51
50
  is treated as purely spatial.
52
51
  num_threads (int): Number of threads for Blosc2 operations.
53
52
  copy (Optional[MLArray]): Another MLArray instance to copy metadata
@@ -58,13 +57,14 @@ class MLArray:
58
57
  self.support_metadata = None
59
58
  self.mmap = None
60
59
  self.meta = None
61
- if isinstance(array, (str, Path)) and (spacing is not None or origin is not None or direction is not None or meta is not None or channel_axis is not None or copy is not None):
62
- raise ("Spacing, origin, direction, meta, channel_axis or copy cannot be set when array is a filepath.")
60
+ if isinstance(array, (str, Path)) and (spacing is not None or origin is not None or direction is not None or meta is not None or axis_labels is not None or copy is not None):
61
+ raise ("Spacing, origin, direction, meta, axis_labels or copy cannot be set when array is a filepath.")
63
62
  if isinstance(array, (str, Path)):
64
63
  self._load(array, num_threads)
65
64
  else:
66
65
  self._store = array
67
- self._validate_and_add_meta(meta, spacing, origin, direction, channel_axis, True)
66
+ has_array = array is not None
67
+ self._validate_and_add_meta(meta, spacing, origin, direction, axis_labels, has_array)
68
68
  if copy is not None:
69
69
  self.meta.copy_from(copy.meta)
70
70
 
@@ -74,7 +74,7 @@ class MLArray:
74
74
  filepath: Union[str, Path],
75
75
  shape: Optional[Union[List, Tuple, np.ndarray]] = None,
76
76
  dtype: Optional[np.dtype] = None,
77
- channel_axis: Optional[int] = None,
77
+ axis_labels: Optional[List[Union[str, AxisLabel]]] = None,
78
78
  mmap: str = 'r',
79
79
  patch_size: Optional[Union[int, List, Tuple]] = 'default', # 'default' means that the default of 192 is used. However, if set to 'default', the patch_size will be skipped if self.patch_size is set from a previously loaded MLArray image. In that case the self.patch_size is used.
80
80
  chunk_size: Optional[Union[int, List, Tuple]]= None,
@@ -99,10 +99,10 @@ class MLArray:
99
99
  ".b2nd" or ".mla".
100
100
  shape (Optional[Union[List, Tuple, np.ndarray]]): Shape of the array
101
101
  to create. If provided, a new file is created. Length must match
102
- the full array dimensionality (including channels if present).
102
+ the full array dimensionality (including non-spatial axes if present).
103
103
  dtype (Optional[np.dtype]): Numpy dtype for a newly created array.
104
- channel_axis (Optional[int]): Axis index for channels in the array.
105
- Used for patch/chunk/block calculations.
104
+ axis_labels (Optional[List[Union[str, AxisLabel]]]): Per-axis labels or roles. Length must match ndims. If None, the array
105
+ is treated as purely spatial. Used for patch/chunk/block calculations.
106
106
  mmap (str): Blosc2 mmap mode. One of "r", "r+", "w+", "c".
107
107
  patch_size (Optional[Union[int, List, Tuple]]): Patch size hint for
108
108
  chunk/block optimization. Provide an int for isotropic sizes or
@@ -126,7 +126,7 @@ class MLArray:
126
126
  inconsistent, or if mmap mode is invalid for creation.
127
127
  """
128
128
  class_instance = cls()
129
- class_instance._open(filepath, shape, dtype, channel_axis, mmap, patch_size, chunk_size, block_size, num_threads, cparams, dparams)
129
+ class_instance._open(filepath, shape, dtype, axis_labels, mmap, patch_size, chunk_size, block_size, num_threads, cparams, dparams)
130
130
  return class_instance
131
131
 
132
132
  def _open(
@@ -134,7 +134,7 @@ class MLArray:
134
134
  filepath: Union[str, Path],
135
135
  shape: Optional[Union[List, Tuple, np.ndarray]] = None,
136
136
  dtype: Optional[np.dtype] = None,
137
- channel_axis: Optional[int] = None,
137
+ axis_labels: Optional[list[Union[str, AxisLabel]]] = None,
138
138
  mmap: str = 'r',
139
139
  patch_size: Optional[Union[int, List, Tuple]] = 'default', # 'default' means that the default of 192 is used. However, if set to 'default', the patch_size will be skipped if self.patch_size is set from a previously loaded MLArray image. In that case the self.patch_size is used.
140
140
  chunk_size: Optional[Union[int, List, Tuple]]= None,
@@ -159,10 +159,10 @@ class MLArray:
159
159
  ".b2nd" or ".mla".
160
160
  shape (Optional[Union[List, Tuple, np.ndarray]]): Shape of the array
161
161
  to create. If provided, a new file is created. Length must match
162
- the full array dimensionality (including channels if present).
162
+ the full array dimensionality (including non-spatial axes if present).
163
163
  dtype (Optional[np.dtype]): Numpy dtype for a newly created array.
164
- channel_axis (Optional[int]): Axis index for channels in the array.
165
- Used for patch/chunk/block calculations.
164
+ axis_labels (Optional[List[Union[str, AxisLabel]]]): Per-axis labels or roles. Length must match ndims. If None, the array
165
+ is treated as purely spatial.
166
166
  mmap (str): Blosc2 mmap mode. One of "r", "r+", "w+", "c".
167
167
  patch_size (Optional[Union[int, List, Tuple]]): Patch size hint for
168
168
  chunk/block optimization. Provide an int for isotropic sizes or
@@ -203,7 +203,8 @@ class MLArray:
203
203
  create_array = mmap == 'w+'
204
204
 
205
205
  if create_array:
206
- self.meta._blosc2 = self._comp_and_validate_blosc2_meta(self.meta._blosc2, patch_size, chunk_size, block_size, shape, channel_axis)
206
+ spatial_axis_mask = [True] * len(shape) if axis_labels is None else _spatial_axis_mask(axis_labels)
207
+ self.meta._blosc2 = self._comp_and_validate_blosc2_meta(self.meta._blosc2, patch_size, chunk_size, block_size, shape, spatial_axis_mask)
207
208
  self.meta._has_array.has_array = True
208
209
 
209
210
  self.support_metadata = str(filepath).endswith(f".{MLARRAY_SUFFIX}")
@@ -332,7 +333,8 @@ class MLArray:
332
333
  raise RuntimeError(f"MLArray requires '.b2nd' or '.{MLARRAY_SUFFIX}' as extension.")
333
334
 
334
335
  if self._store is not None:
335
- self.meta._blosc2 = self._comp_and_validate_blosc2_meta(self.meta._blosc2, patch_size, chunk_size, block_size, self._store.shape, self.meta.spatial.channel_axis)
336
+ spatial_axis_mask = [True] * self.ndim if self.meta.spatial.axis_labels is None else _spatial_axis_mask(self.meta.spatial.axis_labels)
337
+ self.meta._blosc2 = self._comp_and_validate_blosc2_meta(self.meta._blosc2, patch_size, chunk_size, block_size, self._store.shape, spatial_axis_mask)
336
338
  self.meta._has_array.has_array = True
337
339
 
338
340
  self.support_metadata = str(filepath).endswith(f".{MLARRAY_SUFFIX}")
@@ -568,7 +570,7 @@ class MLArray:
568
570
 
569
571
  @property
570
572
  def ndim(self) -> int:
571
- """Returns the number of dimensions of the array.
573
+ """Returns the number of spatial and non-spatial dimensions of the array.
572
574
 
573
575
  Returns:
574
576
  int: Number of dimensions, or None if no array is loaded.
@@ -581,23 +583,21 @@ class MLArray:
581
583
  def _spatial_ndim(self) -> int:
582
584
  """Returns the number of spatial dimensions.
583
585
 
584
- If ``channel_axis`` is set, the channel dimension is excluded.
585
-
586
586
  Returns:
587
587
  int: Number of spatial dimensions, or None if no array is loaded.
588
588
  """
589
589
  if self._store is None or self.meta._has_array.has_array == False:
590
590
  return None
591
591
  ndim = len(self._store.shape)
592
- if self.meta.spatial.channel_axis is not None:
593
- ndim -= 1
592
+ if self.meta.spatial._num_spatial_axes is not None:
593
+ ndim = self.meta.spatial._num_spatial_axes
594
594
  return ndim
595
595
 
596
596
  def comp_blosc2_params(
597
597
  self,
598
598
  image_size: Union[Tuple[int, int], Tuple[int, int, int], Tuple[int, int, int, int]],
599
599
  patch_size: Union[Tuple[int, int], Tuple[int, int, int]],
600
- channel_axis: Optional[int] = None,
600
+ spatial_axis_mask: Optional[list[bool]] = None,
601
601
  bytes_per_pixel: int = 4, # 4 byte are float32
602
602
  l1_cache_size_per_core_in_bytes: int = 32768, # 1 Kibibyte (KiB) = 2^10 Byte; 32 KiB = 32768 Byte
603
603
  l3_cache_size_per_core_in_bytes: int = 1441792, # 1 Mibibyte (MiB) = 2^20 Byte = 1.048.576 Byte; 1.375MiB = 1441792 Byte
@@ -624,13 +624,11 @@ class MLArray:
624
624
  Args:
625
625
  image_size (Union[Tuple[int, int], Tuple[int, int, int], Tuple[int, int, int, int]]):
626
626
  Image shape. Use a 2D, 3D, or 4D size; 2D/3D inputs are
627
- internally expanded to 4D (with channels first).
627
+ internally expanded to 4D (with non-spatial axes first).
628
628
  patch_size (Union[Tuple[int, int], Tuple[int, int, int]]): Patch
629
629
  size for spatial dimensions. Use a 2-tuple (x, y) or 3-tuple
630
630
  (x, y, z).
631
- channel_axis (Optional[int]): Axis index for channels in the
632
- source array. If set, the size is moved to channels-first
633
- for cache calculations.
631
+ spatial_axis_mask (Optional[list[bool]]): Mask indicating for every axis whether it is spatial or not.
634
632
  bytes_per_pixel (int): Number of bytes per element. Defaults to 4
635
633
  for float32.
636
634
  l1_cache_size_per_core_in_bytes (int): L1 cache per core in bytes.
@@ -654,8 +652,14 @@ class MLArray:
654
652
  image_size = (1, *image_size)
655
653
  num_squeezes = 1
656
654
 
657
- if channel_axis is not None:
658
- image_size = _move_index_list(image_size, channel_axis+num_squeezes, 0)
655
+ non_spatial_axis = None
656
+ if spatial_axis_mask is not None:
657
+ non_spatial_axis_mask = [not b for b in spatial_axis_mask]
658
+ if sum(non_spatial_axis_mask) > 1:
659
+ raise RuntimeError("Automatic blosc2 optimization currently only supports one non-spatial axis. Please set chunk and block size manually.")
660
+ non_spatial_axis = next((i for i, v in enumerate(non_spatial_axis_mask) if v), None)
661
+ if non_spatial_axis is not None:
662
+ image_size = _move_index_list(image_size, non_spatial_axis+num_squeezes, 0)
659
663
 
660
664
  if len(image_size) != 4:
661
665
  raise RuntimeError("Image size must be 4D.")
@@ -663,11 +667,11 @@ class MLArray:
663
667
  if not (len(patch_size) == 2 or len(patch_size) == 3):
664
668
  raise RuntimeError("Patch size must be 2D or 3D.")
665
669
 
666
- num_channels = image_size[0]
670
+ non_spatial_size = image_size[0]
667
671
  if len(patch_size) == 2:
668
672
  patch_size = [1, *patch_size]
669
673
  patch_size = np.array(patch_size)
670
- block_size = np.array((num_channels, *[2 ** (max(0, math.ceil(math.log2(i)))) for i in patch_size]))
674
+ block_size = np.array((non_spatial_size, *[2 ** (max(0, math.ceil(math.log2(i)))) for i in patch_size]))
671
675
 
672
676
  # shrink the block size until it fits in L1
673
677
  estimated_nbytes_block = np.prod(block_size) * bytes_per_pixel
@@ -713,16 +717,16 @@ class MLArray:
713
717
  # better safe than sorry
714
718
  chunk_size = [min(i, j) for i, j in zip(image_size, chunk_size)]
715
719
 
716
- if channel_axis is not None:
717
- block_size = _move_index_list(block_size, 0, channel_axis+num_squeezes)
718
- chunk_size = _move_index_list(chunk_size, 0, channel_axis+num_squeezes)
720
+ if non_spatial_axis is not None:
721
+ block_size = _move_index_list(block_size, 0, non_spatial_axis+num_squeezes)
722
+ chunk_size = _move_index_list(chunk_size, 0, non_spatial_axis+num_squeezes)
719
723
 
720
724
  block_size = block_size[num_squeezes:]
721
725
  chunk_size = chunk_size[num_squeezes:]
722
726
 
723
727
  return [int(value) for value in chunk_size], [int(value) for value in block_size]
724
728
 
725
- def _comp_and_validate_blosc2_meta(self, meta_blosc2, patch_size, chunk_size, block_size, shape, channel_axis):
729
+ def _comp_and_validate_blosc2_meta(self, meta_blosc2, patch_size, chunk_size, block_size, shape, spatial_axis_mask):
726
730
  """Compute and validate Blosc2 chunk/block metadata.
727
731
 
728
732
  Args:
@@ -732,32 +736,32 @@ class MLArray:
732
736
  or "default". See ``open``/``save`` for expected shapes.
733
737
  chunk_size (Optional[Union[int, List, Tuple]]): Explicit chunk size.
734
738
  block_size (Optional[Union[int, List, Tuple]]): Explicit block size.
735
- shape (Union[List, Tuple, np.ndarray]): Full array shape including
736
- channels if present.
737
- channel_axis (Optional[int]): Channel axis index, if any.
739
+ shape (Union[List, Tuple, np.ndarray]): Full array shape including non-spatial axes.
740
+ spatial_axis_mask (Optional[list[bool]]): Mask indicating for every axis whether it is spatial or not.
738
741
 
739
742
  Returns:
740
743
  MetaBlosc2: Validated Blosc2 metadata instance.
741
744
  """
742
- if patch_size is not None and patch_size != "default" and not ((len(shape) == 2 and channel_axis is None) or (len(shape) == 3 and channel_axis is None) or (len(shape) == 4 and channel_axis is not None) or (len(shape) == 4 and channel_axis is not None)):
743
- raise NotImplementedError("Chunk and block size optimization based on patch size is only implemented for 2D and 3D images. Please set the chunk and block size manually or set to None for blosc2 to determine a chunk and block size.")
745
+ num_spatial_axes = sum(spatial_axis_mask)
746
+ num_non_spatial_axes = sum([not b for b in spatial_axis_mask])
747
+ if patch_size is not None and patch_size != "default" and (num_spatial_axes == 1 or num_spatial_axes > 3 or num_non_spatial_axes > 1):
748
+ raise NotImplementedError("Chunk and block size optimization based on patch size is only implemented for 2D and 3D spatial images with at most one further non-spatial axis. Please set the chunk and block size manually or set to None for blosc2 to determine a chunk and block size.")
744
749
  if patch_size is not None and patch_size != "default" and (chunk_size is not None or block_size is not None):
745
750
  raise RuntimeError("patch_size and chunk_size / block_size cannot both be explicitly set.")
746
751
 
747
- ndims = len(shape) if channel_axis is None else len(shape) - 1
748
752
  if patch_size == "default":
749
753
  if meta_blosc2 is not None and meta_blosc2.patch_size is not None: # Use previously loaded patch size, when patch size is not explicitly set and a patch size from a previously loaded image exists
750
754
  patch_size = meta_blosc2.patch_size
751
755
  else: # Use default patch size, when patch size is not explicitly set and no patch size from a previously loaded image exists
752
- patch_size = [MLARRAY_DEFAULT_PATCH_SIZE] * ndims
756
+ patch_size = [MLARRAY_DEFAULT_PATCH_SIZE] * num_spatial_axes
753
757
 
754
758
  patch_size = [patch_size] * len(shape) if isinstance(patch_size, int) else patch_size
755
759
 
756
760
  if patch_size is not None:
757
- chunk_size, block_size = self.comp_blosc2_params(shape, patch_size, channel_axis)
761
+ chunk_size, block_size = self.comp_blosc2_params(shape, patch_size, spatial_axis_mask)
758
762
 
759
763
  meta_blosc2 = MetaBlosc2(chunk_size, block_size, patch_size)
760
- meta_blosc2._validate_and_cast(ndims=len(shape), channel_axis=channel_axis)
764
+ meta_blosc2._validate_and_cast(ndims=len(shape), spatial_ndims=num_spatial_axes)
761
765
  return meta_blosc2
762
766
 
763
767
  def _read_meta(self):
@@ -780,7 +784,7 @@ class MLArray:
780
784
  raise RuntimeError("Metadata is not serializable.")
781
785
  self._store.vlmeta["mlarray"] = metadata
782
786
 
783
- def _validate_and_add_meta(self, meta, spacing=None, origin=None, direction=None, channel_axis=None, has_array=None):
787
+ def _validate_and_add_meta(self, meta, spacing=None, origin=None, direction=None, axis_labels=None, has_array=None):
784
788
  """Validate and attach metadata to the MLArray instance.
785
789
 
786
790
  Args:
@@ -792,7 +796,7 @@ class MLArray:
792
796
  spatial axis.
793
797
  direction (Optional[Union[List, Tuple, np.ndarray]]): Direction
794
798
  cosine matrix with shape (ndims, ndims).
795
- channel_axis (Optional[int]): Channel axis index, if any.
799
+ axis_labels (Optional[List[Union[str, AxisLabel]]]): Per-axis labels or roles. Length must match ndims.
796
800
 
797
801
  Raises:
798
802
  ValueError: If ``meta`` is not None, dict, or Meta.
@@ -812,11 +816,13 @@ class MLArray:
812
816
  self.meta.spatial.origin = origin
813
817
  if direction is not None:
814
818
  self.meta.spatial.direction = direction
815
- if channel_axis is not None:
816
- self.meta.spatial.channel_axis = channel_axis
817
- if self.meta._has_array.has_array or has_array:
819
+ if axis_labels is not None:
820
+ self.meta.spatial.axis_labels = axis_labels
821
+ if has_array == True:
822
+ self.meta._has_array.has_array = True
823
+ if self.meta._has_array.has_array:
818
824
  self.meta.spatial.shape = self.shape
819
- self.meta.spatial._validate_and_cast(ndims=self._spatial_ndim)
825
+ self.meta.spatial._validate_and_cast(ndims=self.ndim, spatial_ndims=self._spatial_ndim)
820
826
 
821
827
  def _update_blosc2_meta(self):
822
828
  """Sync Blosc2 chunk and block sizes into metadata.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlarray
3
- Version: 0.0.34
3
+ Version: 0.0.36
4
4
  Summary: Array format specialized for Machine Learning with Blosc2 backend and standardized metadata.
5
5
  Author-email: Karol Gotkowski <karol.gotkowski@dkfz.de>
6
6
  License: MIT
@@ -21,6 +21,7 @@ docs/usage.md
21
21
  docs/why.md
22
22
  examples/example_channel.py
23
23
  examples/example_metadata_only.py
24
+ examples/example_non_spatial.py
24
25
  examples/example_open.py
25
26
  examples/example_save_load.py
26
27
  mlarray/__init__.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes