tiny-metaio 0.1.1__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.
metaio/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .image import MetaImage
2
+ from .read import read_mha
3
+
4
+ __all__ = "MetaImage", "read_mha"
metaio/image.py ADDED
@@ -0,0 +1,134 @@
1
+ import os
2
+ import zlib
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+ import numpy.typing as npt
7
+
8
+ from . import write
9
+
10
+
11
+ class MetaImage:
12
+ def __init__(
13
+ self,
14
+ data: npt.NDArray[np.generic],
15
+ *,
16
+ spacing: npt.NDArray[np.floating] | None = None,
17
+ origin: npt.NDArray[np.floating] | None = None,
18
+ direction: npt.NDArray[np.floating] | None = None,
19
+ metadata: dict[str, str] | None = None,
20
+ vector: bool = False,
21
+ ) -> None:
22
+ self.data = np.asarray(data)
23
+ self.vector = vector
24
+
25
+ self.spacing = spacing
26
+ self.origin = origin
27
+ self.direction = direction
28
+
29
+ self.metadata: dict[str, str] = {}
30
+
31
+ @property
32
+ def size(self) -> tuple[int, ...]:
33
+ if self.vector:
34
+ return self.data.shape[:-1][::-1]
35
+ else:
36
+ return self.data.shape[::-1]
37
+
38
+ @property
39
+ def spacing(self) -> npt.NDArray[np.floating]:
40
+ return self._spacing
41
+
42
+ @spacing.setter
43
+ def spacing(self, spacing: npt.NDArray[np.floating] | None) -> None:
44
+ if spacing is None:
45
+ spacing = np.ones(self.ndim)
46
+ else:
47
+ if len(spacing) != self.ndim:
48
+ raise ValueError
49
+ spacing = np.asarray(spacing)
50
+ self._spacing = spacing
51
+
52
+ @property
53
+ def origin(self) -> npt.NDArray[np.floating]:
54
+ return self._origin
55
+
56
+ @origin.setter
57
+ def origin(self, origin: npt.NDArray[np.floating] | None) -> None:
58
+ if origin is None:
59
+ origin = np.zeros(self.ndim)
60
+ else:
61
+ if len(origin) != self.ndim:
62
+ raise ValueError
63
+ origin = np.asarray(origin)
64
+ self._origin = origin
65
+
66
+ @property
67
+ def direction(self) -> npt.NDArray[np.floating]:
68
+ return self._direction
69
+
70
+ @direction.setter
71
+ def direction(self, direction: npt.NDArray[np.floating] | None) -> None:
72
+ if direction is None:
73
+ direction = np.eye(self.ndim).ravel(order="F")
74
+ else:
75
+ if len(direction) != self.ndim**2:
76
+ raise ValueError
77
+ direction = np.asarray(direction)
78
+ self._direction = direction
79
+
80
+ @property
81
+ def ndim(self) -> int:
82
+ return self.data.ndim - (1 if self.vector else 0)
83
+
84
+ def save(self, path: os.PathLike[str], compress: bool = False) -> None:
85
+ path = Path(path)
86
+ data = np.asarray(self.data)
87
+
88
+ native_dtype: np.dtype[np.generic] = data.dtype.newbyteorder("=")
89
+ data = data.astype(native_dtype, copy=False)
90
+ element_type = write.element_type_for(native_dtype)
91
+
92
+ le_dtype: np.dtype[np.generic] = native_dtype.newbyteorder("<")
93
+ pixel_bytes = data.astype(le_dtype, copy=False).tobytes()
94
+
95
+ if compress:
96
+ pixel_bytes = zlib.compress(pixel_bytes)
97
+
98
+ header_path = path.with_suffix(".mha")
99
+ element_data_file = "LOCAL"
100
+
101
+ def line(key: str, val: object) -> None:
102
+ hfh.write(f"{key} = {val}\n".encode("ascii"))
103
+
104
+ with header_path.open("wb") as hfh:
105
+ line("ObjectType", self.metadata.get("ObjectType", "Image"))
106
+ line("NDims", self.ndim)
107
+ line("BinaryData", "True")
108
+ line("BinaryDataByteOrderMSB", "False")
109
+ line("CompressedData", "True" if compress else "False")
110
+ if compress:
111
+ line("CompressedDataSize", len(pixel_bytes))
112
+ if self.vector:
113
+ line("ElementNumberOfChannels", self.ndim)
114
+ line(
115
+ "TransformMatrix",
116
+ write.fmt_floats(
117
+ np.array(self.direction)
118
+ .reshape((self.ndim, self.ndim))
119
+ .ravel(order="F")
120
+ .tolist()
121
+ ),
122
+ )
123
+ line("Offset", write.fmt_floats(self.origin))
124
+ line("ElementSpacing", write.fmt_floats(self.spacing))
125
+ line("DimSize", write.fmt_ints(self.size))
126
+
127
+ for k, v in self.metadata.items():
128
+ if k not in write.MANAGED:
129
+ line(k, v)
130
+
131
+ line("ElementType", element_type)
132
+ line("ElementDataFile", element_data_file)
133
+
134
+ hfh.write(pixel_bytes)
metaio/py.typed ADDED
File without changes
metaio/read.py ADDED
@@ -0,0 +1,185 @@
1
+ import os
2
+ import zlib
3
+ from pathlib import Path
4
+ from typing import BinaryIO
5
+
6
+ import numpy as np
7
+ import numpy.typing as npt
8
+
9
+ from .image import MetaImage
10
+
11
+ _MET_DTYPE: dict[str, str] = {
12
+ "MET_CHAR": "int8",
13
+ "MET_UCHAR": "uint8",
14
+ "MET_SHORT": "int16",
15
+ "MET_USHORT": "uint16",
16
+ "MET_INT": "int32",
17
+ "MET_UINT": "uint32",
18
+ "MET_LONG": "int32",
19
+ "MET_ULONG": "uint32",
20
+ "MET_LONG_LONG": "int64",
21
+ "MET_ULONG_LONG": "uint64",
22
+ "MET_FLOAT": "float32",
23
+ "MET_DOUBLE": "float64",
24
+ }
25
+
26
+
27
+ def _parse_header(src: BinaryIO) -> tuple[dict[str, str], int]:
28
+ header: dict[str, str] = {}
29
+ line_buf = bytearray()
30
+
31
+ while True:
32
+ byte = src.read(1)
33
+ if not byte:
34
+ raise EOFError("Reached end of file before finding ElementDataFile.")
35
+ if byte == b"\n":
36
+ line = line_buf.decode("ascii", errors="replace").strip()
37
+ line_buf.clear()
38
+ if not line or line.startswith("#"):
39
+ continue
40
+ if "=" not in line:
41
+ continue
42
+ key_raw, _, val_raw = line.partition("=")
43
+ key = key_raw.strip()
44
+ val = val_raw.strip()
45
+ header[key] = val
46
+ if key == "ElementDataFile":
47
+ break
48
+ else:
49
+ line_buf.extend(byte)
50
+
51
+ return header, src.tell()
52
+
53
+
54
+ def _require(header: dict[str, str], key: str) -> str:
55
+ try:
56
+ return header[key]
57
+ except KeyError:
58
+ raise KeyError(f"Required MetaIO key '{key}' not found in header.") from None
59
+
60
+
61
+ def _parse_float_list(s: str) -> list[float]:
62
+ return [float(x) for x in s.split()]
63
+
64
+
65
+ def _parse_int_list(s: str) -> list[int]:
66
+ return [int(x) for x in s.split()]
67
+
68
+
69
+ def read_mha(path: str | os.PathLike[str]) -> MetaImage:
70
+ """
71
+ Read a MetaIO image file (``.mha`` or ``.mhd``).
72
+
73
+ Parameters
74
+ ----------
75
+ path:
76
+ Path to the ``.mha`` or ``.mhd`` file.
77
+
78
+ Returns
79
+ -------
80
+ MhaImage
81
+ Parsed image with voxel ``data`` shaped ``(Z, Y, X[, C])`` and all
82
+ available spatial metadata populated.
83
+
84
+ Raises
85
+ ------
86
+ KeyError
87
+ If a required header field is absent.
88
+ ValueError
89
+ If the pixel type is unsupported or the data length is wrong.
90
+ FileNotFoundError
91
+ If the file (or its companion ``.raw``) cannot be found.
92
+ """
93
+ path = Path(path)
94
+ with path.open("rb") as fh:
95
+ header, _data_offset = _parse_header(fh)
96
+
97
+ element_type = _require(header, "ElementType").upper()
98
+ if element_type not in _MET_DTYPE:
99
+ raise ValueError(
100
+ f"Unsupported ElementType '{element_type}'. "
101
+ f"Supported: {list(_MET_DTYPE)}"
102
+ )
103
+ dtype = np.dtype(_MET_DTYPE[element_type])
104
+
105
+ ndim = int(_require(header, "NDims"))
106
+ sizes = _parse_int_list(_require(header, "DimSize"))
107
+ if len(sizes) != ndim:
108
+ raise ValueError(f"NDims={ndim} but DimSize has {len(sizes)} entries.")
109
+
110
+ spacing: list[float] = []
111
+ if "ElementSpacing" in header:
112
+ spacing = _parse_float_list(header["ElementSpacing"])
113
+ elif "ElementSize" in header:
114
+ spacing = _parse_float_list(header["ElementSize"])
115
+
116
+ origin: list[float] = []
117
+ if "Offset" in header:
118
+ origin = _parse_float_list(header["Offset"])
119
+ elif "Position" in header:
120
+ origin = _parse_float_list(header["Position"])
121
+
122
+ transform_matrix: list[float] | None = None
123
+ if "TransformMatrix" in header:
124
+ transform_matrix = _parse_float_list(header["TransformMatrix"])
125
+ elif "Rotation" in header:
126
+ transform_matrix = _parse_float_list(header["Rotation"])
127
+
128
+ transform_matrix = (
129
+ np.array(transform_matrix).reshape((ndim, ndim)).ravel(order="F")
130
+ ).tolist()
131
+
132
+ big_endian_str = header.get("BinaryDataByteOrderMSB", "False").strip()
133
+ big_endian = big_endian_str.lower() in ("true", "1")
134
+ dtype = dtype.newbyteorder(">" if big_endian else "<")
135
+
136
+ n_channels = int(header.get("ElementNumberOfChannels", "1"))
137
+
138
+ element_data_file = _require(header, "ElementDataFile").strip()
139
+ compressed = header.get("CompressedData", "False").strip().lower() in (
140
+ "true",
141
+ "1",
142
+ )
143
+
144
+ if element_data_file == "LOCAL":
145
+ raw_bytes: bytes = fh.read()
146
+ else:
147
+ raw_path = path.parent / element_data_file
148
+ if not raw_path.exists():
149
+ raise FileNotFoundError(f"Companion data file not found: {raw_path}")
150
+ with raw_path.open("rb") as raw_fh:
151
+ raw_bytes = raw_fh.read()
152
+
153
+ if compressed:
154
+ raw_bytes = zlib.decompress(raw_bytes)
155
+
156
+ n_voxels = 1
157
+ for s in sizes:
158
+ n_voxels *= s
159
+ n_elements = n_voxels * n_channels
160
+ expected_bytes = n_elements * dtype.itemsize
161
+
162
+ if len(raw_bytes) < expected_bytes:
163
+ raise ValueError(
164
+ f"Expected {expected_bytes} bytes of pixel data but got {len(raw_bytes)}."
165
+ )
166
+
167
+ flat: npt.NDArray[np.generic] = np.frombuffer(
168
+ raw_bytes[:expected_bytes], dtype=dtype
169
+ )
170
+
171
+ shape: tuple[int, ...] = tuple(reversed(sizes))
172
+ if n_channels > 1:
173
+ shape = shape + (n_channels,)
174
+
175
+ data = flat.reshape(shape)
176
+ data = data.astype(data.dtype.newbyteorder("="), copy=False)
177
+
178
+ return MetaImage(
179
+ data=data,
180
+ spacing=np.asarray(spacing),
181
+ origin=np.asarray(origin),
182
+ direction=np.asarray(transform_matrix),
183
+ metadata=header,
184
+ vector=n_channels != 1,
185
+ )
metaio/write.py ADDED
@@ -0,0 +1,57 @@
1
+ from collections.abc import Sequence
2
+
3
+ import numpy as np
4
+ import numpy.typing as npt
5
+
6
+ _DTYPE_MET: dict[tuple[str, int], str] = {
7
+ ("u", 1): "MET_UCHAR",
8
+ ("i", 1): "MET_CHAR",
9
+ ("u", 2): "MET_USHORT",
10
+ ("i", 2): "MET_SHORT",
11
+ ("u", 4): "MET_UINT",
12
+ ("i", 4): "MET_INT",
13
+ ("u", 8): "MET_ULONG_LONG",
14
+ ("i", 8): "MET_LONG_LONG",
15
+ ("f", 4): "MET_FLOAT",
16
+ ("f", 8): "MET_DOUBLE",
17
+ }
18
+
19
+ MANAGED = {
20
+ "ObjectType",
21
+ "NDims",
22
+ "BinaryData",
23
+ "BinaryDataByteOrderMSB",
24
+ "CompressedData",
25
+ "CompressedDataSize",
26
+ "TransformMatrix",
27
+ "Offset",
28
+ "Position",
29
+ "ElementSpacing",
30
+ "ElementSize",
31
+ "DimSize",
32
+ "ElementNumberOfChannels",
33
+ "ElementType",
34
+ "ElementDataFile",
35
+ }
36
+
37
+
38
+ def fmt_floats(values: Sequence[float] | npt.NDArray[np.floating]) -> str:
39
+ if isinstance(values, np.ndarray):
40
+ values = values.tolist()
41
+ return " ".join(repr(v) for v in values)
42
+
43
+
44
+ def fmt_ints(values: Sequence[int]) -> str:
45
+ return " ".join(str(v) for v in values)
46
+
47
+
48
+ def element_type_for(dtype: np.dtype[np.generic]) -> str:
49
+ """Return the MetaIO ElementType string for *dtype*, raising on failure."""
50
+ key = (dtype.kind, dtype.itemsize)
51
+ if key not in _DTYPE_MET:
52
+ raise ValueError(
53
+ f"No MetaIO ElementType for dtype '{dtype}' "
54
+ f"(kind='{dtype.kind}', itemsize={dtype.itemsize}). "
55
+ f"Supported numpy kinds/sizes: {list(_DTYPE_MET)}"
56
+ )
57
+ return _DTYPE_MET[key]
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: tiny-metaio
3
+ Version: 0.1.1
4
+ Summary: Read and write MetaImages with minimal dependencies
5
+ Author-email: Nicolas Cedilnik <nicolas.cedilnik@inria.fr>
6
+ Project-URL: Homepage, https://gitlab.inria.fr/ncedilni/metaio
7
+ Project-URL: Issues, https://gitlab.inria.fr/ncedilni/metaio/-/issues
8
+ Project-URL: Repository, https://gitlab.inria.fr/ncedilni/metaio
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Topic :: Scientific/Engineering
12
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
13
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: numpy~=2.0
17
+
18
+ # MetaIO
19
+
20
+ Read and write [MetaImages](https://docs.itk.org/en/latest/learn/metaio.html)
21
+ in python with minimal dependencies.
22
+
23
+ ## Installation
24
+
25
+ Available on pypi.org: `pip install metaio`.
26
+
27
+ ## Usage
28
+
29
+ ### Writing a MHA file
30
+
31
+ ```python
32
+ >>> from metaio import MetaImage, read_mha
33
+ >>> img = MetaImage([[0, 1], [42, 43]], spacing=[1, 2])
34
+ >>> img.save("some-name.mha")
35
+
36
+ ```
37
+
38
+ ### Reading a MHA file
39
+
40
+ ```python
41
+ >>> img = read_mha("some-name.mha")
42
+ >>> img.spacing
43
+ array([1., 2.])
44
+ >>> img.data
45
+ array([[ 0, 1],
46
+ [42, 43]])
47
+ >>> img.direction
48
+ array([1., 0., 0., 1.])
49
+ >>> img.origin
50
+ array([0., 0.])
51
+
52
+ ```
53
+
54
+ ## Philosophy
55
+
56
+ - `numpy` as only runtime dependency.
57
+ - idiomatic python.
58
+ - similar behavior than SimpleITK's `GetArrayFromImage`, `GetImageFromArray`,
59
+ `GetDirection`, `GetOrigin`, `GetSpacing`, `ReadImage`, `WriteImage`.
60
+
61
+ ## Why should I use this over SimpleITK?
62
+
63
+ If you just need the IO parts and do not want the large SimpleITK package,
64
+ this package might be for you.
65
+ If you do not care about having SimpleITK as a dependency for your project,
66
+ this package is not for you.
@@ -0,0 +1,9 @@
1
+ metaio/__init__.py,sha256=ESxrAVQNytShMXAbnloV45CNK-DdoauwoeDrpL9obYU,91
2
+ metaio/image.py,sha256=OUDB8BYP2AC4nfOzfpRjdzSOmvU0GshwFJH1CZQ4hTE,4208
3
+ metaio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ metaio/read.py,sha256=Vqd-xfj8Gtwy05FMTN6fd1YYJUPkTzK1srpbujHf18Y,5591
5
+ metaio/write.py,sha256=EALxGlvJzLWyJHfwaBlVhvIeOd3egLRNtUEXuF4JdQs,1474
6
+ tiny_metaio-0.1.1.dist-info/METADATA,sha256=PJrC4rFwfR4fKoh5sR-9YjYBOxUOzmrjfV-lNzuUq2Q,1809
7
+ tiny_metaio-0.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ tiny_metaio-0.1.1.dist-info/top_level.txt,sha256=mbuJhHfrVASzx9c0IbjP6llb_znhMzrsAYhM0KreOgw,7
9
+ tiny_metaio-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ metaio