Skip to content
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

RF: Simplify MGHImage and add footer fields #569

Merged
merged 34 commits into from
Dec 18, 2017
Merged

Conversation

effigies
Copy link
Member

@effigies effigies commented Oct 25, 2017

This PR includes a number of simplifications to the MGHHeader and MGHImage data structures.

The most significant is subclassing MGHHeader from LabeledWrapStruct. It appeared to have been a reimplementation of LabeledWrapStruct in many places without the endianness option. I have simply raised an exception on attempts to instantiate as little-endian.

The next most significant change is moving from Mdc (matrix of direction cosines) and Pxyz_c (center point in world coordinates) to the more explicit x/y/z/c_ras column vectors. These are consistent with most FreeSurfer literature and program outputs, and correctly encode the shape of the direction cosines as column-major rather than row-major (needing to be transposed when reading/writing).

Somewhat confusingly, there are two ways of referring to the voxel and world coordinates, and XYZ means world in one, and voxel in the other, and in both cases the alternative includes "R" and "S".

Voxel World
Column Row Slice XYZ
XYZ Right Anterior Superior

As CRS/XYZ appears to be FreeSurfer-unique, I'd prefer to settle on XYZ/RAS, and perhaps consider non-XYZ variable names when they can reduce confusion.

TODO:

  • Update tests with new header fields
  • Annotate header dtype with descriptions of fields (possibly further updating field names, shapes)
  • Improve footer handling (not targeting full features, but access to TR would be good)
  • Fix get_data_shape handling (see ENH: Add image slicing #550)

@effigies effigies force-pushed the rf/mgh branch 3 times, most recently from 5241b1b to 832f584 Compare October 28, 2017 14:43
@effigies effigies changed the title [WIP] RF: Simplify MGHImage RF: Simplify MGHImage and add footer fields Oct 28, 2017
@coveralls
Copy link

Coverage Status

Coverage increased (+0.02%) to 96.27% when pulling 8870068 on effigies:rf/mgh into b1bf5e7 on nipy:master.

@codecov-io
Copy link

codecov-io commented Oct 28, 2017

Codecov Report

Merging #569 into master will increase coverage by 0.09%.
The diff coverage is 97.83%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #569      +/-   ##
==========================================
+ Coverage   94.32%   94.41%   +0.09%     
==========================================
  Files         177      177              
  Lines       24697    24884     +187     
  Branches     2640     2656      +16     
==========================================
+ Hits        23295    23495     +200     
+ Misses        925      913      -12     
+ Partials      477      476       -1
Impacted Files Coverage Δ
nibabel/spatialimages.py 96.05% <100%> (ø) ⬆️
nibabel/tests/test_spatialimages.py 99.7% <100%> (ø) ⬆️
nibabel/freesurfer/tests/test_mghformat.py 98.44% <97.31%> (-1.56%) ⬇️
nibabel/freesurfer/mghformat.py 95.53% <98.42%> (+7.03%) ⬆️

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 2817d1b...8d720fe. Read the comment docs.

@coveralls
Copy link

Coverage Status

Coverage increased (+0.06%) to 96.311% when pulling 21cbbcf on effigies:rf/mgh into b1bf5e7 on nipy:master.

@effigies
Copy link
Member Author

This is ready for review, if anybody's got a few minutes.

Copy link
Member

@matthew-brett matthew-brett left a comment

Choose a reason for hiding this comment

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

Main question - is it really worth renaming Mdc and the other fields?

@@ -84,6 +92,7 @@ class MGHHeader(object):

def __init__(self,
binaryblock=None,
endianness='>',
Copy link
Member

Choose a reason for hiding this comment

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

Does it make sense to allow passing this flag? It also changes the API.

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 did this because of places where LabeledWrapStruct calls __init__ assuming the type signature of the base class. I can instead make sure to override any methods that make such calls, just figured this would be a little shorter.

Copy link
Member

Choose a reason for hiding this comment

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

I played with the idea of making WrapStruct have a parent or similar that had forced endianness, but I seem to remember it got a bit messy.


def check_fix(self):
''' Pass. maybe for now'''
return klass(hdr_str + ftr_str, check=check)

def get_affine(self):
''' Get the affine transform from the header information.
Copy link
Member

Choose a reason for hiding this comment

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

Blank line after first line (sorry, I know that wasn't present before either).

MdcD = np.hstack((hdr['x_ras'], hdr['y_ras'], hdr['z_ras'])) * hdr['voxelsize']
vol_center = MdcD.dot(hdr['dims'][:3].reshape(-1, 1)) / 2
affine[:3, :3] = MdcD
affine[:3, [3]] = hdr['c_ras'] - vol_center
Copy link
Member

Choose a reason for hiding this comment

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

3 rather than [3] ?

Copy link
Member Author

Choose a reason for hiding this comment

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

The RHS is a column, so the [3] keeps the LHS expecting a column. Is there a trick I don't know here?

hdr = self._header_data
zooms = hdr['delta']
return tuple(zooms[:])
# Do not return time zoom (TR) if 3D image
Copy link
Member

Choose a reason for hiding this comment

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

Maybe worth a note in the docstring about the time zoom and 3D?

('dims', '>i4', (4,)), # 4; width, height, depth, nframes
('type', '>i4'), # 20; data type
('dof', '>i4'), # 24; degrees of freedom
('ras_good', '>i2'), # 28; *_ras fields valid
Copy link
Member

Choose a reason for hiding this comment

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

I guess these renamings are API changes - our our Freesurfer colleagues happy with these name changes? Any way to offer the old names as alternatives for a while?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, I can re-add a _header_data structure (which I've replaced with LabeledWrapStruct._structarr) that will expose the old fields and raise a DeprecationWarning.

('dof', '>i4'), # 24; degrees of freedom
('ras_good', '>i2'), # 28; *_ras fields valid
('voxelsize', '>f4', (3,)), # 30; zooms (X, Y, Z)
('x_ras', '>f4', (3, 1)), # 42; X direction cosine column
Copy link
Member

Choose a reason for hiding this comment

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

(3, 1) rather than (3,)?

Copy link
Member

Choose a reason for hiding this comment

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

Is this split into three columns really useful? Compared to the native 3, 3 size?

Copy link
Member Author

Choose a reason for hiding this comment

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

By including all three columns as a single, row-ordered Mdc matrix, we're exposing a transposed matrix. It was being transposed correctly in and out, but if someone does decide to use it directly, they need to know this. I prefer explicit to silently misleading...

I could use (3,) if that's important. But it will mean users knowing that they're columns and not row vectors (can add to the docs).

For context, this is the comparable portion of the C data structure, which is just 16 individual float fields: https://github.com/freesurfer/freesurfer/blob/5367eec84621b331271e97f4e1f1502c3f6d0d87/include/mri.h#L184-L191

Copy link
Member

Choose a reason for hiding this comment

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

OK - fine to leave as is (3,1) etc.

hdr = self._structarr
MdcD = np.hstack((hdr['x_ras'], hdr['y_ras'], hdr['z_ras'])) * hdr['voxelsize']
vol_center = MdcD.dot(hdr['dims'][:3].reshape(-1, 1)) / 2
affine[:3, :3] = MdcD
Copy link
Member

Choose a reason for hiding this comment

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

return nib.affines.from_matvec(MdcD, hdr['c_ras'] - vol_center)

?


Ignores byte order; always big endian
'''
structarr = super(MGHHeader, klass).default_structarr()
Copy link
Member

Choose a reason for hiding this comment

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

Here might be worth checking and error if endian not big.


# Assign after we've had a chance to raise exceptions
hdr['voxelsize'] = voxelsize
hdr['x_ras'] = Mdc[:, [0]]
Copy link
Member

Choose a reason for hiding this comment

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

hdr['x_ras'], hdr['y_ras'], hdr['z_ras'] = Mdc.T

?

@effigies
Copy link
Member Author

Re: Renaming, the biggest motivation is that a lot of the field names seem only to align with one slideshow that documents the spaces/transforms, and not at all with either the FreeSurfer source code or the outputs of mri_info or other FreeSurfer tools, let alone how anybody outside FreeSurfer refers to these spaces.

So my strong preference is to move to a more consistent naming scheme (I'm open to alternative suggestions), but I'm also perfectly happy to provide a backwards-compatible interface to access the data using the old field names to get back the old shapes.

Copy link
Member

@matthew-brett matthew-brett left a comment

Choose a reason for hiding this comment

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

I guess it would be good to get more feedback from Freesurferers out there.

('dof', '>i4'), # 24; degrees of freedom
('ras_good', '>i2'), # 28; *_ras fields valid
('voxelsize', '>f4', (3,)), # 30; zooms (X, Y, Z)
('x_ras', '>f4', (3, 1)), # 42; X direction cosine column
Copy link
Member

Choose a reason for hiding this comment

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

OK - fine to leave as is (3,1) etc.

@@ -84,6 +92,7 @@ class MGHHeader(object):

def __init__(self,
binaryblock=None,
endianness='>',
Copy link
Member

Choose a reason for hiding this comment

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

I played with the idea of making WrapStruct have a parent or similar that had forced endianness, but I seem to remember it got a bit messy.

@effigies
Copy link
Member Author

effigies commented Nov 1, 2017

Okay. I've pinged the Python neuroimaging list and the FreeSurfer support list. Hopefully we can find somebody willing to share their opinions on the Internet. :-)

@mwaskom
Copy link
Member

mwaskom commented Nov 1, 2017

I don't have a strong opinion here, but I would guess it's more likely that a user has encountered the fscoordinates slide deck than read through the freesurfer source code.

@satra
Copy link
Member

satra commented Nov 1, 2017

@effigies - perhaps chatting with doug would be a good idea as well. also folks at corticometrics may have some suggestions as well.

however, i do not have a strong opinion either. since i rarely do much with those files than reading into an array, doing some processing, and writing. or just visualization.

@effigies
Copy link
Member Author

effigies commented Nov 1, 2017

@satra Can I expect them to respond to the post on the FreeSurfer list, or is there a preferred way of contacting them more directly?

@satra
Copy link
Member

satra commented Nov 1, 2017

@effigies - i hadn't seen that the mail was on the freesurfer list. hopefully they respond - otherwise it's your call :)

@effigies
Copy link
Member Author

effigies commented Nov 7, 2017

Okay, it sounds like there's a slight preference from @mwaskom to follow the FS documentary slideshow and a preference (of unknown strength) from @matthew-brett to avoid changing names without a Very Good Reason. I'll defer to this for now and revert Mdc and Pxyz_c, because I don't think it's critical to my more pressing goals here of getting zooms for 4D images to include TR (to match the behavior of Analyze-like images) and enforcing the 3/4D constraint.

However, I do think I would like to move to more "standard" FreeSurfer naming, because this construct ("volume geometry", or dimensions + x/y/z/c_ras) shows up in several places, including m3z warp files, LTA source and destination geometries (see #565), and in a text appendage to surface files:

def _read_volume_info(fobj):
"""Helper for reading the footer from a surface file."""
volume_info = OrderedDict()
head = np.fromfile(fobj, '>i4', 1)
if not np.array_equal(head, [20]): # Read two bytes more
head = np.concatenate([head, np.fromfile(fobj, '>i4', 2)])
if not np.array_equal(head, [2, 0, 20]):
warnings.warn("Unknown extension code.")
return volume_info
volume_info['head'] = head
for key in ['valid', 'filename', 'volume', 'voxelsize', 'xras', 'yras',
'zras', 'cras']:
pair = fobj.readline().decode('utf-8').split('=')
if pair[0].strip() != key or len(pair) != 2:
raise IOError('Error parsing volume info.')
if key in ('valid', 'filename'):
volume_info[key] = pair[1].strip()
elif key == 'volume':
volume_info[key] = np.array(pair[1].split()).astype(int)
else:
volume_info[key] = np.array(pair[1].split()).astype(float)
# Ignore the rest
return volume_info

def _serialize_volume_info(volume_info):
"""Helper for serializing the volume info."""
keys = ['head', 'valid', 'filename', 'volume', 'voxelsize', 'xras', 'yras',
'zras', 'cras']
diff = set(volume_info.keys()).difference(keys)
if len(diff) > 0:
raise ValueError('Invalid volume info: %s.' % diff.pop())
strings = list()
for key in keys:
if key == 'head':
if not (np.array_equal(volume_info[key], [20]) or np.array_equal(
volume_info[key], [2, 0, 20])):
warnings.warn("Unknown extension code.")
strings.append(np.array(volume_info[key], dtype='>i4').tostring())
elif key in ('valid', 'filename'):
val = volume_info[key]
strings.append('{0} = {1}\n'.format(key, val).encode('utf-8'))
elif key == 'volume':
val = volume_info[key]
strings.append('{0} = {1} {2} {3}\n'.format(
key, val[0], val[1], val[2]).encode('utf-8'))
else:
val = volume_info[key]
strings.append('{0} = {1:0.10g} {2:0.10g} {3:0.10g}\n'.format(
key.ljust(6), val[0], val[1], val[2]).encode('utf-8'))
return b''.join(strings)

But I think it does make sense to postpone the discussion of field naming to an actual attempt to provide a common interface to volume geometries (which is actually re-implemented in several FreeSurfer data structures).

@coveralls
Copy link

Coverage Status

Coverage increased (+0.004%) to 96.257% when pulling 1bad727 on effigies:rf/mgh into b1bf5e7 on nipy:master.

@matthew-brett
Copy link
Member

Chris - sorry - I should have said - I don't care too much about the name changes, especially if there's a deprecation route. I should have said before, but if you think these names are more canonical, that's OK - we can always explain in the docstring and comments what the relationship is to the slide deck.

@effigies
Copy link
Member Author

Okay. I think I'll still push it off for now. If/when we add support for LTA/M3Z etc, it will be time enough to consider a consistent naming scheme, rather than deprecating the old one now and finding later that we would have preferred something slightly different.

I will add docs though noting that Mdc is a transposed matrix. (It's a little annoying that I can't specify row/column order in the dtype.)

@coveralls
Copy link

Coverage Status

Coverage increased (+0.003%) to 96.258% when pulling 79a5876 on effigies:rf/mgh into d420dcf on nipy:master.

@coveralls
Copy link

Coverage Status

Coverage increased (+0.003%) to 96.258% when pulling 821b6b6 on effigies:rf/mgh into 2817d1b on nipy:master.

@coveralls
Copy link

Coverage Status

Coverage increased (+0.004%) to 96.258% when pulling fede660 on effigies:rf/mgh into 2817d1b on nipy:master.

@coveralls
Copy link

Coverage Status

Coverage decreased (-0.02%) to 96.236% when pulling 0d4cdd0 on effigies:rf/mgh into 2817d1b on nipy:master.

@coveralls
Copy link

Coverage Status

Coverage increased (+0.04%) to 96.293% when pulling 664f11c on effigies:rf/mgh into 2817d1b on nipy:master.

@effigies
Copy link
Member Author

effigies commented Dec 9, 2017

@matthew-brett Got back to this. I think I've addressed all of your comments, and I've fleshed out some more tests. Ready for another review.

@coveralls
Copy link

coveralls commented Dec 9, 2017

Coverage Status

Coverage increased (+0.08%) to 96.331% when pulling fa73ad4 on effigies:rf/mgh into 2817d1b on nipy:master.

@effigies
Copy link
Member Author

Hi @matthew-brett, just bugging you about this. No worries if you're busy.

Copy link
Member

@matthew-brett matthew-brett left a comment

Choose a reason for hiding this comment

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

Just a few small comments.


def set_zooms(self, zooms):
''' Set zooms into header fields

See docstring for ``get_zooms`` for examples
Sets the spaing of voxels in the x, y, and z dimensions.
Copy link
Member

Choose a reason for hiding this comment

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

Typo 'spacing'


def set_zooms(self, zooms):
''' Set zooms into header fields

See docstring for ``get_zooms`` for examples
Sets the spaing of voxels in the x, y, and z dimensions.
For four-dimensional files, a temporal zoom (repetition time, or TR, in
Copy link
Member

Choose a reason for hiding this comment

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

Ouch - it's really in milliseconds? That's unfortunate (compared to nifti).

Copy link
Member Author

Choose a reason for hiding this comment

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

Annoyingly. Fortunately #567 proposes a way to get normalized zooms. (It was working on that that led to this PR in the first place.)

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
Member

Choose a reason for hiding this comment

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

Add reference to docstring?

@coveralls
Copy link

Coverage Status

Coverage increased (+0.08%) to 96.331% when pulling c066cea on effigies:rf/mgh into 2817d1b on nipy:master.

@matthew-brett
Copy link
Member

Thanks for your patience, in it goes.

@matthew-brett matthew-brett merged commit b646d5f into nipy:master Dec 18, 2017
@effigies effigies deleted the rf/mgh branch December 18, 2017 17:19
@coveralls
Copy link

Coverage Status

Coverage increased (+0.08%) to 96.331% when pulling 8d720fe on effigies:rf/mgh into 2817d1b on nipy:master.

@effigies effigies mentioned this pull request Dec 18, 2017
@matthew-brett
Copy link
Member

Oops - a couple of PEP8 failures : https://travis-ci.org/nipy/nibabel/builds/318192252

@effigies
Copy link
Member Author

Fixed in 7afc6d2. Feel free to cherry-pick into master if #550 isn't ready to merge.

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.

6 participants