Skip to content

Commit d5268d7

Browse files
committed
Merge remote-tracking branch 'upstream/main' into AddStaticTypeChecking
2 parents f92ae6f + 0dadde7 commit d5268d7

File tree

9 files changed

+1138
-320
lines changed

9 files changed

+1138
-320
lines changed

docs/notebooks/data_structures.ipynb

+268
Large diffs are not rendered by default.

src/nifreeze/data/base.py

+289
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
2+
# vi: set ft=python sts=4 ts=4 sw=4 et:
3+
#
4+
# Copyright The NiPreps Developers <[email protected]>
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
# We support and encourage derived works from this project, please read
19+
# about our expectations at
20+
#
21+
# https://www.nipreps.org/community/licensing/
22+
#
23+
"""Four-dimensional data representation in hard-disk and memory."""
24+
25+
from __future__ import annotations
26+
27+
from collections import namedtuple
28+
from pathlib import Path
29+
from tempfile import mkdtemp
30+
from typing import Any
31+
32+
import attr
33+
import h5py
34+
import nibabel as nb
35+
import numpy as np
36+
from nitransforms.linear import Affine
37+
38+
NFDH5_EXT = ".h5"
39+
40+
41+
def _data_repr(value: np.ndarray | None) -> str:
42+
if value is None:
43+
return "None"
44+
return f"<{'x'.join(str(v) for v in value.shape)} ({value.dtype})>"
45+
46+
47+
def _cmp(lh: Any, rh: Any) -> bool:
48+
if isinstance(lh, np.ndarray) and isinstance(rh, np.ndarray):
49+
return np.allclose(lh, rh)
50+
51+
return lh == rh
52+
53+
54+
@attr.s(slots=True)
55+
class BaseDataset:
56+
"""
57+
Base dataset representation structure.
58+
59+
A general data structure to represent 4D images and the necessary metadata
60+
for head-motion estimation (that is, potentially a brain mask and the head-motion
61+
estimates).
62+
63+
The data structure has a direct HDF5 mapping to facilitate memory efficiency.
64+
For modalities requiring additional metadata such as DWI (which requires the gradient table
65+
and potentially a b=0 reference), this class may be derived to override certain behaviors
66+
(in the case of DWIs, the indexed access should also return the corresponding gradient
67+
specification).
68+
69+
"""
70+
71+
dataobj = attr.ib(default=None, repr=_data_repr, eq=attr.cmp_using(eq=_cmp))
72+
"""A :obj:`~numpy.ndarray` object for the data array."""
73+
affine = attr.ib(default=None, repr=_data_repr, eq=attr.cmp_using(eq=_cmp))
74+
"""Best affine for RAS-to-voxel conversion of coordinates (NIfTI header)."""
75+
brainmask = attr.ib(default=None, repr=_data_repr, eq=attr.cmp_using(eq=_cmp))
76+
"""A boolean ndarray object containing a corresponding brainmask."""
77+
motion_affines = attr.ib(default=None, eq=attr.cmp_using(eq=_cmp))
78+
"""List of :obj:`~nitransforms.linear.Affine` realigning the dataset."""
79+
datahdr = attr.ib(default=None)
80+
"""A :obj:`~nibabel.spatialimages.SpatialHeader` header corresponding to the data."""
81+
82+
_filepath = attr.ib(
83+
factory=lambda: Path(mkdtemp()) / "hmxfms_cache.h5",
84+
repr=False,
85+
eq=False,
86+
)
87+
"""A path to an HDF5 file to store the whole dataset."""
88+
89+
def __len__(self) -> int:
90+
"""Obtain the number of volumes/frames in the dataset."""
91+
if self.dataobj is None:
92+
return 0
93+
94+
return self.dataobj.shape[-1]
95+
96+
def __getitem__(
97+
self, idx: int | slice | tuple | np.ndarray
98+
) -> tuple[np.ndarray, np.ndarray | None]:
99+
"""
100+
Returns volume(s) and corresponding affine(s) through fancy indexing.
101+
102+
Parameters
103+
----------
104+
idx : :obj:`int` or :obj:`slice` or :obj:`tuple` or :obj:`~numpy.ndarray`
105+
Indexer for the last dimension (or possibly other dimensions if extended).
106+
107+
Returns
108+
-------
109+
volumes : :obj:`~numpy.ndarray`
110+
The selected data subset.
111+
If ``idx`` is a single integer, this will have shape ``(X, Y, Z)``,
112+
otherwise it may have shape ``(X, Y, Z, k)``.
113+
motion_affine : :obj:`~numpy.ndarray` or ``None``
114+
The corresponding per-volume motion affine(s) or ``None`` if identity transform(s).
115+
116+
"""
117+
if self.dataobj is None:
118+
raise ValueError("No data available (dataobj is None).")
119+
120+
affine = self.motion_affines[idx] if self.motion_affines is not None else None
121+
return self.dataobj[..., idx], affine
122+
123+
@classmethod
124+
def from_filename(cls, filename: Path | str) -> BaseDataset:
125+
"""
126+
Read an HDF5 file from disk and create a BaseDataset.
127+
128+
Parameters
129+
----------
130+
filename : :obj:`os.pathlike`
131+
The HDF5 file path to read.
132+
133+
Returns
134+
-------
135+
:obj:`~nifreeze.data.base.BaseDataset`
136+
The constructed dataset with data loaded from the file.
137+
138+
"""
139+
with h5py.File(filename, "r") as in_file:
140+
root = in_file["/0"]
141+
data = {k: np.asanyarray(v) for k, v in root.items() if not k.startswith("_")}
142+
return cls(**data)
143+
144+
def get_filename(self) -> Path:
145+
"""Get the filepath of the HDF5 file."""
146+
return self._filepath
147+
148+
def set_transform(self, index: int, affine: np.ndarray, order: int = 3) -> None:
149+
"""
150+
Set an affine transform for a particular index and update the data object.
151+
152+
Parameters
153+
----------
154+
index : :obj:`int`
155+
The volume index to transform.
156+
affine : :obj:`numpy.ndarray`
157+
The 4x4 affine matrix to be applied.
158+
order : :obj:`int`, optional
159+
The order of the spline interpolation.
160+
161+
"""
162+
reference = namedtuple("ImageGrid", ("shape", "affine"))(
163+
shape=self.dataobj.shape[:3], affine=self.affine
164+
)
165+
166+
xform = Affine(matrix=affine, reference=reference)
167+
168+
if not Path(self._filepath).exists():
169+
self.to_filename(self._filepath)
170+
171+
# read original DWI data & b-vector
172+
with h5py.File(self._filepath, "r") as in_file:
173+
root = in_file["/0"]
174+
dataframe = np.asanyarray(root["dataobj"][..., index])
175+
176+
datamoving = nb.Nifti1Image(dataframe, self.affine, None)
177+
178+
# resample and update orientation at index
179+
self.dataobj[..., index] = np.asanyarray(
180+
xform.apply(datamoving, order=order).dataobj,
181+
dtype=self.dataobj.dtype,
182+
)
183+
184+
# if head motion affines are to be used, initialized to identities
185+
if self.motion_affines is None:
186+
self.motion_affines = np.repeat(np.zeros((4, 4))[None, ...], len(self), axis=0)
187+
188+
self.motion_affines[index] = xform.matrix
189+
190+
def to_filename(
191+
self, filename: Path | str, compression: str | None = None, compression_opts: Any = None
192+
) -> None:
193+
"""
194+
Write an HDF5 file to disk.
195+
196+
Parameters
197+
----------
198+
filename : :obj:`os.pathlike`
199+
The HDF5 file path to write to.
200+
compression : :obj:`str`, optional
201+
Compression strategy.
202+
See :obj:`~h5py.Group.create_dataset` documentation.
203+
compression_opts : :obj:`typing.Any`, optional
204+
Parameters for compression
205+
`filters <https://docs.h5py.org/en/stable/high/dataset.html#dataset-compression>`__.
206+
207+
"""
208+
filename = Path(filename)
209+
if not filename.name.endswith(NFDH5_EXT):
210+
filename = filename.parent / f"{filename.name}.h5"
211+
212+
with h5py.File(filename, "w") as out_file:
213+
out_file.attrs["Format"] = "NFDH5" # NiFreeze Data HDF5
214+
out_file.attrs["Version"] = np.uint16(1)
215+
root = out_file.create_group("/0")
216+
root.attrs["Type"] = "base dataset"
217+
for f in attr.fields(self.__class__):
218+
if f.name.startswith("_"):
219+
continue
220+
221+
value = getattr(self, f.name)
222+
if value is not None:
223+
root.create_dataset(
224+
f.name,
225+
data=value,
226+
compression=compression,
227+
compression_opts=compression_opts,
228+
)
229+
230+
def to_nifti(self, filename: Path) -> None:
231+
"""
232+
Write a NIfTI file to disk.
233+
234+
Parameters
235+
----------
236+
filename : :obj:`os.pathlike`
237+
The output NIfTI file path.
238+
239+
"""
240+
nii = nb.Nifti1Image(self.dataobj, self.affine, self.datahdr)
241+
if self.datahdr is None:
242+
nii.header.set_xyzt_units("mm")
243+
nii.to_filename(filename)
244+
245+
246+
def load(
247+
filename: Path | str,
248+
brainmask_file: Path | str | None = None,
249+
motion_file: Path | str | None = None,
250+
) -> BaseDataset:
251+
"""
252+
Load 4D data from a filename or an HDF5 file.
253+
254+
Parameters
255+
----------
256+
filename : :obj:`os.pathlike`
257+
The NIfTI or HDF5 file.
258+
brainmask_file : :obj:`os.pathlike`, optional
259+
A brainmask NIfTI file. If provided, will be loaded and
260+
stored in the returned dataset.
261+
motion_file : :obj:`os.pathlike`
262+
A file containing head-motion affine matrices (linear).
263+
264+
Returns
265+
-------
266+
:obj:`~nifreeze.data.base.BaseDataset`
267+
The loaded dataset.
268+
269+
Raises
270+
------
271+
ValueError
272+
If the file extension is not supported or the file cannot be loaded.
273+
274+
"""
275+
if motion_file:
276+
raise NotImplementedError
277+
278+
filename = Path(filename)
279+
if filename.name.endswith(NFDH5_EXT):
280+
return BaseDataset.from_filename(filename)
281+
282+
img = nb.load(filename)
283+
retval = BaseDataset(dataobj=img.dataobj, affine=img.affine)
284+
285+
if brainmask_file:
286+
mask = nb.load(brainmask_file)
287+
retval.brainmask = np.asanyarray(mask.dataobj)
288+
289+
return retval

0 commit comments

Comments
 (0)