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.
- tiny_metaio-0.1.1/.gitignore +13 -0
- tiny_metaio-0.1.1/.gitlab-ci.yml +64 -0
- tiny_metaio-0.1.1/.pre-commit-config.yaml +36 -0
- tiny_metaio-0.1.1/.python-version +1 -0
- tiny_metaio-0.1.1/Containerfile +20 -0
- tiny_metaio-0.1.1/PKG-INFO +66 -0
- tiny_metaio-0.1.1/README.md +49 -0
- tiny_metaio-0.1.1/pyproject.toml +58 -0
- tiny_metaio-0.1.1/setup.cfg +4 -0
- tiny_metaio-0.1.1/src/metaio/__init__.py +4 -0
- tiny_metaio-0.1.1/src/metaio/image.py +134 -0
- tiny_metaio-0.1.1/src/metaio/py.typed +0 -0
- tiny_metaio-0.1.1/src/metaio/read.py +185 -0
- tiny_metaio-0.1.1/src/metaio/write.py +57 -0
- tiny_metaio-0.1.1/src/tiny_metaio.egg-info/PKG-INFO +66 -0
- tiny_metaio-0.1.1/src/tiny_metaio.egg-info/SOURCES.txt +21 -0
- tiny_metaio-0.1.1/src/tiny_metaio.egg-info/dependency_links.txt +1 -0
- tiny_metaio-0.1.1/src/tiny_metaio.egg-info/requires.txt +1 -0
- tiny_metaio-0.1.1/src/tiny_metaio.egg-info/top_level.txt +1 -0
- tiny_metaio-0.1.1/tests/conftest.py +43 -0
- tiny_metaio-0.1.1/tests/test_symmetry.py +40 -0
- tiny_metaio-0.1.1/tests/test_vs_simpleitk.py +84 -0
- tiny_metaio-0.1.1/uv.lock +609 -0
|
@@ -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,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
|
+
)
|