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.
Files changed (28) hide show
  1. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/PKG-INFO +42 -1
  2. tiny_metaio-0.3.0/README.md +96 -0
  3. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/pyproject.toml +3 -0
  4. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/metaio/__init__.py +6 -4
  5. tiny_metaio-0.3.0/src/metaio/__main__.py +41 -0
  6. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/metaio/image.py +58 -3
  7. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/metaio/mha.py +11 -12
  8. tiny_metaio-0.3.0/src/metaio/quant.py +36 -0
  9. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/tiny_metaio.egg-info/PKG-INFO +42 -1
  10. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/tiny_metaio.egg-info/SOURCES.txt +4 -0
  11. tiny_metaio-0.3.0/src/tiny_metaio.egg-info/entry_points.txt +2 -0
  12. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/tests/conftest.py +3 -2
  13. tiny_metaio-0.3.0/tests/test_quantization.py +79 -0
  14. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/tests/test_vs_simpleitk.py +29 -0
  15. tiny_metaio-0.2.0/README.md +0 -55
  16. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/.gitignore +0 -0
  17. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/.gitlab-ci.yml +0 -0
  18. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/.pre-commit-config.yaml +0 -0
  19. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/Containerfile +0 -0
  20. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/setup.cfg +0 -0
  21. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/metaio/nifti.py +0 -0
  22. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/metaio/py.typed +0 -0
  23. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/metaio/write.py +0 -0
  24. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/tiny_metaio.egg-info/dependency_links.txt +0 -0
  25. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/tiny_metaio.egg-info/requires.txt +0 -0
  26. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/src/tiny_metaio.egg-info/top_level.txt +0 -0
  27. {tiny_metaio-0.2.0 → tiny_metaio-0.3.0}/tests/test_symmetry.py +0 -0
  28. {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.2.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 read_mha(path)
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 os
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: os.PathLike[str], compress: bool = False) -> None:
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[key]
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 read_mha(path: str | os.PathLike[str]) -> MetaImage:
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["ElementSpacing"])
111
+ spacing = _parse_float_list(header.pop("ElementSpacing"))
113
112
  elif "ElementSize" in header:
114
- spacing = _parse_float_list(header["ElementSize"])
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["Offset"])
117
+ origin = _parse_float_list(header.pop("Offset"))
119
118
  elif "Position" in header:
120
- origin = _parse_float_list(header["Position"])
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["TransformMatrix"])
123
+ transform_matrix = _parse_float_list(header.pop("TransformMatrix"))
125
124
  elif "Rotation" in header:
126
- transform_matrix = _parse_float_list(header["Rotation"])
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.get("BinaryDataByteOrderMSB", "False").strip()
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.get("ElementNumberOfChannels", "1"))
135
+ n_channels = int(header.pop("ElementNumberOfChannels", "1"))
137
136
 
138
137
  element_data_file = _require(header, "ElementDataFile").strip()
139
- compressed = header.get("CompressedData", "False").strip().lower() in (
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.2.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
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ metaio = metaio.__main__:main
@@ -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
- if Path("some-name.mha").exists():
12
- Path("some-name.mha").unlink()
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
@@ -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