nifti2bids 0.1.1__py3-none-any.whl
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.
- nifti2bids/__init__.py +20 -0
- nifti2bids/_decorators.py +53 -0
- nifti2bids/_exceptions.py +51 -0
- nifti2bids/_helpers.py +6 -0
- nifti2bids/bids.py +192 -0
- nifti2bids/io.py +135 -0
- nifti2bids/logging.py +86 -0
- nifti2bids/metadata.py +766 -0
- nifti2bids/simulate.py +59 -0
- nifti2bids-0.1.1.dist-info/METADATA +67 -0
- nifti2bids-0.1.1.dist-info/RECORD +14 -0
- nifti2bids-0.1.1.dist-info/WHEEL +5 -0
- nifti2bids-0.1.1.dist-info/licenses/LICENSE +21 -0
- nifti2bids-0.1.1.dist-info/top_level.txt +1 -0
nifti2bids/metadata.py
ADDED
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
"""Utility functions to extract or create metadata."""
|
|
2
|
+
|
|
3
|
+
import datetime, os, re
|
|
4
|
+
from typing import Any, Literal, Optional
|
|
5
|
+
|
|
6
|
+
import nibabel as nib, numpy as np
|
|
7
|
+
|
|
8
|
+
from ._exceptions import SliceAxisError, DataDimensionError
|
|
9
|
+
from ._decorators import check_all_none
|
|
10
|
+
from .io import load_nifti, get_nifti_header
|
|
11
|
+
from .logging import setup_logger
|
|
12
|
+
|
|
13
|
+
LGR = setup_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@check_all_none(parameter_names=["nifti_file_or_img", "nifti_header"])
|
|
17
|
+
def determine_slice_axis(
|
|
18
|
+
nifti_file_or_img: Optional[str | nib.nifti1.Nifti1Image] = None,
|
|
19
|
+
nifti_header: Optional[nib.nifti1.Nifti1Header] = None,
|
|
20
|
+
) -> int:
|
|
21
|
+
"""
|
|
22
|
+
Determine the slice axis.
|
|
23
|
+
|
|
24
|
+
Uses "slice_end" plus one to determine the likely slice axis.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
nifti_file_or_img: :obj:`str` or :obj:`Nifti1Image`, default=None
|
|
29
|
+
Path to the NIfTI file or a NIfTI image. Must be specified
|
|
30
|
+
if ``nifti_header`` is None.
|
|
31
|
+
|
|
32
|
+
nifti_header: :obj:`Nifti1Header`, default=None
|
|
33
|
+
Path to the NIfTI file or a NIfTI image. Must be specified
|
|
34
|
+
if ``nifti_file_or_img`` is None.
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
int
|
|
39
|
+
A number representing the slice axis.
|
|
40
|
+
"""
|
|
41
|
+
kwargs = {"nifti_file_or_img": nifti_file_or_img, "nifti_header": nifti_header}
|
|
42
|
+
slice_end, hdr = get_hdr_metadata(
|
|
43
|
+
**kwargs, metadata_name="slice_end", return_header=True
|
|
44
|
+
)
|
|
45
|
+
if not slice_end or np.isnan(slice_end):
|
|
46
|
+
raise ValueError("'slice_end' metadata field not set.")
|
|
47
|
+
|
|
48
|
+
n_slices = int(slice_end) + 1
|
|
49
|
+
dims = np.array(hdr.get_data_shape()[:3])
|
|
50
|
+
|
|
51
|
+
return np.where(dims == n_slices)[0][0]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_numeric(value: Any) -> bool:
|
|
55
|
+
"""
|
|
56
|
+
Check if value is a number.
|
|
57
|
+
"""
|
|
58
|
+
return isinstance(value, (float, int))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _to_native_numeric(value):
|
|
62
|
+
"""
|
|
63
|
+
Ensures numpy floats and integers are converted
|
|
64
|
+
to regular Python floats and integers.
|
|
65
|
+
"""
|
|
66
|
+
return float(value) if isinstance(value, np.floating) else int(value)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@check_all_none(parameter_names=["nifti_file_or_img", "nifti_header"])
|
|
70
|
+
def get_hdr_metadata(
|
|
71
|
+
metadata_name: str,
|
|
72
|
+
nifti_file_or_img: Optional[str | nib.nifti1.Nifti1Image] = None,
|
|
73
|
+
nifti_header: Optional[nib.nifti1.Nifti1Header] = None,
|
|
74
|
+
return_header: bool = False,
|
|
75
|
+
) -> Any | tuple[Any, nib.nifti1.Nifti1Header]:
|
|
76
|
+
"""
|
|
77
|
+
Get metadata from a NIfTI header.
|
|
78
|
+
|
|
79
|
+
Parameters
|
|
80
|
+
----------
|
|
81
|
+
metadata_name: :obj:`str`
|
|
82
|
+
Name of the metadata field to return.
|
|
83
|
+
|
|
84
|
+
nifti_file_or_img: :obj:`str` or :obj:`Nifti1Image`, default=None
|
|
85
|
+
Path to the NIfTI file or a NIfTI image. Must be specified
|
|
86
|
+
if ``nifti_header`` is None.
|
|
87
|
+
|
|
88
|
+
nifti_header: :obj:`Nifti1Header`, default=None
|
|
89
|
+
Path to the NIfTI file or a NIfTI image. Must be specified
|
|
90
|
+
if ``nifti_file_or_img`` is None.
|
|
91
|
+
|
|
92
|
+
return_header: :obj:`bool`
|
|
93
|
+
Returns the NIfTI header
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
Any or tuple[Any, nibabel.nifti1.Nifti1Header]
|
|
98
|
+
If ``return_header`` is False, only returns the associated
|
|
99
|
+
value of the metadata. If ``return_header`` is True returns
|
|
100
|
+
a tuple containing the assoicated value of the metadata
|
|
101
|
+
and the NIfTI header.
|
|
102
|
+
"""
|
|
103
|
+
hdr = nifti_header if nifti_header else get_nifti_header(nifti_file_or_img)
|
|
104
|
+
metadata_value = hdr.get(metadata_name)
|
|
105
|
+
metadata_value = (
|
|
106
|
+
_to_native_numeric(metadata_value)
|
|
107
|
+
if _is_numeric(metadata_value)
|
|
108
|
+
else metadata_value
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return metadata_value if not return_header else (metadata_value, hdr)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_n_volumes(nifti_file_or_img: str | nib.nifti1.Nifti1Image) -> int:
|
|
115
|
+
"""
|
|
116
|
+
Get the number of volumes from a 4D NIftI image.
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
nifti_file_or_img: :obj:`str` or :obj:`Nifti1Image`
|
|
121
|
+
Path to the NIfTI file or a NIfTI image.
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
int
|
|
126
|
+
The number of volumes in img.
|
|
127
|
+
"""
|
|
128
|
+
img = load_nifti(nifti_file_or_img)
|
|
129
|
+
|
|
130
|
+
if is_3d_img(img):
|
|
131
|
+
raise DataDimensionError("Image is 3D not 4D.")
|
|
132
|
+
|
|
133
|
+
return img.get_fdata().shape[-1]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_n_slices(
|
|
137
|
+
nifti_file_or_img: str | nib.nifti1.Nifti1Image,
|
|
138
|
+
slice_axis: Optional[Literal["x", "y", "z"]] = None,
|
|
139
|
+
) -> int:
|
|
140
|
+
"""
|
|
141
|
+
Gets the number of slices from the header of a NIfTI image.
|
|
142
|
+
|
|
143
|
+
Parameters
|
|
144
|
+
----------
|
|
145
|
+
nifti_file_or_img: :obj:`str` or :obj:`Nifti1Image`
|
|
146
|
+
Path to the NIfTI file or a NIfTI image.
|
|
147
|
+
|
|
148
|
+
slice_axis: :obj:`Literal["x", "y", "z"]` or :obj:`None`, default=None
|
|
149
|
+
Axis the image slices were collected in. If None,
|
|
150
|
+
determines the slice axis using metadata ("slice_end")
|
|
151
|
+
from the NIfTI header.
|
|
152
|
+
|
|
153
|
+
Returns
|
|
154
|
+
-------
|
|
155
|
+
int
|
|
156
|
+
The number of slices.
|
|
157
|
+
"""
|
|
158
|
+
slice_dim_map = {"x": 0, "y": 1, "z": 2}
|
|
159
|
+
|
|
160
|
+
hdr = get_nifti_header(nifti_file_or_img)
|
|
161
|
+
if slice_axis:
|
|
162
|
+
n_slices = hdr.get_data_shape()[slice_dim_map[slice_axis]]
|
|
163
|
+
if slice_end := get_hdr_metadata(nifti_header=hdr, metadata_name="slice_end"):
|
|
164
|
+
if not np.isnan(slice_end) and n_slices != slice_end + 1:
|
|
165
|
+
raise SliceAxisError(slice_axis, n_slices, slice_end)
|
|
166
|
+
|
|
167
|
+
slice_dim_indx = slice_dim_map[slice_axis]
|
|
168
|
+
else:
|
|
169
|
+
slice_dim_indx = determine_slice_axis(nifti_header=hdr)
|
|
170
|
+
|
|
171
|
+
reversed_slice_dim_map = {v: k for v, k in slice_dim_map.items()}
|
|
172
|
+
|
|
173
|
+
n_slices = hdr.get_data_shape()[slice_dim_indx]
|
|
174
|
+
LGR.info(
|
|
175
|
+
f"Number of slices based on "
|
|
176
|
+
f"{reversed_slice_dim_map.get(slice_dim_indx)}: {n_slices}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return _to_native_numeric(n_slices)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_tr(nifti_file_or_img: str | nib.nifti1.Nifti1Image) -> float:
|
|
183
|
+
"""
|
|
184
|
+
Get the repetition time from the header of a NIfTI image.
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
nifti_file_or_img: :obj:`str` or :obj:`Nifti1Image`
|
|
189
|
+
Path to the NIfTI file or a NIfTI image.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
float
|
|
194
|
+
The repetition time.
|
|
195
|
+
"""
|
|
196
|
+
hdr = get_nifti_header(nifti_file_or_img)
|
|
197
|
+
|
|
198
|
+
if not (tr := hdr.get_zooms()[3]):
|
|
199
|
+
raise ValueError(f"Suspicious repetition time: {tr}.")
|
|
200
|
+
|
|
201
|
+
LGR.info(f"Repetition Time: {tr}.")
|
|
202
|
+
|
|
203
|
+
return round(_to_native_numeric(tr), 2)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _flip_slice_order(slice_order, ascending: bool) -> list[int]:
|
|
207
|
+
"""
|
|
208
|
+
Flip slice index order.
|
|
209
|
+
|
|
210
|
+
Parameters
|
|
211
|
+
----------
|
|
212
|
+
slice_order: :obj:`list[int]`
|
|
213
|
+
List containing integer values representing the slices.
|
|
214
|
+
|
|
215
|
+
ascending: :obj:`bool`, default=True
|
|
216
|
+
If slices were collected in ascending order (True) or descending
|
|
217
|
+
order (False).
|
|
218
|
+
|
|
219
|
+
Returns
|
|
220
|
+
-------
|
|
221
|
+
list[int]
|
|
222
|
+
The order of the slices.
|
|
223
|
+
"""
|
|
224
|
+
return np.flip(slice_order) if not ascending else slice_order
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _create_sequential_order(n_slices: int, ascending: bool = True) -> list[int]:
|
|
228
|
+
"""
|
|
229
|
+
Create index ordering for sequential acquisition method.
|
|
230
|
+
|
|
231
|
+
Parameters
|
|
232
|
+
----------
|
|
233
|
+
n_slices: :obj:`int`
|
|
234
|
+
The number of slices.
|
|
235
|
+
|
|
236
|
+
ascending: :obj:`bool`, default=True
|
|
237
|
+
If slices were collected in ascending order (True) or descending
|
|
238
|
+
order (False).
|
|
239
|
+
|
|
240
|
+
Returns
|
|
241
|
+
-------
|
|
242
|
+
list[int]
|
|
243
|
+
The order of the slices.
|
|
244
|
+
"""
|
|
245
|
+
slice_order = list(range(0, n_slices))
|
|
246
|
+
|
|
247
|
+
return _flip_slice_order(slice_order, ascending)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _create_interleaved_order(
|
|
251
|
+
n_slices: int,
|
|
252
|
+
ascending: bool = True,
|
|
253
|
+
interleaved_start: Literal["even", "odd"] = "odd",
|
|
254
|
+
) -> list[int]:
|
|
255
|
+
"""
|
|
256
|
+
Create index ordering for interleaved acquisition method.
|
|
257
|
+
|
|
258
|
+
.. note:: Equivalent to Philips default order.
|
|
259
|
+
|
|
260
|
+
Parameters
|
|
261
|
+
----------
|
|
262
|
+
n_slices: :obj:`int`
|
|
263
|
+
The number of slices.
|
|
264
|
+
|
|
265
|
+
ascending: :obj:`bool`, default=True
|
|
266
|
+
If slices were collected in ascending order (True) or descending
|
|
267
|
+
order (False).
|
|
268
|
+
|
|
269
|
+
interleaved_start: :obj:`Literal["even", "odd"]`, default="odd"
|
|
270
|
+
If slices for interleaved acquisition were collected
|
|
271
|
+
by acquiring the "even" or "odd" slices first.
|
|
272
|
+
|
|
273
|
+
Returns
|
|
274
|
+
-------
|
|
275
|
+
list[int]
|
|
276
|
+
The order of the slices.
|
|
277
|
+
"""
|
|
278
|
+
if interleaved_start not in ["even", "odd"]:
|
|
279
|
+
raise ValueError("``interleaved_start`` must be either 'even' or 'odd'.")
|
|
280
|
+
|
|
281
|
+
if interleaved_start == "odd":
|
|
282
|
+
slice_order = list(range(0, n_slices, 2)) + list(range(1, n_slices, 2))
|
|
283
|
+
else:
|
|
284
|
+
slice_order = list(range(1, n_slices, 2)) + list(range(0, n_slices, 2))
|
|
285
|
+
|
|
286
|
+
return _flip_slice_order(slice_order, ascending)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _create_interleaved_sqrt_step_order(n_slices: int, ascending: bool = True):
|
|
290
|
+
"""
|
|
291
|
+
Create index ordering for interleaved square root acquisition method.
|
|
292
|
+
In this method, slices are acquired by a step factor equivalent to
|
|
293
|
+
the rounded square root of the total slices.
|
|
294
|
+
|
|
295
|
+
.. note:: This acquisition method is Philip's interleaved order.
|
|
296
|
+
|
|
297
|
+
Parameters
|
|
298
|
+
----------
|
|
299
|
+
n_slices: :obj:`int`
|
|
300
|
+
The number of slices.
|
|
301
|
+
|
|
302
|
+
ascending: :obj:`bool`, default=True
|
|
303
|
+
If slices were collected in ascending order (True) or descending
|
|
304
|
+
order (False).
|
|
305
|
+
|
|
306
|
+
Returns
|
|
307
|
+
-------
|
|
308
|
+
list[int]
|
|
309
|
+
The order of the slices.
|
|
310
|
+
"""
|
|
311
|
+
slice_order = []
|
|
312
|
+
step = round(np.sqrt(n_slices))
|
|
313
|
+
for slice_indx in range(step):
|
|
314
|
+
slice_order.extend(list(range(slice_indx, n_slices, step)))
|
|
315
|
+
|
|
316
|
+
return _flip_slice_order(slice_order, ascending)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _create_singleband_timing(tr: float | int, slice_order: list[int]) -> list[float]:
|
|
320
|
+
"""
|
|
321
|
+
Create singleband timing based on slice order.
|
|
322
|
+
|
|
323
|
+
Parameters
|
|
324
|
+
----------
|
|
325
|
+
tr: :obj:`float` or :obj:`int`
|
|
326
|
+
Repetition time in seconds.
|
|
327
|
+
|
|
328
|
+
slice_order: :obj:`list[int]`
|
|
329
|
+
Order of the slices.
|
|
330
|
+
|
|
331
|
+
Returns
|
|
332
|
+
-------
|
|
333
|
+
list[float]
|
|
334
|
+
Ordered slice timing information.
|
|
335
|
+
"""
|
|
336
|
+
n_slices = len(slice_order)
|
|
337
|
+
slice_duration = tr / n_slices
|
|
338
|
+
slice_timing = np.linspace(0, tr - slice_duration, n_slices)
|
|
339
|
+
# Pair slice with timing then sort dict
|
|
340
|
+
sorted_slice_timing = dict(
|
|
341
|
+
sorted({k: v for k, v in zip(slice_order, slice_timing.tolist())}.items())
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
return list(sorted_slice_timing.values())
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _generate_sequence(
|
|
348
|
+
start: int, n_count: int, step: int, ascending: bool
|
|
349
|
+
) -> list[int]:
|
|
350
|
+
"""
|
|
351
|
+
Generate a sequence of numbers.
|
|
352
|
+
|
|
353
|
+
Parameters
|
|
354
|
+
----------
|
|
355
|
+
start: :obj:`int`
|
|
356
|
+
Starting number.
|
|
357
|
+
|
|
358
|
+
n_count: :obj:`int`
|
|
359
|
+
The amount of numbers to generate.
|
|
360
|
+
|
|
361
|
+
step: :obj:`int`
|
|
362
|
+
Step size between numbers.
|
|
363
|
+
|
|
364
|
+
ascending: :obj:`int`
|
|
365
|
+
If numbers are ascending or descending relative to ``start``.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
list[int]
|
|
369
|
+
The sequence list.
|
|
370
|
+
"""
|
|
371
|
+
if ascending:
|
|
372
|
+
stop = start + n_count * step
|
|
373
|
+
return np.arange(start, stop, step).tolist()
|
|
374
|
+
else:
|
|
375
|
+
stop = start - n_count * step
|
|
376
|
+
return np.arange(start, stop, -step).tolist()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _create_multiband_slice_groupings(
|
|
380
|
+
slice_order: list[int], multiband_factor: int, n_time_steps: int, ascending: bool
|
|
381
|
+
) -> list[tuple[int, int]]:
|
|
382
|
+
"""
|
|
383
|
+
Create slice groupings for multiband based on ``multiband_factor``.
|
|
384
|
+
|
|
385
|
+
Parameters
|
|
386
|
+
----------
|
|
387
|
+
slice_order: :obj:`list[int]`
|
|
388
|
+
Order of the slices from single slice acquisition.
|
|
389
|
+
|
|
390
|
+
multiband_factor: :obj:`int`
|
|
391
|
+
The multiband acceleration factor, which is the number of slices
|
|
392
|
+
acquired simultaneously during multislice acquistion.
|
|
393
|
+
|
|
394
|
+
n_time_steps: :obj:`int`
|
|
395
|
+
The number of time steps computed by dividing the number of slices
|
|
396
|
+
by the multiband factor.
|
|
397
|
+
|
|
398
|
+
Returns
|
|
399
|
+
-------
|
|
400
|
+
list[tuple[int, int]]
|
|
401
|
+
A list of tuples containing the binned slice indices
|
|
402
|
+
|
|
403
|
+
Example
|
|
404
|
+
-------
|
|
405
|
+
>>> from nifti2bids.metadata import _create_mutiband_timing
|
|
406
|
+
>>> slice_order = [0, 2, 4, 6, 8, 1, 3, 5, 7, 9] # interleaved order
|
|
407
|
+
>>> _create_mutiband_timing(slice_order, multiband_factor=2, n_time_steps=5, ascending=True)
|
|
408
|
+
>>> [(0, 5), (2, 7), (4, 9), (1, 6), (3, 8)]
|
|
409
|
+
"""
|
|
410
|
+
slice_groupings = []
|
|
411
|
+
for slice_indx in slice_order:
|
|
412
|
+
if not any(
|
|
413
|
+
slice_indx in multiband_group for multiband_group in slice_groupings
|
|
414
|
+
):
|
|
415
|
+
# Prevents invalid slice groupings
|
|
416
|
+
# which produce values outside of possible range
|
|
417
|
+
sequence = _generate_sequence(
|
|
418
|
+
slice_indx, multiband_factor, n_time_steps, ascending
|
|
419
|
+
)
|
|
420
|
+
if max(sequence) >= len(slice_order) or min(sequence) < 0:
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
slice_groupings.append(tuple(sequence))
|
|
424
|
+
|
|
425
|
+
return slice_groupings
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _create_multiband_timing(
|
|
429
|
+
tr: float | int, slice_order: list[int], multiband_factor: int, ascending: bool
|
|
430
|
+
) -> list[float]:
|
|
431
|
+
"""
|
|
432
|
+
Create multiband timing based on slice order.
|
|
433
|
+
|
|
434
|
+
Parameters
|
|
435
|
+
----------
|
|
436
|
+
tr: :obj:`float` or :obj:`int`
|
|
437
|
+
Repetition time in seconds.
|
|
438
|
+
|
|
439
|
+
slice_order: :obj:`list[int]`
|
|
440
|
+
Order of the slices from single slice acquisition.
|
|
441
|
+
|
|
442
|
+
multiband_factor: :obj:`int`
|
|
443
|
+
The multiband acceleration factor, which is the number of slices
|
|
444
|
+
acquired simultaneously during multislice acquisition.
|
|
445
|
+
|
|
446
|
+
ascending: :obj:`bool`, default=True
|
|
447
|
+
If slices were collected in ascending order (True) or descending
|
|
448
|
+
order (False).
|
|
449
|
+
|
|
450
|
+
Returns
|
|
451
|
+
-------
|
|
452
|
+
list[float]
|
|
453
|
+
Ordered slice timing information for multiband acquisition.
|
|
454
|
+
|
|
455
|
+
Example
|
|
456
|
+
-------
|
|
457
|
+
>>> from nifti2bids.metadata import _create_mutiband_timing
|
|
458
|
+
>>> slice_order = [0, 2, 4, 6, 8, 1, 3, 5, 7, 9] # interleaved order
|
|
459
|
+
>>> _create_mutiband_timing(0.8, slice_order, multiband_factor=2, ascending=True)
|
|
460
|
+
>>> [0.0, 0.48, 0.16, 0.64, 0.32, 0.0, 0.48, 0.16, 0.64, 0.32]
|
|
461
|
+
>>> # slices grouping: [[0, 5], [2, 7], [4, 9], [1, 6], [3, 8]]
|
|
462
|
+
"""
|
|
463
|
+
n_slices = len(slice_order)
|
|
464
|
+
if n_slices % multiband_factor != 0:
|
|
465
|
+
raise ValueError(
|
|
466
|
+
f"Number of slices ({n_slices}) must be evenly divisible by "
|
|
467
|
+
f"multiband factor ({multiband_factor})."
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# Step corresponds to number of unique slice timings and the index step size
|
|
471
|
+
n_time_steps = n_slices // multiband_factor
|
|
472
|
+
slice_duration = tr / n_time_steps
|
|
473
|
+
unique_slice_timings = np.linspace(0, tr - slice_duration, n_time_steps)
|
|
474
|
+
slice_timing = np.zeros(n_slices)
|
|
475
|
+
|
|
476
|
+
slice_groupings = _create_multiband_slice_groupings(
|
|
477
|
+
slice_order, multiband_factor, n_time_steps, ascending
|
|
478
|
+
)
|
|
479
|
+
for time_indx, multiband_group in enumerate(slice_groupings):
|
|
480
|
+
slice_timing[list(multiband_group)] = unique_slice_timings[time_indx]
|
|
481
|
+
|
|
482
|
+
return slice_timing.tolist()
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def create_slice_timing(
|
|
486
|
+
nifti_file_or_img: str | nib.nifti1.Nifti1Image,
|
|
487
|
+
tr: Optional[float | int] = None,
|
|
488
|
+
slice_axis: Optional[Literal["x", "y", "z"]] = None,
|
|
489
|
+
slice_acquisition_method: Literal[
|
|
490
|
+
"sequential", "interleaved", "interleaved_sqrt_step"
|
|
491
|
+
] = "interleaved",
|
|
492
|
+
ascending: bool = True,
|
|
493
|
+
interleaved_start: Literal["even", "odd"] = "odd",
|
|
494
|
+
multiband_factor: Optional[int] = None,
|
|
495
|
+
) -> list[float]:
|
|
496
|
+
"""
|
|
497
|
+
Create slice timing dictionary mapping the slice index to its
|
|
498
|
+
acquisition time.
|
|
499
|
+
|
|
500
|
+
.. important::
|
|
501
|
+
Multiband grouping is primarily based on based on
|
|
502
|
+
Philip's ordering for multiband acquisition for different
|
|
503
|
+
slice acquisition methods. For more information refer to the
|
|
504
|
+
`University of Washington Diagnostic Imaging Sciences Center Technical Notes
|
|
505
|
+
<https://depts.washington.edu/mrlab/technotes/fmri.shtml>`_.
|
|
506
|
+
|
|
507
|
+
Parameters
|
|
508
|
+
----------
|
|
509
|
+
nifti_file_or_img: :obj:`str` or :obj:`Nifti1Image`
|
|
510
|
+
Path to the NIfTI file or a NIfTI image.
|
|
511
|
+
|
|
512
|
+
tr: :obj:`float` or :obj:`int`
|
|
513
|
+
Repetition time in seconds. If None, the repetition time is
|
|
514
|
+
extracted from the NIfTI header.
|
|
515
|
+
|
|
516
|
+
slice_acquisition_method: :obj:`Literal["sequential", "interleaved", "interleaved_sqrt_step"]`, default="interleaved"
|
|
517
|
+
Method used for acquiring slices.
|
|
518
|
+
|
|
519
|
+
.. note::
|
|
520
|
+
"interleaved" is the common interleaving pattern (e.g [0, 2, 4, 6, 1, 3, 5, 7]),
|
|
521
|
+
which is also equivalent to Philip's "default". The "interleaved_sqrt_step"
|
|
522
|
+
is an method where slices are acquired by a step factor equivalent
|
|
523
|
+
to the rounded square root of the total slices (this method is Philip's "interleaved"
|
|
524
|
+
mode).
|
|
525
|
+
|
|
526
|
+
slice_axis: :obj:`Literal["x", "y", "z"]` or :obj:`None`, default=None
|
|
527
|
+
Axis the image slices were collected in. If None,
|
|
528
|
+
determines the slice axis using metadata ("slice_end")
|
|
529
|
+
from the NIfTI header.
|
|
530
|
+
|
|
531
|
+
ascending: :obj:`bool`, default=True
|
|
532
|
+
If slices were collected in ascending order (True) or descending
|
|
533
|
+
order (False).
|
|
534
|
+
|
|
535
|
+
interleaved_start: :obj:`Literal["even", "odd"]`, default="odd"
|
|
536
|
+
If slices for interleaved acquisition were collected
|
|
537
|
+
by acquiring the "even" or "odd" slices first.
|
|
538
|
+
|
|
539
|
+
.. important:: Only used when ``slice_acquisition_method="interleaved"``.
|
|
540
|
+
|
|
541
|
+
multiband_factor: :obj:`int` or :obj:`None`, default == None
|
|
542
|
+
The multiband acceleration factor, which is the number of slices
|
|
543
|
+
acquired simultaneously during multislice acquisition. Slice
|
|
544
|
+
ordering is created using a step factor equivalent to
|
|
545
|
+
``n_slices / multiband_factor``. For instance, if ``n_slices`` is
|
|
546
|
+
12 and ``slice_acquisition_method`` is "interleaved" with
|
|
547
|
+
``multiband_factor`` of 3, then the traditional interleaved
|
|
548
|
+
order using the "odd" first ascending pattern is [0, 2, 4, 6, 8, 10,
|
|
549
|
+
1, 3, 5, 7, 9, 11]. This order is then grouped into sets of 3
|
|
550
|
+
with a step of 4 (12 slices divided by multiband factor of 3),
|
|
551
|
+
resulting in slice groups: (0, 4, 8), (2, 6, 10), (1, 5, 9), (3, 7, 11).
|
|
552
|
+
The final slice timing order is [0, 4, 8, 2, 6, 10, 1, 5, 9, 3, 7, 11].
|
|
553
|
+
|
|
554
|
+
Returns
|
|
555
|
+
-------
|
|
556
|
+
list[float]
|
|
557
|
+
List containing the slice timing acquisition.
|
|
558
|
+
"""
|
|
559
|
+
slice_ordering_func = {
|
|
560
|
+
"sequential": _create_sequential_order,
|
|
561
|
+
"interleaved": _create_interleaved_order,
|
|
562
|
+
"interleaved_sqrt_step": _create_interleaved_sqrt_step_order,
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
n_slices = get_n_slices(nifti_file_or_img, slice_axis)
|
|
566
|
+
|
|
567
|
+
kwargs = {"n_slices": n_slices, "ascending": ascending}
|
|
568
|
+
if slice_acquisition_method == "interleaved":
|
|
569
|
+
kwargs.update({"interleaved_start": interleaved_start})
|
|
570
|
+
|
|
571
|
+
slice_order = slice_ordering_func[slice_acquisition_method](**kwargs)
|
|
572
|
+
tr = tr if tr else get_tr(nifti_file_or_img)
|
|
573
|
+
kwargs = {"tr": tr, "slice_order": slice_order}
|
|
574
|
+
|
|
575
|
+
return (
|
|
576
|
+
_create_singleband_timing(**kwargs)
|
|
577
|
+
if not multiband_factor
|
|
578
|
+
else _create_multiband_timing(
|
|
579
|
+
multiband_factor=multiband_factor, ascending=ascending, **kwargs
|
|
580
|
+
)
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def is_3d_img(nifti_file_or_img: str | nib.nifti1.Nifti1Image) -> bool:
|
|
585
|
+
"""
|
|
586
|
+
Determines if ``nifti_file_or_img`` is a 3D image.
|
|
587
|
+
|
|
588
|
+
Parameters
|
|
589
|
+
----------
|
|
590
|
+
nifti_file_or_img: :obj:`str` or :obj:`Nifti1Image`
|
|
591
|
+
Path to the NIfTI file or a NIfTI image.
|
|
592
|
+
|
|
593
|
+
Returns
|
|
594
|
+
-------
|
|
595
|
+
bool
|
|
596
|
+
True if ``nifti_file_or_img`` is a 3D image.
|
|
597
|
+
"""
|
|
598
|
+
return len(get_nifti_header(nifti_file_or_img).get_zooms()) == 3
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def get_scanner_info(
|
|
602
|
+
nifti_file_or_img: str | nib.nifti1.Nifti1Image,
|
|
603
|
+
) -> tuple[str, str]:
|
|
604
|
+
"""
|
|
605
|
+
Determines the manufacturer and model name of scanner.
|
|
606
|
+
|
|
607
|
+
.. important::
|
|
608
|
+
Assumes this information is in the "descrip" of the NIfTI
|
|
609
|
+
header, which can contain any information.
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
Parameters
|
|
613
|
+
----------
|
|
614
|
+
nifti_file_or_img: :obj:`str` or :obj:`Nifti1Image`
|
|
615
|
+
Path to the NIfTI file or a NIfTI image.
|
|
616
|
+
|
|
617
|
+
Returns
|
|
618
|
+
-------
|
|
619
|
+
tuple[str, str]
|
|
620
|
+
The manufacturer and model name for the scanner.
|
|
621
|
+
"""
|
|
622
|
+
if not (
|
|
623
|
+
scanner_info := get_hdr_metadata(
|
|
624
|
+
nifti_file_or_img=nifti_file_or_img,
|
|
625
|
+
metadata_name="descrip",
|
|
626
|
+
return_header=False,
|
|
627
|
+
)
|
|
628
|
+
):
|
|
629
|
+
raise ValueError("No scanner information in NIfTI header.")
|
|
630
|
+
|
|
631
|
+
scanner_info = str(scanner_info.astype(str)).rstrip(" ")
|
|
632
|
+
manufacturer_name, _, model_name = scanner_info.partition(" ")
|
|
633
|
+
|
|
634
|
+
return manufacturer_name, model_name
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def is_valid_date(date_str: str, date_fmt: str) -> bool:
|
|
638
|
+
"""
|
|
639
|
+
Determine if a string is a valid date based on format.
|
|
640
|
+
|
|
641
|
+
Parameters
|
|
642
|
+
----------
|
|
643
|
+
date_str: :obj:`str`
|
|
644
|
+
The string to be validated.
|
|
645
|
+
|
|
646
|
+
date_fmt:
|
|
647
|
+
The expected format of the date.
|
|
648
|
+
|
|
649
|
+
Return
|
|
650
|
+
------
|
|
651
|
+
bool
|
|
652
|
+
True if ``date_str`` has the format specified by ``date_fmt``
|
|
653
|
+
|
|
654
|
+
Example
|
|
655
|
+
-------
|
|
656
|
+
>>> from nifti2bids.metadata import is_valid_date
|
|
657
|
+
>>> is_valid_date("241010", "%y%m%d")
|
|
658
|
+
True
|
|
659
|
+
"""
|
|
660
|
+
try:
|
|
661
|
+
datetime.datetime.strptime(date_str, date_fmt)
|
|
662
|
+
return True
|
|
663
|
+
except ValueError:
|
|
664
|
+
return False
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def get_date_from_filename(filename: str, date_fmt: str) -> str | None:
|
|
668
|
+
"""
|
|
669
|
+
Get date from filename.
|
|
670
|
+
|
|
671
|
+
Extracts the date from the name a file.
|
|
672
|
+
|
|
673
|
+
Parameters
|
|
674
|
+
----------
|
|
675
|
+
filename: :obj:`str`
|
|
676
|
+
The absolute path or name of file.
|
|
677
|
+
|
|
678
|
+
date_fmt:
|
|
679
|
+
The expected format of the date.
|
|
680
|
+
|
|
681
|
+
Returns
|
|
682
|
+
-------
|
|
683
|
+
str or None:
|
|
684
|
+
A string if a valid date based on specified ``date_fmt`` is detected
|
|
685
|
+
or None if no valid date is detected.
|
|
686
|
+
|
|
687
|
+
Example
|
|
688
|
+
-------
|
|
689
|
+
>>> from nifti2bids.metadata import get_date_from_filename
|
|
690
|
+
>>> get_date_from_filename("101_240820_mprage_32chan.nii", "%y%m%d")
|
|
691
|
+
"240820"
|
|
692
|
+
"""
|
|
693
|
+
split_pattern = "|".join(map(re.escape, ["_", "-", " "]))
|
|
694
|
+
|
|
695
|
+
basename = os.path.basename(filename)
|
|
696
|
+
split_basename = re.split(split_pattern, basename)
|
|
697
|
+
|
|
698
|
+
date_str = None
|
|
699
|
+
for part in split_basename:
|
|
700
|
+
if is_valid_date(part, date_fmt):
|
|
701
|
+
date_str = part
|
|
702
|
+
break
|
|
703
|
+
|
|
704
|
+
return date_str
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def get_entity_value(filename: str, entity: str) -> str | None:
|
|
708
|
+
"""
|
|
709
|
+
Gets entity value of a BIDS compliant filename.
|
|
710
|
+
|
|
711
|
+
Parameters
|
|
712
|
+
----------
|
|
713
|
+
filename: :obj:`str`
|
|
714
|
+
Filename to extract entity from.
|
|
715
|
+
|
|
716
|
+
entity: :obj:`str`
|
|
717
|
+
The entity key (e.g. "sub", "task")
|
|
718
|
+
|
|
719
|
+
Returns
|
|
720
|
+
-------
|
|
721
|
+
str or None
|
|
722
|
+
The entity value.
|
|
723
|
+
|
|
724
|
+
Example
|
|
725
|
+
-------
|
|
726
|
+
>>> from nifti2bids.metadata import get_entity_value
|
|
727
|
+
>>> get_entity_value("sub-01_task-flanker_bold.nii.gz", "task")
|
|
728
|
+
"flanker"
|
|
729
|
+
"""
|
|
730
|
+
basename = os.path.basename(filename)
|
|
731
|
+
match = re.search(rf"{entity}-([^_\.]+)", basename)
|
|
732
|
+
|
|
733
|
+
return match.group(1) if match else None
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def infer_task_from_image(
|
|
737
|
+
nifti_file_or_img: str | nib.nifti1.Nifti1Image, volume_to_task_map: dict[int, str]
|
|
738
|
+
) -> str:
|
|
739
|
+
"""
|
|
740
|
+
Infer the task based on the number of volumes in a 4D NIfTI image.
|
|
741
|
+
|
|
742
|
+
Parameters
|
|
743
|
+
----------
|
|
744
|
+
nifti_file_or_img: :obj:`str` or :obj:`Nifti1Image`, default=None
|
|
745
|
+
Path to the NIfTI file or a NIfTI image.
|
|
746
|
+
|
|
747
|
+
volume_to_task_map: :obj:`dict[int, str]`
|
|
748
|
+
A mapping of the number of volumes for each taskname.
|
|
749
|
+
|
|
750
|
+
Returns
|
|
751
|
+
-------
|
|
752
|
+
str
|
|
753
|
+
The task name.
|
|
754
|
+
|
|
755
|
+
Example
|
|
756
|
+
-------
|
|
757
|
+
>>> from nifti2bids.io import simulate_nifti_image
|
|
758
|
+
>>> from nifti2bids.metadata import infer_task_from_image
|
|
759
|
+
>>> img = simulate_nifti_image((100, 100, 100, 260))
|
|
760
|
+
>>> volume_to_task_map = {300: "flanker", 260: "nback"}
|
|
761
|
+
>>> infer_task_from_image(img, volume_to_task_map)
|
|
762
|
+
"nback"
|
|
763
|
+
"""
|
|
764
|
+
n_volumes = get_n_volumes(nifti_file_or_img)
|
|
765
|
+
|
|
766
|
+
return volume_to_task_map.get(n_volumes)
|