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