Skip to content

Commit b401b14

Browse files
committed
ENH: Add validation when saving CIFTI2 images
- Enabled by default, validation will parse the output filename for a valid CIFTI2 extension. - If found, the intent code of the image will be set. Also, the CIFTI2Header will be check for compliant index maps for the intent code
1 parent 270e921 commit b401b14

File tree

2 files changed

+131
-38
lines changed

2 files changed

+131
-38
lines changed

Diff for: nibabel/cifti2/cifti2.py

+79-28
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from ..nifti1 import Nifti1Extensions
2626
from ..nifti2 import Nifti2Image, Nifti2Header
2727
from ..arrayproxy import reshape_dataobj
28+
from ..volumeutils import Recoder
2829
from warnings import warn
2930

3031

@@ -91,20 +92,50 @@ class Cifti2HeaderError(Exception):
9192

9293
# "Standard CIFTI Mapping Combinations" within CIFTI-2 spec
9394
# https://www.nitrc.org/forum/attachment.php?attachid=341&group_id=454&forum_id=1955
94-
CIFTI_EXTENSIONS_TO_INTENTS = {
95-
'.dconn': 'NIFTI_INTENT_CONNECTIVITY_DENSE',
96-
'.dtseries': 'NIFTI_INTENT_CONNECTIVITY_DENSE_SERIES',
97-
'.pconn': 'NIFTI_INTENT_CONNECTIVITY_PARCELLATED',
98-
'.ptseries': 'NIFTI_INTENT_CONNECTIVITY_PARCELLATED_SERIES',
99-
'.dscalar': 'NIFTI_INTENT_CONNECTIVITY_DENSE_SCALARS',
100-
'.dlabel': 'NIFTI_INTENT_CONNECTIVITY_DENSE_LABELS',
101-
'.pscalar': 'NIFTI_INTENT_CONNECTIVITY_PARCELLATED_SCALAR',
102-
'.pdconn': 'NIFTI_INTENT_CONNECTIVITY_PARCELLATED_DENSE',
103-
'.dpconn': 'NIFTI_INTENT_CONNECTIVITY_DENSE_PARCELLATED',
104-
'.pconnseries': 'NIFTI_INTENT_CONNECTIVITY_PARCELLATED_PARCELLATED_SERIES',
105-
'.pconnscalar': 'NIFTI_INTENT_CONNECTIVITY_PARCELLATED_PARCELLATED_SCALAR',
106-
'.dfan': 'NIFTI_INTENT_CONNECTIVITY_DENSE_SERIES',
107-
}
95+
CIFTI_CODES = Recoder((
96+
('dconn', 'NIFTI_INTENT_CONNECTIVITY_DENSE', (
97+
'CIFTI_INDEX_TYPE_BRAIN_MODELS', 'CIFTI_INDEX_TYPE_BRAIN_MODELS',
98+
)),
99+
('dtseries', 'NIFTI_INTENT_CONNECTIVITY_DENSE_SERIES', (
100+
'CIFTI_INDEX_TYPE_SERIES', 'CIFTI_INDEX_TYPE_BRAIN_MODELS',
101+
)),
102+
('pconn', 'NIFTI_INTENT_CONNECTIVITY_PARCELLATED', (
103+
'CIFTI_INDEX_TYPE_PARCELS', 'CIFTI_INDEX_TYPE_PARCELS',
104+
)),
105+
('ptseries', 'NIFTI_INTENT_CONNECTIVITY_PARCELLATED_SERIES', (
106+
'CIFTI_INDEX_TYPE_SERIES', 'CIFTI_INDEX_TYPE_PARCELS',
107+
)),
108+
('dscalar', 'NIFTI_INTENT_CONNECTIVITY_DENSE_SCALARS', (
109+
'CIFTI_INDEX_TYPE_SCALARS', 'CIFTI_INDEX_TYPE_BRAIN_MODELS',
110+
)),
111+
('dlabel', 'NIFTI_INTENT_CONNECTIVITY_DENSE_LABELS', (
112+
'CIFTI_INDEX_TYPE_LABELS', 'CIFTI_INDEX_TYPE_BRAIN_MODELS',
113+
)),
114+
('pscalar', 'NIFTI_INTENT_CONNECTIVITY_PARCELLATED_SCALAR', (
115+
'CIFTI_INDEX_TYPE_SCALARS', 'CIFTI_INDEX_TYPE_PARCELS',
116+
)),
117+
('pdconn', 'NIFTI_INTENT_CONNECTIVITY_PARCELLATED_DENSE', (
118+
'CIFTI_INDEX_TYPE_BRAIN_MODELS', 'CIFTI_INDEX_TYPE_PARCELS',
119+
)),
120+
('dpconn', 'NIFTI_INTENT_CONNECTIVITY_DENSE_PARCELLATED', (
121+
'CIFTI_INDEX_TYPE_PARCELS', 'CIFTI_INDEX_TYPE_BRAIN_MODELS',
122+
)),
123+
('pconnseries', 'NIFTI_INTENT_CONNECTIVITY_PARCELLATED_PARCELLATED_SERIES', (
124+
'CIFTI_INDEX_TYPE_PARCELS', 'CIFTI_INDEX_TYPE_PARCELS', 'CIFTI_INDEX_TYPE_SERIES',
125+
)),
126+
('pconnscalar', 'NIFTI_INTENT_CONNECTIVITY_PARCELLATED_PARCELLATED_SCALAR', (
127+
'CIFTI_INDEX_TYPE_PARCELS', 'CIFTI_INDEX_TYPE_PARCELS', 'CIFTI_INDEX_TYPE_SCALARS',
128+
)),
129+
('dfan', 'NIFTI_INTENT_CONNECTIVITY_DENSE_SERIES', (
130+
'CIFTI_INDEX_TYPE_SCALARS', 'CIFTI_INDEX_TYPE_BRAIN_MODELS',
131+
)),
132+
('dfibersamp', 'NIFTI_INTENT_CONNECTIVITY_UNKNOWN', (
133+
'CIFTI_INDEX_TYPE_SCALARS', 'CIFTI_INDEX_TYPE_SCALARS', 'CIFTI_INDEX_TYPE_BRAIN_MODELS',
134+
)),
135+
('dfansamp', 'NIFTI_INTENT_CONNECTIVITY_UNKNOWN', (
136+
'CIFTI_INDEX_TYPE_SCALARS', 'CIFTI_INDEX_TYPE_SCALARS', 'CIFTI_INDEX_TYPE_BRAIN_MODELS',
137+
)),
138+
), fields=('extension', 'niistring', 'map_types'))
108139

