tiny-metaio 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/PKG-INFO +42 -1
- tiny_metaio-0.3.0/README.md +96 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/pyproject.toml +3 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/metaio/__init__.py +6 -4
- tiny_metaio-0.3.0/src/metaio/__main__.py +41 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/metaio/image.py +58 -3
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/metaio/mha.py +11 -12
- tiny_metaio-0.3.0/src/metaio/quant.py +36 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/tiny_metaio.egg-info/PKG-INFO +42 -1
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/tiny_metaio.egg-info/SOURCES.txt +4 -0
- tiny_metaio-0.3.0/src/tiny_metaio.egg-info/entry_points.txt +2 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/tests/conftest.py +3 -2
- tiny_metaio-0.3.0/tests/test_quantization.py +79 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/tests/test_vs_simpleitk.py +29 -0
- tiny_metaio-0.2.0/README.md +0 -55
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/.gitignore +0 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/.gitlab-ci.yml +0 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/.pre-commit-config.yaml +0 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/Containerfile +0 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/setup.cfg +0 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/metaio/nifti.py +0 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/metaio/py.typed +0 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/metaio/write.py +0 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/tiny_metaio.egg-info/dependency_links.txt +0 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/tiny_metaio.egg-info/requires.txt +0 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/tiny_metaio.egg-info/top_level.txt +0 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/tests/test_symmetry.py +0 -0
- {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tiny-metaio
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Read and write MetaImages with minimal dependencies
|
|
5
5
|
Author-email: Nicolas Cedilnik <nicolas.cedilnik@inria.fr>
|
|
6
6
|
Project-URL: Homepage, https://gitlab.inria.fr/ncedilni/metaio
|
|
@@ -26,6 +26,8 @@ images (`.nii` or `.nii.gz`)
|
|
|
26
26
|
and write `.mha` images
|
|
27
27
|
in python with minimal dependencies.
|
|
28
28
|
|
|
29
|
+
Bonus: provides a custom 8-bit MHA-like format when size matters: `.metaq`.
|
|
30
|
+
|
|
29
31
|
## Installation
|
|
30
32
|
|
|
31
33
|
Available on pypi.org: `pip install tiny-metaio`.
|
|
@@ -57,6 +59,43 @@ array([0., 0.])
|
|
|
57
59
|
|
|
58
60
|
```
|
|
59
61
|
|
|
62
|
+
### Custom format
|
|
63
|
+
|
|
64
|
+
You need to specify a range of values covered by the quantization.
|
|
65
|
+
Values outside this ranges will be clipped.
|
|
66
|
+
A loss of precision is expected.
|
|
67
|
+
NaNs will be implicitly converted to zero.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
>>> img = metaio.MetaImage([[-0.5, 1], [2.5, 5]])
|
|
71
|
+
>>> img.save_quantized("some-name.metaq", min_=0, max_=2.5)
|
|
72
|
+
>>> metaio.read("some-name.metaq").data
|
|
73
|
+
array([[0. , 1. ],
|
|
74
|
+
[2.5, 2.5]], dtype=float32)
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Additionally, if some values do not matter, you can specify a mask for further compression.
|
|
79
|
+
The mask must be a binary array of the same shape as the image.
|
|
80
|
+
Values where the mask is `False` will not be stored at all and restored as NaN on dequantization.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
>>> img = metaio.MetaImage([[-0.5, 1], [2.5, 5]])
|
|
84
|
+
>>> img.save_quantized("some-name.metaq", min_=0, max_=2.5, mask=img.data <= 2.5)
|
|
85
|
+
>>> metaio.read("some-name.metaq").data
|
|
86
|
+
array([[0. , 1. ],
|
|
87
|
+
[2.5, nan]], dtype=float32)
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Command-line interface
|
|
92
|
+
|
|
93
|
+
A command-line interface is available to convert supported input format to a MHA.
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
$ metaio /path/to/input.metaq /path/to/output.mha
|
|
97
|
+
```
|
|
98
|
+
|
|
60
99
|
## Philosophy
|
|
61
100
|
|
|
62
101
|
- `numpy` as only runtime dependency.
|
|
@@ -67,6 +106,8 @@ array([0., 0.])
|
|
|
67
106
|
## Why should I use this over SimpleITK?
|
|
68
107
|
|
|
69
108
|
If you just need the IO parts and do not want the large SimpleITK package,
|
|
109
|
+
or if you need to efficiently store some large MetaImage-like data,
|
|
70
110
|
this package might be for you.
|
|
111
|
+
|
|
71
112
|
If you do not care about having SimpleITK as a dependency for your project,
|
|
72
113
|
this package is not for you.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# MetaIO
|
|
2
|
+
|
|
3
|
+
Read
|
|
4
|
+
[MetaImages](https://docs.itk.org/en/latest/learn/metaio.html)
|
|
5
|
+
(`.mha` or `.mhd`)
|
|
6
|
+
and
|
|
7
|
+
[NIFTI](https://en.wikipedia.org/wiki/Neuroimaging_Informatics_Technology_Initiative)
|
|
8
|
+
images (`.nii` or `.nii.gz`)
|
|
9
|
+
and write `.mha` images
|
|
10
|
+
in python with minimal dependencies.
|
|
11
|
+
|
|
12
|
+
Bonus: provides a custom 8-bit MHA-like format when size matters: `.metaq`.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Available on pypi.org: `pip install tiny-metaio`.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
### Writing a MHA file
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
>>> import metaio
|
|
24
|
+
>>> img = metaio.MetaImage([[0, 1], [42, 43]], spacing=[1, 2])
|
|
25
|
+
>>> img.save("some-name.mha")
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Reading a MHA file
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
>>> img = metaio.read("some-name.mha")
|
|
33
|
+
>>> img.spacing
|
|
34
|
+
array([1., 2.])
|
|
35
|
+
>>> img.data
|
|
36
|
+
array([[ 0, 1],
|
|
37
|
+
[42, 43]])
|
|
38
|
+
>>> img.direction
|
|
39
|
+
array([1., 0., 0., 1.])
|
|
40
|
+
>>> img.origin
|
|
41
|
+
array([0., 0.])
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Custom format
|
|
46
|
+
|
|
47
|
+
You need to specify a range of values covered by the quantization.
|
|
48
|
+
Values outside this ranges will be clipped.
|
|
49
|
+
A loss of precision is expected.
|
|
50
|
+
NaNs will be implicitly converted to zero.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
>>> img = metaio.MetaImage([[-0.5, 1], [2.5, 5]])
|
|
54
|
+
>>> img.save_quantized("some-name.metaq", min_=0, max_=2.5)
|
|
55
|
+
>>> metaio.read("some-name.metaq").data
|
|
56
|
+
array([[0. , 1. ],
|
|
57
|
+
[2.5, 2.5]], dtype=float32)
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Additionally, if some values do not matter, you can specify a mask for further compression.
|
|
62
|
+
The mask must be a binary array of the same shape as the image.
|
|
63
|
+
Values where the mask is `False` will not be stored at all and restored as NaN on dequantization.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
>>> img = metaio.MetaImage([[-0.5, 1], [2.5, 5]])
|
|
67
|
+
>>> img.save_quantized("some-name.metaq", min_=0, max_=2.5, mask=img.data <= 2.5)
|
|
68
|
+
>>> metaio.read("some-name.metaq").data
|
|
69
|
+
array([[0. , 1. ],
|
|
70
|
+
[2.5, nan]], dtype=float32)
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Command-line interface
|
|
75
|
+
|
|
76
|
+
A command-line interface is available to convert supported input format to a MHA.
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
$ metaio /path/to/input.metaq /path/to/output.mha
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Philosophy
|
|
83
|
+
|
|
84
|
+
- `numpy` as only runtime dependency.
|
|
85
|
+
- idiomatic python.
|
|
86
|
+
- similar behavior than SimpleITK's `GetArrayFromImage`, `GetImageFromArray`,
|
|
87
|
+
`GetDirection`, `GetOrigin`, `GetSpacing`, `ReadImage`, `WriteImage`.
|
|
88
|
+
|
|
89
|
+
## Why should I use this over SimpleITK?
|
|
90
|
+
|
|
91
|
+
If you just need the IO parts and do not want the large SimpleITK package,
|
|
92
|
+
or if you need to efficiently store some large MetaImage-like data,
|
|
93
|
+
this package might be for you.
|
|
94
|
+
|
|
95
|
+
If you do not care about having SimpleITK as a dependency for your project,
|
|
96
|
+
this package is not for you.
|
|
@@ -23,6 +23,9 @@ Homepage = "https://gitlab.inria.fr/ncedilni/metaio"
|
|
|
23
23
|
Issues = "https://gitlab.inria.fr/ncedilni/metaio/-/issues"
|
|
24
24
|
Repository = "https://gitlab.inria.fr/ncedilni/metaio"
|
|
25
25
|
|
|
26
|
+
[project.scripts]
|
|
27
|
+
metaio = "metaio.__main__:main"
|
|
28
|
+
|
|
26
29
|
[build-system]
|
|
27
30
|
requires = ["setuptools>=64", "setuptools-scm>=8"]
|
|
28
31
|
build-backend = "setuptools.build_meta"
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
|
|
3
|
+
from . import mha, nifti, quant
|
|
3
4
|
from .image import MetaImage
|
|
4
|
-
from .mha import read_mha
|
|
5
|
-
from .nifti import load
|
|
6
5
|
|
|
7
6
|
|
|
8
7
|
def read(path: Path | str) -> MetaImage:
|
|
@@ -13,11 +12,14 @@ def read(path: Path | str) -> MetaImage:
|
|
|
13
12
|
if len(split) == 3:
|
|
14
13
|
suffix = ".".join(split[-2:])
|
|
15
14
|
|
|
15
|
+
if suffix in (".metaq"):
|
|
16
|
+
return quant.load(path)
|
|
17
|
+
|
|
16
18
|
if suffix in (".mha", ".mhd"):
|
|
17
|
-
return
|
|
19
|
+
return mha.load(path)
|
|
18
20
|
|
|
19
21
|
if suffix in (".nii", "nii.gz"):
|
|
20
|
-
return load(path)
|
|
22
|
+
return nifti.load(path)
|
|
21
23
|
|
|
22
24
|
raise RuntimeError(f"Unsupported file format: {path.suffix}")
|
|
23
25
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from argparse import ArgumentParser
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from . import read
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main() -> int:
|
|
8
|
+
parser = ArgumentParser()
|
|
9
|
+
parser.add_argument("INPUT", help="Path to a file with a supported format.")
|
|
10
|
+
parser.add_argument(
|
|
11
|
+
"OUTPUT",
|
|
12
|
+
help="Path to the output file. It must have a '.mha' or '.metaq' extension.",
|
|
13
|
+
type=Path,
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"--no-compression",
|
|
17
|
+
help="Disable compression of the output. Only effective for '.mha'.",
|
|
18
|
+
dest="compression",
|
|
19
|
+
action="store_false",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--quantization-range",
|
|
23
|
+
help="Quantization range. Only effective for '.metaq'.",
|
|
24
|
+
type=float,
|
|
25
|
+
nargs=2,
|
|
26
|
+
default=(None, None),
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--mask", help="Boolean mask to use. Only effective for '.metaq'.", type=Path
|
|
30
|
+
)
|
|
31
|
+
args = parser.parse_args()
|
|
32
|
+
img = read(args.INPUT)
|
|
33
|
+
if args.OUTPUT.suffix == ".metaq":
|
|
34
|
+
mask = None if args.mask is None else read(args.mask).data.astype(bool)
|
|
35
|
+
img.save_quantized(args.OUTPUT, *args.quantization_range, mask=mask)
|
|
36
|
+
else:
|
|
37
|
+
img.save(args.OUTPUT, compress=args.compression)
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
exit(main())
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
2
4
|
import zlib
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
|
|
@@ -26,7 +28,7 @@ class MetaImage:
|
|
|
26
28
|
self.origin = origin
|
|
27
29
|
self.direction = direction
|
|
28
30
|
|
|
29
|
-
self.metadata: dict[str, str] = {}
|
|
31
|
+
self.metadata: dict[str, str] = metadata or {}
|
|
30
32
|
|
|
31
33
|
@property
|
|
32
34
|
def size(self) -> tuple[int, ...]:
|
|
@@ -81,7 +83,7 @@ class MetaImage:
|
|
|
81
83
|
def ndim(self) -> int:
|
|
82
84
|
return self.data.ndim - (1 if self.vector else 0)
|
|
83
85
|
|
|
84
|
-
def save(self, path:
|
|
86
|
+
def save(self, path: Path | str, compress: bool = False) -> None:
|
|
85
87
|
path = Path(path)
|
|
86
88
|
data = np.asarray(self.data)
|
|
87
89
|
|
|
@@ -132,3 +134,56 @@ class MetaImage:
|
|
|
132
134
|
line("ElementDataFile", element_data_file)
|
|
133
135
|
|
|
134
136
|
hfh.write(pixel_bytes)
|
|
137
|
+
|
|
138
|
+
def save_quantized(
|
|
139
|
+
self,
|
|
140
|
+
path: Path | str,
|
|
141
|
+
min_: float | None = None,
|
|
142
|
+
max_: float | None = None,
|
|
143
|
+
*,
|
|
144
|
+
mask: npt.NDArray[np.bool] | None = None,
|
|
145
|
+
) -> None:
|
|
146
|
+
path = Path(path)
|
|
147
|
+
if not path.suffix == ".metaq":
|
|
148
|
+
raise ValueError(
|
|
149
|
+
"For quantized images, the file extension must be '.metaq'", path.name
|
|
150
|
+
)
|
|
151
|
+
if mask is None:
|
|
152
|
+
kwargs = {}
|
|
153
|
+
data = self.data.astype(np.float32)
|
|
154
|
+
else:
|
|
155
|
+
kwargs = {"mask": np.packbits(mask.astype(bool)), "mask_shape": mask.shape}
|
|
156
|
+
data = self.data[mask].astype(np.float32)
|
|
157
|
+
if min_ is None:
|
|
158
|
+
min_ = np.nanmin(self.data)
|
|
159
|
+
if max_ is None:
|
|
160
|
+
max_ = np.nanmax(self.data)
|
|
161
|
+
quantized = _quantize(data, min_, max_)
|
|
162
|
+
meta = _dict_to_structured_array(self.metadata)
|
|
163
|
+
np.savez_compressed(
|
|
164
|
+
path,
|
|
165
|
+
data=quantized,
|
|
166
|
+
spacing=self.spacing,
|
|
167
|
+
direction=self.direction,
|
|
168
|
+
origin=self.origin,
|
|
169
|
+
min=min_,
|
|
170
|
+
max=max_,
|
|
171
|
+
meta=meta,
|
|
172
|
+
**kwargs,
|
|
173
|
+
allow_pickle=False,
|
|
174
|
+
)
|
|
175
|
+
# numpy automatically adds the `.npz` suffix, we don't want that
|
|
176
|
+
shutil.move(str(path) + ".npz", path)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _quantize(
|
|
180
|
+
arr: npt.NDArray[np.floating], min_: float = 0, max_: float = 20
|
|
181
|
+
) -> npt.NDArray[np.uint8]:
|
|
182
|
+
clamped = np.clip(arr, min_, max_)
|
|
183
|
+
scaled = (clamped - min_) / (max_ - min_) * 255
|
|
184
|
+
return np.round(scaled).astype(np.uint8)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _dict_to_structured_array(dct: dict[str, str]) -> npt.NDArray[np.void]:
|
|
188
|
+
dtype = [(key, f"U{len(v)}") for key, v in dct.items()]
|
|
189
|
+
return np.array([tuple(dct.values())], dtype=dtype)
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import os
|
|
2
1
|
import zlib
|
|
3
2
|
from pathlib import Path
|
|
4
3
|
from typing import BinaryIO
|
|
@@ -53,7 +52,7 @@ def _parse_header(src: BinaryIO) -> tuple[dict[str, str], int]:
|
|
|
53
52
|
|
|
54
53
|
def _require(header: dict[str, str], key: str) -> str:
|
|
55
54
|
try:
|
|
56
|
-
return header
|
|
55
|
+
return header.pop(key)
|
|
57
56
|
except KeyError:
|
|
58
57
|
raise KeyError(f"Required MetaIO key '{key}' not found in header.") from None
|
|
59
58
|
|
|
@@ -66,7 +65,7 @@ def _parse_int_list(s: str) -> list[int]:
|
|
|
66
65
|
return [int(x) for x in s.split()]
|
|
67
66
|
|
|
68
67
|
|
|
69
|
-
def
|
|
68
|
+
def load(path: Path | str) -> MetaImage:
|
|
70
69
|
"""
|
|
71
70
|
Read a MetaIO image file (``.mha`` or ``.mhd``).
|
|
72
71
|
|
|
@@ -109,34 +108,34 @@ def read_mha(path: str | os.PathLike[str]) -> MetaImage:
|
|
|
109
108
|
|
|
110
109
|
spacing: list[float] = []
|
|
111
110
|
if "ElementSpacing" in header:
|
|
112
|
-
spacing = _parse_float_list(header
|
|
111
|
+
spacing = _parse_float_list(header.pop("ElementSpacing"))
|
|
113
112
|
elif "ElementSize" in header:
|
|
114
|
-
spacing = _parse_float_list(header
|
|
113
|
+
spacing = _parse_float_list(header.pop("ElementSize"))
|
|
115
114
|
|
|
116
115
|
origin: list[float] = []
|
|
117
116
|
if "Offset" in header:
|
|
118
|
-
origin = _parse_float_list(header
|
|
117
|
+
origin = _parse_float_list(header.pop("Offset"))
|
|
119
118
|
elif "Position" in header:
|
|
120
|
-
origin = _parse_float_list(header
|
|
119
|
+
origin = _parse_float_list(header.pop("Position"))
|
|
121
120
|
|
|
122
121
|
transform_matrix: list[float] | None = None
|
|
123
122
|
if "TransformMatrix" in header:
|
|
124
|
-
transform_matrix = _parse_float_list(header
|
|
123
|
+
transform_matrix = _parse_float_list(header.pop("TransformMatrix"))
|
|
125
124
|
elif "Rotation" in header:
|
|
126
|
-
transform_matrix = _parse_float_list(header
|
|
125
|
+
transform_matrix = _parse_float_list(header.pop("Rotation"))
|
|
127
126
|
|
|
128
127
|
transform_matrix = (
|
|
129
128
|
np.array(transform_matrix).reshape((ndim, ndim)).ravel(order="F")
|
|
130
129
|
).tolist()
|
|
131
130
|
|
|
132
|
-
big_endian_str = header.
|
|
131
|
+
big_endian_str = header.pop("BinaryDataByteOrderMSB", "False").strip()
|
|
133
132
|
big_endian = big_endian_str.lower() in ("true", "1")
|
|
134
133
|
dtype = dtype.newbyteorder(">" if big_endian else "<")
|
|
135
134
|
|
|
136
|
-
n_channels = int(header.
|
|
135
|
+
n_channels = int(header.pop("ElementNumberOfChannels", "1"))
|
|
137
136
|
|
|
138
137
|
element_data_file = _require(header, "ElementDataFile").strip()
|
|
139
|
-
compressed = header.
|
|
138
|
+
compressed = header.pop("CompressedData", "False").strip().lower() in (
|
|
140
139
|
"true",
|
|
141
140
|
"1",
|
|
142
141
|
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import numpy.typing as npt
|
|
5
|
+
|
|
6
|
+
from .image import MetaImage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load(path: Path | str) -> MetaImage:
|
|
10
|
+
npz = np.load(path, allow_pickle=False)
|
|
11
|
+
arr_float = _dequantize(npz["data"], npz["min"], npz["max"])
|
|
12
|
+
mask = npz.get("mask")
|
|
13
|
+
if mask is not None:
|
|
14
|
+
shape = npz["mask_shape"]
|
|
15
|
+
mask = np.unpackbits(mask, count=np.prod(shape)).reshape(shape).astype(bool)
|
|
16
|
+
data = np.full_like(mask, np.nan, dtype=np.float32)
|
|
17
|
+
data[mask] = arr_float
|
|
18
|
+
arr_float = data
|
|
19
|
+
return MetaImage(
|
|
20
|
+
arr_float,
|
|
21
|
+
spacing=npz["spacing"],
|
|
22
|
+
direction=npz["direction"],
|
|
23
|
+
origin=npz["origin"],
|
|
24
|
+
metadata=_structured_array_to_dict(npz["meta"]),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _dequantize(
|
|
29
|
+
arr: npt.NDArray[np.uint8], min_: float = 0, max_: float = 20
|
|
30
|
+
) -> npt.NDArray[np.float32]:
|
|
31
|
+
scaled = arr.astype(np.float32) / 255
|
|
32
|
+
return scaled * np.float32(max_ - min_) + np.float32(min_)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _structured_array_to_dict(arr: npt.NDArray[np.void]) -> dict[str, str]:
|
|
36
|
+
return {field_name: str(arr[field_name].item()) for field_name in arr.dtype.names} # ty:ignore[not-iterable]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tiny-metaio
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Read and write MetaImages with minimal dependencies
|
|
5
5
|
Author-email: Nicolas Cedilnik <nicolas.cedilnik@inria.fr>
|
|
6
6
|
Project-URL: Homepage, https://gitlab.inria.fr/ncedilni/metaio
|
|
@@ -26,6 +26,8 @@ images (`.nii` or `.nii.gz`)
|
|
|
26
26
|
and write `.mha` images
|
|
27
27
|
in python with minimal dependencies.
|
|
28
28
|
|
|
29
|
+
Bonus: provides a custom 8-bit MHA-like format when size matters: `.metaq`.
|
|
30
|
+
|
|
29
31
|
## Installation
|
|
30
32
|
|
|
31
33
|
Available on pypi.org: `pip install tiny-metaio`.
|
|
@@ -57,6 +59,43 @@ array([0., 0.])
|
|
|
57
59
|
|
|
58
60
|
```
|
|
59
61
|
|
|
62
|
+
### Custom format
|
|
63
|
+
|
|
64
|
+
You need to specify a range of values covered by the quantization.
|
|
65
|
+
Values outside this ranges will be clipped.
|
|
66
|
+
A loss of precision is expected.
|
|
67
|
+
NaNs will be implicitly converted to zero.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
>>> img = metaio.MetaImage([[-0.5, 1], [2.5, 5]])
|
|
71
|
+
>>> img.save_quantized("some-name.metaq", min_=0, max_=2.5)
|
|
72
|
+
>>> metaio.read("some-name.metaq").data
|
|
73
|
+
array([[0. , 1. ],
|
|
74
|
+
[2.5, 2.5]], dtype=float32)
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Additionally, if some values do not matter, you can specify a mask for further compression.
|
|
79
|
+
The mask must be a binary array of the same shape as the image.
|
|
80
|
+
Values where the mask is `False` will not be stored at all and restored as NaN on dequantization.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
>>> img = metaio.MetaImage([[-0.5, 1], [2.5, 5]])
|
|
84
|
+
>>> img.save_quantized("some-name.metaq", min_=0, max_=2.5, mask=img.data <= 2.5)
|
|
85
|
+
>>> metaio.read("some-name.metaq").data
|
|
86
|
+
array([[0. , 1. ],
|
|
87
|
+
[2.5, nan]], dtype=float32)
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Command-line interface
|
|
92
|
+
|
|
93
|
+
A command-line interface is available to convert supported input format to a MHA.
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
$ metaio /path/to/input.metaq /path/to/output.mha
|
|
97
|
+
```
|
|
98
|
+
|
|
60
99
|
## Philosophy
|
|
61
100
|
|
|
62
101
|
- `numpy` as only runtime dependency.
|
|
@@ -67,6 +106,8 @@ array([0., 0.])
|
|
|
67
106
|
## Why should I use this over SimpleITK?
|
|
68
107
|
|
|
69
108
|
If you just need the IO parts and do not want the large SimpleITK package,
|
|
109
|
+
or if you need to efficiently store some large MetaImage-like data,
|
|
70
110
|
this package might be for you.
|
|
111
|
+
|
|
71
112
|
If you do not care about having SimpleITK as a dependency for your project,
|
|
72
113
|
this package is not for you.
|
|
@@ -6,16 +6,20 @@ README.md
|
|
|
6
6
|
pyproject.toml
|
|
7
7
|
uv.lock
|
|
8
8
|
src/metaio/__init__.py
|
|
9
|
+
src/metaio/__main__.py
|
|
9
10
|
src/metaio/image.py
|
|
10
11
|
src/metaio/mha.py
|
|
11
12
|
src/metaio/nifti.py
|
|
12
13
|
src/metaio/py.typed
|
|
14
|
+
src/metaio/quant.py
|
|
13
15
|
src/metaio/write.py
|
|
14
16
|
src/tiny_metaio.egg-info/PKG-INFO
|
|
15
17
|
src/tiny_metaio.egg-info/SOURCES.txt
|
|
16
18
|
src/tiny_metaio.egg-info/dependency_links.txt
|
|
19
|
+
src/tiny_metaio.egg-info/entry_points.txt
|
|
17
20
|
src/tiny_metaio.egg-info/requires.txt
|
|
18
21
|
src/tiny_metaio.egg-info/top_level.txt
|
|
19
22
|
tests/conftest.py
|
|
23
|
+
tests/test_quantization.py
|
|
20
24
|
tests/test_symmetry.py
|
|
21
25
|
tests/test_vs_simpleitk.py
|
|
@@ -8,8 +8,9 @@ import pytest
|
|
|
8
8
|
@pytest.fixture(scope="session", autouse=True)
|
|
9
9
|
def _rm_doctest_file() -> Iterator[None]:
|
|
10
10
|
yield
|
|
11
|
-
|
|
12
|
-
Path("some-name.
|
|
11
|
+
for ext in "mha", "metaq":
|
|
12
|
+
if Path(f"some-name.{ext}").exists():
|
|
13
|
+
Path(f"some-name.{ext}").unlink()
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
def get_rotation_matrix(ndim: int) -> np.typing.NDArray[np.floating]:
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from metaio import MetaImage, read
|
|
7
|
+
from metaio.image import _dict_to_structured_array, _quantize
|
|
8
|
+
from metaio.quant import _dequantize, _structured_array_to_dict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize(
|
|
12
|
+
"dtype", [np.float16, np.float32, np.int64, np.int32, np.uint16, np.uint32]
|
|
13
|
+
)
|
|
14
|
+
@pytest.mark.parametrize("shape", [(10, 11), (3, 4, 5)])
|
|
15
|
+
@pytest.mark.parametrize("min_,max_", [(0, 35), (-10, 700), (-150, 800)])
|
|
16
|
+
def test_symmetry(
|
|
17
|
+
tmp_path: Path, dtype: np.dtype, shape: tuple[int, ...], min_: float, max_: float
|
|
18
|
+
) -> None:
|
|
19
|
+
path = tmp_path / "image.metaq"
|
|
20
|
+
meta = {"k1": "v1", "k2": "v2", "k3": "v3"}
|
|
21
|
+
|
|
22
|
+
arr1 = np.linspace(-100, 500, np.prod(shape)).astype(dtype).reshape(shape)
|
|
23
|
+
img1 = MetaImage(
|
|
24
|
+
arr1,
|
|
25
|
+
spacing=np.array([0.5] * len(shape)),
|
|
26
|
+
origin=np.array([-4] * len(shape)),
|
|
27
|
+
direction=np.eye(len(shape)).ravel(),
|
|
28
|
+
metadata=meta,
|
|
29
|
+
)
|
|
30
|
+
img1.save_quantized(path, min_, max_)
|
|
31
|
+
|
|
32
|
+
below_mask = arr1 <= min_
|
|
33
|
+
above_mask = arr1 >= max_
|
|
34
|
+
preserved_mask = ~(below_mask | above_mask)
|
|
35
|
+
|
|
36
|
+
# ensure we are actually testing something
|
|
37
|
+
assert np.any(below_mask) or np.any(above_mask) or np.any(preserved_mask)
|
|
38
|
+
|
|
39
|
+
img2 = read(path)
|
|
40
|
+
arr2 = img2.data
|
|
41
|
+
|
|
42
|
+
assert np.all(arr2[below_mask] == min_)
|
|
43
|
+
assert np.all(arr2[above_mask] == max_)
|
|
44
|
+
assert np.allclose(
|
|
45
|
+
arr1[preserved_mask], arr2[preserved_mask], atol=(max_ - min_) / 256
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
assert img2.data.dtype == np.float32
|
|
49
|
+
assert np.all(img1.spacing == img2.spacing)
|
|
50
|
+
assert np.all(img1.origin == img2.origin)
|
|
51
|
+
assert np.all(img1.direction == img2.direction)
|
|
52
|
+
|
|
53
|
+
for k, v in meta.items():
|
|
54
|
+
assert img2.metadata[k] == v
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_quantize() -> None:
|
|
58
|
+
assert np.all(_quantize(np.array([1, 2, 3, 4, 5]), 2, 4) == [0, 0, 128, 255, 255])
|
|
59
|
+
assert np.all(_quantize(np.array([-4, 0, 4]), -4, 4) == [0, 128, 255])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_dequantize() -> None:
|
|
63
|
+
arr = np.array([0, 0, 127, 255, 255])
|
|
64
|
+
arr_q = _dequantize(arr, 2, 4)
|
|
65
|
+
assert np.all(np.round(arr_q) == [2, 2, 3, 4, 4])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_integer() -> None:
|
|
69
|
+
arr = np.array([0, 1, 2, 3, 4])
|
|
70
|
+
quantized = _quantize(arr, 0, 4)
|
|
71
|
+
deq = _dequantize(quantized, 0, 4)
|
|
72
|
+
assert np.allclose(arr, deq, atol=4 / 256), quantized
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_dict_to_struct() -> None:
|
|
76
|
+
dct1 = {"k1": "v1", "k2": "vvvv2"}
|
|
77
|
+
arr = _dict_to_structured_array(dct1)
|
|
78
|
+
dct2 = _structured_array_to_dict(arr)
|
|
79
|
+
assert dct1 == dct2
|
|
@@ -90,3 +90,32 @@ def test_write(
|
|
|
90
90
|
assert np.allclose(img_us.direction, img.GetDirection())
|
|
91
91
|
assert np.all(img_us.data == array)
|
|
92
92
|
assert np.all(img_us.data == sitk.GetArrayViewFromImage(img))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@pytest.mark.parametrize("key", ["key1", "Key2"])
|
|
96
|
+
@pytest.mark.parametrize("value", ["some-other-string", "some-string"])
|
|
97
|
+
def test_metadata_reader(tmp_path: Path, key: str, value: str) -> None:
|
|
98
|
+
sitk_img = sitk.GetImageFromArray(np.empty([5, 5]))
|
|
99
|
+
sitk_img.SetMetaData(key, value)
|
|
100
|
+
path = tmp_path / "image.mha"
|
|
101
|
+
sitk.WriteImage(sitk_img, path)
|
|
102
|
+
|
|
103
|
+
img = read(path)
|
|
104
|
+
assert img.metadata.pop(key) == value
|
|
105
|
+
# these keys are automatically added by simpleITK
|
|
106
|
+
assert set(img.metadata.keys()) == {
|
|
107
|
+
"AnatomicalOrientation",
|
|
108
|
+
"ObjectType",
|
|
109
|
+
"BinaryData",
|
|
110
|
+
"CenterOfRotation",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_metadata_writer(tmp_path: Path) -> None:
|
|
115
|
+
path = tmp_path / "image.mha"
|
|
116
|
+
meta = {"key1": "value1", "key2": "value2"}
|
|
117
|
+
img = MetaImage(np.empty([5, 5]), metadata=meta)
|
|
118
|
+
img.save(path)
|
|
119
|
+
img = read(path)
|
|
120
|
+
for k, v in meta.items():
|
|
121
|
+
assert img.metadata[k] == v
|
tiny_metaio-0.2.0/README.md
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
# MetaIO
|
|
2
|
-
|
|
3
|
-
Read
|
|
4
|
-
[MetaImages](https://docs.itk.org/en/latest/learn/metaio.html)
|
|
5
|
-
(`.mha` or `.mhd`)
|
|
6
|
-
and
|
|
7
|
-
[NIFTI](https://en.wikipedia.org/wiki/Neuroimaging_Informatics_Technology_Initiative)
|
|
8
|
-
images (`.nii` or `.nii.gz`)
|
|
9
|
-
and write `.mha` images
|
|
10
|
-
in python with minimal dependencies.
|
|
11
|
-
|
|
12
|
-
## Installation
|
|
13
|
-
|
|
14
|
-
Available on pypi.org: `pip install tiny-metaio`.
|
|
15
|
-
|
|
16
|
-
## Usage
|
|
17
|
-
|
|
18
|
-
### Writing a MHA file
|
|
19
|
-
|
|
20
|
-
```python
|
|
21
|
-
>>> import metaio
|
|
22
|
-
>>> img = metaio.MetaImage([[0, 1], [42, 43]], spacing=[1, 2])
|
|
23
|
-
>>> img.save("some-name.mha")
|
|
24
|
-
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
### Reading a MHA file
|
|
28
|
-
|
|
29
|
-
```python
|
|
30
|
-
>>> img = metaio.read("some-name.mha")
|
|
31
|
-
>>> img.spacing
|
|
32
|
-
array([1., 2.])
|
|
33
|
-
>>> img.data
|
|
34
|
-
array([[ 0, 1],
|
|
35
|
-
[42, 43]])
|
|
36
|
-
>>> img.direction
|
|
37
|
-
array([1., 0., 0., 1.])
|
|
38
|
-
>>> img.origin
|
|
39
|
-
array([0., 0.])
|
|
40
|
-
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
## Philosophy
|
|
44
|
-
|
|
45
|
-
- `numpy` as only runtime dependency.
|
|
46
|
-
- idiomatic python.
|
|
47
|
-
- similar behavior than SimpleITK's `GetArrayFromImage`, `GetImageFromArray`,
|
|
48
|
-
`GetDirection`, `GetOrigin`, `GetSpacing`, `ReadImage`, `WriteImage`.
|
|
49
|
-
|
|
50
|
-
## Why should I use this over SimpleITK?
|
|
51
|
-
|
|
52
|
-
If you just need the IO parts and do not want the large SimpleITK package,
|
|
53
|
-
this package might be for you.
|
|
54
|
-
If you do not care about having SimpleITK as a dependency for your project,
|
|
55
|
-
this package is not for you.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|