mlarray 0.0.48__tar.gz → 0.0.50__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 (47) hide show
  1. {mlarray-0.0.48 → mlarray-0.0.50}/PKG-INFO +2 -2
  2. {mlarray-0.0.48 → mlarray-0.0.50}/README.md +1 -1
  3. {mlarray-0.0.48 → mlarray-0.0.50}/docs/cli.md +1 -1
  4. {mlarray-0.0.48 → mlarray-0.0.50}/mlarray/cli.py +5 -3
  5. {mlarray-0.0.48 → mlarray-0.0.50}/mlarray/meta.py +401 -31
  6. {mlarray-0.0.48 → mlarray-0.0.50}/mlarray/mlarray.py +150 -103
  7. {mlarray-0.0.48 → mlarray-0.0.50}/mlarray.egg-info/PKG-INFO +2 -2
  8. {mlarray-0.0.48 → mlarray-0.0.50}/mlarray.egg-info/SOURCES.txt +1 -0
  9. mlarray-0.0.50/tests/test_meta_safety.py +141 -0
  10. {mlarray-0.0.48 → mlarray-0.0.50}/tests/test_usage.py +68 -13
  11. {mlarray-0.0.48 → mlarray-0.0.50}/.github/workflows/workflow.yml +0 -0
  12. {mlarray-0.0.48 → mlarray-0.0.50}/.gitignore +0 -0
  13. {mlarray-0.0.48 → mlarray-0.0.50}/LICENSE +0 -0
  14. {mlarray-0.0.48 → mlarray-0.0.50}/MANIFEST.in +0 -0
  15. {mlarray-0.0.48 → mlarray-0.0.50}/assets/banner.png +0 -0
  16. {mlarray-0.0.48 → mlarray-0.0.50}/assets/banner.png~ +0 -0
  17. {mlarray-0.0.48 → mlarray-0.0.50}/docs/api.md +0 -0
  18. {mlarray-0.0.48 → mlarray-0.0.50}/docs/index.md +0 -0
  19. {mlarray-0.0.48 → mlarray-0.0.50}/docs/optimization.md +0 -0
  20. {mlarray-0.0.48 → mlarray-0.0.50}/docs/schema.md +0 -0
  21. {mlarray-0.0.48 → mlarray-0.0.50}/docs/usage.md +0 -0
  22. {mlarray-0.0.48 → mlarray-0.0.50}/docs/why.md +0 -0
  23. {mlarray-0.0.48 → mlarray-0.0.50}/examples/example_asarray.py +0 -0
  24. {mlarray-0.0.48 → mlarray-0.0.50}/examples/example_bboxes_only.py +0 -0
  25. {mlarray-0.0.48 → mlarray-0.0.50}/examples/example_channel.py +0 -0
  26. {mlarray-0.0.48 → mlarray-0.0.50}/examples/example_compressed_vs_uncompressed.py +0 -0
  27. {mlarray-0.0.48 → mlarray-0.0.50}/examples/example_in_memory_constructors.py +0 -0
  28. {mlarray-0.0.48 → mlarray-0.0.50}/examples/example_metadata_only.py +0 -0
  29. {mlarray-0.0.48 → mlarray-0.0.50}/examples/example_non_spatial.py +0 -0
  30. {mlarray-0.0.48 → mlarray-0.0.50}/examples/example_open.py +0 -0
  31. {mlarray-0.0.48 → mlarray-0.0.50}/examples/example_save_load.py +0 -0
  32. {mlarray-0.0.48 → mlarray-0.0.50}/mkdocs.yml +0 -0
  33. {mlarray-0.0.48 → mlarray-0.0.50}/mlarray/__init__.py +0 -0
  34. {mlarray-0.0.48 → mlarray-0.0.50}/mlarray/utils.py +0 -0
  35. {mlarray-0.0.48 → mlarray-0.0.50}/mlarray.egg-info/dependency_links.txt +0 -0
  36. {mlarray-0.0.48 → mlarray-0.0.50}/mlarray.egg-info/entry_points.txt +0 -0
  37. {mlarray-0.0.48 → mlarray-0.0.50}/mlarray.egg-info/requires.txt +0 -0
  38. {mlarray-0.0.48 → mlarray-0.0.50}/mlarray.egg-info/top_level.txt +0 -0
  39. {mlarray-0.0.48 → mlarray-0.0.50}/pyproject.toml +0 -0
  40. {mlarray-0.0.48 → mlarray-0.0.50}/setup.cfg +0 -0
  41. {mlarray-0.0.48 → mlarray-0.0.50}/tests/test_asarray.py +0 -0
  42. {mlarray-0.0.48 → mlarray-0.0.50}/tests/test_bboxes.py +0 -0
  43. {mlarray-0.0.48 → mlarray-0.0.50}/tests/test_constructors.py +0 -0
  44. {mlarray-0.0.48 → mlarray-0.0.50}/tests/test_create.py +0 -0
  45. {mlarray-0.0.48 → mlarray-0.0.50}/tests/test_metadata.py +0 -0
  46. {mlarray-0.0.48 → mlarray-0.0.50}/tests/test_open.py +0 -0
  47. {mlarray-0.0.48 → mlarray-0.0.50}/tests/test_optimization.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlarray
