nifti2bids 0.1.2__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.

Potentially problematic release.


This version of nifti2bids might be problematic. Click here for more details.

nifti2bids/metadata.py ADDED
@@ -0,0 +1,758 @@
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
+ interleave_pattern: Literal["even", "odd", "philips"] = "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
+ interleave_pattern: :obj:`Literal["even", "odd", "philips"]`, default="odd"
270
+ If slices for interleaved acquisition were collected by acquiring the
271
+ "even" or "odd" slices first. For "philips" (the interleaved implementation
272
+ by Philip's), slices are acquired by a step factor equivalent to the rounded
273
+ square root of the total slices.
274
+ mode).
275
+
276
+ .. important::
277
+ Philip's "default" mode is equivalent to "interleave" with the pattern
278
+ set to "odd", and ascending set to True.
279
+
280
+ Returns
281
+ -------
282
+ list[int]
283
+ The order of the slices.
284
+ """
285
+ if interleave_pattern not in ["even", "odd", "philips"]:
286
+ raise ValueError(
287
+ "``interleaved_start`` must be either 'even', 'odd', or 'philips'."
288
+ )
289
+
290
+ if interleave_pattern == "odd":
291
+ slice_order = list(range(0, n_slices, 2)) + list(range(1, n_slices, 2))
292
+ elif interleave_pattern == "even":
293
+ slice_order = list(range(1, n_slices, 2)) + list(range(0, n_slices, 2))
294
+ else:
295
+ slice_order = []
296
+ step = round(np.sqrt(n_slices))
297
+ for slice_indx in range(step):
298
+ slice_order.extend(list(range(slice_indx, n_slices, step)))
299
+
300
+ return _flip_slice_order(slice_order, ascending)
301
+
302
+
303
+ def _create_singleband_timing(tr: float | int, slice_order: list[int]) -> list[float]:
304
+ """
305
+ Create singleband timing based on slice order.
306
+
307
+ Parameters
308
+ ----------
309
+ tr: :obj:`float` or :obj:`int`
310
+ Repetition time in seconds.
311
+
312
+ slice_order: :obj:`list[int]`
313
+ Order of the slices.
314
+
315
+ Returns
316
+ -------
317
+ list[float]
318
+ Ordered slice timing information.
319
+ """
320
+ n_slices = len(slice_order)
321
+ slice_duration = tr / n_slices
322
+ slice_timing = np.linspace(0, tr - slice_duration, n_slices)
323
+ # Pair slice with timing then sort dict
324
+ sorted_slice_timing = dict(
325
+ sorted({k: v for k, v in zip(slice_order, slice_timing.tolist())}.items())
326
+ )
327
+
328
+ return list(sorted_slice_timing.values())
329
+
330
+
331
+ def _generate_sequence(
332
+ start: int, n_count: int, step: int, ascending: bool
333
+ ) -> list[int]:
334
+ """
335
+ Generate a sequence of numbers.
336
+
337
+ Parameters
338
+ ----------
339
+ start: :obj:`int`
340
+ Starting number.
341
+
342
+ n_count: :obj:`int`
343
+ The amount of numbers to generate.
344
+
345
+ step: :obj:`int`
346
+ Step size between numbers.
347
+
348
+ ascending: :obj:`int`
349
+ If numbers are ascending or descending relative to ``start``.
350
+
351
+ Returns:
352
+ list[int]
353
+ The sequence list.
354
+ """
355
+ if ascending:
356
+ stop = start + n_count * step
357
+ return np.arange(start, stop, step).tolist()
358
+ else:
359
+ stop = start - n_count * step
360
+ return np.arange(start, stop, -step).tolist()
361
+
362
+
363
+ def _create_multiband_slice_groupings(
364
+ slice_order: list[int], multiband_factor: int, n_time_steps: int, ascending: bool
365
+ ) -> list[tuple[int, int]]:
366
+ """
367
+ Create slice groupings for multiband based on ``multiband_factor``.
368
+
369
+ Parameters
370
+ ----------
371
+ slice_order: :obj:`list[int]`
372
+ Order of the slices from single slice acquisition.
373
+
374
+ multiband_factor: :obj:`int`
375
+ The multiband acceleration factor, which is the number of slices
376
+ acquired simultaneously during multislice acquistion.
377
+
378
+ n_time_steps: :obj:`int`
379
+ The number of time steps computed by dividing the number of slices
380
+ by the multiband factor.
381
+
382
+ Returns
383
+ -------
384
+ list[tuple[int, int]]
385
+ A list of tuples containing the binned slice indices
386
+
387
+ Example
388
+ -------
389
+ >>> from nifti2bids.metadata import _create_mutiband_timing
390
+ >>> slice_order = [0, 2, 4, 6, 8, 1, 3, 5, 7, 9] # interleaved order
391
+ >>> _create_mutiband_timing(slice_order, multiband_factor=2, n_time_steps=5, ascending=True)
392
+ >>> [(0, 5), (2, 7), (4, 9), (1, 6), (3, 8)]
393
+ """
394
+ slice_groupings = []
395
+ for slice_indx in slice_order:
396
+ if not any(
397
+ slice_indx in multiband_group for multiband_group in slice_groupings
398
+ ):
399
+ # Prevents invalid slice groupings
400
+ # which produce values outside of possible range
401
+ sequence = _generate_sequence(
402
+ slice_indx, multiband_factor, n_time_steps, ascending
403
+ )
404
+ if max(sequence) >= len(slice_order) or min(sequence) < 0:
405
+ continue
406
+
407
+ slice_groupings.append(tuple(sequence))
408
+
409
+ return slice_groupings
410
+
411
+
412
+ def _create_multiband_timing(
413
+ tr: float | int, slice_order: list[int], multiband_factor: int, ascending: bool
414
+ ) -> list[float]:
415
+ """
416
+ Create multiband timing based on slice order.
417
+
418
+ Parameters
419
+ ----------
420
+ tr: :obj:`float` or :obj:`int`
421
+ Repetition time in seconds.
422
+
423
+ slice_order: :obj:`list[int]`
424
+ Order of the slices from single slice acquisition.
425
+
426
+ multiband_factor: :obj:`int`
427
+ The multiband acceleration factor, which is the number of slices
428
+ acquired simultaneously during multislice acquisition.
429
+
430
+ ascending: :obj:`bool`, default=True
431
+ If slices were collected in ascending order (True) or descending
432
+ order (False).
433
+
434
+ Returns
435
+ -------
436
+ list[float]
437
+ Ordered slice timing information for multiband acquisition.
438
+
439
+ Example
440
+ -------
441
+ >>> from nifti2bids.metadata import _create_mutiband_timing
442
+ >>> slice_order = [0, 2, 4, 6, 8, 1, 3, 5, 7, 9] # interleaved order
443
+ >>> _create_mutiband_timing(0.8, slice_order, multiband_factor=2, ascending=True)
444
+ >>> [0.0, 0.48, 0.16, 0.64, 0.32, 0.0, 0.48, 0.16, 0.64, 0.32]
445
+ >>> # slices grouping: [[0, 5], [2, 7], [4, 9], [1, 6], [3, 8]]
446
+ """
447
+ n_slices = len(slice_order)
448
+ if n_slices % multiband_factor != 0:
449
+ raise ValueError(
450
+ f"Number of slices ({n_slices}) must be evenly divisible by "
451
+ f"multiband factor ({multiband_factor})."
452
+ )
453
+
454
+ # Step corresponds to number of unique slice timings and the index step size
455
+ n_time_steps = n_slices // multiband_factor
456
+ slice_duration = tr / n_time_steps
457
+ unique_slice_timings = np.linspace(0, tr - slice_duration, n_time_steps)
458
+ slice_timing = np.zeros(n_slices)
459
+
460
+ slice_groupings = _create_multiband_slice_groupings(
461
+ slice_order, multiband_factor, n_time_steps, ascending
462
+ )
463
+ for time_indx, multiband_group in enumerate(slice_groupings):
464
+ slice_timing[list(multiband_group)] = unique_slice_timings[time_indx]
465
+
466
+ return slice_timing.tolist()
467
+
468
+
469
+ def create_slice_timing(
470
+ nifti_file_or_img: str | nib.nifti1.Nifti1Image,
471
+ tr: Optional[float | int] = None,
472
+ slice_axis: Optional[Literal["x", "y", "z"]] = None,
473
+ acquisition: Literal["sequential", "interleaved"] = "interleaved",
474
+ ascending: bool = True,
475
+ interleave_pattern: Literal["even", "odd", "philips"] = "odd",
476
+ multiband_factor: Optional[int] = None,
477
+ ) -> list[float]:
478
+ """
479
+ Create slice timing dictionary mapping the slice index to its
480
+ acquisition time.
481
+
482
+ .. important::
483
+ Multiband grouping is primarily based on based on
484
+ Philip's ordering for multiband acquisition for different
485
+ slice acquisition methods. For more information refer to the
486
+ `University of Washington Diagnostic Imaging Sciences Center Technical Notes
487
+ <https://depts.washington.edu/mrlab/technotes/fmri.shtml>`_.
488
+
489
+ Parameters
490
+ ----------
491
+ nifti_file_or_img: :obj:`str` or :obj:`Nifti1Image`
492
+ Path to the NIfTI file or a NIfTI image.
493
+
494
+ tr: :obj:`float` or :obj:`int`
495
+ Repetition time in seconds. If None, the repetition time is
496
+ extracted from the NIfTI header.
497
+
498
+ slice_axis: :obj:`Literal["x", "y", "z"]` or :obj:`None`, default=None
499
+ Axis the image slices were collected in. If None,
500
+ determines the slice axis using metadata ("slice_end")
501
+ from the NIfTI header.
502
+
503
+ acquisition :obj:`Literal["sequential", "interleaved"]`, default="interleaved"
504
+ Method used for acquiring slices.
505
+
506
+ .. note::
507
+ "interleaved" is the common interleaving pattern (e.g [0, 2, 4, 6, 1, 3, 5, 7]),
508
+ which is also equivalent to Philip's "default". The "interleaved_sqrt_step"
509
+ is an method where slices are acquired by a step factor equivalent
510
+ to the rounded square root of the total slices (this method is Philip's "interleaved"
511
+ mode).
512
+
513
+ ascending: :obj:`bool`, default=True
514
+ If slices were collected in ascending order (True) or descending
515
+ order (False).
516
+
517
+ interleave_pattern: :obj:`Literal["even", "odd", "philips"]`, default="odd"
518
+ If slices for interleaved acquisition were collected by acquiring the
519
+ "even" or "odd" slices first. For "philips" (the interleaved implementation
520
+ by Philip's), slices are acquired by a step factor equivalent to the rounded
521
+ square root of the total slices.
522
+ mode).
523
+
524
+ .. important::
525
+ Philip's "default" mode is equivalent to "interleave" with the pattern
526
+ set to "odd", and ascending set to True.
527
+
528
+ multiband_factor: :obj:`int` or :obj:`None`, default == None
529
+ The multiband acceleration factor, which is the number of slices
530
+ acquired simultaneously during multislice acquisition. Slice
531
+ ordering is created using a step factor equivalent to
532
+ ``n_slices / multiband_factor``. For instance, if ``n_slices`` is
533
+ 12 and ``slice_acquisition_method`` is "interleaved" with
534
+ ``multiband_factor`` of 3, then the traditional interleaved
535
+ order using the "odd" first ascending pattern is [0, 2, 4, 6, 8, 10,
536
+ 1, 3, 5, 7, 9, 11]. This order is then grouped into sets of 3
537
+ with a step of 4 (12 slices divided by multiband factor of 3),
538
+ resulting in slice groups: (0, 4, 8), (2, 6, 10), (1, 5, 9), (3, 7, 11).
539
+ The final slice timing order is [0, 4, 8, 2, 6, 10, 1, 5, 9, 3, 7, 11].
540
+
541
+ Returns
542
+ -------
543
+ list[float]
544
+ List containing the slice timing acquisition.
545
+
546
+ Referenes
547
+ ---------
548
+ Parker, David, et al. "Optimal Slice Timing Correction and Its Interaction with
549
+ FMRI Parameters and Artifacts." Medical Image Analysis, vol. 35, Jan. 2017, pp. 434–445,
550
+ https://doi.org/10.1016/j.media.2016.08.006. Accessed 28 Jan. 2022.
551
+ """
552
+ slice_ordering_func = {
553
+ "sequential": _create_sequential_order,
554
+ "interleaved": _create_interleaved_order,
555
+ }
556
+
557
+ n_slices = get_n_slices(nifti_file_or_img, slice_axis)
558
+
559
+ acquisition_kwargs = {"n_slices": n_slices, "ascending": ascending}
560
+ if acquisition == "interleaved":
561
+ acquisition_kwargs.update({"interleave_pattern": interleave_pattern})
562
+
563
+ slice_order = slice_ordering_func[acquisition](**acquisition_kwargs)
564
+ tr = tr if tr else get_tr(nifti_file_or_img)
565
+ band_kwargs = {"tr": tr, "slice_order": slice_order}
566
+
567
+ return (
568
+ _create_singleband_timing(**band_kwargs)
569
+ if not multiband_factor
570
+ else _create_multiband_timing(
571
+ multiband_factor=multiband_factor, ascending=ascending, **band_kwargs
572
+ )
573
+ )
574
+
575
+
576
+ def is_3d_img(nifti_file_or_img: str | nib.nifti1.Nifti1Image) -> bool:
577
+ """
578
+ Determines if ``nifti_file_or_img`` is a 3D image.
579
+
580
+ Parameters
581
+ ----------
582
+ nifti_file_or_img: :obj:`str` or :obj:`Nifti1Image`
583
+ Path to the NIfTI file or a NIfTI image.
584
+
585
+ Returns
586
+ -------
587
+ bool
588
+ True if ``nifti_file_or_img`` is a 3D image.
589
+ """
590
+ return len(get_nifti_header(nifti_file_or_img).get_zooms()) == 3
591
+
592
+
593
+ def get_scanner_info(
594
+ nifti_file_or_img: str | nib.nifti1.Nifti1Image,
595
+ ) -> tuple[str, str]:
596
+ """
597
+ Determines the manufacturer and model name of scanner.
598
+
599
+ .. important::
600
+ Assumes this information is in the "descrip" of the NIfTI
601
+ header, which can contain any information.
602
+
603
+
604
+ Parameters
605
+ ----------
606
+ nifti_file_or_img: :obj:`str` or :obj:`Nifti1Image`
607
+ Path to the NIfTI file or a NIfTI image.
608
+
609
+ Returns
610
+ -------
611
+ tuple[str, str]
612
+ The manufacturer and model name for the scanner.
613
+ """
614
+ if not (
615
+ scanner_info := get_hdr_metadata(
616
+ nifti_file_or_img=nifti_file_or_img,
617
+ metadata_name="descrip",
618
+ return_header=False,
619
+ )
620
+ ):
621
+ raise ValueError("No scanner information in NIfTI header.")
622
+
623
+ scanner_info = str(scanner_info.astype(str)).rstrip(" ")
624
+ manufacturer_name, _, model_name = scanner_info.partition(" ")
625
+
626
+ return manufacturer_name, model_name
627
+
628
+
629
+ def is_valid_date(date_str: str, date_fmt: str) -> bool:
630
+ """
631
+ Determine if a string is a valid date based on format.
632
+
633
+ Parameters
634
+ ----------
635
+ date_str: :obj:`str`
636
+ The string to be validated.
637
+
638
+ date_fmt:
639
+ The expected format of the date.
640
+
641
+ Return
642
+ ------
643
+ bool
644
+ True if ``date_str`` has the format specified by ``date_fmt``
645
+
646
+ Example
647
+ -------
648
+ >>> from nifti2bids.metadata import is_valid_date
649
+ >>> is_valid_date("241010", "%y%m%d")
650
+ True
651
+ """
652
+ try:
653
+ datetime.datetime.strptime(date_str, date_fmt)
654
+ return True
655
+ except ValueError:
656
+ return False
657
+
658
+
659
+ def get_date_from_filename(filename: str, date_fmt: str) -> str | None:
660
+ """
661
+ Get date from filename.
662
+
663
+ Extracts the date from the name a file.
664
+
665
+ Parameters
666
+ ----------
667
+ filename: :obj:`str`
668
+ The absolute path or name of file.
669
+
670
+ date_fmt:
671
+ The expected format of the date.
672
+
673
+ Returns
674
+ -------
675
+ str or None:
676
+ A string if a valid date based on specified ``date_fmt`` is detected
677
+ or None if no valid date is detected.
678
+
679
+ Example
680
+ -------
681
+ >>> from nifti2bids.metadata import get_date_from_filename
682
+ >>> get_date_from_filename("101_240820_mprage_32chan.nii", "%y%m%d")
683
+ "240820"
684
+ """
685
+ split_pattern = "|".join(map(re.escape, ["_", "-", " "]))
686
+
687
+ basename = os.path.basename(filename)
688
+ split_basename = re.split(split_pattern, basename)
689
+
690
+ date_str = None
691
+ for part in split_basename:
692
+ if is_valid_date(part, date_fmt):
693
+ date_str = part
694
+ break
695
+
696
+ return date_str
697
+
698
+
699
+ def get_entity_value(filename: str, entity: str) -> str | None:
700
+ """
701
+ Gets entity value of a BIDS compliant filename.
702
+
703
+ Parameters
704
+ ----------
705
+ filename: :obj:`str`
706
+ Filename to extract entity from.
707
+
708
+ entity: :obj:`str`
709
+ The entity key (e.g. "sub", "task")
710
+
711
+ Returns
712
+ -------
713
+ str or None
714
+ The entity value.
715
+
716
+ Example
717
+ -------
718
+ >>> from nifti2bids.metadata import get_entity_value
719
+ >>> get_entity_value("sub-01_task-flanker_bold.nii.gz", "task")
720
+ "flanker"
721
+ """
722
+ basename = os.path.basename(filename)
723
+ match = re.search(rf"{entity}-([^_\.]+)", basename)
724
+
725
+ return match.group(1) if match else None
726
+
727
+
728
+ def infer_task_from_image(
729
+ nifti_file_or_img: str | nib.nifti1.Nifti1Image, volume_to_task_map: dict[int, str]
730
+ ) -> str:
731
+ """
732
+ Infer the task based on the number of volumes in a 4D NIfTI image.
733
+
734
+ Parameters
735
+ ----------
736
+ nifti_file_or_img: :obj:`str` or :obj:`Nifti1Image`, default=None
737
+ Path to the NIfTI file or a NIfTI image.
738
+
739
+ volume_to_task_map: :obj:`dict[int, str]`
740
+ A mapping of the number of volumes for each taskname.
741
+
742
+ Returns
743
+ -------
744
+ str
745
+ The task name.
746
+
747
+ Example
748
+ -------
749
+ >>> from nifti2bids.io import simulate_nifti_image
750
+ >>> from nifti2bids.metadata import infer_task_from_image
751
+ >>> img = simulate_nifti_image((100, 100, 100, 260))
752
+ >>> volume_to_task_map = {300: "flanker", 260: "nback"}
753
+ >>> infer_task_from_image(img, volume_to_task_map)
754
+ "nback"
755
+ """
756
+ n_volumes = get_n_volumes(nifti_file_or_img)
757
+
758
+ return volume_to_task_map.get(n_volumes)