mlarray 0.0.10__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.
mlarray/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ """A standardized blosc2 image reader and writer for medical images.."""
2
+
3
+ from importlib import metadata as _metadata
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from mlarray.mlarray import MLArray, MLARRAY_DEFAULT_PATCH_SIZE
8
+ from mlarray.meta import Meta, MetaBlosc2, MetaSpatial
9
+ from mlarray.utils import is_serializable
10
+ from mlarray.cli import cli_print_header, cli_convert_to_mlarray
11
+
12
+ __all__ = [
13
+ "__version__",
14
+ "MLArray",
15
+ "MLARRAY_DEFAULT_PATCH_SIZE",
16
+ "Meta",
17
+ "MetaBlosc2",
18
+ "MetaSpatial",
19
+ "is_serializable",
20
+ "cli_print_header",
21
+ "cli_convert_to_mlarray",
22
+ ]
23
+
24
+ try:
25
+ __version__ = _metadata.version(__name__)
26
+ except _metadata.PackageNotFoundError: # pragma: no cover - during editable installs pre-build
27
+ __version__ = "0.0.0"
28
+
29
+
30
+ def __getattr__(name: str):
31
+ if name in {"MLArray", "MLARRAY_DEFAULT_PATCH_SIZE"}:
32
+ from mlarray.mlarray import MLArray, MLARRAY_DEFAULT_PATCH_SIZE
33
+
34
+ return MLArray if name == "MLArray" else MLARRAY_DEFAULT_PATCH_SIZE
35
+ if name in {"Meta", "MetaBlosc2", "MetaSpatial"}:
36
+ from mlarray.meta import Meta, MetaBlosc2, MetaSpatial
37
+
38
+ return {"Meta": Meta, "MetaBlosc2": MetaBlosc2, "MetaSpatial": MetaSpatial}[name]
39
+ if name == "is_serializable":
40
+ from mlarray.utils import is_serializable
41
+
42
+ return is_serializable
43
+ if name in {"cli_print_header", "cli_convert_to_mlarray"}:
44
+ from mlarray.cli import cli_print_header, cli_convert_to_mlarray
45
+
46
+ return {
47
+ "cli_print_header": cli_print_header,
48
+ "cli_convert_to_mlarray": cli_convert_to_mlarray,
49
+ }[name]
50
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
51
+
52
+
53
+ def __dir__():
54
+ return sorted(__all__)
mlarray/cli.py ADDED
@@ -0,0 +1,58 @@
1
+ import argparse
2
+ import json
3
+ from typing import Union
4
+ from pathlib import Path
5
+ from mlarray import MLArray
6
+
7
+ try:
8
+ from medvol import MedVol
9
+ except ImportError:
10
+ MedVol = None
11
+
12
+
13
+ def print_header(filepath: Union[str, Path]) -> None:
14
+ """Print the MLArray metadata header for a file.
15
+
16
+ Args:
17
+ filepath: Path to a ".mla" or ".b2nd" file.
18
+ """
19
+ meta = MLArray(filepath).meta
20
+ if meta is None:
21
+ print("null")
22
+ return
23
+ print(json.dumps(meta.to_dict(include_none=True), indent=2, sort_keys=True))
24
+
25
+
26
+ def convert_to_mlarray(load_filepath: Union[str, Path], save_filepath: Union[str, Path]):
27
+ if MedVol is None:
28
+ raise RuntimeError("medvol is required for mlarray_convert; install with 'pip install mlarray[all]'.")
29
+ image_meta_format = None
30
+ if str(load_filepath).endswith(f".nii.gz") or str(load_filepath).endswith(f".nii"):
31
+ image_meta_format = "nifti"
32
+ elif str(load_filepath).endswith(f".nrrd"):
33
+ image_meta_format = "nrrd"
34
+ image_medvol = MedVol(load_filepath)
35
+ 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
+ image_mlarray.save(save_filepath)
38
+
39
+
40
+ def cli_print_header() -> None:
41
+ parser = argparse.ArgumentParser(
42
+ prog="mlarray_header",
43
+ description="Print the MLArray metadata header for a file.",
44
+ )
45
+ parser.add_argument("filepath", help="Path to a .mla or .b2nd file.")
46
+ args = parser.parse_args()
47
+ print_header(args.filepath)
48
+
49
+
50
+ def cli_convert_to_mlarray() -> None:
51
+ parser = argparse.ArgumentParser(
52
+ prog="mlarray_convert",
53
+ description="Convert a NiFTi or NRRD file to MLArray and copy all metadata.",
54
+ )
55
+ parser.add_argument("load_filepath", help="Path to the NiFTi (.nii.gz, .nii) or NRRD (.nrrd) file to load.")
56
+ parser.add_argument("save_filepath", help="Path to the MLArray (.mla) file to save.")
57
+ args = parser.parse_args()
58
+ convert_to_mlarray(args.load_filepath, args.save_filepath)
mlarray/meta.py ADDED
@@ -0,0 +1,578 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, List, Mapping, Optional, Union
5
+ import numpy as np
6
+ from mlarray.utils import is_serializable
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class MetaBlosc2:
11
+ chunk_size: Optional[list] = None
12
+ block_size: Optional[list] = None
13
+ patch_size: Optional[list] = None
14
+
15
+ def __post_init__(self) -> None:
16
+ self._validate_and_cast()
17
+
18
+ def __repr__(self) -> str:
19
+ return repr(self.to_dict())
20
+
21
+ def to_dict(self, *, include_none: bool = True) -> Dict[str, Any]:
22
+ out: Dict[str, Any] = {
23
+ "chunk_size": self.chunk_size,
24
+ "block_size": self.block_size,
25
+ "patch_size": self.patch_size,
26
+ }
27
+ if not include_none:
28
+ out = {k: v for k, v in out.items() if v is not None}
29
+ return out
30
+
31
+ def _validate_and_cast(self, ndims: Optional[int] = None, channel_axis: Optional[int] = None) -> None:
32
+ if self.chunk_size is not None:
33
+ self.chunk_size = _cast_to_list(self.chunk_size, "meta._blosc2.chunk_size")
34
+ _validate_float_int_list(self.chunk_size, f"meta._blosc2.chunk_size", ndims)
35
+ if self.block_size is not None:
36
+ self.block_size = _cast_to_list(self.block_size, "meta._blosc2.block_size")
37
+ _validate_float_int_list(self.block_size, f"meta._blosc2.block_size", ndims)
38
+ if self.patch_size is not None:
39
+ _ndims = ndims if (ndims is None or channel_axis is None) else ndims-1
40
+ self.patch_size = _cast_to_list(self.patch_size, "meta._blosc2.patch_size")
41
+ _validate_float_int_list(self.patch_size, f"meta._blosc2.patch_size", _ndims)
42
+
43
+ @classmethod
44
+ def from_dict(cls, d: Mapping[str, Any], *, strict: bool = True) -> MetaBlosc2:
45
+ if not isinstance(d, Mapping):
46
+ raise TypeError(f"MetaBlosc2.from_dict expects a mapping, got {type(d).__name__}")
47
+ known = {"chunk_size", "block_size", "patch_size"}
48
+ d = dict(d)
49
+ unknown = set(d.keys()) - known
50
+ if unknown and strict:
51
+ raise KeyError(f"Unknown MetaBlosc2 keys in from_dict: {sorted(unknown)}")
52
+ return cls(
53
+ chunk_size=d.get("chunk_size"),
54
+ block_size=d.get("block_size"),
55
+ patch_size=d.get("patch_size"),
56
+ )
57
+
58
+
59
+ @dataclass(slots=True)
60
+ class MetaSpatial:
61
+ spacing: Optional[List] = None
62
+ origin: Optional[List] = None
63
+ direction: Optional[List[List]] = None
64
+ shape: Optional[List] = None
65
+ channel_axis: Optional[int] = None
66
+
67
+ def __post_init__(self) -> None:
68
+ self._validate_and_cast()
69
+
70
+ def __repr__(self) -> str:
71
+ return repr(self.to_dict())
72
+
73
+ def to_dict(self, *, include_none: bool = True) -> Dict[str, Any]:
74
+ out: Dict[str, Any] = {
75
+ "spacing": self.spacing,
76
+ "origin": self.origin,
77
+ "direction": self.direction,
78
+ "shape": self.shape,
79
+ "channel_axis": self.channel_axis
80
+ }
81
+ if not include_none:
82
+ out = {k: v for k, v in out.items() if v is not None}
83
+ return out
84
+
85
+ def _validate_and_cast(self, ndims: Optional[int] = None) -> None:
86
+ if self.channel_axis is not None:
87
+ _validate_int(self.channel_axis, "meta.spatial.channel_axis")
88
+ if self.spacing is not None:
89
+ self.spacing = _cast_to_list(self.spacing, "meta.spatial.spacing")
90
+ _validate_float_int_list(self.spacing, "meta.spatial.spacing", ndims)
91
+ if self.origin is not None:
92
+ self.origin = _cast_to_list(self.origin, "meta.spatial.origin")
93
+ _validate_float_int_list(self.origin, "meta.spatial.origin", ndims)
94
+ if self.direction is not None:
95
+ self.direction = _cast_to_list(self.direction, "meta.spatial.direction")
96
+ _validate_float_int_matrix(self.direction, "meta.spatial.direction", ndims)
97
+ if self.shape is not None:
98
+ _ndims = ndims if (ndims is None or self.channel_axis is None) else ndims+1
99
+ self.shape = _cast_to_list(self.shape, "meta.spatial.shape")
100
+ _validate_float_int_list(self.shape, "meta.spatial.shape", _ndims)
101
+
102
+ @classmethod
103
+ def from_dict(cls, d: Mapping[str, Any], *, strict: bool = True) -> MetaSpatial:
104
+ if not isinstance(d, Mapping):
105
+ raise TypeError(f"MetaSpatial.from_dict expects a mapping, got {type(d).__name__}")
106
+ known = {"spacing", "origin", "direction", "shape", "channel_axis"}
107
+ d = dict(d)
108
+ unknown = set(d.keys()) - known
109
+ if unknown and strict:
110
+ raise KeyError(f"Unknown MetaSpatial keys in from_dict: {sorted(unknown)}")
111
+ return cls(
112
+ spacing=d.get("spacing"),
113
+ origin=d.get("origin"),
114
+ direction=d.get("direction"),
115
+ shape=d.get("shape"),
116
+ channel_axis=d.get("channel_axis")
117
+ )
118
+
119
+
120
+ @dataclass(slots=True)
121
+ class MetaStatistics:
122
+ min: Optional[float] = None
123
+ max: Optional[float] = None
124
+ mean: Optional[float] = None
125
+ median: Optional[float] = None
126
+ std: Optional[float] = None
127
+ percentile_min: Optional[float] = None
128
+ percentile_max: Optional[float] = None
129
+ percentile_mean: Optional[float] = None
130
+ percentile_median: Optional[float] = None
131
+ percentile_std: Optional[float] = None
132
+
133
+ def __post_init__(self) -> None:
134
+ self._validate_and_cast()
135
+
136
+ def __repr__(self) -> str:
137
+ return repr(self.to_dict())
138
+
139
+ def to_dict(self, *, include_none: bool = True) -> Dict[str, Any]:
140
+ out: Dict[str, Any] = {
141
+ "min": self.min,
142
+ "max": self.max,
143
+ "mean": self.mean,
144
+ "median": self.median,
145
+ "std": self.std,
146
+ "percentile_min": self.percentile_min,
147
+ "percentile_max": self.percentile_max,
148
+ "percentile_mean": self.percentile_mean,
149
+ "percentile_median": self.percentile_median,
150
+ "percentile_std": self.percentile_std,
151
+ }
152
+ if not include_none:
153
+ out = {k: v for k, v in out.items() if v is not None}
154
+ return out
155
+
156
+ def _validate_and_cast(self) -> None:
157
+ for name in (
158
+ "min",
159
+ "max",
160
+ "mean",
161
+ "median",
162
+ "std",
163
+ "percentile_min",
164
+ "percentile_max",
165
+ "percentile_mean",
166
+ "percentile_median",
167
+ "percentile_std",
168
+ ):
169
+ value = getattr(self, name)
170
+ if value is not None and not isinstance(value, (float, int)):
171
+ raise TypeError(f"meta.stats.{name} must be a float or int")
172
+
173
+ @classmethod
174
+ def from_dict(cls, d: Mapping[str, Any], *, strict: bool = True) -> MetaStatistics:
175
+ if not isinstance(d, Mapping):
176
+ raise TypeError(f"MetaStatistics.from_dict expects a mapping, got {type(d).__name__}")
177
+ known = {
178
+ "min",
179
+ "max",
180
+ "mean",
181
+ "median",
182
+ "std",
183
+ "percentile_min",
184
+ "percentile_max",
185
+ "percentile_mean",
186
+ "percentile_median",
187
+ "percentile_std",
188
+ }
189
+ d = dict(d)
190
+ unknown = set(d.keys()) - known
191
+ if unknown and strict:
192
+ raise KeyError(f"Unknown MetaStatistics keys in from_dict: {sorted(unknown)}")
193
+ return cls(
194
+ min=d.get("min"),
195
+ max=d.get("max"),
196
+ mean=d.get("mean"),
197
+ median=d.get("median"),
198
+ std=d.get("std"),
199
+ percentile_min=d.get("percentile_min"),
200
+ percentile_max=d.get("percentile_max"),
201
+ percentile_mean=d.get("percentile_mean"),
202
+ percentile_median=d.get("percentile_median"),
203
+ percentile_std=d.get("percentile_std"),
204
+ )
205
+
206
+
207
+ @dataclass(slots=True)
208
+ class MetaBbox:
209
+ bboxes: Optional[List[List[List[int]]]] = None
210
+
211
+ def __post_init__(self) -> None:
212
+ self._validate_and_cast()
213
+
214
+ def __iter__(self):
215
+ return iter(self.bboxes or [])
216
+
217
+ def __getitem__(self, index):
218
+ if self.bboxes is None:
219
+ raise TypeError("meta.bbox is None")
220
+ return self.bboxes[index]
221
+
222
+ def __setitem__(self, index, value):
223
+ if self.bboxes is None:
224
+ raise TypeError("meta.bbox is None")
225
+ self.bboxes[index] = value
226
+
227
+ def __len__(self):
228
+ return len(self.bboxes or [])
229
+
230
+ def __repr__(self) -> str:
231
+ return repr(self.bboxes)
232
+
233
+ def to_list(self) -> Optional[List[List[List[int]]]]:
234
+ return self.bboxes
235
+
236
+ def _validate_and_cast(self, ndims: Optional[int] = None) -> None:
237
+ if self.bboxes is None:
238
+ return
239
+ self.bboxes = _cast_to_list(self.bboxes, "meta.bbox.bboxes")
240
+ if not isinstance(self.bboxes, list):
241
+ raise TypeError("meta.bbox.bboxes must be a list of bboxes")
242
+ for bbox in self.bboxes:
243
+ if not isinstance(bbox, list):
244
+ raise TypeError("meta.bbox.bboxes must be a list of bboxes")
245
+ if ndims is not None and len(bbox) != ndims:
246
+ raise ValueError(f"meta.bbox.bboxes entries must have length {ndims}")
247
+ for row in bbox:
248
+ if not isinstance(row, list):
249
+ raise TypeError("meta.bbox.bboxes must be a list of lists")
250
+ if len(row) != 2:
251
+ raise ValueError("meta.bbox.bboxes entries must have length 2 per dimension")
252
+ for item in row:
253
+ if isinstance(item, bool) or not isinstance(item, int):
254
+ raise TypeError("meta.bbox.bboxes must contain only ints")
255
+
256
+ @classmethod
257
+ def from_list(cls, bboxes: Any) -> MetaBbox:
258
+ return cls(bboxes=bboxes)
259
+
260
+
261
+ @dataclass(slots=True)
262
+ class Meta:
263
+ image: Optional[Dict[str, Any]] = None
264
+ spatial: MetaSpatial = field(default_factory=MetaSpatial)
265
+ stats: Optional[Union[dict, MetaStatistics]] = None
266
+ bbox: Optional[MetaBbox] = None
267
+ is_seg: Optional[bool] = None
268
+ _blosc2: MetaBlosc2 = field(default_factory=MetaBlosc2)
269
+ _has_array: Optional[bool] = None
270
+ _image_meta_format: Optional[str] = None
271
+ _mlarray_version: Optional[str] = None
272
+
273
+ # controlled escape hatch for future/experimental metadata
274
+ extra: Dict[str, Any] = field(default_factory=dict)
275
+
276
+ def __post_init__(self) -> None:
277
+ # Validate anything passed in the constructor
278
+ for name in ("image",):
279
+ val = getattr(self, name)
280
+ if val is not None:
281
+ if not isinstance(val, dict):
282
+ raise TypeError(f"meta.{name} must be a dict or None, got {type(val).__name__}")
283
+ if not is_serializable(val):
284
+ raise TypeError(f"meta.{name} is not JSON-serializable")
285
+ if self.stats is not None:
286
+ if isinstance(self.stats, MetaStatistics):
287
+ pass
288
+ elif isinstance(self.stats, Mapping):
289
+ self.stats = MetaStatistics.from_dict(self.stats, strict=False)
290
+ else:
291
+ raise TypeError(f"meta.stats must be a MetaStatistics or mapping, got {type(self.stats).__name__}")
292
+ if self.bbox is not None:
293
+ if isinstance(self.bbox, MetaBbox):
294
+ pass
295
+ elif isinstance(self.bbox, (list, tuple)) or (np is not None and isinstance(self.bbox, np.ndarray)):
296
+ self.bbox = MetaBbox(bboxes=self.bbox)
297
+ else:
298
+ raise TypeError(f"meta.bbox must be a MetaBbox or list-like, got {type(self.bbox).__name__}")
299
+
300
+ if self.spatial is None:
301
+ self.spatial = MetaSpatial()
302
+ if not isinstance(self.spatial, MetaSpatial):
303
+ raise TypeError(f"meta.spatial must be a MetaSpatial, got {type(self.spatial).__name__}")
304
+
305
+ if self._blosc2 is None:
306
+ self._blosc2 = MetaBlosc2()
307
+ if not isinstance(self._blosc2, MetaBlosc2):
308
+ raise TypeError(f"meta._blosc2 must be a MetaBlosc2, got {type(self._blosc2).__name__}")
309
+
310
+ if self.is_seg is not None and not isinstance(self.is_seg, bool):
311
+ raise TypeError("meta.is_seg must be a bool or None")
312
+ if self._has_array is not None and not isinstance(self._has_array, bool):
313
+ raise TypeError("meta._has_array must be a bool or None")
314
+ if self._image_meta_format is not None and not isinstance(self._image_meta_format, str):
315
+ raise TypeError("meta._image_meta_format must be a str or None")
316
+ if self._mlarray_version is not None and not isinstance(self._mlarray_version, str):
317
+ raise TypeError("meta._mlarray_version must be a str or None")
318
+
319
+ if not isinstance(self.extra, dict):
320
+ raise TypeError(f"meta.extra must be a dict, got {type(self.extra).__name__}")
321
+ if not is_serializable(self.extra):
322
+ raise TypeError("meta.extra is not JSON-serializable")
323
+
324
+ def __repr__(self) -> str:
325
+ return repr(self.to_dict())
326
+
327
+ def set(self, key: str, value: Any) -> None:
328
+ """
329
+ Set a known meta section explicitly (typos raise).
330
+ Ensures the provided value is JSON-serializable.
331
+ """
332
+ if not hasattr(self, key) and key not in {"_blosc2", "_mlarray_version"}:
333
+ raise AttributeError(f"Unknown meta section: {key!r}")
334
+ if key == "extra":
335
+ raise AttributeError("Use meta.extra[...] to add to extra")
336
+ if key == "spatial":
337
+ if isinstance(value, MetaSpatial):
338
+ setattr(self, key, value)
339
+ return
340
+ if isinstance(value, Mapping):
341
+ setattr(self, key, MetaSpatial.from_dict(value, strict=False))
342
+ return
343
+ raise TypeError("meta.spatial must be a MetaSpatial or mapping")
344
+ if key == "stats":
345
+ if isinstance(value, MetaStatistics):
346
+ self.stats = value
347
+ return
348
+ if isinstance(value, Mapping):
349
+ self.stats = MetaStatistics.from_dict(value, strict=False)
350
+ return
351
+ raise TypeError("meta.stats must be a MetaStatistics or mapping")
352
+ if key == "bbox":
353
+ if isinstance(value, MetaBbox):
354
+ self.bbox = value
355
+ return
356
+ if isinstance(value, (list, tuple)) or (np is not None and isinstance(value, np.ndarray)):
357
+ self.bbox = MetaBbox(bboxes=value)
358
+ return
359
+ raise TypeError("meta.bbox must be a MetaBbox or list-like")
360
+ if key == "_blosc2":
361
+ if isinstance(value, MetaBlosc2):
362
+ self._blosc2 = value
363
+ return
364
+ if isinstance(value, Mapping):
365
+ self._blosc2 = MetaBlosc2.from_dict(value, strict=False)
366
+ return
367
+ raise TypeError("meta._blosc2 must be a MetaBlosc2 or mapping")
368
+ if key == "is_seg":
369
+ if value is not None and not isinstance(value, bool):
370
+ raise TypeError("meta.is_seg must be a bool or None")
371
+ setattr(self, key, value)
372
+ return
373
+ if key == "_has_array":
374
+ if value is not None and not isinstance(value, bool):
375
+ raise TypeError("meta._has_array must be a bool or None")
376
+ setattr(self, key, value)
377
+ return
378
+ if key == "_image_meta_format":
379
+ if value is not None and not isinstance(value, str):
380
+ raise TypeError("meta._image_meta_format must be a str or None")
381
+ setattr(self, key, value)
382
+ return
383
+ if key == "_mlarray_version":
384
+ if value is not None and not isinstance(value, str):
385
+ raise TypeError("meta._mlarray_version must be a str or None")
386
+ self._mlarray_version = value
387
+ return
388
+
389
+ value_dict = dict(value)
390
+
391
+ if not is_serializable(value_dict):
392
+ raise TypeError(f"meta.{key} is not JSON-serializable")
393
+
394
+ setattr(self, key, value_dict)
395
+
396
+ def to_dict(self, *, include_none: bool = True) -> Dict[str, Any]:
397
+ """
398
+ Convert to a plain dict. All entries are guaranteed JSON-serializable
399
+ due to validation in __post_init__ and set().
400
+ """
401
+ out: Dict[str, Any] = {
402
+ "image": self.image,
403
+ "stats": self.stats.to_dict() if self.stats is not None else None,
404
+ "bbox": self.bbox.to_list() if self.bbox is not None else None,
405
+ "is_seg": self.is_seg,
406
+ "spatial": self.spatial.to_dict(),
407
+ "_has_array": self._has_array,
408
+ "_image_meta_format": self._image_meta_format,
409
+ "_blosc2": self._blosc2.to_dict(),
410
+ "_mlarray_version": self._mlarray_version,
411
+ "extra": self.extra,
412
+ }
413
+
414
+ if not include_none:
415
+ out = {k: v for k, v in out.items() if v is not None and not (k == "extra" and v == {})}
416
+
417
+ return out
418
+
419
+ def _validate_and_cast(self, ndims: int) -> None:
420
+ self.spatial._validate_and_cast(ndims)
421
+ if self.bbox is not None:
422
+ self.bbox._validate_and_cast(ndims)
423
+ self._blosc2._validate_and_cast(ndims)
424
+
425
+ @classmethod
426
+ def from_dict(cls, d: Mapping[str, Any], *, strict: bool = True) -> Meta:
427
+ """
428
+ Construct Meta from a dict.
429
+
430
+ Args:
431
+ d: Mapping with keys in {"image", "stats", "bbox", "spatial",
432
+ "_blosc2", "_mlarray_version", "_image_meta_format", "is_seg", "extra"}.
433
+ strict: If True, unknown keys raise. If False, unknown keys go into extra.
434
+
435
+ Returns:
436
+ Meta instance (validated).
437
+ """
438
+ if not isinstance(d, Mapping):
439
+ raise TypeError(f"from_dict expects a mapping, got {type(d).__name__}")
440
+
441
+ known = {"image", "stats", "bbox", "spatial", "_has_array", "_image_meta_format", "_blosc2", "_mlarray_version", "is_seg", "extra"}
442
+ d = dict(d)
443
+ unknown = set(d.keys()) - known
444
+
445
+ if unknown and strict:
446
+ raise KeyError(f"Unknown meta keys in from_dict: {sorted(unknown)}")
447
+
448
+ extra = dict(d.get("extra") or {})
449
+ if unknown and not strict:
450
+ for k in unknown:
451
+ extra[k] = d[k]
452
+
453
+ spatial = d.get("spatial")
454
+ if spatial is None:
455
+ spatial = MetaSpatial()
456
+ else:
457
+ spatial = MetaSpatial.from_dict(spatial, strict=strict)
458
+
459
+ stats = d.get("stats")
460
+ if stats is None:
461
+ stats = None
462
+ else:
463
+ stats = MetaStatistics.from_dict(stats, strict=strict)
464
+
465
+ bbox = d.get("bbox")
466
+ if bbox is None:
467
+ bbox = None
468
+ else:
469
+ bbox = MetaBbox.from_list(bbox)
470
+
471
+ _blosc2 = d.get("_blosc2")
472
+ if _blosc2 is None:
473
+ _blosc2 = MetaBlosc2()
474
+ else:
475
+ _blosc2 = MetaBlosc2.from_dict(_blosc2, strict=strict)
476
+
477
+ return cls(
478
+ image=d.get("image"),
479
+ stats=stats,
480
+ bbox=bbox,
481
+ is_seg=d.get("is_seg"),
482
+ spatial=spatial,
483
+ _has_array=d.get("_has_array"),
484
+ _image_meta_format=d.get("_image_meta_format"),
485
+ _blosc2=_blosc2,
486
+ _mlarray_version=d.get("_mlarray_version"),
487
+ extra=extra,
488
+ )
489
+
490
+ def copy_from(self, other: Meta) -> None:
491
+ """
492
+ Copy fields from another Meta if they are not set on this instance.
493
+ """
494
+ if self.image is None:
495
+ self.image = other.image
496
+ if self.stats is None:
497
+ self.stats = other.stats
498
+ if self.bbox is None:
499
+ self.bbox = other.bbox
500
+ if self.is_seg is None:
501
+ self.is_seg = other.is_seg
502
+ if self.spatial is None:
503
+ self.spatial = other.spatial
504
+ elif other.spatial is not None:
505
+ if self.spatial.spacing is None:
506
+ self.spatial.spacing = other.spatial.spacing
507
+ if self.spatial.origin is None:
508
+ self.spatial.origin = other.spatial.origin
509
+ if self.spatial.direction is None:
510
+ self.spatial.direction = other.spatial.direction
511
+ if self.spatial.shape is None:
512
+ self.spatial.shape = other.spatial.shape
513
+ if self._has_array is not None:
514
+ self._has_array = other._has_array
515
+ if self._image_meta_format is not None:
516
+ self._image_meta_format = other._image_meta_format
517
+ if self._blosc2 is None:
518
+ self._blosc2 = other._blosc2
519
+ elif other._blosc2 is not None:
520
+ if self._blosc2.chunk_size is None:
521
+ self._blosc2.chunk_size = other._blosc2.chunk_size
522
+ if self._blosc2.block_size is None:
523
+ self._blosc2.block_size = other._blosc2.block_size
524
+ if self._blosc2.patch_size is None:
525
+ self._blosc2.patch_size = other._blosc2.patch_size
526
+ if self._mlarray_version is None:
527
+ self._mlarray_version = other._mlarray_version
528
+ if self.extra == {}:
529
+ self.extra = other.extra
530
+
531
+
532
+ def _cast_to_list(value: Any, label: str):
533
+ if isinstance(value, list):
534
+ out = value
535
+ elif isinstance(value, tuple):
536
+ out = list(value)
537
+ elif np is not None and isinstance(value, np.ndarray):
538
+ out = value.tolist()
539
+ else:
540
+ raise TypeError(f"{label} must be a list, tuple, or numpy array")
541
+
542
+ if not isinstance(out, list):
543
+ raise TypeError(f"{label} must be a list, tuple, or numpy array")
544
+
545
+ for idx, item in enumerate(out):
546
+ if isinstance(item, (list, tuple)) or (np is not None and isinstance(item, np.ndarray)):
547
+ out[idx] = _cast_to_list(item, label)
548
+ return out
549
+
550
+
551
+ def _validate_int(value: Any, label: str) -> None:
552
+ if not isinstance(value, int):
553
+ raise TypeError(f"{label} must be an int")
554
+
555
+
556
+ def _validate_float_int_list(value: Any, label: str, ndims: Optional[int] = None) -> None:
557
+ if not isinstance(value, list):
558
+ raise TypeError(f"{label} must be a list")
559
+ if ndims is not None and len(value) != ndims:
560
+ raise ValueError(f"{label} must have length {ndims}")
561
+ for item in value:
562
+ if not isinstance(item, (float, int)):
563
+ raise TypeError(f"{label} must contain only floats or ints")
564
+
565
+
566
+ def _validate_float_int_matrix(value: Any, label: str, ndims: Optional[int] = None) -> None:
567
+ if not isinstance(value, list):
568
+ raise TypeError(f"{label} must be a list of lists")
569
+ if ndims is not None and len(value) != ndims:
570
+ raise ValueError(f"{label} must have shape [{ndims}, {ndims}]")
571
+ for row in value:
572
+ if not isinstance(row, list):
573
+ raise TypeError(f"{label} must be a list of lists")
574
+ if ndims is not None and len(row) != ndims:
575
+ raise ValueError(f"{label} must have shape [{ndims}, {ndims}]")
576
+ for item in row:
577
+ if not isinstance(item, (float, int)):
578
+ raise TypeError(f"{label} must contain only floats or ints")