Skip to content

Commit 0ad2d09

Browse files
committed
nrrd_io
1 parent 9319824 commit 0ad2d09

File tree

4 files changed

+177
-4
lines changed

4 files changed

+177
-4
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ dependencies = [
3737
"pyyaml",
3838
"seaborn",
3939
"scikit-learn",
40-
"opencv-python-headless"
40+
"opencv-python-headless",
41+
"pynrrd",
4142
]
4243

4344
[project.optional-dependencies]

src/vidata/io/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .tif_io import load_tif, save_tif
88
from .blosc2_io import load_blosc2, load_blosc2pkl, save_blosc2, save_blosc2pkl
99
from .numpy_io import load_npy, load_npz, save_npy, save_npz
10+
from .nrrd_io import load_nrrd, save_nrrd
1011
from .json_io import load_json, save_json, load_jsongz, save_jsongz
1112
from .pickle_io import load_pickle, save_pickle
1213
from .txt_io import load_txt, save_txt
@@ -19,6 +20,8 @@
1920
"save_nib",
2021
"load_nibRO",
2122
"save_nibRO",
23+
"load_nrrd",
24+
"save_nrrd",
2225
"load_blosc2",
2326
"save_blosc2",
2427
"load_blosc2pkl",

src/vidata/io/nrrd_io.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from pathlib import Path
2+
3+
import nrrd
4+
import numpy as np
5+
6+
from vidata.registry import register_loader, register_writer
7+
from vidata.utils.affine import build_affine
8+
9+
10+
@register_writer("image", ".nrrd", backend="nrrd")
11+
@register_writer("mask", ".nrrd", backend="nrrd")
12+
def save_nrrd(data: np.ndarray, file: str | Path, metadata: dict | None = None) -> list[str]:
13+
"""Save a NumPy array as a nrrd using pynrrd.
14+
15+
Args:
16+
data (np.ndarray): Image data array (z, y, x) or (y, x).
17+
file (Union[str, Path]): Output file path (.nrrd).
18+
metadata (Optional[dict]): Optional metadata dictionary containing:
19+
- "spacing" (list or np.ndarray): Physical spacing per axis.
20+
- "origin" (list or np.ndarray): Origin of the image.
21+
- "direction" (list or np.ndarray): Flattened or matrix-form direction.
22+
"""
23+
ndims = data.ndim
24+
25+
if ndims == 3:
26+
data = data.transpose(2, 1, 0) # (X,Y,Z) → (Z,Y,X)
27+
elif ndims == 2:
28+
data = data.transpose(1, 0)
29+
30+
# if metadata is not None:
31+
if metadata is not None and "spacing" in metadata:
32+
spacing = np.array(metadata["spacing"][::-1])
33+
else:
34+
spacing = np.ones(ndims)
35+
36+
if metadata is not None and "origin" in metadata:
37+
origin = np.array(metadata["origin"][::-1])
38+
else:
39+
origin = np.zeros(ndims)
40+
41+
if metadata is not None and "direction" in metadata:
42+
direction = np.array(metadata["direction"]).flatten()[::-1].reshape(ndims, ndims)
43+
else:
44+
direction = np.eye(ndims)
45+
46+
space_dirs = direction * spacing[:, None]
47+
48+
header = {
49+
"type": "short",
50+
"dimension": ndims,
51+
"space origin": origin.tolist(),
52+
"space directions": space_dirs.tolist(),
53+
"encoding": "gzip", # compressed NRRD
54+
}
55+
nrrd.write(str(file), data, header)
56+
return [str(file)]
57+
58+
59+
@register_loader("image", ".nrrd", backend="nrrd")
60+
@register_loader("mask", ".nrrd", backend="nrrd")
61+
def load_nrrd(file: str | Path) -> tuple[np.ndarray, dict]:
62+
"""Load a nrrd file using pynrrd and return data and metadata. This function is consistent with
63+
vidata.io.sitk_io.load_sitk
64+
65+
Args:
66+
file (Union[str, Path]): Path to the medical image file (.nrrd).
67+
68+
Returns:
69+
tuple[np.ndarray, dict[str, np.ndarray]]: A tuple containing:
70+
- Image data as a NumPy array (z, y, x or y, x).
71+
- Metadata dictionary with:
72+
- "spacing": voxel spacing (np.ndarray)
73+
- "origin": image origin (np.ndarray)
74+
- "direction": orientation matrix (np.ndarray)
75+
- "affine": computed affine matrix (np.ndarray)
76+
"""
77+
array, header = nrrd.read(str(file))
78+
ndims = array.ndim
79+
80+
# Convert the array to z,y,x axis ordering (numpy)
81+
if ndims == 3:
82+
array = array.transpose(2, 1, 0) # (X,Y,Z) → (Z,Y,X)
83+
elif ndims == 2:
84+
array = array.transpose(1, 0) # (X,Y) → (Y,X)
85+
86+
# Extract the metadata
87+
origin = np.array(header["space origin"]) if "space origin" in header else np.zeros(ndims)
88+
spacing = (
89+
np.linalg.norm(np.array(header["space directions"]), axis=1)
90+
if "space directions" in header
91+
else np.ones(ndims)
92+
)
93+
94+
if "space directions" in header:
95+
norms = np.linalg.norm(header["space directions"], axis=1, keepdims=True)
96+
direction = header["space directions"] / (norms + 1e-12)
97+
else:
98+
direction = np.eye(ndims)
99+
direction = direction.flatten() # Flattened SITK style
100+
101+
# Convert Metadata from (X,Y,Z) → (Z,Y,X)
102+
spacing = spacing[::-1]
103+
origin = origin[::-1]
104+
direction = np.array(direction[::-1]).reshape(ndims, ndims)
105+
106+
affine = build_affine(ndims, spacing, origin, direction)
107+
108+
metadata = {
109+
"spacing": spacing,
110+
"origin": origin,
111+
"direction": direction,
112+
"affine": affine,
113+
"header": header,
114+
}
115+
116+
return array, metadata

