Skip to content

tst: explicitly set intent codes to allow proper loading #604

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Mar 9, 2018

Conversation

mgxd
Copy link
Member

@mgxd mgxd commented Feb 23, 2018

related to #603

without explicitly setting the intent code in the header, CIFTI files opened through nibabel.load become Nifti2Images. Essentially this adds to the "documentation" of creating a CIFTI from scratch.

Perhaps this can automated somehow within ci.save() in the future

@effigies
Copy link
Member

It might make sense also to add a warning (or exception) when saving Cifti2Images without valid intent codes.

@satra
Copy link
Member

satra commented Feb 23, 2018

@effigies and @mgxd - i'm thinking ci.save should raise an exception if intent is not set and valid.

@@ -367,6 +384,7 @@ def test_plabel():
with InTemporaryDirectory():
ci.save(img, 'test.plabel.nii')
img2 = ci.load('test.plabel.nii')
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a valid CIFTI type? I cannot find *plabel.nii in the specifications
https://www.nitrc.org/forum/attachment.php?attachid=341&group_id=454&forum_id=1955

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we will raise an exception upon saving, we have some options

  • remove the plabel test
  • alter ci.save's signature to include a new parameter, force=False, that can be switched on to allow saving without intent codes

WDYT?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about skipping the plabel test for now till we find the INTENT code. i would rather not have a parameter.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link

@coalsont coalsont Mar 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

plabel is not in the cifti-2 spec. We didn't see it being useful at the time, so it was not in the draft. We later found that the information needed to reorder parcellated files to group certain parcels together in matrix display could be stored as a parcels by labels file, and decided to use that extention, matching the previous pattern of extension naming.

Per the "unknown" special cifti intent (main cifti-2 document, top of page 13, middle of paragraph), it is in fact valid to make such a file, and its extension is technically open to be anything that ends in ".<something>.nii". Feel free to write a test that loads and/or saves a cifti mapping combination that is not in the spec, this is an expected use case.

@codecov-io
Copy link

codecov-io commented Feb 23, 2018

Codecov Report

Merging #604 into master will increase coverage by 0.01%.
The diff coverage is 100%.

Impacted file tree graph

@@            Coverage Diff            @@
##           master    #604      +/-   ##
=========================================
+ Coverage   94.48%   94.5%   +0.01%     
=========================================
  Files         177     177              
  Lines       25078   25116      +38     
  Branches     2667    2668       +1     
=========================================
+ Hits        23696   23735      +39     
+ Misses        908     907       -1     
  Partials      474     474
Impacted Files Coverage Δ
nibabel/cifti2/parse_cifti2.py 83.76% <ø> (ø) ⬆️
nibabel/cifti2/tests/test_new_cifti2.py 100% <100%> (ø) ⬆️
nibabel/cifti2/cifti2.py 96% <100%> (+0.01%) ⬆️
nibabel/pydicom_compat.py 65% <0%> (+5%) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update e48b746...ba81e30. Read the comment docs.

@coveralls
Copy link

coveralls commented Feb 23, 2018

Coverage Status

Coverage increased (+0.009%) to 96.389% when pulling ba81e30 on mgxd:fix/cifti into e48b746 on nipy:master.

@effigies
Copy link
Member

effigies commented Feb 23, 2018 via email

@satra
Copy link
Member

satra commented Feb 23, 2018

@effigies
Copy link
Member

I guess I'd default to NIFTI_INTENT_CONNECTIVITY_UNKNOWN. That way it will still save and load as a CIFTI.

We could ping the HCP list about whether there should be an assigned intent code.