3
- Version: 0.0.48
3
+ Version: 0.0.50
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
@@ -228,7 +228,7 @@ image.save("blosc2-auto.mla")
228
228
 
229
229
  ### mlarray_header
230
230
 
231
- Print the metadata header from a `.mla` or `.b2nd` file.
231
+ Print the metadata header from a `.mla` file.
232
232
 
233
233
  ```bash
234
234
  mlarray_header sample.mla
@@ -194,7 +194,7 @@ image.save("blosc2-auto.mla")
194
194
 
195
195
  ### mlarray_header
196
196
 
197
- Print the metadata header from a `.mla` or `.b2nd` file.
197
+ Print the metadata header from a `.mla` file.
198
198
 
199
199
  ```bash
200
200
  mlarray_header sample.mla
@@ -8,7 +8,7 @@ The CLI currently focuses on core workflows (header inspection and conversion).
8
8
 
9
9
  ## `mlarray_header`
10
10
 
11
- Print the metadata header from a `.mla` or `.b2nd` file.
11
+ Print the metadata header from a `.mla` file.
12
12
 
13
13
  This command is useful for quickly checking spatial metadata, stored schemas, and other file-level information without loading the full array into memory.
14
14
 
@@ -3,6 +3,7 @@ import json
3
3
  from typing import Union
4
4
  from pathlib import Path
5
5
  from mlarray import MLArray
6
+ from mlarray.meta import _meta_internal_write
6
7
 
7
8
  try:
8
9
  from medvol import MedVol
@@ -14,7 +15,7 @@ def print_header(filepath: Union[str, Path]) -> None:
14
15
  """Print the MLArray metadata header for a file.
15
16
 
16
17
  Args:
17
- filepath: Path to a ".mla" or ".b2nd" file.
18
+ filepath: Path to a ".mla" file.
18
19
  """
19
20
  meta = MLArray(filepath).meta
20
21
  if meta is None:
@@ -33,7 +34,8 @@ def convert_to_mlarray(load_filepath: Union[str, Path], save_filepath: Union[str
33
34
  image_meta_format = "nrrd"
34
35
  image_medvol = MedVol(load_filepath)
35
36
  image_mlarray = MLArray(image_medvol.array, spacing=image_medvol.spacing, origin=image_medvol.origin, direction=image_medvol.direction, meta=image_medvol.header)
36
- image_mlarray.meta._image_meta_format = image_meta_format
37
+ with _meta_internal_write():
38
+ image_mlarray.meta._image_meta_format = image_meta_format
37
39
  image_mlarray.save(save_filepath)
38
40
 
39
41
 
@@ -42,7 +44,7 @@ def cli_print_header() -> None:
42
44
  prog="mlarray_header",
43
45
  description="Print the MLArray metadata header for a file.",
44
46
  )
45
- parser.add_argument("filepath", help="Path to a .mla or .b2nd file.")
47
+ parser.add_argument("filepath", help="Path to a .mla file.")
46
48
  args = parser.parse_args()
47
49
  print_header(args.filepath)
48
50
 
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from contextlib import contextmanager
4
+ from contextvars import ContextVar
3
5
  from dataclasses import MISSING, dataclass, field, fields
4
6
  from typing import Any, Mapping, Optional, Type, TypeVar, Union, TypeAlias, Iterable
5
7
  from enum import Enum
@@ -11,6 +13,110 @@ T = TypeVar("T", bound="BaseMeta")
11
13
  SK = TypeVar("SK", bound="SingleKeyBaseMeta")
12
14
 
13
15
 
16
+ _INTERNAL_META_WRITE: ContextVar[bool] = ContextVar(
17
+ "_INTERNAL_META_WRITE",
18
+ default=False,
19
+ )
20
+
21
+
22
+ @contextmanager
23
+ def _meta_internal_write():
24
+ """Allow internal metadata writes within a bounded context."""
25
+ token = _INTERNAL_META_WRITE.set(True)
26
+ try:
27
+ yield
28
+ finally:
29
+ _INTERNAL_META_WRITE.reset(token)
30
+
31
+
32
+ def _is_meta_internal_write() -> bool:
33
+ return _INTERNAL_META_WRITE.get()
34
+
35
+
36
+ def _has_initialized_attr(obj: Any, name: str) -> bool:
37
+ try:
38
+ object.__getattribute__(obj, name)
39
+ return True
40
+ except AttributeError:
41
+ return False
42
+
43
+
44
+ def _raise_internal_only(label: str) -> None:
45
+ raise AttributeError(f"{label} is managed by MLArray and is read-only.")
46
+
47
+
48
+ def _public_dataclass_fields(obj_or_cls: Any):
49
+ return [
50
+ f for f in fields(obj_or_cls)
51
+ if not f.metadata.get("mlarray_internal_state", False)
52
+ ]
53
+
54
+
55
+ class _FrozenList(list):
56
+ """A list that disallows in-place mutation."""
57
+
58
+ def _readonly(self, *_: Any, **__: Any) -> None:
59
+ raise AttributeError("This metadata field is read-only.")
60
+
61
+ __setitem__ = _readonly
62
+ __delitem__ = _readonly
63
+ __iadd__ = _readonly
64
+ __imul__ = _readonly
65
+ append = _readonly
66
+ clear = _readonly
67
+ extend = _readonly
68
+ insert = _readonly
69
+ pop = _readonly
70
+ remove = _readonly
71
+ reverse = _readonly
72
+ sort = _readonly
73
+
74
+ def __reduce_ex__(self, protocol: int):
75
+ return (_FrozenList, (list(self),))
76
+
77
+ def __copy__(self):
78
+ return _FrozenList(self)
79
+
80
+ def __deepcopy__(self, memo):
81
+ copied = _FrozenList(self)
82
+ memo[id(self)] = copied
83
+ return copied
84
+
85
+
86
+ class _FrozenDict(dict):
87
+ """A dict that disallows in-place mutation."""
88
+
89
+ def _readonly(self, *_: Any, **__: Any) -> None:
90
+ raise AttributeError("This metadata field is read-only.")
91
+
92
+ __setitem__ = _readonly
93
+ __delitem__ = _readonly
94
+ clear = _readonly
95
+ pop = _readonly
96
+ popitem = _readonly
97
+ setdefault = _readonly
98
+ update = _readonly
99
+
100
+ def __reduce_ex__(self, protocol: int):
101
+ return (_FrozenDict, (dict(self),))
102
+
103
+ def __copy__(self):
104
+ return _FrozenDict(self)
105
+
106
+ def __deepcopy__(self, memo):
107
+ copied = _FrozenDict(self)
108
+ memo[id(self)] = copied
109
+ return copied
110
+
111
+
112
+ def _freeze_jsonable(value: Any) -> Any:
113
+ if isinstance(value, Mapping):
114
+ return _FrozenDict({k: _freeze_jsonable(v) for k, v in value.items()})
115
+ if isinstance(value, list):
116
+ return _FrozenList([_freeze_jsonable(v) for v in value])
117
+ return value
118
+
119
+
14
120
  def _is_unset_value(v: Any) -> bool:
15
121
  """Return True when a value should be treated as "unset".
16
122
 
@@ -37,10 +143,67 @@ class BaseMeta:
37
143
  Subclasses should implement _validate_and_cast to coerce and validate
38
144
  fields after initialization or mutation.
39
145
  """
146
+ _validate_ndims: Optional[int] = field(
147
+ default=None,
148
+ init=False,
149
+ repr=False,
150
+ compare=False,
151
+ metadata={"mlarray_internal_state": True},
152
+ )
153
+ _validate_spatial_ndims: Optional[int] = field(
154
+ default=None,
155
+ init=False,
156
+ repr=False,
157
+ compare=False,
158
+ metadata={"mlarray_internal_state": True},
159
+ )
160
+ _PROTECTED_FIELDS = frozenset()
161
+ _PROTECTED_FIELD_PREFIX = ""
162
+
163
+ def __setattr__(self, name: str, value: Any) -> None:
164
+ if name in {"_validate_ndims", "_validate_spatial_ndims"}:
165
+ object.__setattr__(self, name, value)
166
+ return
167
+
168
+ if _is_meta_internal_write() or not _has_initialized_attr(self, name):
169
+ object.__setattr__(self, name, value)
170
+ return
171
+
172
+ if name in self._PROTECTED_FIELDS:
173
+ prefix = self._PROTECTED_FIELD_PREFIX
174
+ label = f"{prefix}{name}" if prefix else name
175
+ _raise_internal_only(label)
176
+
177
+ current = getattr(self, name)
178
+ if isinstance(current, BaseMeta) and not isinstance(value, current.__class__):
179
+ value = current.__class__.ensure(value)
180
+
181
+ object.__setattr__(self, name, value)
182
+ try:
183
+ with _meta_internal_write():
184
+ self._validate_and_cast(
185
+ ndims=self._validate_ndims,
186
+ spatial_ndims=self._validate_spatial_ndims,
187
+ )
188
+ except Exception:
189
+ object.__setattr__(self, name, current)
190
+ raise
40
191
 
41
192
  def __post_init__(self) -> None:
42
193
  """Validate and normalize fields after dataclass initialization."""
43
- self._validate_and_cast()
194
+ with _meta_internal_write():
195
+ self._validate_and_cast()
196
+
197
+ def _remember_validation_context(
198
+ self,
199
+ *,
200
+ ndims: Optional[int] = None,
201
+ spatial_ndims: Optional[int] = None,
202
+ ) -> None:
203
+ if ndims is not None:
204
+ object.__setattr__(self, "_validate_ndims", ndims)
205
+ if spatial_ndims is not None:
206
+ object.__setattr__(self, "_validate_spatial_ndims", spatial_ndims)
44
207
 
45
208
  def _validate_and_cast(self, **_: Any) -> None:
46
209
  """Validate and normalize fields in subclasses.
@@ -68,7 +231,7 @@ class BaseMeta:
68
231
  A dict of field names to serialized values.
69
232
  """
70
233
  out: dict[str, Any] = {}
71
- for f in fields(self):
234
+ for f in _public_dataclass_fields(self):
72
235
  v = getattr(self, f.name)
73
236
  if v is None and not include_none:
74
237
  continue
@@ -98,14 +261,14 @@ class BaseMeta:
98
261
  )
