Skip to content

Commit b55a363

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 d0bbcc7 commit b55a363

File tree

2 files changed

+131
-38
lines changed

2 files changed

+131
-38
lines changed

nibabel/cifti2/cifti2.py

+79-28
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from ..dataobj_images import DataobjImage
2525
from ..nifti2 import Nifti2Image, Nifti2Header
2626
from ..arrayproxy import reshape_dataobj
27+
from ..volumeutils import Recoder
2728
from warnings import warn
2829

2930

@@ -90,20 +91,50 @@ class Cifti2HeaderError(Exception):
9091

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

108139

109140
def _value_if_klass(val, klass):
@@ -1503,32 +1534,52 @@ def get_data_dtype(self):
15031534
def set_data_dtype(self, dtype):
15041535
self._nifti_header.set_data_dtype(dtype)
15051536

1506-
def to_filename(self, filename, infer_intent=False):
1537+
def to_filename(self, filename, validate=True):
15071538
"""
15081539
Ensures NIfTI header intent code is set prior to saving.
15091540
15101541
Parameters
15111542
----------
1512-
infer_intent : boolean, optional
1513-
If ``True``, attempt to infer and set intent code based on filename suffix.
1543+
validate : boolean, optional
1544+
If ``True``, infer and validate CIFTI type based on filename suffix.
1545+
This includes the setting of the NIfTI intent code and checking the ``CIFTI2Matrix``
1546+
for the expected IndicesMaps attributes.
1547+
If validation fails, an error will be raised instead.
15141548
"""
1515-
header = self._nifti_header
1516-
if infer_intent:
1517-
# try to infer intent code based on filename suffix
1518-
intent = _infer_intent_from_filename(filename)
1519-
if intent is not None:
1520-
header.set_intent(intent)
1549+
nheader = self._nifti_header
1550+
# try to infer intent code based on filename suffix
1551+
if validate:
1552+
ext = _extract_cifti_extension(filename)
1553+
try:
1554+
CIFTI_CODES.extension[ext]
1555+
except KeyError as err:
1556+
raise KeyError(
1557+
f"Validation failed: No information for extension {ext} available"
1558+
) from err
1559+
intent = CIFTI_CODES.niistring[ext]
1560+
nheader.set_intent(intent)
1561+
# validate matrix indices
1562+
for idx, mtype in enumerate(CIFTI_CODES.map_types[ext]):
1563+
try:
1564+
assert self.header.matrix.get_index_map(idx).indices_map_to_data_type == mtype
1565+
except Exception:
1566+
raise Cifti2HeaderError(
1567+
f"Validation failed: Cifti2Matrix index map {idx} does "
1568+
f"not match expected type {mtype}"
1569+
)
15211570
# if intent code is not set, default to unknown
1522-
if header.get_intent()[0] == 'none':
1523-
header.set_intent('NIFTI_INTENT_CONNECTIVITY_UNKNOWN')
1571+
if nheader.get_intent()[0] == 'none':
1572+
nheader.set_intent('NIFTI_INTENT_CONNECTIVITY_UNKNOWN')
15241573
super().to_filename(filename)
15251574

15261575

1527-
def _infer_intent_from_filename(filename):
1576+
def _extract_cifti_extension(filename):
15281577
"""Parses output filename for common suffixes and fetches corresponding intent code"""
15291578
from pathlib import Path
1530-
ext = Path(filename).suffixes[0]
1531-
return CIFTI_EXTENSIONS_TO_INTENTS.get(ext)
1579+
_suf = Path(filename).suffixes
1580+
# select second to last if possible (.<suffix>.nii)
1581+
ext = _suf[-2] if len(_suf) >= 2 else _suf[0]
1582+
return ext.lstrip('.')
15321583

15331584

15341585
load = Cifti2Image.from_filename

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)