tomio 0.1.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.
- tomio-0.1.0/LICENSE +21 -0
- tomio-0.1.0/PKG-INFO +216 -0
- tomio-0.1.0/README.md +179 -0
- tomio-0.1.0/pyproject.toml +51 -0
- tomio-0.1.0/setup.cfg +4 -0
- tomio-0.1.0/src/tomio/__init__.py +21 -0
- tomio-0.1.0/src/tomio/__main__.py +5 -0
- tomio-0.1.0/src/tomio/cli.py +154 -0
- tomio-0.1.0/src/tomio/configfile.py +138 -0
- tomio-0.1.0/src/tomio/imagefile.py +141 -0
- tomio-0.1.0/src/tomio/interp.py +486 -0
- tomio-0.1.0/src/tomio/labelfile.py +488 -0
- tomio-0.1.0/src/tomio/volume.py +718 -0
- tomio-0.1.0/src/tomio.egg-info/PKG-INFO +216 -0
- tomio-0.1.0/src/tomio.egg-info/SOURCES.txt +22 -0
- tomio-0.1.0/src/tomio.egg-info/dependency_links.txt +1 -0
- tomio-0.1.0/src/tomio.egg-info/entry_points.txt +2 -0
- tomio-0.1.0/src/tomio.egg-info/requires.txt +14 -0
- tomio-0.1.0/src/tomio.egg-info/top_level.txt +1 -0
- tomio-0.1.0/test/test_cli.py +174 -0
- tomio-0.1.0/test/test_configfile.py +206 -0
- tomio-0.1.0/test/test_interp.py +384 -0
- tomio-0.1.0/test/test_labelfile.py +444 -0
- tomio-0.1.0/test/test_volume.py +666 -0
tomio-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pierre-Yves Baudin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tomio-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tomio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: I/O utilities for medical images: image volumes with geometry, interpolation, label and config files
|
|
5
|
+
Author: Pierre-Yves Baudin
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/py-baudin/tomio
|
|
8
|
+
Project-URL: Repository, https://github.com/py-baudin/tomio
|
|
9
|
+
Project-URL: Issues, https://github.com/py-baudin/tomio/issues
|
|
10
|
+
Keywords: medical imaging,nifti,itk,simpleitk,volume,interpolation,itk-snap,labels
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: numpy>=1.24
|
|
26
|
+
Requires-Dist: pyyaml
|
|
27
|
+
Requires-Dist: SimpleITK
|
|
28
|
+
Provides-Extra: interp
|
|
29
|
+
Requires-Dist: scipy; extra == "interp"
|
|
30
|
+
Provides-Extra: nibabel
|
|
31
|
+
Requires-Dist: nibabel; extra == "nibabel"
|
|
32
|
+
Provides-Extra: test
|
|
33
|
+
Requires-Dist: pytest; extra == "test"
|
|
34
|
+
Requires-Dist: scipy; extra == "test"
|
|
35
|
+
Requires-Dist: nibabel; extra == "test"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# tomio
|
|
39
|
+
|
|
40
|
+
I/O utilities for medical images: image volumes (thin wrapper over SimpleITK), interpolation, label files (ITK-SNAP, FreeSurfer, 3D Slicer) and config files (JSON, YAML, TOML).
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install tomio
|
|
46
|
+
pip install tomio[interp] # optional: scipy interpolation backend
|
|
47
|
+
pip install tomio[nibabel] # optional: conversion to/from nibabel images
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Requires Python >= 3.10. Reading TOML files on Python 3.10 requires `tomli`.
|
|
51
|
+
|
|
52
|
+
## Conventions
|
|
53
|
+
|
|
54
|
+
- Arrays are indexed `(x, y, z, ...)`, in ITK index order (not the `(z, y, x)` order of `sitk.GetArrayFromImage`).
|
|
55
|
+
- World coordinates are LPS (ITK, DICOM). Conversion to nibabel (RAS) is done by `to_nibabel`/`from_nibabel`.
|
|
56
|
+
- `vol.transform` is a tuple of axis directions: `transform[i]` is the world direction of array axis `i`, i.e. column `i` of the ITK direction matrix.
|
|
57
|
+
- Orientation codes use the ITK "from" convention by default (identity: `RAI`); the "to" convention (identity: `LPS`) is available with `convention="to"`.
|
|
58
|
+
|
|
59
|
+
## Volumes
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import numpy as np
|
|
63
|
+
from tomio import volume
|
|
64
|
+
|
|
65
|
+
# read / write (formats: .nii, .nii.gz, .mha, .mhd, .nrrd...)
|
|
66
|
+
vol = volume.read("vol.nii.gz")
|
|
67
|
+
volume.write("vol2.mha", vol)
|
|
68
|
+
|
|
69
|
+
# options
|
|
70
|
+
vol = volume.read("vol.nii.gz", dtype=np.float32) # cast
|
|
71
|
+
vol = volume.read("complex.mha", as_complex=True) # complex volume (written by volume.write)
|
|
72
|
+
volume.write("vol2.nii.gz", vol, compression_level=9)
|
|
73
|
+
header = volume.read_header("vol.nii.gz") # shape, spacing, origin, transform, metadata (no pixel data)
|
|
74
|
+
|
|
75
|
+
# Volume is a numpy ndarray
|
|
76
|
+
isinstance(vol, np.ndarray) # True
|
|
77
|
+
vol.A # plain np.ndarray
|
|
78
|
+
|
|
79
|
+
# header
|
|
80
|
+
vol.spacing # (sx, sy, sz)
|
|
81
|
+
vol.origin # world coordinates of voxel (0, 0, 0)
|
|
82
|
+
vol.transform # axis directions
|
|
83
|
+
vol.metadata # dict[str, str], written to the file
|
|
84
|
+
vol.header # dict of spacing, origin, transform and metadata
|
|
85
|
+
vol.affine # 4x4 affine transform (indices -> LPS coordinates)
|
|
86
|
+
|
|
87
|
+
# world coordinates <-> indices
|
|
88
|
+
coords = vol.get_coords([1, 2, 3])
|
|
89
|
+
indices = vol.get_indices(coords)
|
|
90
|
+
|
|
91
|
+
# create volumes
|
|
92
|
+
vol2 = volume.Volume(np.zeros((10, 20, 30)), spacing=(1, 1, 2), origin=(0, 0, 0))
|
|
93
|
+
vol2 = volume.asvolume(array, ref=vol) # copy the header of `vol`
|
|
94
|
+
vol2 = volume.tovolume(values, mask=mask, ref=vol) # fill the voxels of a mask
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Volumes with more than 3 dimensions (e.g. time series `(x, y, z, t)`) and complex volumes can be read and written; the geometry applies to the first 3 axes.
|
|
98
|
+
|
|
99
|
+
### Geometry
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
geom = vol.geometry # volume.Geometry: shape, spacing, origin, transform (immutable)
|
|
103
|
+
geom.affine, geom.extent, geom.center, geom.voxel_volume
|
|
104
|
+
geom.same_as(other_vol, tol=1e-4)
|
|
105
|
+
vol2 = volume.Volume(array, geometry=geom)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Slicing and numpy operations
|
|
109
|
+
|
|
110
|
+
The geometry follows numpy operations when it makes sense:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
vol[2:].origin # world coordinates of the first voxel of the slice
|
|
114
|
+
vol[::2].spacing # spacing x 2 in the first axis
|
|
115
|
+
vol[::-1] # axis direction flipped (also np.flip)
|
|
116
|
+
vol.T, np.moveaxis(vol, 0, -1) # spacing and directions permuted
|
|
117
|
+
vol + 1, np.sqrt(vol) # same geometry
|
|
118
|
+
|
|
119
|
+
# no geometry: plain np.ndarray
|
|
120
|
+
vol[0], vol[mask], vol.sum(axis=0), vol.mean()
|
|
121
|
+
# time series: geometry kept
|
|
122
|
+
vol4d[..., 0], vol4d.mean(axis=3)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Orientation
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
vol.anatomical_orientation # e.g. "RAI" (ITK "from" convention)
|
|
129
|
+
vol.get_orientation(convention="to") # e.g. "LPS"
|
|
130
|
+
vol2 = vol.reorient("RAS", convention="to") # flip/permute the axes
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Interoperability
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
image = vol.to_sitk() # SimpleITK Image
|
|
137
|
+
vol = volume.Volume.from_sitk(image)
|
|
138
|
+
nifti = vol.to_nibabel() # nibabel Nifti1Image (requires nibabel)
|
|
139
|
+
vol = volume.Volume.from_nibabel(nifti)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Interpolation
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
import numpy as np
|
|
146
|
+
from tomio import volume
|
|
147
|
+
|
|
148
|
+
vol2 = volume.resample(vol, like=ref) # geometry of another volume (or a Geometry)
|
|
149
|
+
vol2 = volume.resample(vol, spacing=(1, 1, 1)) # new spacing, same extent
|
|
150
|
+
vol2 = volume.resample(vol, geometry={"spacing": (1, 1, 1), "shape": (64, 64, 64)})
|
|
151
|
+
vol2 = volume.resample(vol, like=ref, method="linear", fill=np.nan) # value outside `vol`
|
|
152
|
+
|
|
153
|
+
# label maps and masks
|
|
154
|
+
labels2 = volume.resample(labelmap, like=ref, method="labels") # label by label (smooth boundaries)
|
|
155
|
+
roi2 = volume.resample(roi, like=ref, method="roi") # no label lost when downsampling
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- Methods: `nearest`, `linear`, `spline3` (and `splineN`), `labels`, `roi`. Default: `nearest` for integer and bool data (label maps), `spline3` otherwise. The output has the dtype of the input.
|
|
159
|
+
- Backends: SimpleITK (default, multithreaded) or scipy (`backend="scipy"`, requires `tomio[interp]`). SimpleITK considers voxels inside the source up to half a voxel beyond the border voxel centers.
|
|
160
|
+
- Lower-level functions: `interp.interpolate`, `interpolate_like`, `interpolate_to`, `interpolate_roi`, `interpolate_labels`.
|
|
161
|
+
|
|
162
|
+
## Label files
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from tomio import labelfile
|
|
166
|
+
|
|
167
|
+
labels = labelfile.read("labels.txt") # ITK-SNAP label description file
|
|
168
|
+
labels = labelfile.read("FreeSurferColorLUT.txt", format="freesurfer")
|
|
169
|
+
labels = labelfile.read("labels.ctbl", format="slicer") # 3D Slicer color table
|
|
170
|
+
labelfile.write("labels.txt", labels) # or labels.save("labels.txt"), ITK-SNAP format
|
|
171
|
+
|
|
172
|
+
# Labels: mapping {index: description}, sorted by index
|
|
173
|
+
labels = labelfile.Labels({1: "white matter", 2: "grey matter"}) # automatic colors
|
|
174
|
+
labels = labelfile.Labels({1: {"LABEL": "white matter", "RGBA": (255, 255, 255, 1.0)}})
|
|
175
|
+
labels = labelfile.Labels(["background", "white matter"]) # indices 0, 1
|
|
176
|
+
|
|
177
|
+
labels[1] # description
|
|
178
|
+
labels[3] = "csf" # add or rename (keeps the color)
|
|
179
|
+
del labels[3]
|
|
180
|
+
labels.getindex("grey matter") # index of a description
|
|
181
|
+
labels.indices, labels.descriptions # lists, sorted by index (can be assigned)
|
|
182
|
+
labels.colors # {index: (R, G, B, A)}, RGB in 0..255, alpha in 0..1 (can be assigned)
|
|
183
|
+
|
|
184
|
+
# label maps
|
|
185
|
+
labels = labelfile.Labels.from_volume(labelmap) # one label per value
|
|
186
|
+
rgba = labels.lut[labelmap] # RGBA image (uint8)
|
|
187
|
+
labels2, mapping = labels.reindex() # indices 1..N
|
|
188
|
+
labelmap2 = labelfile.apply_mapping(labelmap, mapping)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Labels are validated: non-negative integer indices, unique descriptions without `"` or line breaks (`allow_duplicates=True` to allow duplicates), RGB in 0..255, alpha in 0..1. Label files are UTF-8.
|
|
192
|
+
|
|
193
|
+
## Config files
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from tomio import configfile
|
|
197
|
+
|
|
198
|
+
cfg = configfile.read("config.yml") # .json, .yaml, .yml, .toml -> dict
|
|
199
|
+
configfile.write("config.yml", cfg) # .json, .yaml, .yml
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
- The format is set by the file extension (no extension: `.json`). TOML files are read-only.
|
|
203
|
+
- Values are written as standard types: numpy arrays as lists, numpy scalars as numbers, complex numbers as strings `"3 + 4i"` (read back as complex numbers).
|
|
204
|
+
- YAML files are loaded safely (no Python-specific tags).
|
|
205
|
+
|
|
206
|
+
## Command line
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
tomio convert vol.mha vol.nii.gz # convert (format from the extensions)
|
|
210
|
+
tomio convert vol.mha vol.nii.gz --dtype float32 --compression-level 9
|
|
211
|
+
tomio resample vol.nii.gz out.nii.gz --spacing 1 1 1 # new spacing
|
|
212
|
+
tomio resample labels.nii.gz out.nii.gz --like ref.nii.gz --method labels
|
|
213
|
+
tomio info vol.nii.gz --metadata # header: shape, pixel type, geometry
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Existing output files are not overwritten unless `--force` is given. See `tomio COMMAND --help`.
|
tomio-0.1.0/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# tomio
|
|
2
|
+
|
|
3
|
+
I/O utilities for medical images: image volumes (thin wrapper over SimpleITK), interpolation, label files (ITK-SNAP, FreeSurfer, 3D Slicer) and config files (JSON, YAML, TOML).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install tomio
|
|
9
|
+
pip install tomio[interp] # optional: scipy interpolation backend
|
|
10
|
+
pip install tomio[nibabel] # optional: conversion to/from nibabel images
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Python >= 3.10. Reading TOML files on Python 3.10 requires `tomli`.
|
|
14
|
+
|
|
15
|
+
## Conventions
|
|
16
|
+
|
|
17
|
+
- Arrays are indexed `(x, y, z, ...)`, in ITK index order (not the `(z, y, x)` order of `sitk.GetArrayFromImage`).
|
|
18
|
+
- World coordinates are LPS (ITK, DICOM). Conversion to nibabel (RAS) is done by `to_nibabel`/`from_nibabel`.
|
|
19
|
+
- `vol.transform` is a tuple of axis directions: `transform[i]` is the world direction of array axis `i`, i.e. column `i` of the ITK direction matrix.
|
|
20
|
+
- Orientation codes use the ITK "from" convention by default (identity: `RAI`); the "to" convention (identity: `LPS`) is available with `convention="to"`.
|
|
21
|
+
|
|
22
|
+
## Volumes
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
import numpy as np
|
|
26
|
+
from tomio import volume
|
|
27
|
+
|
|
28
|
+
# read / write (formats: .nii, .nii.gz, .mha, .mhd, .nrrd...)
|
|
29
|
+
vol = volume.read("vol.nii.gz")
|
|
30
|
+
volume.write("vol2.mha", vol)
|
|
31
|
+
|
|
32
|
+
# options
|
|
33
|
+
vol = volume.read("vol.nii.gz", dtype=np.float32) # cast
|
|
34
|
+
vol = volume.read("complex.mha", as_complex=True) # complex volume (written by volume.write)
|
|
35
|
+
volume.write("vol2.nii.gz", vol, compression_level=9)
|
|
36
|
+
header = volume.read_header("vol.nii.gz") # shape, spacing, origin, transform, metadata (no pixel data)
|
|
37
|
+
|
|
38
|
+
# Volume is a numpy ndarray
|
|
39
|
+
isinstance(vol, np.ndarray) # True
|
|
40
|
+
vol.A # plain np.ndarray
|
|
41
|
+
|
|
42
|
+
# header
|
|
43
|
+
vol.spacing # (sx, sy, sz)
|
|
44
|
+
vol.origin # world coordinates of voxel (0, 0, 0)
|
|
45
|
+
vol.transform # axis directions
|
|
46
|
+
vol.metadata # dict[str, str], written to the file
|
|
47
|
+
vol.header # dict of spacing, origin, transform and metadata
|
|
48
|
+
vol.affine # 4x4 affine transform (indices -> LPS coordinates)
|
|
49
|
+
|
|
50
|
+
# world coordinates <-> indices
|
|
51
|
+
coords = vol.get_coords([1, 2, 3])
|
|
52
|
+
indices = vol.get_indices(coords)
|
|
53
|
+
|
|
54
|
+
# create volumes
|
|
55
|
+
vol2 = volume.Volume(np.zeros((10, 20, 30)), spacing=(1, 1, 2), origin=(0, 0, 0))
|
|
56
|
+
vol2 = volume.asvolume(array, ref=vol) # copy the header of `vol`
|
|
57
|
+
vol2 = volume.tovolume(values, mask=mask, ref=vol) # fill the voxels of a mask
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Volumes with more than 3 dimensions (e.g. time series `(x, y, z, t)`) and complex volumes can be read and written; the geometry applies to the first 3 axes.
|
|
61
|
+
|
|
62
|
+
### Geometry
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
geom = vol.geometry # volume.Geometry: shape, spacing, origin, transform (immutable)
|
|
66
|
+
geom.affine, geom.extent, geom.center, geom.voxel_volume
|
|
67
|
+
geom.same_as(other_vol, tol=1e-4)
|
|
68
|
+
vol2 = volume.Volume(array, geometry=geom)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Slicing and numpy operations
|
|
72
|
+
|
|
73
|
+
The geometry follows numpy operations when it makes sense:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
vol[2:].origin # world coordinates of the first voxel of the slice
|
|
77
|
+
vol[::2].spacing # spacing x 2 in the first axis
|
|
78
|
+
vol[::-1] # axis direction flipped (also np.flip)
|
|
79
|
+
vol.T, np.moveaxis(vol, 0, -1) # spacing and directions permuted
|
|
80
|
+
vol + 1, np.sqrt(vol) # same geometry
|
|
81
|
+
|
|
82
|
+
# no geometry: plain np.ndarray
|
|
83
|
+
vol[0], vol[mask], vol.sum(axis=0), vol.mean()
|
|
84
|
+
# time series: geometry kept
|
|
85
|
+
vol4d[..., 0], vol4d.mean(axis=3)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Orientation
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
vol.anatomical_orientation # e.g. "RAI" (ITK "from" convention)
|
|
92
|
+
vol.get_orientation(convention="to") # e.g. "LPS"
|
|
93
|
+
vol2 = vol.reorient("RAS", convention="to") # flip/permute the axes
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Interoperability
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
image = vol.to_sitk() # SimpleITK Image
|
|
100
|
+
vol = volume.Volume.from_sitk(image)
|
|
101
|
+
nifti = vol.to_nibabel() # nibabel Nifti1Image (requires nibabel)
|
|
102
|
+
vol = volume.Volume.from_nibabel(nifti)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Interpolation
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
import numpy as np
|
|
109
|
+
from tomio import volume
|
|
110
|
+
|
|
111
|
+
vol2 = volume.resample(vol, like=ref) # geometry of another volume (or a Geometry)
|
|
112
|
+
vol2 = volume.resample(vol, spacing=(1, 1, 1)) # new spacing, same extent
|
|
113
|
+
vol2 = volume.resample(vol, geometry={"spacing": (1, 1, 1), "shape": (64, 64, 64)})
|
|
114
|
+
vol2 = volume.resample(vol, like=ref, method="linear", fill=np.nan) # value outside `vol`
|
|
115
|
+
|
|
116
|
+
# label maps and masks
|
|
117
|
+
labels2 = volume.resample(labelmap, like=ref, method="labels") # label by label (smooth boundaries)
|
|
118
|
+
roi2 = volume.resample(roi, like=ref, method="roi") # no label lost when downsampling
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- Methods: `nearest`, `linear`, `spline3` (and `splineN`), `labels`, `roi`. Default: `nearest` for integer and bool data (label maps), `spline3` otherwise. The output has the dtype of the input.
|
|
122
|
+
- Backends: SimpleITK (default, multithreaded) or scipy (`backend="scipy"`, requires `tomio[interp]`). SimpleITK considers voxels inside the source up to half a voxel beyond the border voxel centers.
|
|
123
|
+
- Lower-level functions: `interp.interpolate`, `interpolate_like`, `interpolate_to`, `interpolate_roi`, `interpolate_labels`.
|
|
124
|
+
|
|
125
|
+
## Label files
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from tomio import labelfile
|
|
129
|
+
|
|
130
|
+
labels = labelfile.read("labels.txt") # ITK-SNAP label description file
|
|
131
|
+
labels = labelfile.read("FreeSurferColorLUT.txt", format="freesurfer")
|
|
132
|
+
labels = labelfile.read("labels.ctbl", format="slicer") # 3D Slicer color table
|
|
133
|
+
labelfile.write("labels.txt", labels) # or labels.save("labels.txt"), ITK-SNAP format
|
|
134
|
+
|
|
135
|
+
# Labels: mapping {index: description}, sorted by index
|
|
136
|
+
labels = labelfile.Labels({1: "white matter", 2: "grey matter"}) # automatic colors
|
|
137
|
+
labels = labelfile.Labels({1: {"LABEL": "white matter", "RGBA": (255, 255, 255, 1.0)}})
|
|
138
|
+
labels = labelfile.Labels(["background", "white matter"]) # indices 0, 1
|
|
139
|
+
|
|
140
|
+
labels[1] # description
|
|
141
|
+
labels[3] = "csf" # add or rename (keeps the color)
|
|
142
|
+
del labels[3]
|
|
143
|
+
labels.getindex("grey matter") # index of a description
|
|
144
|
+
labels.indices, labels.descriptions # lists, sorted by index (can be assigned)
|
|
145
|
+
labels.colors # {index: (R, G, B, A)}, RGB in 0..255, alpha in 0..1 (can be assigned)
|
|
146
|
+
|
|
147
|
+
# label maps
|
|
148
|
+
labels = labelfile.Labels.from_volume(labelmap) # one label per value
|
|
149
|
+
rgba = labels.lut[labelmap] # RGBA image (uint8)
|
|
150
|
+
labels2, mapping = labels.reindex() # indices 1..N
|
|
151
|
+
labelmap2 = labelfile.apply_mapping(labelmap, mapping)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Labels are validated: non-negative integer indices, unique descriptions without `"` or line breaks (`allow_duplicates=True` to allow duplicates), RGB in 0..255, alpha in 0..1. Label files are UTF-8.
|
|
155
|
+
|
|
156
|
+
## Config files
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from tomio import configfile
|
|
160
|
+
|
|
161
|
+
cfg = configfile.read("config.yml") # .json, .yaml, .yml, .toml -> dict
|
|
162
|
+
configfile.write("config.yml", cfg) # .json, .yaml, .yml
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
- The format is set by the file extension (no extension: `.json`). TOML files are read-only.
|
|
166
|
+
- Values are written as standard types: numpy arrays as lists, numpy scalars as numbers, complex numbers as strings `"3 + 4i"` (read back as complex numbers).
|
|
167
|
+
- YAML files are loaded safely (no Python-specific tags).
|
|
168
|
+
|
|
169
|
+
## Command line
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
tomio convert vol.mha vol.nii.gz # convert (format from the extensions)
|
|
173
|
+
tomio convert vol.mha vol.nii.gz --dtype float32 --compression-level 9
|
|
174
|
+
tomio resample vol.nii.gz out.nii.gz --spacing 1 1 1 # new spacing
|
|
175
|
+
tomio resample labels.nii.gz out.nii.gz --like ref.nii.gz --method labels
|
|
176
|
+
tomio info vol.nii.gz --metadata # header: shape, pixel type, geometry
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Existing output files are not overwritten unless `--force` is given. See `tomio COMMAND --help`.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tomio"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "I/O utilities for medical images: image volumes with geometry, interpolation, label and config files"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Pierre-Yves Baudin" }]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"numpy>=1.24",
|
|
16
|
+
"pyyaml",
|
|
17
|
+
"SimpleITK",
|
|
18
|
+
]
|
|
19
|
+
keywords = ["medical imaging", "nifti", "itk", "simpleitk", "volume", "interpolation", "itk-snap", "labels"]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Intended Audience :: Science/Research",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Topic :: Scientific/Engineering :: Medical Science Apps.",
|
|
31
|
+
"Topic :: Scientific/Engineering :: Image Processing",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
interp = ["scipy"]
|
|
36
|
+
nibabel = ["nibabel"]
|
|
37
|
+
test = ["pytest", "scipy", "nibabel"]
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
tomio = "tomio.cli:main"
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/py-baudin/tomio"
|
|
44
|
+
Repository = "https://github.com/py-baudin/tomio"
|
|
45
|
+
Issues = "https://github.com/py-baudin/tomio/issues"
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.packages.find]
|
|
48
|
+
where = ["src"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["test"]
|
tomio-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from . import volume, configfile, labelfile, interp
|
|
2
|
+
|
|
3
|
+
# backward compatible name
|
|
4
|
+
config = configfile
|
|
5
|
+
|
|
6
|
+
volread = volume.read
|
|
7
|
+
volwrite = volume.write
|
|
8
|
+
read_volume = volume.read
|
|
9
|
+
write_volume = volume.write
|
|
10
|
+
tovolume = volume.tovolume
|
|
11
|
+
asvolume = volume.asvolume
|
|
12
|
+
resample = volume.resample
|
|
13
|
+
Volume = volume.Volume
|
|
14
|
+
Geometry = volume.Geometry
|
|
15
|
+
|
|
16
|
+
read_config = configfile.read
|
|
17
|
+
write_config = configfile.write
|
|
18
|
+
|
|
19
|
+
read_labels = labelfile.read
|
|
20
|
+
write_labels = labelfile.write
|
|
21
|
+
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
""" command-line interface
|
|
2
|
+
|
|
3
|
+
tomio convert IN OUT
|
|
4
|
+
tomio resample IN OUT (--like REF | --spacing SX SY SZ) [--method METHOD]
|
|
5
|
+
tomio info IN
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
import argparse
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from . import volume, imagefile
|
|
14
|
+
|
|
15
|
+
METHODS = ["nearest", "linear", "spline3", "labels", "roi"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UserError(Exception):
|
|
19
|
+
"""error reported to the user without traceback"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv=None):
|
|
23
|
+
"""run the command line, return the exit code"""
|
|
24
|
+
parser = get_parser()
|
|
25
|
+
try:
|
|
26
|
+
args = parser.parse_args(argv)
|
|
27
|
+
except SystemExit as exc:
|
|
28
|
+
# --help, or invalid arguments (argparse message already printed)
|
|
29
|
+
return exc.code
|
|
30
|
+
try:
|
|
31
|
+
args.func(args)
|
|
32
|
+
except (UserError, FileNotFoundError, ValueError, RuntimeError, ImportError, NotImplementedError) as exc:
|
|
33
|
+
print(f"tomio: error: {exc}", file=sys.stderr)
|
|
34
|
+
return 1
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_parser():
|
|
39
|
+
parser = argparse.ArgumentParser(prog="tomio", description="Convert, resample and inspect medical image volumes.")
|
|
40
|
+
commands = parser.add_subparsers(title="commands", required=True, metavar="COMMAND")
|
|
41
|
+
|
|
42
|
+
# convert
|
|
43
|
+
convert = commands.add_parser("convert", help="convert an image file to another format", description=(
|
|
44
|
+
"Convert an image file to another format (from the file extensions, e.g. .mha, .nii.gz, .nrrd). "
|
|
45
|
+
"Pixel data, geometry, metadata, vector and complex pixels are kept."))
|
|
46
|
+
convert.add_argument("input", help="input image file")
|
|
47
|
+
convert.add_argument("output", help="output image file")
|
|
48
|
+
add_output_options(convert)
|
|
49
|
+
convert.set_defaults(func=run_convert)
|
|
50
|
+
|
|
51
|
+
# resample
|
|
52
|
+
resample = commands.add_parser("resample", help="resample a volume to a new geometry", description=(
|
|
53
|
+
"Resample a volume to the geometry of another volume, or to a new spacing (same extent)."))
|
|
54
|
+
resample.add_argument("input", help="input image file")
|
|
55
|
+
resample.add_argument("output", help="output image file")
|
|
56
|
+
target = resample.add_mutually_exclusive_group(required=True)
|
|
57
|
+
target.add_argument("--like", metavar="REF", help="reference image file: use its geometry (header only)")
|
|
58
|
+
target.add_argument("--spacing", metavar=("SX", "SY", "SZ"), nargs=3, type=float, help="new spacing")
|
|
59
|
+
resample.add_argument("--method", choices=METHODS, help=(
|
|
60
|
+
"interpolation method; default: nearest for integer data, spline3 otherwise; "
|
|
61
|
+
"labels: label maps, label by label; roi: masks/ROIs"))
|
|
62
|
+
resample.add_argument("--fill", type=float, default=0.0, help="value outside the input volume (default: 0, may be nan)")
|
|
63
|
+
add_output_options(resample)
|
|
64
|
+
resample.set_defaults(func=run_resample)
|
|
65
|
+
|
|
66
|
+
# info
|
|
67
|
+
info = commands.add_parser("info", help="print the header of an image file", description=(
|
|
68
|
+
"Print shape, pixel type and geometry of an image file (the pixel data is not read)."))
|
|
69
|
+
info.add_argument("input", help="input image file")
|
|
70
|
+
info.add_argument("--metadata", action="store_true", help="also print the metadata")
|
|
71
|
+
info.set_defaults(func=run_info)
|
|
72
|
+
return parser
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def add_output_options(parser):
|
|
76
|
+
parser.add_argument("--dtype", help="cast the output pixel values (numpy dtype, e.g. float32, uint8)")
|
|
77
|
+
parser.add_argument("--compression-level", type=int, metavar="N", help="compression level (e.g. 1-9 for .nii.gz)")
|
|
78
|
+
parser.add_argument("-f", "--force", action="store_true", help="overwrite the output file if it exists")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# commands
|
|
82
|
+
|
|
83
|
+
def run_convert(args):
|
|
84
|
+
check_files(args)
|
|
85
|
+
imagefile.convert_image(args.input, args.output, dtype=get_dtype(args.dtype), compression_level=args.compression_level)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run_resample(args):
|
|
89
|
+
check_files(args)
|
|
90
|
+
# complex data: 2-component vector image (as written by tomio)
|
|
91
|
+
as_complex = imagefile.read_info(args.input)["components"] == 2
|
|
92
|
+
vol = volume.read(args.input, as_complex=as_complex)
|
|
93
|
+
if args.like is not None:
|
|
94
|
+
check_input(args.like)
|
|
95
|
+
header = volume.read_header(args.like)
|
|
96
|
+
target = dict(like=volume.Geometry(shape=header["shape"][:3], spacing=header["spacing"], origin=header["origin"], transform=header["transform"]))
|
|
97
|
+
else:
|
|
98
|
+
target = dict(spacing=args.spacing)
|
|
99
|
+
kwargs = {} if args.method in ("labels", "roi") else dict(fill=args.fill)
|
|
100
|
+
out = volume.resample(vol, method=args.method, **target, **kwargs)
|
|
101
|
+
dtype = get_dtype(args.dtype)
|
|
102
|
+
if dtype is not None:
|
|
103
|
+
out = out.astype(dtype)
|
|
104
|
+
volume.write(args.output, out, compression_level=args.compression_level)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def run_info(args):
|
|
108
|
+
check_input(args.input)
|
|
109
|
+
header = volume.read_header(args.input)
|
|
110
|
+
info = imagefile.read_info(args.input)
|
|
111
|
+
geometry = volume.Geometry(shape=header["shape"][:3], spacing=header["spacing"], origin=header["origin"], transform=header["transform"])
|
|
112
|
+
lines = [
|
|
113
|
+
("file", args.input),
|
|
114
|
+
("shape", " x ".join(str(n) for n in header["shape"])),
|
|
115
|
+
("pixel type", info["pixel_type"]),
|
|
116
|
+
("spacing", format_values(geometry.spacing)),
|
|
117
|
+
("origin", format_values(geometry.origin)),
|
|
118
|
+
("orientation", f'{volume.get_orientation(geometry)} ("from" convention), {volume.get_orientation(geometry, convention="to")} ("to" convention)'),
|
|
119
|
+
("directions", "; ".join(format_values(vec) for vec in geometry.transform)),
|
|
120
|
+
("extent", format_values(geometry.extent)),
|
|
121
|
+
("metadata", f"{len(header['metadata'])} keys"),
|
|
122
|
+
]
|
|
123
|
+
width = max(len(name) for name, _ in lines)
|
|
124
|
+
for name, value in lines:
|
|
125
|
+
print(f"{name:<{width}} {value}")
|
|
126
|
+
if args.metadata:
|
|
127
|
+
for key, value in sorted(header["metadata"].items()):
|
|
128
|
+
print(f" {key} = {value}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# utilities
|
|
132
|
+
|
|
133
|
+
def check_input(filename):
|
|
134
|
+
if not Path(filename).is_file():
|
|
135
|
+
raise UserError(f"input file not found: {filename}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def check_files(args):
|
|
139
|
+
check_input(args.input)
|
|
140
|
+
if Path(args.output).exists() and not args.force:
|
|
141
|
+
raise UserError(f"output file exists (use --force to overwrite): {args.output}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_dtype(name):
|
|
145
|
+
if name is None:
|
|
146
|
+
return None
|
|
147
|
+
try:
|
|
148
|
+
return np.dtype(name)
|
|
149
|
+
except TypeError:
|
|
150
|
+
raise UserError(f"invalid dtype: {name}") from None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def format_values(values):
|
|
154
|
+
return "(" + ", ".join(f"{v:.6g}" for v in values) + ")"
|