99
262
 
100
263
  dd = dict(d)
101
- known = {f.name for f in fields(cls)}
264
+ known = {f.name for f in _public_dataclass_fields(cls)}
102
265
  unknown = set(dd) - known
103
266
  if unknown:
104
267
  raise KeyError(
105
268
  f"Unknown {cls.__name__} keys in from_mapping: {sorted(unknown)}"
106
269
  )
107
270
 
108
- for f in fields(cls):
271
+ for f in _public_dataclass_fields(cls):
109
272
  if f.name not in dd:
110
273
  continue
111
274
  v = dd[f.name]
@@ -127,7 +290,7 @@ class BaseMeta:
127
290
  overrides this to return its wrapped value.
128
291
  """
129
292
  out: dict[str, Any] = {}
130
- for f in fields(self):
293
+ for f in _public_dataclass_fields(self):
131
294
  v = getattr(self, f.name)
132
295
  if v is None and not include_none:
133
296
  continue
@@ -141,7 +304,7 @@ class BaseMeta:
141
304
  """Return True if this equals a default-constructed instance."""
142
305
  default = self.__class__() # type: ignore[call-arg]
143
306
 
144
- for f in fields(self):
307
+ for f in _public_dataclass_fields(self):
145
308
  a = getattr(self, f.name)
146
309
  b = getattr(default, f.name)
147
310
 
@@ -155,7 +318,7 @@ class BaseMeta:
155
318
 
156
319
  def reset(self) -> None:
157
320
  """Reset all fields to their default or None."""
158
- for f in fields(self):
321
+ for f in _public_dataclass_fields(self):
159
322
  if f.default_factory is not MISSING: # type: ignore[attr-defined]
160
323
  setattr(self, f.name, f.default_factory()) # type: ignore[misc]
161
324
  elif f.default is not MISSING:
@@ -179,7 +342,7 @@ class BaseMeta:
179
342
  if other.__class__ is not self.__class__:
180
343
  raise TypeError(f"copy_from expects {self.__class__.__name__}")
181
344
 
182
- for f in fields(self):
345
+ for f in _public_dataclass_fields(self):
183
346
  src = getattr(other, f.name)
184
347
  dst = getattr(self, f.name)
185
348
 
@@ -230,7 +393,7 @@ class SingleKeyBaseMeta(BaseMeta):
230
393
  Raises:
231
394
  TypeError: If the subclass does not define exactly one field.
232
395
  """
233
- flds = fields(cls)
396
+ flds = _public_dataclass_fields(cls)
234
397
  if len(flds) != 1:
235
398
  raise TypeError(
236
399
  f"{cls.__name__} must define exactly one dataclass field (found {len(flds)})"
@@ -611,6 +774,10 @@ class MetaBlosc2(BaseMeta):
611
774
  patch_size: Optional[list] = None
612
775
  cparams: Optional[dict[str, Any]] = None
613
776
  dparams: Optional[dict[str, Any]] = None
777
+ _PROTECTED_FIELDS = frozenset(
778
+ {"chunk_size", "block_size", "patch_size", "cparams", "dparams"}
779
+ )
780
+ _PROTECTED_FIELD_PREFIX = "meta.blosc2."
614
781
 
615
782
  def _validate_and_cast(self, *, ndims: Optional[int] = None, spatial_ndims: Optional[int] = None, **_: Any) -> None:
616
783
  """Validate and normalize tiling sizes.
@@ -620,24 +787,37 @@ class MetaBlosc2(BaseMeta):
620
787
  spatial_ndims: Number of spatial dimensions.
621
788
  **_: Unused extra context.
622
789
  """
790
+ self._remember_validation_context(
791
+ ndims=ndims,
792
+ spatial_ndims=spatial_ndims,
793
+ )
623
794
  if self.chunk_size is not None:
624
- self.chunk_size = _cast_to_list(self.chunk_size, "meta.blosc2.chunk_size")
625
- _validate_float_int_list(self.chunk_size, "meta.blosc2.chunk_size", ndims)
795
+ chunk_size = _cast_to_list(self.chunk_size, "meta.blosc2.chunk_size")
796
+ _validate_float_int_list(chunk_size, "meta.blosc2.chunk_size", ndims)
797
+ self.chunk_size = _FrozenList(chunk_size)
626
798
 
627
799
  if self.block_size is not None:
628
- self.block_size = _cast_to_list(self.block_size, "meta.blosc2.block_size")
629
- _validate_float_int_list(self.block_size, "meta.blosc2.block_size", ndims)
800
+ block_size = _cast_to_list(self.block_size, "meta.blosc2.block_size")
801
+ _validate_float_int_list(block_size, "meta.blosc2.block_size", ndims)
802
+ self.block_size = _FrozenList(block_size)
630
803
 
631
804
  if self.patch_size is not None:
632
805
  spatial_ndims = ndims if spatial_ndims is None else spatial_ndims
633
- self.patch_size = _cast_to_list(self.patch_size, "meta.blosc2.patch_size")
634
- _validate_float_int_list(self.patch_size, "meta.blosc2.patch_size", spatial_ndims)
806
+ patch_size = _cast_to_list(self.patch_size, "meta.blosc2.patch_size")
807
+ _validate_float_int_list(
808
+ patch_size,
809
+ "meta.blosc2.patch_size",
810
+ spatial_ndims,
811
+ )
812
+ self.patch_size = _FrozenList(patch_size)
635
813
 
636
814
  if self.cparams is not None:
637
- self.cparams = _cast_to_jsonable_mapping(self.cparams, "meta.blosc2.cparams")
815
+ cparams = _cast_to_jsonable_mapping(self.cparams, "meta.blosc2.cparams")
816
+ self.cparams = _freeze_jsonable(cparams)
638
817
 
639
818
  if self.dparams is not None:
640
- self.dparams = _cast_to_jsonable_mapping(self.dparams, "meta.blosc2.dparams")
819
+ dparams = _cast_to_jsonable_mapping(self.dparams, "meta.blosc2.dparams")
820
+ self.dparams = _freeze_jsonable(dparams)
641
821
 
642
822
 
643
823
  class AxisLabelEnum(str, Enum):
@@ -676,6 +856,7 @@ class MetaSpatial(BaseMeta):
676
856
  spacing: Per-dimension spacing values. Length must match ndims.
677
857
  origin: Per-dimension origin values. Length must match ndims.
678
858
  direction: Direction cosine matrix of shape [ndims, ndims].
859
+ affine: Homogeneous affine matrix of shape [ndims + 1, ndims + 1].
679
860
  shape: Array shape. Length must match (spatial + non-spatial) ndims.
680
861
  axis_labels: Per-axis labels or roles. Length must match ndims.
681
862
  axis_units: Per-axis units. Length must match ndims.
@@ -686,11 +867,14 @@ class MetaSpatial(BaseMeta):
686
867
  spacing: Optional[list[Union[int,float]]] = None
687
868
  origin: Optional[list[Union[int,float]]] = None
688
869
  direction: Optional[list[list[Union[int,float]]]] = None
870
+ affine: Optional[list[list[Union[int,float]]]] = None
689
871
  shape: Optional[list[int]] = None
690
872
  axis_labels: Optional[list[Union[str,AxisLabel]]] = None
691
873
  axis_units: Optional[list[str]] = None
692
874
  _num_spatial_axes: Optional[int] = None
693
875
  _num_non_spatial_axes: Optional[int] = None
876
+ _PROTECTED_FIELDS = frozenset({"shape"})
877
+ _PROTECTED_FIELD_PREFIX = "meta.spatial."
694
878
 
695
879
  def _validate_and_cast(self, *, ndims: Optional[int] = None, spatial_ndims: Optional[int] = None, **_: Any) -> None:
696
880
  """Validate and normalize spatial fields.
@@ -700,6 +884,20 @@ class MetaSpatial(BaseMeta):
700
884
  spatial_ndims: Number of spatial dimensions.
701
885
  **_: Unused extra context.
702
886
  """
887
+ self._remember_validation_context(
888
+ ndims=ndims,
889
+ spatial_ndims=spatial_ndims,
890
+ )
891
+
892
+ if self.affine is not None and (
893
+ self.spacing is not None
894
+ or self.origin is not None
895
+ or self.direction is not None
896
+ ):
897
+ raise ValueError(
898
+ "meta.spatial.affine cannot be set together with spacing, origin, or direction."
899
+ )
900
+
703
901
  if self.axis_labels is not None:
704
902
  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)
705
903
  spatial_ndims = spatial_ndims if self._num_spatial_axes is None else self._num_spatial_axes
@@ -716,9 +914,50 @@ class MetaSpatial(BaseMeta):
716
914
  self.direction = _cast_to_list(self.direction, "meta.spatial.direction")
717
915
  _validate_float_int_matrix(self.direction, "meta.spatial.direction", spatial_ndims)
718
916
 
917
+ if self.affine is not None:
918
+ self.affine = _cast_to_list(self.affine, "meta.spatial.affine")
919
+ if spatial_ndims is not None:
920
+ _validate_float_int_matrix(
921
+ self.affine,
922
+ "meta.spatial.affine",
923
+ spatial_ndims + 1,
924
+ )
925
+ else:
926
+ _validate_float_int_matrix(self.affine, "meta.spatial.affine")
927
+ n_rows = len(self.affine)
928
+ for row in self.affine:
929
+ if len(row) != n_rows:
930
+ raise ValueError("meta.spatial.affine must be a square matrix")
931
+
719
932
  if self.shape is not None:
720
- self.shape = _cast_to_list(self.shape, "meta.spatial.shape")
721
- _validate_float_int_list(self.shape, "meta.spatial.shape", ndims)
933
+ shape = _cast_to_list(self.shape, "meta.spatial.shape")
934
+ _validate_float_int_list(shape, "meta.spatial.shape", ndims)
935
+ self.shape = _FrozenList(shape)
936
+
937
+ def copy_from(self, other: "MetaSpatial", *, overwrite: bool = False) -> None:
938
+ if other.__class__ is not self.__class__:
939
+ raise TypeError(f"copy_from expects {self.__class__.__name__}")
940
+
941
+ for f in _public_dataclass_fields(self):
942
+ if f.name == "shape" and not _is_meta_internal_write():
943
+ continue
944
+
945
+ src = getattr(other, f.name)
946
+ dst = getattr(self, f.name)
947
+
948
+ if overwrite:
949
+ setattr(self, f.name, src)
950
+ continue
951
+
952
+ if isinstance(dst, BaseMeta) and isinstance(src, BaseMeta):
953
+ if dst.is_default():
954
+ setattr(self, f.name, src)
955
+ else:
956
+ dst.copy_from(src, overwrite=False)
957
+ continue
958
+
959
+ if _is_unset_value(dst):
960
+ setattr(self, f.name, src)
722
961
 
723
962
 
724
963
  @dataclass(slots=True)
@@ -871,6 +1110,8 @@ class MetaHasArray(SingleKeyBaseMeta):
871
1110
  has_array: True when array data is present.
872
1111
  """
873
1112
  has_array: bool = False
1113
+ _PROTECTED_FIELDS = frozenset({"has_array"})
1114
+ _PROTECTED_FIELD_PREFIX = "meta._has_array."
874
1115
 
875
1116
  def _validate_and_cast(self, **_: Any) -> None:
876
1117
  """Validate has_array as bool."""
@@ -886,6 +1127,8 @@ class MetaImageFormat(SingleKeyBaseMeta):
886
1127
  image_meta_format: Format identifier, or None.
887
1128
  """
888
1129
  image_meta_format: Optional[str] = None
1130
+ _PROTECTED_FIELDS = frozenset({"image_meta_format"})
1131
+ _PROTECTED_FIELD_PREFIX = "meta._image_meta_format."
889
1132
 
890
1133
  def _validate_and_cast(self, **_: Any) -> None:
891
1134
  """Validate image_meta_format as str or None."""
@@ -901,6 +1144,8 @@ class MetaVersion(SingleKeyBaseMeta):
901
1144
  mlarray_version: Version string, or None.