tests/io/test_complex_io.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,15 +164,68 @@ def test_nibRO(dtype, file_ending, size, metadata, tmp_path):
164164

165165
save_nibRO(data, file, metadata)
166166
data_l, metadata_l = load_nibRO(file)
167-
print("D1", data)
168-
print("D2", data_l)
169-
print(metadata_l)
170167
assert np.array_equal(data, data_l)
171168
if metadata is not None:
172169
for key in metadata:
173170
assert np.all(metadata_l[key] == metadata[key])
174171

175172

173+
@pytest.mark.parametrize("dtype", ["float", "int"])
174+
@pytest.mark.parametrize(
175+
"size, metadata",
176+
[
177+
((100, 100, 100), None),
178+
((100, 100), None),
179+
(
180+
(110, 90, 100),
181+
{
182+
"spacing": [1, 0.5, 0.25],
183+
"origin": [10, 20, 30],
184+
"direction": [
185+
[1.0, 0.0, 0.0],
186+
[0.0, -1.0, 0.0],
187+
[0.0, 0.0, -1.0],
188+
],
189+
},
190+
),
191+
(
192+
(110, 90, 100),
193+
{
194+
"spacing": np.array([1, 0.5, 0.25]),
195+
"origin": np.array([10, 20, 30]),
196+
"direction": np.array([[1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, -1.0]]),
197+
},
198+
),
199+
(
200+
(110, 90),
201+
{
202+
"spacing": [1, 0.5],
203+
"origin": [10, 20],
204+
"direction": [[1.0, 0.0], [0.0, -1.0]],
205+
},
206+
),
207+
((120, 100, 80), {"spacing": [1, 0.5, 0.25]}),
208+
((120, 100), {"spacing": [1, 0.5]}),
209+
],
210+
)
211+
def test_nrrd(dtype, size, metadata, tmp_path):
212+
from vidata.io import load_nrrd, save_nrrd
213+
214+
file_ending = ".nrrd"
215+
216+
file = Path(tmp_path).joinpath(f"nrrd_{dtype}{file_ending}")
217+
218+
data = dummy_data(size, dtype)
219+
220+
save_nrrd(data, file, metadata)
221+
data_l, metadata_l = load_nrrd(file)
222+
223+
assert np.array_equal(data, data_l)
224+
if metadata is not None:
225+
for key in metadata:
226+
assert np.allclose(metadata_l[key], metadata[key])
227+
228+
176229
@pytest.mark.parametrize("dtype", ["float", "int"])
177230
@pytest.mark.parametrize(
178231
"size, metadata",

0 commit comments

Comments
 (0)