tiny-metaio 0.1.1__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.
@@ -0,0 +1,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ report.xml
13
+ .coverage
@@ -0,0 +1,64 @@
1
+ image: ${CI_REGISTRY_IMAGE}:ci
2
+
3
+ stages:
4
+ - build-ci-container
5
+ - test
6
+ - publish
7
+
8
+ default:
9
+ tags:
10
+ - ci.inria.fr
11
+ - small
12
+
13
+ build-ci-container:
14
+ stage: build-ci-container
15
+ image: docker:stable
16
+ before_script:
17
+ - docker info
18
+ - docker login -u gitlab-ci-token -p ${CI_JOB_TOKEN} ${CI_REGISTRY}
19
+ script:
20
+ - docker build --tag ${CI_REGISTRY_IMAGE}:ci -f Containerfile .
21
+ - docker push ${CI_REGISTRY_IMAGE}:ci
22
+ rules:
23
+ - changes:
24
+ - Containerfile
25
+ - uv.lock
26
+ if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
27
+
28
+ tests:
29
+ stage: test
30
+ script: uv run poe coverage
31
+ coverage: "/TOTAL.+ ([0-9]{1,3}%)/"
32
+ artifacts:
33
+ paths:
34
+ - htmlcov
35
+ reports:
36
+ junit: report.xml
37
+ coverage_report:
38
+ coverage_format: cobertura
39
+ path: coverage.xml
40
+
41
+ lint:
42
+ stage: test
43
+ script: uv run poe lint
44
+
45
+ publish-gitlab:
46
+ stage: publish
47
+ variables:
48
+ UV_PUBLISH_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/pypi"
49
+ UV_PUBLISH_USERNAME: "gitlab-ci-token"
50
+ UV_PUBLISH_PASSWORD: "${CI_JOB_TOKEN}"
51
+ script:
52
+ - uv build
53
+ - uv publish dist/*
54
+ rules:
55
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
56
+ - if: $CI_COMMIT_TAG
57
+
58
+ publish-pypi:
59
+ stage: publish
60
+ script:
61
+ - uv build
62
+ - uv publish --token $PYPI_TOKEN
63
+ rules:
64
+ - if: $CI_COMMIT_TAG
@@ -0,0 +1,36 @@
1
+ default_stages: [pre-commit]
2
+ repos:
3
+ - repo: https://github.com/pre-commit/pre-commit-hooks
4
+ rev: v6.0.0
5
+ hooks:
6
+ - id: trailing-whitespace
7
+ - id: end-of-file-fixer
8
+ - id: check-yaml
9
+ - id: check-added-large-files
10
+ - id: check-merge-conflict
11
+ args: [--assume-in-merge]
12
+
13
+ - repo: https://github.com/astral-sh/uv-pre-commit
14
+ rev: 0.11.2
15
+ hooks:
16
+ - id: uv-lock
17
+
18
+ - repo: https://github.com/charliermarsh/ruff-pre-commit
19
+ rev: v0.15.8
20
+ hooks:
21
+ - id: ruff
22
+ args: [--fix]
23
+ - id: ruff-format
24
+
25
+ - repo: https://github.com/crate-ci/typos
26
+ rev: v1.44.0
27
+ hooks:
28
+ - id: typos
29
+
30
+ - repo: local
31
+ hooks:
32
+ - id: ty
33
+ name: ty check
34
+ entry: ty check . --python .venv
35
+ language: python
36
+ files: ".*.py"
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,20 @@
1
+ # we use debian because there are no simpleitk wheels for musl and building
2
+ # them is a hassle
3
+
4
+ FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim AS builder
5
+
6
+ ENV UV_LINK_MODE=copy
7
+ RUN apt-get update \
8
+ && apt-get install -y --no-install-recommends git \
9
+ && rm -rf /var/lib/apt/lists/*
10
+ COPY pyproject.toml uv.lock ./
11
+ RUN uv sync --all-groups --all-extras --no-install-project
12
+
13
+ FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim AS ci
14
+
15
+ ENV UV_LINK_MODE=copy
16
+ RUN apt-get update \
17
+ && apt-get install -y --no-install-recommends git \
18
+ && rm -rf /var/lib/apt/lists/*
19
+ COPY --from=builder /root/.cache/uv /root/.cache/uv
20
+ COPY pyproject.toml uv.lock ./
@@ -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,49 @@
1
+ # MetaIO
2
+
3
+ Read and write [MetaImages](https://docs.itk.org/en/latest/learn/metaio.html)
4
+ in python with minimal dependencies.
5
+
6
+ ## Installation
7
+
8
+ Available on pypi.org: `pip install metaio`.
9
+
10
+ ## Usage
11
+
12
+ ### Writing a MHA file
13
+
14
+ ```python
15
+ >>> from metaio import MetaImage, read_mha
16
+ >>> img = MetaImage([[0, 1], [42, 43]], spacing=[1, 2])
17
+ >>> img.save("some-name.mha")
18
+
19
+ ```
20
+
21
+ ### Reading a MHA file
22
+
23
+ ```python
24
+ >>> img = read_mha("some-name.mha")
25
+ >>> img.spacing
26
+ array([1., 2.])
27
+ >>> img.data
28
+ array([[ 0, 1],
29
+ [42, 43]])
30
+ >>> img.direction
31
+ array([1., 0., 0., 1.])
32
+ >>> img.origin
33
+ array([0., 0.])
34
+
35
+ ```
36
+
37
+ ## Philosophy
38
+
39
+ - `numpy` as only runtime dependency.
40
+ - idiomatic python.
41
+ - similar behavior than SimpleITK's `GetArrayFromImage`, `GetImageFromArray`,
42
+ `GetDirection`, `GetOrigin`, `GetSpacing`, `ReadImage`, `WriteImage`.
43
+
44
+ ## Why should I use this over SimpleITK?
45
+
46
+ If you just need the IO parts and do not want the large SimpleITK package,
47
+ this package might be for you.
48
+ If you do not care about having SimpleITK as a dependency for your project,
49
+ this package is not for you.
@@ -0,0 +1,58 @@
1
+ [project]
2
+ name = "tiny-metaio"
3
+ description = "Read and write MetaImages with minimal dependencies"
4
+ readme = "README.md"
5
+ authors = [
6
+ { name = "Nicolas Cedilnik", email = "nicolas.cedilnik@inria.fr" }
7
+ ]
8
+ requires-python = ">=3.10"
9
+ dependencies = [
10
+ "numpy~=2.0",
11
+ ]
12
+ classifiers = [
13
+ "Intended Audience :: Developers",
14
+ "Intended Audience :: Science/Research",
15
+ "Topic :: Scientific/Engineering",
16
+ "Topic :: Scientific/Engineering :: Medical Science Apps.",
17
+ "Topic :: Scientific/Engineering :: Image Processing",
18
+ ]
19
+ dynamic = ["version"]
20
+
21
+ [project.urls]
22
+ Homepage = "https://gitlab.inria.fr/ncedilni/metaio"
23
+ Issues = "https://gitlab.inria.fr/ncedilni/metaio/-/issues"
24
+ Repository = "https://gitlab.inria.fr/ncedilni/metaio"
25
+
26
+ [build-system]
27
+ requires = ["setuptools>=64", "setuptools-scm>=8"]
28
+ build-backend = "setuptools.build_meta"
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "coverage>=7.13.5",
33
+ "poethepoet>=0.42.1",
34
+ "pytest>=9.0.2",
35
+ "ruff>=0.15.8",
36
+ "simpleitk>=2.5.3",
37
+ "ty>=0.0.26",
38
+ "typos>=1.44.0",
39
+ ]
40
+
41
+ [tool.setuptools_scm]
42
+
43
+ [tool.coverage.run]
44
+ omit = ["./tests/*"]
45
+
46
+ [tool.ruff.lint]
47
+ extend-select = ["I", "ANN", "UP"]
48
+
49
+ [tool.poe.tasks]
50
+ ty = "ty check"
51
+ ruff = "ruff check"
52
+ typos = "typos"
53
+ lint = ["ty", "ruff", "typos"]
54
+ autofix = "ruff check --fix"
55
+ test = "coverage run -m pytest --doctest-glob='*.md' --junitxml=report.xml"
56
+ coverage-report = "coverage report"
57
+ coverage-html = "coverage html"
58
+ coverage = ["test", "coverage-report", "coverage-html"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .image import MetaImage
2
+ from .read import read_mha
3
+
4
+ __all__ = "MetaImage", "read_mha"
@@ -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)
File without changes
@@ -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
+ )