902
1145
  """
903
1146
  mlarray_version: Optional[str] = None
1147
+ _PROTECTED_FIELDS = frozenset({"mlarray_version"})
1148
+ _PROTECTED_FIELD_PREFIX = "meta._mlarray_version."
904
1149
 
905
1150
  def _validate_and_cast(self, **_: Any) -> None:
906
1151
  """Validate mlarray_version as str or None."""
@@ -915,7 +1160,7 @@ class Meta(BaseMeta):
915
1160
  Attributes:
916
1161
  source: Source metadata from the original image source (JSON-serializable dict).
917
1162
  extra: Additional metadata (JSON-serializable dict).
918
- spatial: Spatial metadata (spacing, origin, direction, shape).
1163
+ spatial: Spatial metadata (spacing, origin, direction, affine, shape).
919
1164
  stats: Summary statistics.
920
1165
  bbox: Bounding boxes.
921
1166
  is_seg: Segmentation flag.
@@ -934,6 +1179,10 @@ class Meta(BaseMeta):
934
1179
  _has_array: "MetaHasArray" = field(default_factory=lambda: MetaHasArray())
935
1180
  _image_meta_format: "MetaImageFormat" = field(default_factory=lambda: MetaImageFormat())
936
1181
  _mlarray_version: "MetaVersion" = field(default_factory=lambda: MetaVersion())
1182
+ _USER_FIELDS = ("source", "extra", "spatial", "stats", "bbox", "is_seg")
1183
+ _INTERNAL_FIELDS = ("blosc2", "_has_array", "_image_meta_format", "_mlarray_version")
1184
+ _PROTECTED_FIELDS = frozenset(_INTERNAL_FIELDS)
1185
+ _PROTECTED_FIELD_PREFIX = "meta."
937
1186
 
938
1187
  def _validate_and_cast(self, *, ndims: Optional[int] = None, spatial_ndims: Optional[int] = None, **_: Any) -> None:
939
1188
  """Coerce child metas and validate with optional context.
@@ -943,20 +1192,141 @@ class Meta(BaseMeta):
943
1192
  spatial_ndims: Number of spatial dimensions for context-aware validation.
944
1193
  **_: Unused extra context.
