mlarray 0.0.23__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/meta.py ADDED
@@ -0,0 +1,726 @@
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
+ """Blosc2 storage metadata for chunking, blocking, and patch hints."""
12
+ chunk_size: Optional[list] = None
13
+ block_size: Optional[list] = None
14
+ patch_size: Optional[list] = None
15
+
16
+ def __post_init__(self) -> None:
17
+ """Validate and normalize any provided sizes."""
18
+ self._validate_and_cast()
19
+
20
+ def __repr__(self) -> str:
21
+ """Return a repr using the dict form for readability."""
22
+ return repr(self.to_dict())
23
+
24
+ def to_dict(self, *, include_none: bool = True) -> Dict[str, Any]:
25
+ """Serialize to a JSON-compatible dict.
26
+
27
+ Args:
28
+ include_none (bool): If False, keys with None values are omitted.
29
+
30
+ Returns:
31
+ Dict[str, Any]: Serialized metadata.
32
+ """
33
+ out: Dict[str, Any] = {
34
+ "chunk_size": self.chunk_size,
35
+ "block_size": self.block_size,
36
+ "patch_size": self.patch_size,
37
+ }
38
+ if not include_none:
39
+ out = {k: v for k, v in out.items() if v is not None}
40
+ return out
41
+
42
+ def _validate_and_cast(self, ndims: Optional[int] = None, channel_axis: Optional[int] = None) -> None:
43
+ """Validate and cast sizes to list form.
44
+
45
+ Args:
46
+ ndims (Optional[int]): Expected number of array dimensions.
47
+ channel_axis (Optional[int]): Channel axis index for patch size
48
+ validation when channels are present.
49
+ """
50
+ if self.chunk_size is not None:
51
+ self.chunk_size = _cast_to_list(self.chunk_size, "meta._blosc2.chunk_size")
52
+ _validate_float_int_list(self.chunk_size, f"meta._blosc2.chunk_size", ndims)
53
+ if self.block_size is not None:
54
+ self.block_size = _cast_to_list(self.block_size, "meta._blosc2.block_size")
55
+ _validate_float_int_list(self.block_size, f"meta._blosc2.block_size", ndims)
56
+ if self.patch_size is not None:
57
+ _ndims = ndims if (ndims is None or channel_axis is None) else ndims-1
58
+ self.patch_size = _cast_to_list(self.patch_size, "meta._blosc2.patch_size")
59
+ _validate_float_int_list(self.patch_size, f"meta._blosc2.patch_size", _ndims)
60
+
61
+ @classmethod
62
+ def from_dict(cls, d: Mapping[str, Any], *, strict: bool = True) -> MetaBlosc2:
63
+ """Create a MetaBlosc2 instance from a mapping.
64
+
65
+ Args:
66
+ d (Mapping[str, Any]): Source mapping.
67
+ strict (bool): If True, unknown keys raise a KeyError.
68
+
69
+ Returns:
70
+ MetaBlosc2: Parsed instance.
71
+ """
72
+ if not isinstance(d, Mapping):
73
+ raise TypeError(f"MetaBlosc2.from_dict expects a mapping, got {type(d).__name__}")
74
+ known = {"chunk_size", "block_size", "patch_size"}
75
+ d = dict(d)
76
+ unknown = set(d.keys()) - known
77
+ if unknown and strict:
78
+ raise KeyError(f"Unknown MetaBlosc2 keys in from_dict: {sorted(unknown)}")
79
+ return cls(
80
+ chunk_size=d.get("chunk_size"),
81
+ block_size=d.get("block_size"),
82
+ patch_size=d.get("patch_size"),
83
+ )
84
+
85
+
86
+ @dataclass(slots=True)
87
+ class MetaSpatial:
88
+ """Spatial metadata describing array geometry in physical space."""
89
+ spacing: Optional[List] = None
90
+ origin: Optional[List] = None
91
+ direction: Optional[List[List]] = None
92
+ shape: Optional[List] = None
93
+ channel_axis: Optional[int] = None
94
+
95
+ def __post_init__(self) -> None:
96
+ """Validate and normalize spatial fields."""
97
+ self._validate_and_cast()
98
+
99
+ def __repr__(self) -> str:
100
+ """Return a repr using the dict form for readability."""
101
+ return repr(self.to_dict())
102
+
103
+ def to_dict(self, *, include_none: bool = True) -> Dict[str, Any]:
104
+ """Serialize to a JSON-compatible dict.
105
+
106
+ Args:
107
+ include_none (bool): If False, keys with None values are omitted.
108
+
109
+ Returns:
110
+ Dict[str, Any]: Serialized metadata.
111
+ """
112
+ out: Dict[str, Any] = {
113
+ "spacing": self.spacing,
114
+ "origin": self.origin,
115
+ "direction": self.direction,
116
+ "shape": self.shape,
117
+ "channel_axis": self.channel_axis
118
+ }
119
+ if not include_none:
120
+ out = {k: v for k, v in out.items() if v is not None}
121
+ return out
122
+
123
+ def _validate_and_cast(self, ndims: Optional[int] = None) -> None:
124
+ """Validate and cast spatial fields to list form.
125
+
126
+ Args:
127
+ ndims (Optional[int]): Expected number of spatial dimensions.
128
+ """
129
+ if self.channel_axis is not None:
130
+ _validate_int(self.channel_axis, "meta.spatial.channel_axis")
131
+ if self.spacing is not None:
132
+ self.spacing = _cast_to_list(self.spacing, "meta.spatial.spacing")
133
+ _validate_float_int_list(self.spacing, "meta.spatial.spacing", ndims)
134
+ if self.origin is not None:
135
+ self.origin = _cast_to_list(self.origin, "meta.spatial.origin")
136
+ _validate_float_int_list(self.origin, "meta.spatial.origin", ndims)
137
+ if self.direction is not None:
138
+ self.direction = _cast_to_list(self.direction, "meta.spatial.direction")
139
+ _validate_float_int_matrix(self.direction, "meta.spatial.direction", ndims)
140
+ if self.shape is not None:
141
+ _ndims = ndims if (ndims is None or self.channel_axis is None) else ndims+1
142
+ self.shape = _cast_to_list(self.shape, "meta.spatial.shape")
143
+ _validate_float_int_list(self.shape, "meta.spatial.shape", _ndims)
144
+
145
+ @classmethod
146
+ def from_dict(cls, d: Mapping[str, Any], *, strict: bool = True) -> MetaSpatial:
147
+ """Create a MetaSpatial instance from a mapping.
148
+
149
+ Args:
150
+ d (Mapping[str, Any]): Source mapping.
151
+ strict (bool): If True, unknown keys raise a KeyError.
152
+
153
+ Returns:
154
+ MetaSpatial: Parsed instance.
155
+ """
156
+ if not isinstance(d, Mapping):
157
+ raise TypeError(f"MetaSpatial.from_dict expects a mapping, got {type(d).__name__}")
158
+ known = {"spacing", "origin", "direction", "shape", "channel_axis"}
159
+ d = dict(d)
160
+ unknown = set(d.keys()) - known
161
+ if unknown and strict:
162
+ raise KeyError(f"Unknown MetaSpatial keys in from_dict: {sorted(unknown)}")
163
+ return cls(
164
+ spacing=d.get("spacing"),
165
+ origin=d.get("origin"),
166
+ direction=d.get("direction"),
167
+ shape=d.get("shape"),
168
+ channel_axis=d.get("channel_axis")
169
+ )
170
+
171
+
172
+ @dataclass(slots=True)
173
+ class MetaStatistics:
174
+ """Summary statistics for an array or image."""
175
+ min: Optional[float] = None
176
+ max: Optional[float] = None
177
+ mean: Optional[float] = None
178
+ median: Optional[float] = None
179
+ std: Optional[float] = None
180
+ percentile_min: Optional[float] = None
181
+ percentile_max: Optional[float] = None
182
+ percentile_mean: Optional[float] = None
183
+ percentile_median: Optional[float] = None
184
+ percentile_std: Optional[float] = None
185
+
186
+ def __post_init__(self) -> None:
187
+ """Validate that all provided values are numeric."""
188
+ self._validate_and_cast()
189
+
190
+ def __repr__(self) -> str:
191
+ """Return a repr using the dict form for readability."""
192
+ return repr(self.to_dict())
193
+
194
+ def to_dict(self, *, include_none: bool = True) -> Dict[str, Any]:
195
+ """Serialize to a JSON-compatible dict.
196
+
197
+ Args:
198
+ include_none (bool): If False, keys with None values are omitted.
199
+
200
+ Returns:
201
+ Dict[str, Any]: Serialized metadata.
202
+ """
203
+ out: Dict[str, Any] = {
204
+ "min": self.min,
205
+ "max": self.max,
206
+ "mean": self.mean,
207
+ "median": self.median,
208
+ "std": self.std,
209
+ "percentile_min": self.percentile_min,
210
+ "percentile_max": self.percentile_max,
211
+ "percentile_mean": self.percentile_mean,
212
+ "percentile_median": self.percentile_median,
213
+ "percentile_std": self.percentile_std,
214
+ }
215
+ if not include_none:
216
+ out = {k: v for k, v in out.items() if v is not None}
217
+ return out
218
+
219
+ def _validate_and_cast(self) -> None:
220
+ """Validate that all statistic fields are float or int."""
221
+ for name in (
222
+ "min",
223
+ "max",
224
+ "mean",
225
+ "median",
226
+ "std",
227
+ "percentile_min",
228
+ "percentile_max",
229
+ "percentile_mean",
230
+ "percentile_median",
231
+ "percentile_std",
232
+ ):
233
+ value = getattr(self, name)
234
+ if value is not None and not isinstance(value, (float, int)):
235
+ raise TypeError(f"meta.stats.{name} must be a float or int")
236
+
237
+ @classmethod
238
+ def from_dict(cls, d: Mapping[str, Any], *, strict: bool = True) -> MetaStatistics:
239
+ """Create a MetaStatistics instance from a mapping.
240
+
241
+ Args:
242
+ d (Mapping[str, Any]): Source mapping.
243
+ strict (bool): If True, unknown keys raise a KeyError.
244
+
245
+ Returns:
246
+ MetaStatistics: Parsed instance.
247
+ """
248
+ if not isinstance(d, Mapping):
249
+ raise TypeError(f"MetaStatistics.from_dict expects a mapping, got {type(d).__name__}")
250
+ known = {
251
+ "min",
252
+ "max",
253
+ "mean",
254
+ "median",
255
+ "std",
256
+ "percentile_min",
257
+ "percentile_max",
258
+ "percentile_mean",
259
+ "percentile_median",
260
+ "percentile_std",
261
+ }
262
+ d = dict(d)
263
+ unknown = set(d.keys()) - known
264
+ if unknown and strict:
265
+ raise KeyError(f"Unknown MetaStatistics keys in from_dict: {sorted(unknown)}")
266
+ return cls(
267
+ min=d.get("min"),
268
+ max=d.get("max"),
269
+ mean=d.get("mean"),
270
+ median=d.get("median"),
271
+ std=d.get("std"),
272
+ percentile_min=d.get("percentile_min"),
273
+ percentile_max=d.get("percentile_max"),
274
+ percentile_mean=d.get("percentile_mean"),
275
+ percentile_median=d.get("percentile_median"),
276
+ percentile_std=d.get("percentile_std"),
277
+ )
278
+
279
+
280
+ @dataclass(slots=True)
281
+ class MetaBbox:
282
+ """Bounding boxes stored as per-dimension min/max pairs."""
283
+ bboxes: Optional[List[List[List[int]]]] = None
284
+
285
+ def __post_init__(self) -> None:
286
+ """Validate and normalize bounding box lists."""
287
+ self._validate_and_cast()
288
+
289
+ def __iter__(self):
290
+ """Iterate over bounding boxes."""
291
+ return iter(self.bboxes or [])
292
+
293
+ def __getitem__(self, index):
294
+ """Return the bbox at ``index``."""
295
+ if self.bboxes is None:
296
+ raise TypeError("meta.bbox is None")
297
+ return self.bboxes[index]
298
+
299
+ def __setitem__(self, index, value):
300
+ """Set the bbox at ``index``."""
301
+ if self.bboxes is None:
302
+ raise TypeError("meta.bbox is None")
303
+ self.bboxes[index] = value
304
+
305
+ def __len__(self):
306
+ """Return the number of bounding boxes."""
307
+ return len(self.bboxes or [])
308
+
309
+ def __repr__(self) -> str:
310
+ """Return a repr of the bounding box list."""
311
+ return repr(self.bboxes)
312
+
313
+ def to_list(self) -> Optional[List[List[List[int]]]]:
314
+ """Return the raw bbox list."""
315
+ return self.bboxes
316
+
317
+ def _validate_and_cast(self, ndims: Optional[int] = None) -> None:
318
+ """Validate bbox structure and cast to list form.
319
+
320
+ Args:
321
+ ndims (Optional[int]): Expected number of spatial dimensions.
322
+ """
323
+ if self.bboxes is None:
324
+ return
325
+ self.bboxes = _cast_to_list(self.bboxes, "meta.bbox.bboxes")
326
+ if not isinstance(self.bboxes, list):
327
+ raise TypeError("meta.bbox.bboxes must be a list of bboxes")
328
+ for bbox in self.bboxes:
329
+ if not isinstance(bbox, list):
330
+ raise TypeError("meta.bbox.bboxes must be a list of bboxes")
331
+ if ndims is not None and len(bbox) != ndims:
332
+ raise ValueError(f"meta.bbox.bboxes entries must have length {ndims}")
333
+ for row in bbox:
334
+ if not isinstance(row, list):
335
+ raise TypeError("meta.bbox.bboxes must be a list of lists")
336
+ if len(row) != 2:
337
+ raise ValueError("meta.bbox.bboxes entries must have length 2 per dimension")
338
+ for item in row:
339
+ if isinstance(item, bool) or not isinstance(item, int):
340
+ raise TypeError("meta.bbox.bboxes must contain only ints")
341
+
342
+ @classmethod
343
+ def from_list(cls, bboxes: Any) -> MetaBbox:
344
+ """Create a MetaBbox instance from a list-like object.
345
+
346
+ Args:
347
+ bboxes (Any): List-like structure shaped as
348
+ [[ [min,max], [min,max], ...], ...].
349
+
350
+ Returns:
351
+ MetaBbox: Parsed instance.
352
+ """
353
+ return cls(bboxes=bboxes)
354
+
355
+
356
+ @dataclass(slots=True)
357
+ class Meta:
358
+ """Container for MLArray metadata sections."""
359
+ image: Optional[Dict[str, Any]] = None
360
+ spatial: MetaSpatial = field(default_factory=MetaSpatial)
361
+ stats: Optional[Union[dict, MetaStatistics]] = None
362
+ bbox: Optional[MetaBbox] = None
363
+ is_seg: Optional[bool] = None
364
+ _blosc2: MetaBlosc2 = field(default_factory=MetaBlosc2)
365
+ _has_array: Optional[bool] = None
366
+ _image_meta_format: Optional[str] = None
367
+ _mlarray_version: Optional[str] = None
368
+
369
+ # controlled escape hatch for future/experimental metadata
370
+ extra: Dict[str, Any] = field(default_factory=dict)
371
+
372
+ def __post_init__(self) -> None:
373
+ """Validate and normalize metadata sections."""
374
+ # Validate anything passed in the constructor
375
+ for name in ("image",):
376
+ val = getattr(self, name)
377
+ if val is not None:
378
+ if not isinstance(val, dict):
379
+ raise TypeError(f"meta.{name} must be a dict or None, got {type(val).__name__}")
380
+ if not is_serializable(val):
381
+ raise TypeError(f"meta.{name} is not JSON-serializable")
382
+ if self.stats is not None:
383
+ if isinstance(self.stats, MetaStatistics):
384
+ pass
385
+ elif isinstance(self.stats, Mapping):
386
+ self.stats = MetaStatistics.from_dict(self.stats, strict=False)
387
+ else:
388
+ raise TypeError(f"meta.stats must be a MetaStatistics or mapping, got {type(self.stats).__name__}")
389
+ if self.bbox is not None:
390
+ if isinstance(self.bbox, MetaBbox):
391
+ pass
392
+ elif isinstance(self.bbox, (list, tuple)) or (np is not None and isinstance(self.bbox, np.ndarray)):
393
+ self.bbox = MetaBbox(bboxes=self.bbox)
394
+ else:
395
+ raise TypeError(f"meta.bbox must be a MetaBbox or list-like, got {type(self.bbox).__name__}")
396
+
397
+ if self.spatial is None:
398
+ self.spatial = MetaSpatial()
399
+ if not isinstance(self.spatial, MetaSpatial):
400
+ raise TypeError(f"meta.spatial must be a MetaSpatial, got {type(self.spatial).__name__}")
401
+
402
+ if self._blosc2 is None:
403
+ self._blosc2 = MetaBlosc2()
404
+ if not isinstance(self._blosc2, MetaBlosc2):
405
+ raise TypeError(f"meta._blosc2 must be a MetaBlosc2, got {type(self._blosc2).__name__}")
406
+
407
+ if self.is_seg is not None and not isinstance(self.is_seg, bool):
408
+ raise TypeError("meta.is_seg must be a bool or None")
409
+ if self._has_array is not None and not isinstance(self._has_array, bool):
410
+ raise TypeError("meta._has_array must be a bool or None")
411
+ if self._image_meta_format is not None and not isinstance(self._image_meta_format, str):
412
+ raise TypeError("meta._image_meta_format must be a str or None")
413
+ if self._mlarray_version is not None and not isinstance(self._mlarray_version, str):
414
+ raise TypeError("meta._mlarray_version must be a str or None")
415
+
416
+ if not isinstance(self.extra, dict):
417
+ raise TypeError(f"meta.extra must be a dict, got {type(self.extra).__name__}")
418
+ if not is_serializable(self.extra):
419
+ raise TypeError("meta.extra is not JSON-serializable")
420
+
421
+ def __repr__(self) -> str:
422
+ """Return a repr using the dict form for readability."""
423
+ return repr(self.to_dict())
424
+
425
+ def set(self, key: str, value: Any) -> None:
426
+ """Set a known meta section explicitly.
427
+
428
+ Args:
429
+ key (str): Name of the meta section (e.g., "image", "spatial",
430
+ "stats", "bbox", "_blosc2", "is_seg").
431
+ value (Any): Value to set. Must be JSON-serializable for dict
432
+ sections.
433
+
434
+ Raises:
435
+ AttributeError: If ``key`` is unknown or disallowed.
436
+ TypeError: If the value has an unexpected type.
437
+ """
438
+ if not hasattr(self, key) and key not in {"_blosc2", "_mlarray_version"}:
439
+ raise AttributeError(f"Unknown meta section: {key!r}")
440
+ if key == "extra":
441
+ raise AttributeError("Use meta.extra[...] to add to extra")
442
+ if key == "spatial":
443
+ if isinstance(value, MetaSpatial):
444
+ setattr(self, key, value)
445
+ return
446
+ if isinstance(value, Mapping):
447
+ setattr(self, key, MetaSpatial.from_dict(value, strict=False))
448
+ return
449
+ raise TypeError("meta.spatial must be a MetaSpatial or mapping")
450
+ if key == "stats":
451
+ if isinstance(value, MetaStatistics):
452
+ self.stats = value
453
+ return
454
+ if isinstance(value, Mapping):
455
+ self.stats = MetaStatistics.from_dict(value, strict=False)
456
+ return
457
+ raise TypeError("meta.stats must be a MetaStatistics or mapping")
458
+ if key == "bbox":
459
+ if isinstance(value, MetaBbox):
460
+ self.bbox = value
461
+ return
462
+ if isinstance(value, (list, tuple)) or (np is not None and isinstance(value, np.ndarray)):
463
+ self.bbox = MetaBbox(bboxes=value)
464
+ return
465
+ raise TypeError("meta.bbox must be a MetaBbox or list-like")
466
+ if key == "_blosc2":
467
+ if isinstance(value, MetaBlosc2):
468
+ self._blosc2 = value
469
+ return
470
+ if isinstance(value, Mapping):
471
+ self._blosc2 = MetaBlosc2.from_dict(value, strict=False)
472
+ return
473
+ raise TypeError("meta._blosc2 must be a MetaBlosc2 or mapping")
474
+ if key == "is_seg":
475
+ if value is not None and not isinstance(value, bool):
476
+ raise TypeError("meta.is_seg must be a bool or None")
477
+ setattr(self, key, value)
478
+ return
479
+ if key == "_has_array":
480
+ if value is not None and not isinstance(value, bool):
481
+ raise TypeError("meta._has_array must be a bool or None")
482
+ setattr(self, key, value)
483
+ return
484
+ if key == "_image_meta_format":
485
+ if value is not None and not isinstance(value, str):
486
+ raise TypeError("meta._image_meta_format must be a str or None")
487
+ setattr(self, key, value)
488
+ return
489
+ if key == "_mlarray_version":
490
+ if value is not None and not isinstance(value, str):
491
+ raise TypeError("meta._mlarray_version must be a str or None")
492
+ self._mlarray_version = value
493
+ return
494
+
495
+ value_dict = dict(value)
496
+
497
+ if not is_serializable(value_dict):
498
+ raise TypeError(f"meta.{key} is not JSON-serializable")
499
+
500
+ setattr(self, key, value_dict)
501
+
502
+ def to_dict(self, *, include_none: bool = True) -> Dict[str, Any]:
503
+ """Convert to a JSON-serializable dict.
504
+
505
+ Args:
506
+ include_none (bool): If False, keys with None (and empty extra) are
507
+ omitted.
508
+
509
+ Returns:
510
+ Dict[str, Any]: Serialized metadata.
511
+ """
512
+ out: Dict[str, Any] = {
513
+ "image": self.image,
514
+ "stats": self.stats.to_dict() if self.stats is not None else None,
515
+ "bbox": self.bbox.to_list() if self.bbox is not None else None,
516
+ "is_seg": self.is_seg,
517
+ "spatial": self.spatial.to_dict(),
518
+ "_has_array": self._has_array,
519
+ "_image_meta_format": self._image_meta_format,
520
+ "_blosc2": self._blosc2.to_dict(),
521
+ "_mlarray_version": self._mlarray_version,
522
+ "extra": self.extra,
523
+ }
524
+
525
+ if not include_none:
526
+ out = {k: v for k, v in out.items() if v is not None and not (k == "extra" and v == {})}
527
+
528
+ return out
529
+
530
+ def _validate_and_cast(self, ndims: int) -> None:
531
+ """Validate and normalize metadata with a known dimensionality.
532
+
533
+ Args:
534
+ ndims (int): Number of spatial dimensions.
535
+ """
536
+ self.spatial._validate_and_cast(ndims)
537
+ if self.bbox is not None:
538
+ self.bbox._validate_and_cast(ndims)
539
+ self._blosc2._validate_and_cast(ndims)
540
+
541
+ @classmethod
542
+ def from_dict(cls, d: Mapping[str, Any], *, strict: bool = True) -> Meta:
543
+ """Construct Meta from a mapping.
544
+
545
+ Args:
546
+ d (Mapping[str, Any]): Mapping with keys in {"image", "stats",
547
+ "bbox", "spatial", "_blosc2", "_mlarray_version",
548
+ "_image_meta_format", "_has_array", "is_seg", "extra"}.
549
+ strict (bool): If True, unknown keys raise. If False, unknown keys
550
+ are added to ``extra``.
551
+
552
+ Returns:
553
+ Meta: Parsed instance.
554
+ """
555
+ if not isinstance(d, Mapping):
556
+ raise TypeError(f"from_dict expects a mapping, got {type(d).__name__}")
557
+
558
+ known = {"image", "stats", "bbox", "spatial", "_has_array", "_image_meta_format", "_blosc2", "_mlarray_version", "is_seg", "extra"}
559
+ d = dict(d)
560
+ unknown = set(d.keys()) - known
561
+
562
+ if unknown and strict:
563
+ raise KeyError(f"Unknown meta keys in from_dict: {sorted(unknown)}")
564
+
565
+ extra = dict(d.get("extra") or {})
566
+ if unknown and not strict:
567
+ for k in unknown:
568
+ extra[k] = d[k]
569
+
570
+ spatial = d.get("spatial")
571
+ if spatial is None:
572
+ spatial = MetaSpatial()
573
+ else:
574
+ spatial = MetaSpatial.from_dict(spatial, strict=strict)
575
+
576
+ stats = d.get("stats")
577
+ if stats is None:
578
+ stats = None
579
+ else:
580
+ stats = MetaStatistics.from_dict(stats, strict=strict)
581
+
582
+ bbox = d.get("bbox")
583
+ if bbox is None:
584
+ bbox = None
585
+ else:
586
+ bbox = MetaBbox.from_list(bbox)
587
+
588
+ _blosc2 = d.get("_blosc2")
589
+ if _blosc2 is None:
590
+ _blosc2 = MetaBlosc2()
591
+ else:
592
+ _blosc2 = MetaBlosc2.from_dict(_blosc2, strict=strict)
593
+
594
+ return cls(
595
+ image=d.get("image"),
596
+ stats=stats,
597
+ bbox=bbox,
598
+ is_seg=d.get("is_seg"),
599
+ spatial=spatial,
600
+ _has_array=d.get("_has_array"),
601
+ _image_meta_format=d.get("_image_meta_format"),
602
+ _blosc2=_blosc2,
603
+ _mlarray_version=d.get("_mlarray_version"),
604
+ extra=extra,
605
+ )
606
+
607
+ def copy_from(self, other: Meta) -> None:
608
+ """Copy fields from another Meta where this instance is missing data.
609
+
610
+ Args:
611
+ other (Meta): Source Meta instance.
612
+ """
613
+ if self.image is None:
614
+ self.image = other.image
615
+ if self.stats is None:
616
+ self.stats = other.stats
617
+ if self.bbox is None:
618
+ self.bbox = other.bbox
619
+ if self.is_seg is None:
620
+ self.is_seg = other.is_seg
621
+ if self.spatial is None:
622
+ self.spatial = other.spatial
623
+ elif other.spatial is not None:
624
+ if self.spatial.spacing is None:
625
+ self.spatial.spacing = other.spatial.spacing
626
+ if self.spatial.origin is None:
627
+ self.spatial.origin = other.spatial.origin
628
+ if self.spatial.direction is None:
629
+ self.spatial.direction = other.spatial.direction
630
+ if self.spatial.shape is None:
631
+ self.spatial.shape = other.spatial.shape
632
+ if self._has_array is not None:
633
+ self._has_array = other._has_array
634
+ if self._image_meta_format is not None:
635
+ self._image_meta_format = other._image_meta_format
636
+ if self._blosc2 is None:
637
+ self._blosc2 = other._blosc2
638
+ elif other._blosc2 is not None:
639
+ if self._blosc2.chunk_size is None:
640
+ self._blosc2.chunk_size = other._blosc2.chunk_size
641
+ if self._blosc2.block_size is None:
642
+ self._blosc2.block_size = other._blosc2.block_size
643
+ if self._blosc2.patch_size is None:
644
+ self._blosc2.patch_size = other._blosc2.patch_size
645
+ if self._mlarray_version is None:
646
+ self._mlarray_version = other._mlarray_version
647
+ if self.extra == {}:
648
+ self.extra = other.extra
649
+
650
+
651
+ def _cast_to_list(value: Any, label: str):
652
+ """Cast a list/tuple/ndarray to a Python list (recursively).
653
+
654
+ Args:
655
+ value (Any): Input value to cast.
656
+ label (str): Label used in error messages.
657
+
658
+ Returns:
659
+ list: List representation of the input value.
660
+ """
661
+ if isinstance(value, list):
662
+ out = value
663
+ elif isinstance(value, tuple):
664
+ out = list(value)
665
+ elif np is not None and isinstance(value, np.ndarray):
666
+ out = value.tolist()
667
+ else:
668
+ raise TypeError(f"{label} must be a list, tuple, or numpy array")
669
+
670
+ if not isinstance(out, list):
671
+ raise TypeError(f"{label} must be a list, tuple, or numpy array")
672
+
673
+ for idx, item in enumerate(out):
674
+ if isinstance(item, (list, tuple)) or (np is not None and isinstance(item, np.ndarray)):
675
+ out[idx] = _cast_to_list(item, label)
676
+ return out
677
+
678
+
679
+ def _validate_int(value: Any, label: str) -> None:
680
+ """Validate that a value is an int.
681
+
682
+ Args:
683
+ value (Any): Value to validate.
684
+ label (str): Label used in error messages.
685
+ """
686
+ if not isinstance(value, int):
687
+ raise TypeError(f"{label} must be an int")
688
+
689
+
690
+ def _validate_float_int_list(value: Any, label: str, ndims: Optional[int] = None) -> None:
691
+ """Validate a list of float/int values with optional length.
692
+
693
+ Args:
694
+ value (Any): Value to validate.
695
+ label (str): Label used in error messages.
696
+ ndims (Optional[int]): Required length if provided.
697
+ """
698
+ if not isinstance(value, list):
699
+ raise TypeError(f"{label} must be a list")
700
+ if ndims is not None and len(value) != ndims:
701
+ raise ValueError(f"{label} must have length {ndims}")
702
+ for item in value:
703
+ if not isinstance(item, (float, int)):
704
+ raise TypeError(f"{label} must contain only floats or ints")
705
+
706
+
707
+ def _validate_float_int_matrix(value: Any, label: str, ndims: Optional[int] = None) -> None:
708
+ """Validate a 2D list of float/int values with optional shape.
709
+
710
+ Args:
711
+ value (Any): Value to validate.
712
+ label (str): Label used in error messages.
713
+ ndims (Optional[int]): Required square dimension if provided.
714
+ """
715
+ if not isinstance(value, list):
716
+ raise TypeError(f"{label} must be a list of lists")
717
+ if ndims is not None and len(value) != ndims:
718
+ raise ValueError(f"{label} must have shape [{ndims}, {ndims}]")
719
+ for row in value:
720
+ if not isinstance(row, list):
721
+ raise TypeError(f"{label} must be a list of lists")
722
+ if ndims is not None and len(row) != ndims:
723
+ raise ValueError(f"{label} must have shape [{ndims}, {ndims}]")
724
+ for item in row:
725
+ if not isinstance(item, (float, int)):
726
+ raise TypeError(f"{label} must contain only floats or ints")