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/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)