@@ -1442,4 +1442,6 @@ def save(img, filename):
filename : str
filename to which to save image
"""
if img.nifti_header.get_intent()[0] == 'none':
raise AttributeError("CIFTI image has an invalid intent code.")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I think I would rather set the intent by default to 'NIFTI_INTENT_CONNECTIVITY_UNKNOWN', and I think we should do this in Cifti2Image.to_file_map(), maybe after resetting pixdim.

if header.get_intent()[0] == 'none':
    header.set_intent('NIFTI_INTENT_CONNECTIVITY_UNKNOWN')

This feels a little cleaner to me, as the CIFTI equivalent of saving a NIFTI without setting an intent code, which we also allow.

@matthew-brett
Copy link
Member

@coalsont - what do you think?

@coalsont
Copy link

The intent code is completely automatic in our c++ cifti implementation. They are specified for specific XML setups, and the XML is known at time of file writing. So, we call a helper function to generate the correct intent code/string at header writing time:
https://github.com/Washington-University/CiftiLib/blob/master/src/CiftiFile.cxx#L575
The implementation, containing the intents in the cifti spec:
https://github.com/Washington-University/CiftiLib/blob/master/src/Cifti/CiftiXML.cxx#L249
Our implementation uses the 3000 "ConnUnknown" intent code if it doesn't know about a specific XML combination.

@coalsont
Copy link

Additionally, it may be more robust to identify Cifti by the presence of a cifti extension, which is vital to the format, while we actually ignore the intent code on reading. Of course, when workbench actually doesn't know the file type in advance, we merely use the filename extension, which is even less robust. We added a warning for when a file is saved with an incorrect extension because of this:
https://github.com/Washington-University/CiftiLib/blob/master/src/CiftiFile.cxx#L562
https://github.com/Washington-University/CiftiLib/blob/master/src/CiftiFile.cxx#L473

@satra
Copy link
Member

satra commented Feb 24, 2018

we should start creating a higher level API that addresses creating the different filetypes with appropriate intent. but at the lower level i would prefer raising an Attribute/ValueError Exception and not defaulting to 'NIFTI_INTENT_CONNECTIVITY_UNKNOWN'.

@@ -1442,4 +1442,6 @@ def save(img, filename):
filename : str
filename to which to save image
"""
if img.nifti_header.get_intent()[0] == 'none':
raise ValueError("CIFTI image has an invalid intent code.")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if we're not going to auto-set the intent code, I think this check should be in Cifti2Image.to_file_map(). That way it's hit whether you use nb.cifti2.save, nb.save, or img.to_filename.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, you should exercise this code in the test. Use assert_raises. Easy to stick immediately before a set_intent, like:

with assert_raises(ValueError):
    img.to_filename(...)
img.nifti_header.set_intent(...)
img.to_filename(...)