945
1194
  """
946
- self.source = MetaSource.ensure(self.source)
947
- self.extra = MetaExtra.ensure(self.extra)
948
- self.spatial = MetaSpatial.ensure(self.spatial)
949
- self.stats = MetaStatistics.ensure(self.stats)
950
- self.bbox = MetaBbox.ensure(self.bbox)
951
- self.is_seg = MetaIsSeg.ensure(self.is_seg)
952
- self.blosc2 = MetaBlosc2.ensure(self.blosc2)
953
- self._has_array = MetaHasArray.ensure(self._has_array)
954
- self._image_meta_format = MetaImageFormat.ensure(self._image_meta_format)
955
- self._mlarray_version = MetaVersion.ensure(self._mlarray_version)
1195
+ self._remember_validation_context(
1196
+ ndims=ndims,
1197
+ spatial_ndims=spatial_ndims,
1198
+ )
1199
+ object.__setattr__(self, "source", MetaSource.ensure(self.source))
1200
+ object.__setattr__(self, "extra", MetaExtra.ensure(self.extra))
1201
+ object.__setattr__(self, "spatial", MetaSpatial.ensure(self.spatial))
1202
+ object.__setattr__(self, "stats", MetaStatistics.ensure(self.stats))
1203
+ object.__setattr__(self, "bbox", MetaBbox.ensure(self.bbox))
1204
+ object.__setattr__(self, "is_seg", MetaIsSeg.ensure(self.is_seg))
1205
+ object.__setattr__(self, "blosc2", MetaBlosc2.ensure(self.blosc2))
1206
+ object.__setattr__(self, "_has_array", MetaHasArray.ensure(self._has_array))
1207
+ object.__setattr__(
1208
+ self,
1209
+ "_image_meta_format",
1210
+ MetaImageFormat.ensure(self._image_meta_format),
1211
+ )
1212
+ object.__setattr__(self, "_mlarray_version", MetaVersion.ensure(self._mlarray_version))
956
1213
 
957
1214
  self.spatial._validate_and_cast(ndims=ndims, spatial_ndims=spatial_ndims)
958
1215
  self.blosc2._validate_and_cast(ndims=ndims, spatial_ndims=spatial_ndims)
959
1216
 
1217
+ def copy_from(self, other: "Meta", *, overwrite: bool = False) -> None:
1218
+ if other.__class__ is not self.__class__:
1219
+ raise TypeError(f"copy_from expects {self.__class__.__name__}")
1220
+
1221
+ field_names = list(self._USER_FIELDS)
1222
+ if _is_meta_internal_write():
1223
+ field_names.extend(self._INTERNAL_FIELDS)
1224
+
1225
+ for name in field_names:
1226
+ src = getattr(other, name)
1227
+ dst = getattr(self, name)
1228
+
1229
+ if overwrite:
1230
+ object.__setattr__(self, name, src)
1231
+ continue
1232
+
1233
+ if isinstance(dst, BaseMeta) and isinstance(src, BaseMeta):
1234
+ if dst.is_default():
1235
+ object.__setattr__(self, name, src)
1236
+ else:
1237
+ dst.copy_from(src, overwrite=False)
1238
+ continue
1239
+
1240
+ if _is_unset_value(dst):
1241
+ object.__setattr__(self, name, src)
1242
+
1243
+ def set_source(self, value: Union["MetaSource", Mapping[str, Any]]) -> "Meta":
1244
+ object.__setattr__(self, "source", MetaSource.ensure(value))
1245
+ return self
1246
+
1247
+ def set_extra(self, value: Union["MetaExtra", Mapping[str, Any]]) -> "Meta":
1248
+ object.__setattr__(self, "extra", MetaExtra.ensure(value))
1249
+ return self
1250
+
1251
+ def set_spatial(self, value: Union["MetaSpatial", Mapping[str, Any]]) -> "Meta":
1252
+ spatial = MetaSpatial.ensure(value)
1253
+ if spatial.shape is not None and not _is_meta_internal_write():
1254
+ _raise_internal_only("meta.spatial.shape")
1255
+ object.__setattr__(self, "spatial", spatial)
1256
+ return self
1257
+
1258
+ def set_stats(self, value: Union["MetaStatistics", Mapping[str, Any]]) -> "Meta":
1259
+ object.__setattr__(self, "stats", MetaStatistics.ensure(value))
1260
+ return self
1261
+
1262
+ def set_bbox(
1263
+ self,
1264
+ value: Union["MetaBbox", Mapping[str, Any], list[list[list[Union[int, float]]]]],
1265
+ ) -> "Meta":
1266
+ bbox = MetaBbox.ensure(value) if isinstance(value, (MetaBbox, Mapping)) else MetaBbox(bboxes=value)
1267
+ object.__setattr__(self, "bbox", bbox)
1268
+ return self
1269
+
1270
+ def set_is_seg(self, value: Union["MetaIsSeg", Optional[bool]]) -> "Meta":
1271
+ is_seg = (
1272
+ MetaIsSeg.ensure(value)
1273
+ if isinstance(value, (MetaIsSeg, Mapping))
1274
+ else MetaIsSeg(is_seg=value)
1275
+ )
1276
+ object.__setattr__(self, "is_seg", is_seg)
1277
+ return self
1278
+
1279
+ def update_source(self, value: Mapping[str, Any]) -> "Meta":
1280
+ if not isinstance(value, Mapping):
1281
+ raise TypeError("source update value must be a mapping")
1282
+ self.source.data.update(dict(value))
1283
+ self.source._validate_and_cast()
1284
+ return self
1285
+
1286
+ def update_extra(self, value: Mapping[str, Any]) -> "Meta":
1287
+ if not isinstance(value, Mapping):
1288
+ raise TypeError("extra update value must be a mapping")
1289
+ self.extra.data.update(dict(value))
1290
+ self.extra._validate_and_cast()
1291
+ return self
1292
+
1293
+ def add_bbox(
1294
+ self,
1295
+ bbox: list[list[Union[int, float]]],
1296
+ score: Optional[Union[int, float]] = None,
1297
+ label: Optional[Union[str, int, float]] = None,
1298
+ ) -> "Meta":
1299
+ prev_n = 0 if self.bbox.bboxes is None else len(self.bbox.bboxes)
1300
+ if prev_n > 0 and self.bbox.scores is None and score is not None:
1301
+ raise ValueError(
1302
+ "Cannot add a scored bbox when existing bboxes have no scores."
1303
+ )
1304
+ if prev_n > 0 and self.bbox.labels is None and label is not None:
1305
+ raise ValueError(
1306
+ "Cannot add a labeled bbox when existing bboxes have no labels."
1307
+ )
1308
+
1309
+ if self.bbox.bboxes is None:
1310
+ self.bbox.bboxes = []
1311
+ self.bbox.bboxes.append(_cast_to_list(bbox, "meta.bbox.bbox"))
1312
+
1313
+ if score is not None:
1314
+ if self.bbox.scores is None:
1315
+ self.bbox.scores = []
1316
+ self.bbox.scores.append(score)
1317
+ elif self.bbox.scores is not None:
1318
+ raise ValueError("score must be provided because meta.bbox.scores already exists")
1319
+
1320
+ if label is not None:
1321
+ if self.bbox.labels is None:
1322
+ self.bbox.labels = []
1323
+ self.bbox.labels.append(label)
1324
+ elif self.bbox.labels is not None:
1325
+ raise ValueError("label must be provided because meta.bbox.labels already exists")
1326
+
1327
+ self.bbox._validate_and_cast()
1328
+ return self
1329
+
960
1330
  def to_plain(self, *, include_none: bool = False) -> Any:
961
1331
  """Convert to plain values, suppressing default sub-metas.
962
1332
 
@@ -968,7 +1338,7 @@ class Meta(BaseMeta):
968
1338
  as None and optionally filtered out.
969
1339
  """
970
1340
  out: dict[str, Any] = {}
971
- for f in fields(self):
1341
+ for f in _public_dataclass_fields(self):
972
1342
  v = getattr(self, f.name)
973
1343
 
974
1344
  if isinstance(v, BaseMeta):