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/__init__.py +54 -0
- mlarray/cli.py +58 -0
- mlarray/meta.py +726 -0
- mlarray/mlarray.py +739 -0
- mlarray/utils.py +17 -0
- mlarray-0.0.23.data/data/mlarray/assets/banner.png +0 -0
- mlarray-0.0.23.data/data/mlarray/assets/banner.png~ +0 -0
- mlarray-0.0.23.dist-info/METADATA +246 -0
- mlarray-0.0.23.dist-info/RECORD +13 -0
- mlarray-0.0.23.dist-info/WHEEL +5 -0
- mlarray-0.0.23.dist-info/entry_points.txt +3 -0
- mlarray-0.0.23.dist-info/licenses/LICENSE +21 -0
- mlarray-0.0.23.dist-info/top_level.txt +1 -0
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")
|