@@ -362,11 +379,13 @@ def test_plabel():
matrix.append(parcel_map)
hdr = ci.Cifti2Header(matrix)
data = np.random.randn(2, 3)
img = ci.Cifti2Image(data, hdr)
img = ci.Cifti2Image(data, hdr
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing close paren.

@coalsont
Copy link

coalsont commented Mar 6, 2018

So, just some perspective here: the cifti-2 specification intended to say that if you use the wrong intent code for the XML in the file, then the cifti file is malformed (though to us, it isn't particularly important), so even people using the low-level interface probably won't have a reason to specify an intent code manually.

The cifti processing commands in workbench generally don't care about which of the standard cifti types you are working on (or even if it is a nonstandard type) - they have either an explicitly provided or a hardcoded implicit dimension to operate on, and if the mapping type for an important dimension doesn't match what they need to operate, they throw an error. In essence, operating on a low-level interface gives our processing commands more flexibility (spatial smoothing can be done on dscalar, dtseries, dconn, pdconn, and dpconn, using only one function signature taking a generic CiftiFile and a dimension specifier, rather than needing separate functions to take those files as different high-level types), and having them need to remember to set the intent code in their outputs would be a hassle and error-prone. Only our display logic needs to know how to specially handle specific file types (dscalar is presented as maps to cycle through, connectivity types load a row when clicked, etc).

I don't know how well this maps onto how you plan for people to use your interface, but I don't see a compelling reason not to solve this format detail at the low level.

@satra
Copy link
Member

satra commented Mar 6, 2018

@coalsont - thanks for chiming in. the only way our code currently recognizes a nifti-2 file as being a cifti file is based on the nifti intent code. thus if the intent is not set when someone saves a file created using the current low-level API, the nibabel load function returns a Nifti2Image rather than a CiftiImage. the goal of this PR is to ensure that people save the file with the proper intent.

a higher level api will indeed try to do some operations agnostic of the intent type, but others will guide what sort of metadata as prescribed by the standard are compulsory vs not.

@satra
Copy link
Member

satra commented Mar 6, 2018

@mgxd - the current error is happening in a place that automates testing certain file types. so one option is to set intent to unknown by default and creating a warning for the user when saving a file with type unknown instead of raising an exception.

======================================================================
ERROR: autogenerated test from validate_filenames
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/travis/build/nipy/nibabel/venv/lib/python3.5/site-packages/nose/case.py", line 198, in runTest
    self.test(*self.arg)
  File "/home/travis/build/nipy/nibabel/venv/lib/python3.5/site-packages/nibabel/tests/test_api_validators.py", line 23, in meth
    validator(self, imaker, params)
  File "/home/travis/build/nipy/nibabel/venv/lib/python3.5/site-packages/nibabel/tests/test_image_api.py", line 131, in validate_filenames
    rt_img = bytesio_round_trip(img)
  File "/home/travis/build/nipy/nibabel/venv/lib/python3.5/site-packages/nibabel/tests/test_helpers.py", line 30, in bytesio_round_trip
    img.to_file_map(bytes_map)
  File "/home/travis/build/nipy/nibabel/venv/lib/python3.5/site-packages/nibabel/cifti2/cifti2.py", line 1388, in to_file_map
    raise ValueError("CIFTI image has an invalid intent code.")
ValueError: CIFTI image has an invalid intent code.

@coalsont
Copy link

coalsont commented Mar 6, 2018

Thoughts on having the nifti-2 code look for a header extension with the cifti extension code? Without that extension, it simply can't be interpreted as cifti, and nothing else should use that extension code.

If you aren't intending for the low-level code to ever be used by anything but the higher-level API (or people that specifically want to make malformed files), then I suppose it doesn't matter which of them figures out the correct intent code.

@coalsont
Copy link

coalsont commented Mar 6, 2018

The "NIFTI_INTENT_CONNECTIVITY_UNKNOWN" code is in fact explicitly valid and acceptable for cifti file types that aren't one of the standard mapping combinations - this is to allow flexibility beyond the "ordinary" types (want to make a series by series file? no problem!), without having to issue an update to the standard. Having it always trigger a warning may not be a good idea.

@satra
Copy link
Member

satra commented Mar 6, 2018

thanks @coalsont - we will remove exceptions and warnings then.

@effigies - we can go with your idea of defaulting to unknown, with the user putting in the correct intent code when possible.

@effigies
Copy link
Member

effigies commented Mar 6, 2018

Sounds good to me. With regard to checking XML headers to make sure they match the intent codes, I think that would also be reasonable at this level (though I can see an argument for a higher level interface doing that work, as well). I think that's beyond the scope of this PR, but if we do want to have that discussion it may be worth opening an issue.


with InTemporaryDirectory():
ci.save(img, 'test.plabel.nii')
img2 = ci.load('test.plabel.nii')
assert_true(img.nifti_header.get_intent()[0]
== 'dense fiber/fan samples')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this be unknown?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I matched it with what's consistent in the intent codes

(3000, 'dense fiber/fan samples', (), 'NIFTI_INTENT_CONNECTIVITY_UNKNOWN'),

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert_equal

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is something incorrect here (maybe more than one thing). Dense fiber samples and dense fan samples are just dscalar files with a specific number and meaning of maps - they use the dscalar intent code and name. Their extensions are .dfibersamp.nii and .dfansamp.nii. The dense fans file type is another instance of this.

parcel by label files (which we use the extension .plabel.nii for) are not a "standard" mapping combination, so they should use intent 3000, and intent name "ConnUnknown".

Copy link
Member

@effigies effigies left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cosmetic suggestions. I went ahead and annoyingly/helpfully labeled all of them, since it's easy to miss one or two with repetitive changes.

img2 = ci.load('test.dtseries.nii')
img2 = nib.load('test.dtseries.nii')
assert_true(img2.nifti_header.get_intent()[0]
== 'dense data series/fiber fans')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert_equal(img2.nifti_header.get_intent()[0], 'dense data series/fiber fans')

@@ -212,10 +213,15 @@ def test_dtseries():
hdr = ci.Cifti2Header(matrix)
data = np.random.randn(13, 9)
img = ci.Cifti2Image(data, hdr)
print(img.nifti_header.get_intent())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove print.


with InTemporaryDirectory():
ci.save(img, 'test.dscalar.nii')
img2 = ci.load('test.dscalar.nii')
img2 = nib.load('test.dscalar.nii')
assert_true(img2.nifti_header.get_intent()[0] == 'dense scalar')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert_equal


with InTemporaryDirectory():
ci.save(img, 'test.dconn.nii')
img2 = ci.load('test.dconn.nii')
img2 = nib.load('test.dconn.nii')
assert_true(img2.nifti_header.get_intent()[0] == 'dense connectivity')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert_equal

img2 = ci.load('test.ptseries.nii')
img2 = nib.load('test.ptseries.nii')
assert_true(img2.nifti_header.get_intent()[0]
== 'parcellated data series')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert_equal


with InTemporaryDirectory():
ci.save(img, 'test.pconn.nii')
img2 = ci.load('test.pconn.nii')
assert_true(img.nifti_header.get_intent()[0]
== 'parcellated connectivity')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert_equal

@@ -394,17 +431,21 @@ def test_pconn():
def test_pconnseries():
parcel_map = create_parcel_map((0, 1))
series_map = create_series_map((2, ))

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd re-add this line to clean the diff.


with InTemporaryDirectory():
ci.save(img, 'test.pconnseries.nii')
img2 = ci.load('test.pconnseries.nii')
assert_true(img.nifti_header.get_intent()[0]
== 'parcellated connectivity series')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert_equal


with InTemporaryDirectory():
ci.save(img, 'test.pconnscalar.nii')
img2 = ci.load('test.pconnscalar.nii')
assert_true(img.nifti_header.get_intent()[0]
== 'parcellated connectivity scalar')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert_equal

@@ -416,17 +457,21 @@ def test_pconnseries():
def test_pconnscalar():
parcel_map = create_parcel_map((0, 1))
scalar_map = create_scalar_map((2, ))

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-add.

@effigies
Copy link
Member

effigies commented Mar 7, 2018

I agree with @coalsont (#604 (comment)) that "dense fiber/fan samples" doesn't make much sense and isn't really justified by the links immediately above its entry in the recoder.

intent_codes.add_codes((
# The codes below appear on the CIFTI-2 standard
# http://www.nitrc.org/plugins/mwiki/index.php/cifti:ConnectivityMatrixFileFormats
# https://www.nitrc.org/forum/attachment.php?attachid=341&group_id=454&forum_id=1955
(3000, 'dense fiber/fan samples', (), 'NIFTI_INTENT_CONNECTIVITY_UNKNOWN'),
(3001, 'dense connectivity', (), 'NIFTI_INTENT_CONNECTIVITY_DENSE'),
(3002, 'dense data series/fiber fans', (),
'NIFTI_INTENT_CONNECTIVITY_DENSE_SERIES'),
(3003, 'parcellated connectivity', (),
'NIFTI_INTENT_CONNECTIVITY_PARCELLATED'),
(3004, 'parcellated data series', (),
"NIFTI_INTENT_CONNECTIVITY_PARCELLATED_SERIES"),
(3006, 'dense scalar', (),
'NIFTI_INTENT_CONNECTIVITY_DENSE_SCALARS'),
(3007, 'dense label', (),
'NIFTI_INTENT_CONNECTIVITY_DENSE_LABELS'),
(3008, 'parcellated scalar', (),
'NIFTI_INTENT_CONNECTIVITY_PARCELLATED_SCALAR'),
(3009, 'parcellated dense connectivity', (),
'NIFTI_INTENT_CONNECTIVITY_PARCELLATED_DENSE'),
(3010, 'dense parcellated connectivity', (),
'NIFTI_INTENT_CONNECTIVITY_DENSE_PARCELLATED'),
(3011, 'parcellated connectivity series', (),
'NIFTI_INTENT_CONNECTIVITY_PARCELLATED_PARCELLATED_SERIES'),
(3012, 'parcellated connectivity scalar', (),
'NIFTI_INTENT_CONNECTIVITY_PARCELLATED_PARCELLATED_SCALAR')))

These were added in 7551209, with no explanation for the names chosen. I would suggest we move to ConnUnknown and the other valid values of the intent_name field:

https://github.com/Washington-University/workbench/blob/b565e5c2e6b40a8ca70ac68b1568fa0a0c732216/src/Cifti/CiftiXML.cxx#L297-L319

Is there any argument for keeping the current names?

(I'm also open to this being a separate PR. We are moving beyond the scope of what @mgxd was here to do.)

@mgxd
Copy link
Member Author

mgxd commented Mar 8, 2018

@effigies @coalsont no problem, this PR was looking to update/improve our CIFTI support, so I'm happy to change it.

@effigies
Copy link
Member

effigies commented Mar 8, 2018

Thanks, that looks good to me.

I'm comfortable with this PR as it is, but there is one thing I noticed while looking through the CIFTI sources: CIFTI-2 seems to very strongly tie intent codes and the intent name field. Neither NIFTI-1 nor NIFTI-2 constrain intent_name at all, as far as I can tell, so updating Nifti{1,2}Header.set_intent() doesn't seem quite appropriate.

However, if we move the default setting to Cifti2Image.update_headers, then this constraint can be done pretty naturally:

def update_headers(self):
    """...DOCSTRING..."""
    header = self._nifti_header
    header.set_data_shape(self._dataobj.shape)
    # if intent code is not set, default to unknown CIFTI
    if header.get_intent()[0] == 'none':
        header.set_intent('NIFTI_INTENT_CONNECTIVITY_UNKNOWN')
    header._structarr['intent_name'] = header.get_intent()[0]

This could be a simple addition to to_file_map, instead, if people prefer. If we do this, the tests should also be updated to make sure that the correct intent_name is set.

@coalsont If you could comment on whether my read of intent_code/intent_name correspondence is correct before we go ahead and make another round of revisions, that would be helpful.

@satra @matthew-brett @mgxd Does this seem reasonable to you? Or does this make more sense to push into the higher-level API, whenever that happens? Also, I recognize that get/set_intent already handle intent_name, so the above might be improved upon using the existing API, but I'm not sure of the best logic.

Also, sorry if I keep moving the goal posts. Please say so if this is getting too quibbly. As I said, I think this looks good without further changes.

@matthew-brett
Copy link
Member

@effigies - just to say - I'm happy if you're happy, feel free to merge when you think it's ready.

@effigies
Copy link
Member

effigies commented Mar 9, 2018

Alright. I think this is good to go, as is. That last comment can be addressed in a separate PR, if it's important. @satra @coalsont unless you have any last comments, I'll plan merge by end of day EST.

@coalsont
Copy link

coalsont commented Mar 9, 2018

Just a response on the intent code/name question: cifti does treat them as being linked. It seemed simple and useful to have intent_name be a human-readable form of intent_code, as our software leaves intent_name blank for volume files, because it doesn't know or care whether the volume is T1w, diffusion, or what atlas the warpfield is intended to align to (which are some examples of suggested intent_name usage from nifti-1.h).

So, modifying Nifti2Header to do this cifti-specific intent code/name stuff is probably not the right place to do it - the obvious way to me is for the code that figures out the correct intent_code for the cifti file to also return the linked intent_name. This is squarely in the CiftiFile section of our c++ implementation (which naturally has access to the CiftiXML object, which is where we put the ugly code that checks for the combinations), and the NiftiHeader just accepts whatever we tell it to put in there:

https://github.com/Washington-University/CiftiLib/blob/master/src/CiftiFile.cxx#L575-L576
https://github.com/Washington-University/CiftiLib/blob/master/src/Nifti/NiftiHeader.cxx#L373-L379

However, I should explain that in our code, the CiftiXML object is not merely responsible for the XML parsing, but also as the friendly interface to the mapping information and dimensions, and as the main object for initializing a new CiftiFile for writing.

@effigies effigies merged commit 1140e18 into nipy:master Mar 9, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants