Skip to content

Commit 3dd861c

Browse files
authored
Merge branch 'main' into tst/test-pet-init-load
2 parents 7f03b0a + e92e725 commit 3dd861c

34 files changed

+1597
-958
lines changed

.github/workflows/benchmark.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,6 @@ jobs:
5555
export OMP_NUM_THREADS=1
5656
- name: Run benchmarks
5757
run: |
58-
asv machine --yes --config benchmarks/asv.conf.json
59-
asv run --config benchmarks/asv.conf.json --show-stderr
58+
CONFIG_FILE="$(pwd)/benchmarks/asv.conf.json"
59+
asv machine --yes --config "$CONFIG_FILE"
60+
asv run --config "$CONFIG_FILE" --show-stderr

benchmarks/benchmarks/bench_model.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from skimage.morphology import ball
3535

3636
from nifreeze.model.gpr import DiffusionGPR, SphericalKriging
37+
from nifreeze.utils.ndimage import load_api
3738

3839

3940
class DiffusionGPRBenchmark(ABC):
@@ -67,7 +68,7 @@ def make_data(self):
6768
name = "sherbrooke_3shell"
6869

6970
dwi_fname, bval_fname, bvec_fname = dpd.get_fnames(name=name)
70-
dwi_data = nb.load(dwi_fname).get_fdata()
71+
dwi_data = load_api(dwi_fname, nb.Nifti1Image).get_fdata()
7172
bvals, bvecs = read_bvals_bvecs(bval_fname, bvec_fname)
7273

7374
_, brain_mask = median_otsu(dwi_data, vol_idx=[0])
@@ -96,7 +97,10 @@ def make_data(self):
9697
self._y_test = y[:, train_test_mask]
9798

9899
def time_fit(self, *args):
100+
assert self._estimator is not None
101+
assert self._y_train is not None
99102
self._estimator = self._estimator.fit(self._X_train, self._y_train.T)
100103

101104
def time_predict(self):
105+
assert self._estimator is not None
102106
self._estimator.predict(self._X_test)

docs/_static/nifreeze-flowchart.svg

Lines changed: 721 additions & 693 deletions
Loading

docs/notebooks/bold_realignment.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090
" if not dilmask_path.exists():\n",
9191
" niimsk = nb.load(bmask_path)\n",
9292
" niimsk.__class__(\n",
93-
" binary_dilation(niimsk.get_fdata() > 0.0, ball(4)).astype(\"uint8\"),\n",
93+
" binary_dilation(niimsk.get_fdata() > 0.0, ball(4)).astype(np.uint8),\n",
9494
" niimsk.affine,\n",
9595
" niimsk.header,\n",
9696
" ).to_filename(dilmask_path)\n",

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ version-file = "src/nifreeze/_version.py"
141141
# Developer tool configurations
142142
#
143143

144+
[tool.mypy]
145+
check_untyped_defs = true
146+
144147
[[tool.mypy.overrides]]
145148
module = [
146149
"nipype.*",
@@ -233,9 +236,7 @@ branch = true
233236
parallel = true
234237
concurrency = ["multiprocessing"]
235238
omit = [
236-
"*/tests/*",
237-
"*/testing/*",
238-
"*/viz/*",
239+
"^test/*",
239240
"*/__init__.py",
240241
"*/conftest.py",
241242
"src/nifreeze/_version.py"

src/nifreeze/cli/parser.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,12 @@ def _parse_yaml_config(file_path: str) -> dict:
3434
3535
Parameters
3636
----------
37-
file_path : str
37+
file_path : :obj:`str`
3838
Path to the YAML configuration file.
3939
4040
Returns
4141
-------
42-
dict
42+
:obj:`dict`
4343
A dictionary containing the parsed YAML configuration.
4444
"""
4545
with open(file_path, "r") as file:
@@ -201,20 +201,20 @@ def _normalize_model_name(model_name: str) -> str:
201201
return model_name.lower().replace("single", "")
202202

203203

204-
def parse_args(argv: list) -> tuple[Namespace, dict, dict, dict]:
204+
def parse_args(argv: list[str] | None = None) -> tuple[Namespace, dict, dict, dict]:
205205
"""Parse the command line arguments and return a curated arguments.
206206
207207
Performs further checks to ensure that necessary data is provided for the
208208
estimation process.
209209
210210
Parameters
211211
----------
212-
argv : list
212+
argv : :obj:`list`, optional
213213
Arguments.
214214
215215
Returns
216216
-------
217-
args : :obj:`Namespace`
217+
args : :obj:`~argparse.Namespace`
218218
Populated namespace.
219219
extra_kwargs : :obj:`dict`
220220
Extra keyword arguments passed to the dataset.

src/nifreeze/cli/run.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,15 @@
2929
from nifreeze.estimator import Estimator
3030

3131

32-
def main(argv=None) -> None:
32+
def main(argv: list[str] | None = None) -> None:
3333
"""
3434
Entry point.
3535
36+
Parameters
37+
----------
38+
argv : obj:`list`, optional
39+
Arguments.
40+
3641
Returns
3742
-------
3843
None

src/nifreeze/data/base.py

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
import h5py
3535
import nibabel as nb
3636
import numpy as np
37-
from nibabel.spatialimages import SpatialHeader
3837
from nitransforms.linear import LinearTransformsMapping
3938
from typing_extensions import Self, TypeVarTuple, Unpack
4039

@@ -79,12 +78,14 @@ class BaseDataset(Generic[Unpack[Ts]]):
7978
"""A :obj:`~numpy.ndarray` object for the data array."""
8079
affine: np.ndarray = attrs.field(default=None, repr=_data_repr, eq=attrs.cmp_using(eq=_cmp))
8180
"""Best affine for RAS-to-voxel conversion of coordinates (NIfTI header)."""
82-
brainmask: np.ndarray = attrs.field(default=None, repr=_data_repr, eq=attrs.cmp_using(eq=_cmp))
81+
brainmask: np.ndarray | None = attrs.field(
82+
default=None, repr=_data_repr, eq=attrs.cmp_using(eq=_cmp)
83+
)
8384
"""A boolean ndarray object containing a corresponding brainmask."""
84-
motion_affines: np.ndarray = attrs.field(default=None, eq=attrs.cmp_using(eq=_cmp))
85-
"""List of :obj:`~nitransforms.linear.Affine` realigning the dataset."""
86-
datahdr: SpatialHeader = attrs.field(default=None)
87-
"""A :obj:`~nibabel.spatialimages.SpatialHeader` header corresponding to the data."""
85+
motion_affines: np.ndarray | None = attrs.field(default=None, eq=attrs.cmp_using(eq=_cmp))
86+
"""Array of :obj:`~nitransforms.linear.Affine` realigning the dataset."""
87+
datahdr: nb.Nifti1Header | None = attrs.field(default=None)
88+
"""A :obj:`~nibabel.Nifti1Header` header corresponding to the data."""
8889

8990
_filepath: Path = attrs.field(
9091
factory=lambda: Path(mkdtemp()) / "hmxfms_cache.h5",
@@ -95,12 +96,24 @@ class BaseDataset(Generic[Unpack[Ts]]):
9596

9697
def __len__(self) -> int:
9798
"""Obtain the number of volumes/frames in the dataset."""
98-
if self.dataobj is None:
99-
return 0
100-
10199
return self.dataobj.shape[-1]
102100

103101
def _getextra(self, idx: int | slice | tuple | np.ndarray) -> tuple[Unpack[Ts]]:
102+
"""
103+
Extracts extra fields synchronized with the indexed access of the corresponding data object.
104+
105+
Parameters
106+
----------
107+
idx : :obj:`int` or :obj:`slice` or :obj:`tuple` or :obj:`~numpy.ndarray`
108+
Index (or indexing type/object) for which extra information will be extracted.
109+
110+
Returns
111+
-------
112+
:obj:`tuple`
113+
A tuple with the extra fields (may be an empty tuple if no extra fields are defined).
114+
115+
"""
116+
_ = idx # Avoid unused parameter warning
104117
return () # type: ignore[return-value]
105118

106119
def __getitem__(
@@ -116,16 +129,24 @@ def __getitem__(
116129
117130
Returns
118131
-------
119-
volumes : :obj:`~numpy.ndarray`
132+
:obj:`~numpy.ndarray`
120133
The selected data subset.
121134
If ``idx`` is a single integer, this will have shape ``(X, Y, Z)``,
122135
otherwise it may have shape ``(X, Y, Z, k)``.
123-
motion_affine : :obj:`~numpy.ndarray` or ``None``
136+
affine : :obj:`~numpy.ndarray` or ``None``
124137
The corresponding per-volume motion affine(s) or ``None`` if identity transform(s).
138+
Unpack[:obj:`~nifreeze.data.base.Ts`]
139+
Zero or more additional per-volume fields returned as unpacked
140+
trailing elements. The exact number, order, and types of elements
141+
are determined by the type variables :obj:`~nifreeze.data.base.Ts`
142+
and by the values returned from :meth:`_getextra`. Subclasses
143+
provide these values by implementing :meth:`_getextra`. If no extra
144+
fields are defined, no element is returned.
145+
Example usages:
146+
- vols, aff, *extras = dataset[0:10]
147+
- vol, aff, bvecs, bvals = dataset[0] # when two extras are present
125148
126149
"""
127-
if self.dataobj is None:
128-
raise ValueError("No data available (dataobj is None).")
129150

130151
affine = self.motion_affines[idx] if self.motion_affines is not None else None
131152
return self.dataobj[..., idx], affine, *self._getextra(idx)

src/nifreeze/data/dmri.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565

6666

6767
@attrs.define(slots=True)
68-
class DWI(BaseDataset[np.ndarray | None]):
68+
class DWI(BaseDataset[np.ndarray]):
6969
"""Data representation structure for dMRI data."""
7070

7171
bzero: np.ndarray = attrs.field(default=None, repr=_data_repr, eq=attrs.cmp_using(eq=_cmp))
@@ -75,13 +75,13 @@ class DWI(BaseDataset[np.ndarray | None]):
7575
eddy_xfms: list = attrs.field(default=None)
7676
"""List of transforms to correct for estimated eddy current distortions."""
7777

78-
def _getextra(self, idx: int | slice | tuple | np.ndarray) -> tuple[np.ndarray | None]:
79-
return (self.gradients[..., idx] if self.gradients is not None else None,)
78+
def _getextra(self, idx: int | slice | tuple | np.ndarray) -> tuple[np.ndarray]:
79+
return (self.gradients[..., idx],)
8080

8181
# For the sake of the docstring
8282
def __getitem__(
8383
self, idx: int | slice | tuple | np.ndarray
84-
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray | None]:
84+
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray]:
8585
"""
8686
Returns volume(s) and corresponding affine(s) and gradient(s) through fancy indexing.
8787
@@ -210,7 +210,7 @@ def to_nifti(
210210
211211
Parameters
212212
----------
213-
filename : :obj:`os.pathlike`
213+
filename : :obj:`os.pathlike`, optional
214214
The output NIfTI file path.
215215
write_hmxfms : :obj:`bool`, optional
216216
If ``True``, the head motion affines will be written out to filesystem
@@ -310,7 +310,7 @@ def from_nii(
310310
brainmask_file : :obj:`os.pathlike`, optional
311311
A brainmask NIfTI file. If provided, will be loaded and
312312
stored in the returned dataset.
313-
motion_file : :obj:`os.pathlike`
313+
motion_file : :obj:`os.pathlike`, optional
314314
A file containing head motion affine matrices (linear)
315315
gradients_file : :obj:`os.pathlike`, optional
316316
A text file containing the gradients table, shape (4, N) or (N, 4).

src/nifreeze/data/pet.py

Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242

4343

4444
@attrs.define(slots=True)
45-
class PET(BaseDataset[np.ndarray | None]):
45+
class PET(BaseDataset[np.ndarray]):
4646
"""Data representation structure for PET data."""
4747

4848
midframe: np.ndarray = attrs.field(default=None, repr=_data_repr, eq=attrs.cmp_using(eq=_cmp))
@@ -52,13 +52,13 @@ class PET(BaseDataset[np.ndarray | None]):
5252
uptake: np.ndarray = attrs.field(default=None, repr=_data_repr, eq=attrs.cmp_using(eq=_cmp))
5353
"""A (N,) numpy array specifying the uptake value of each sample or frame."""
5454

55-
def _getextra(self, idx: int | slice | tuple | np.ndarray) -> tuple[np.ndarray | None]:
56-
return (self.midframe[idx] if self.midframe is not None else None,)
55+
def _getextra(self, idx: int | slice | tuple | np.ndarray) -> tuple[np.ndarray]:
56+
return (self.midframe[idx],)
5757

5858
# For the sake of the docstring
5959
def __getitem__(
6060
self, idx: int | slice | tuple | np.ndarray
61-
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray | None]:
61+
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray]:
6262
"""
6363
Returns volume(s) and corresponding affine(s) and timing(s) through fancy indexing.
6464
@@ -75,7 +75,7 @@ def __getitem__(
7575
otherwise it may have shape ``(X, Y, Z, k)``.
7676
motion_affine : :obj:`~numpy.ndarray` or ``None``
7777
The corresponding per-volume motion affine(s) or ``None`` if identity transform(s).
78-
time : :obj:`float` or ``None``
78+
time : :obj:`~numpy.ndarray`
7979
The frame time corresponding to the index(es).
8080
8181
"""
@@ -105,18 +105,17 @@ def lofo_split(self, index):
105105
with h5py.File(self._filepath, "r") as in_file:
106106
root = in_file["/0"]
107107
pet_frame = np.asanyarray(root["dataobj"][..., index])
108-
if self.midframe is not None:
109-
timing_frame = np.asanyarray(root["midframe"][..., index])
108+
timing_frame = np.asanyarray(root["midframe"][..., index])
110109

111110
# Mask to exclude the selected frame
112111
mask = np.ones(self.dataobj.shape[-1], dtype=bool)
113112
mask[index] = False
114113

115114
train_data = self.dataobj[..., mask]
116-
train_timings = self.midframe[mask] if self.midframe is not None else None
115+
train_timings = self.midframe[mask]
117116

118117
test_data = pet_frame
119-
test_timing = timing_frame if self.midframe is not None else None
118+
test_timing = timing_frame
120119

121120
return (train_data, train_timings), (test_data, test_timing)
122121

@@ -144,7 +143,7 @@ def set_transform(self, index: int, affine: np.ndarray, order: int = 3) -> None:
144143

145144
# update transform
146145
if self.motion_affines is None:
147-
self.motion_affines = [None] * len(self)
146+
self.motion_affines = np.asarray([None] * len(self))
148147

149148
self.motion_affines[index] = xform
150149

@@ -174,12 +173,6 @@ def to_filename(
174173
compression_opts=compression_opts,
175174
)
176175

177-
def to_nifti(self, filename, *_):
178-
"""Write a NIfTI 1.0 file to disk."""
179-
nii = nb.Nifti1Image(self.dataobj, self.affine, None)
180-
nii.header.set_xyzt_units("mm")
181-
nii.to_filename(filename)
182-
183176
@classmethod
184177
def from_filename(cls, filename: Path | str) -> Self:
185178
"""Read an HDF5 file from disk."""
@@ -225,9 +218,9 @@ def load(
225218

226219
def from_nii(
227220
filename: Path | str,
221+
frame_time: np.ndarray | list[float],
228222
brainmask_file: Path | str | None = None,
229223
motion_file: Path | str | None = None,
230-
frame_time: np.ndarray | list[float] | None = None,
231224
frame_duration: np.ndarray | list[float] | None = None,
232225
) -> PET:
233226
"""
@@ -237,14 +230,13 @@ def from_nii(
237230
----------
238231
filename : :obj:`os.pathlike`
239232
The NIfTI file.
233+
frame_time : :obj:`numpy.ndarray` or :obj:`list` of :obj:`float`
234+
The start times of each frame relative to the beginning of the acquisition.
240235
brainmask_file : :obj:`os.pathlike`, optional
241236
A brainmask NIfTI file. If provided, will be loaded and
242237
stored in the returned dataset.
243238
motion_file : :obj:`os.pathlike`, optional
244239
A file containing head motion affine matrices (linear).
245-
frame_time : :obj:`numpy.ndarray` or :obj:`list` of :obj:`float`, optional
246-
The start times of each frame relative to the beginning of the acquisition.
247-
If ``None``, an error is raised (since BIDS requires ``FrameTimesStart``).
248240
frame_duration : :obj:`numpy.ndarray` or :obj:`list` of :obj:`float`, optional
249241
The duration of each frame.
250242
If ``None``, it is derived by the difference of consecutive frame times,
@@ -261,8 +253,6 @@ def from_nii(
261253
If ``frame_time`` is not provided (BIDS requires it).
262254
263255
"""
264-
if frame_time is None:
265-
raise RuntimeError("frame_time must be provided")
266256
if motion_file:
267257
raise NotImplementedError
268258

@@ -277,17 +267,14 @@ def from_nii(
277267

278268
pet_obj.uptake = _compute_uptake_statistic(data, stat_func=np.sum)
279269

280-
# If the user supplied new values, set them
281-
if frame_time is not None:
282-
# Convert to a float32 numpy array and zero out the earliest time
283-
frame_time_arr = np.array(frame_time, dtype=np.float32)
284-
frame_time_arr -= frame_time_arr[0]
285-
pet_obj.midframe = frame_time_arr
270+
# Convert to a float32 numpy array and zero out the earliest time
271+
frame_time_arr = np.array(frame_time, dtype=np.float32)
272+
frame_time_arr -= frame_time_arr[0]
273+
pet_obj.midframe = frame_time_arr
286274

287275
# If the user doesn't provide frame_duration, we derive it:
288276
if frame_duration is None:
289-
if pet_obj.midframe is not None:
290-
durations = _compute_frame_duration(pet_obj.midframe)
277+
durations = _compute_frame_duration(pet_obj.midframe)
291278
else:
292279
durations = np.array(frame_duration, dtype=np.float32)
293280

0 commit comments

Comments
 (0)