109140

110141
def _value_if_klass(val, klass):
@@ -1522,32 +1553,52 @@ def get_data_dtype(self):
15221553
def set_data_dtype(self, dtype):
15231554
self._nifti_header.set_data_dtype(dtype)
15241555

1525-
def to_filename(self, filename, infer_intent=False):
1556+
def to_filename(self, filename, validate=True):
15261557
"""
15271558
Ensures NIfTI header intent code is set prior to saving.
15281559
15291560
Parameters
15301561
----------
1531-
infer_intent : boolean, optional
1532-
If ``True``, attempt to infer and set intent code based on filename suffix.
1562+
validate : boolean, optional
1563+
If ``True``, infer and validate CIFTI type based on filename suffix.
1564+
This includes the setting of the NIfTI intent code and checking the ``CIFTI2Matrix``
1565+
for the expected IndicesMaps attributes.
1566+
If validation fails, an error will be raised instead.
15331567
"""
1534-
header = self._nifti_header
1535-
if infer_intent:
1536-
# try to infer intent code based on filename suffix
1537-
intent = _infer_intent_from_filename(filename)
1538-
if intent is not None:
1539-
header.set_intent(intent)
1568+
nheader = self._nifti_header
1569+
# try to infer intent code based on filename suffix
1570+
if validate:
1571+
ext = _extract_cifti_extension(filename)
1572+
try:
1573+
CIFTI_CODES.extension[ext]
1574+
except KeyError as err:
1575+
raise KeyError(
1576+
f"Validation failed: No information for extension {ext} available"
1577+
) from err
1578+
intent = CIFTI_CODES.niistring[ext]
1579+
nheader.set_intent(intent)
1580+
# validate matrix indices
1581+
for idx, mtype in enumerate(CIFTI_CODES.map_types[ext]):
1582+
try:
1583+
assert self.header.matrix.get_index_map(idx).indices_map_to_data_type == mtype
1584+
except Exception:
1585+
raise Cifti2HeaderError(
1586+
f"Validation failed: Cifti2Matrix index map {idx} does "
1587+
f"not match expected type {mtype}"
1588+
)
15401589
# if intent code is not set, default to unknown
1541-
if header.get_intent()[0] == 'none':
1542-
header.set_intent('NIFTI_INTENT_CONNECTIVITY_UNKNOWN')
1590+
if nheader.get_intent()[0] == 'none':
1591+
nheader.set_intent('NIFTI_INTENT_CONNECTIVITY_UNKNOWN')
15431592
super().to_filename(filename)
15441593

15451594

1546-
def _infer_intent_from_filename(filename):
1595+
def _extract_cifti_extension(filename):
15471596
"""Parses output filename for common suffixes and fetches corresponding intent code"""
15481597
from pathlib import Path
1549-
ext = Path(filename).suffixes[0]
1550-
return CIFTI_EXTENSIONS_TO_INTENTS.get(ext)
1598+
_suf = Path(filename).suffixes
1599+
# select second to last if possible (.<suffix>.nii)
1600+
ext = _suf[-2] if len(_suf) >= 2 else _suf[0]
1601+
return ext.lstrip('.')
15511602

15521603

15531604
load = Cifti2Image.from_filename

Diff for: nibabel/cifti2/tests/test_new_cifti2.py

+52-10
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ def test_dtseries():
239239
img = ci.Cifti2Image(data, hdr)
240240

241241
with InTemporaryDirectory():
242-
ci.save(img, 'test.dtseries.nii', infer_intent=True)
242+
ci.save(img, 'test.dtseries.nii')
243243
img2 = nib.load('test.dtseries.nii')
244244
assert img2.nifti_header.get_intent()[0] == 'ConnDenseSeries'
245245
assert isinstance(img2, ci.Cifti2Image)
@@ -282,7 +282,7 @@ def test_dlabel():
282282
img = ci.Cifti2Image(data, hdr)
283283

284284
with InTemporaryDirectory():
285-
ci.save(img, 'test.dlabel.nii', infer_intent=True)
285+
ci.save(img, 'test.dlabel.nii')
286286
img2 = nib.load('test.dlabel.nii')
287287
assert img2.nifti_header.get_intent()[0] == 'ConnDenseLabel'
288288
assert isinstance(img2, ci.Cifti2Image)
@@ -301,7 +301,7 @@ def test_dconn():
301301
img = ci.Cifti2Image(data, hdr)
302302

303303
with InTemporaryDirectory():
304-
ci.save(img, 'test.dconn.nii', infer_intent=True)
304+
ci.save(img, 'test.dconn.nii')
305305
img2 = nib.load('test.dconn.nii')
306306
assert img2.nifti_header.get_intent()[0] == 'ConnDense'
307307
assert isinstance(img2, ci.Cifti2Image)
@@ -322,7 +322,7 @@ def test_ptseries():
322322
img = ci.Cifti2Image(data, hdr)
323323

324324
with InTemporaryDirectory():
325-
ci.save(img, 'test.ptseries.nii', infer_intent=True)
325+
ci.save(img, 'test.ptseries.nii')
326326
img2 = nib.load('test.ptseries.nii')
327327
assert img2.nifti_header.get_intent()[0] == 'ConnParcelSries'
328328
assert isinstance(img2, ci.Cifti2Image)
@@ -343,7 +343,7 @@ def test_pscalar():
343343
img = ci.Cifti2Image(data, hdr)
344344

345345
with InTemporaryDirectory():
346-
ci.save(img, 'test.pscalar.nii', infer_intent=True)
346+
ci.save(img, 'test.pscalar.nii')
347347
img2 = nib.load('test.pscalar.nii')
348348
assert img2.nifti_header.get_intent()[0] == 'ConnParcelScalr'
349349
assert isinstance(img2, ci.Cifti2Image)
@@ -364,7 +364,7 @@ def test_pdconn():
364364
img = ci.Cifti2Image(data, hdr)
365365

366366
with InTemporaryDirectory():
367-
ci.save(img, 'test.pdconn.nii', infer_intent=True)
367+
ci.save(img, 'test.pdconn.nii')
368368
img2 = ci.load('test.pdconn.nii')
369369
assert img2.nifti_header.get_intent()[0] == 'ConnParcelDense'
370370
assert isinstance(img2, ci.Cifti2Image)
@@ -385,7 +385,7 @@ def test_dpconn():
385385
img = ci.Cifti2Image(data, hdr)
386386

387387
with InTemporaryDirectory():
388-
ci.save(img, 'test.dpconn.nii', infer_intent=True)
388+
ci.save(img, 'test.dpconn.nii')
389389
img2 = ci.load('test.dpconn.nii')
390390
assert img2.nifti_header.get_intent()[0] == 'ConnDenseParcel'
391391
assert isinstance(img2, ci.Cifti2Image)
@@ -425,7 +425,7 @@ def test_pconn():
425425
img = ci.Cifti2Image(data, hdr)
426426

427427
with InTemporaryDirectory():
428-
ci.save(img, 'test.pconn.nii', infer_intent=True)
428+
ci.save(img, 'test.pconn.nii')
429429
img2 = ci.load('test.pconn.nii')
430430
assert img.nifti_header.get_intent()[0] == 'ConnParcels'
431431
assert isinstance(img2, ci.Cifti2Image)
@@ -447,7 +447,7 @@ def test_pconnseries():
447447
img = ci.Cifti2Image(data, hdr)
448448

449449
with InTemporaryDirectory():
450-
ci.save(img, 'test.pconnseries.nii', infer_intent=True)
450+
ci.save(img, 'test.pconnseries.nii')
451451
img2 = ci.load('test.pconnseries.nii')
452452
assert img.nifti_header.get_intent()[0] == 'ConnPPSr'
453453
assert isinstance(img2, ci.Cifti2Image)
@@ -470,7 +470,7 @@ def test_pconnscalar():
470470
img = ci.Cifti2Image(data, hdr)
471471

472472
with InTemporaryDirectory():
473-
ci.save(img, 'test.pconnscalar.nii', infer_intent=True)
473+
ci.save(img, 'test.pconnscalar.nii')
474474
img2 = ci.load('test.pconnscalar.nii')
475475
assert img.nifti_header.get_intent()[0] == 'ConnPPSc'
476476
assert isinstance(img2, ci.Cifti2Image)
@@ -509,3 +509,45 @@ def test_wrong_shape():
509509
with pytest.raises(ValueError):
510510
img.to_file_map()
511511

512+
513+
def test_cifti_validation():
514+
# flip label / brain_model index maps
515+
geometry_map = create_geometry_map((0, ))
516+
label_map = create_label_map((1, ))
517+
matrix = ci.Cifti2Matrix()
518+
matrix.append(label_map)
519+
matrix.append(geometry_map)
520+
hdr = ci.Cifti2Header(matrix)
521+
data = np.random.randn(10, 2)
522+
img = ci.Cifti2Image(data, hdr)
523+
524+
# attempt to save and validate with an invalid extension
525+
with pytest.raises(KeyError):
526+
ci.save(img, 'test.dlabelz.nii')
527+
# even with a proper extension, flipped index maps will fail
528+
with pytest.raises(ci.Cifti2HeaderError):
529+
ci.save(img, 'test.dlabel.nii')
530+
531+
label_map = create_label_map((0, ))
532+
geometry_map = create_geometry_map((1, ))
533+
matrix = ci.Cifti2Matrix()
534+
matrix.append(label_map)
535+
matrix.append(geometry_map)
536+
hdr = ci.Cifti2Header(matrix)
537+
data = np.random.randn(2, 10)
538+
img = ci.Cifti2Image(data, hdr)
539+
540+
with InTemporaryDirectory():
541+
# still fail with invalid extension and validation
542+
with pytest.raises(KeyError):
543+
ci.save(img, 'test.dlabelz.nii')
544+
# but removing validation should work (though intent code will be unknown)
545+
ci.save(img, 'test.dlabelz.nii', validate=False)
546+
547+
img2 = nib.load('test.dlabelz.nii')
548+
assert img2.nifti_header.get_intent()[0] == 'ConnUnknown'
549+
assert isinstance(img2, ci.Cifti2Image)
550+
assert_array_equal(img2.get_fdata(), data)
551+
check_label_map(img2.header.matrix.get_index_map(0))
552+
check_geometry_map(img2.header.matrix.get_index_map(1))
553+
del img2

0 commit comments

Comments
 (0)