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

ENH: Add get/set_zooms(units='norm') to manipulate zooms in mm/s units #567

Open
wants to merge 24 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3f3b450
ENH: Add get_norm_zooms for zooms in mm/s units
effigies Oct 24, 2017
d26acfb
ENH: Add get_norm_zooms for MGHHeader
effigies Dec 18, 2017
de9323c
ENH: Add set_norm_zooms
effigies Dec 21, 2017
5c30366
FIX: Various unit issues
effigies Dec 21, 2017
4b86d3e
TEST: Test get/set_norm_zooms
effigies Dec 21, 2017
eb1d8ff
TEST: Fix warnings
effigies Dec 21, 2017
6ba8b66
RF: get_norm_zooms -> get_zooms(units="canonical")
effigies Jan 12, 2018
1357a3b
TEST: Update get_zooms tests
effigies Jan 13, 2018
096da88
FIX: Set default t_code even if no t_zoom
effigies Jan 13, 2018
798fc4e
TEST: Add units specification to tests
effigies Feb 19, 2018
f31aba5
TEST: Add setup/teardown to try to catch warnings properly [WIP]
effigies Feb 19, 2018
27234c3
TEST: Try using clear_and_catch_warnings
effigies Feb 19, 2018
8899bc7
RF: Add units parameter to set_zooms
effigies Mar 12, 2018
da54665
TEST: More complete zoom testing, revert unnecessary change
effigies Mar 12, 2018
1620f31
TEST: Improve warning filters, check set_zooms behavior more thoroughly
effigies Mar 12, 2018
5d96f5c
TEST: Explicitly test non-temporal t_units during set_zooms(units="no…
effigies Mar 12, 2018
233f7f0
TEST: Filter warnings for unmodified superclass tests only
effigies Mar 12, 2018
4babf08
ENH: Add units/raise_unknown to MINC/ECAT get_zooms
effigies Mar 12, 2018
6cd83f7
ENH: Validate units parameter in all get/set_zooms
effigies Mar 13, 2018
94cbd0d
FIX: Correct and simplify NIFTI logic
effigies Mar 14, 2018
cfc5abe
TEST: Clear nifti1 module warnings
effigies Mar 20, 2018
3562921
TEST: Check edge cases
effigies Mar 20, 2018
d9199ca
TEST: Add unit to bad set_zoom call
effigies Mar 20, 2018
98e43a0
TEST: Check get_zooms units arg in MINC/ECAT
effigies Mar 20, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions nibabel/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ def from_header(klass, header=None, check=True):
f"but output header {klass} does not support it")
obj.set_data_dtype(header.get_data_dtype())
obj.set_data_shape(header.get_data_shape())
obj.set_zooms(header.get_zooms())
obj.set_zooms(header.get_zooms(units='raw'), units='raw')
Copy link
Member Author

Choose a reason for hiding this comment

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

Perhaps using units='norm' in both cases would be better, as it would also entail setting sensible units for NIfTI. But that may be making a bad assumption if you're in a non-temporal 4th dimension case (ppm, rads, hz).

if check:
obj.check_fix()
return obj
Expand Down Expand Up @@ -661,13 +661,22 @@ def get_base_affine(self):

get_best_affine = get_base_affine

def get_zooms(self):
""" Get zooms from header
def get_zooms(self, units='norm', raise_unknown=False):
""" Get zooms (spacing between voxels along each axis) from header

Parameters
----------
units : {'norm', 'raw'}, optional
Return zooms in normalized units of mm/sec for spatial/temporal or
as raw values stored in header.
raise_unkown : bool, optional
If normalized units are requested and the units are ambiguous, raise
a ``ValueError``

Returns
-------
z : tuple
tuple of header zoom values
zooms : tuple
tuple of header zoom values

Examples
--------
Expand All @@ -681,6 +690,8 @@ def get_zooms(self):
>>> hdr.get_zooms()
(3.0, 4.0)
"""
if units not in ('norm', 'raw'):
raise ValueError("`units` parameter must be 'norm' or 'raw'")
hdr = self._structarr
dims = hdr['dim']
ndim = dims[0]
Expand All @@ -689,11 +700,22 @@ def get_zooms(self):
pixdims = hdr['pixdim']
return tuple(pixdims[1:ndim + 1])

def set_zooms(self, zooms):
def set_zooms(self, zooms, units='norm'):
""" Set zooms into header fields

See docstring for ``get_zooms`` for examples

Parameters
----------
zooms : sequence of floats
Zoom values to set in header
units : {'norm', 'raw'}, optional
Zooms are specified in normalized units of mm/sec for
spatial/temporal or as raw values to be interpreted according to
format specification.
"""
if units not in ('norm', 'raw'):
raise ValueError("`units` parameter must be 'norm' or 'raw'")
hdr = self._structarr
dims = hdr['dim']
ndim = dims[0]
Expand Down
4 changes: 3 additions & 1 deletion nibabel/ecat.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,8 +578,10 @@ def get_frame_affine(self, frame=0):
z_off])
return aff

def get_zooms(self, frame=0):
def get_zooms(self, frame=0, units='norm', raise_unknown=False):
"""returns zooms ...pixdims"""
if units not in ('norm', 'raw'):
raise ValueError("`units` parameter must be 'norm' or 'raw'")
subhdr = self.subheaders[frame]
x_zoom = subhdr['x_pixel_size'] * 10
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 wasn't sure whether to make 'raw' change this factor to 1.

y_zoom = subhdr['y_pixel_size'] * 10
Expand Down
61 changes: 51 additions & 10 deletions nibabel/freesurfer/mghformat.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,40 +237,75 @@ def _ndims(self):
"""
return 3 + (self._structarr['dims'][3] > 1)

def get_zooms(self):
def get_zooms(self, units='norm', raise_unknown=False):
""" Get zooms from header

Returns the spacing of voxels in the x, y, and z dimensions.
For four-dimensional files, a fourth zoom is included, equal to the
repetition time (TR) in ms (see `The MGH/MGZ Volume Format
<mghformat>`_).
repetition time (TR).
TR is stored in milliseconds (see `The MGH/MGZ Volume Format
<mghformat>`_),
so if ``units == 'raw'``, the fourth zoom will be in ms.
If ``units == 'norm'`` (default), the fourth zoom will be in
seconds.

To access only the spatial zooms, use `hdr['delta']`.

Parameters
----------
units : {'norm', 'raw'}, optional
Return zooms in normalized units of mm/sec for spatial/temporal or
as raw values stored in header.
raise_unkown : bool, optional
If normalized units are requested and the units are ambiguous, raise
a ``ValueError``

Returns
-------
z : tuple
tuple of header zoom values
zooms : tuple
tuple of header zoom values

.. _mghformat: https://surfer.nmr.mgh.harvard.edu/fswiki/FsTutorial/MghFormat#line-82
"""
if units == 'norm':
tfactor = 0.001
elif units == 'raw':
tfactor = 1
else:
raise ValueError("`units` parameter must be 'norm' or 'raw'")

# Do not return time zoom (TR) if 3D image
tzoom = (self['tr'],) if self._ndims() > 3 else ()
tzoom = (self['tr'] * tfactor,) if self._ndims() > 3 else ()
return tuple(self._structarr['delta']) + tzoom

def set_zooms(self, zooms):
def set_zooms(self, zooms, units='norm'):
""" Set zooms into header fields

Sets the spacing of voxels in the x, y, and z dimensions.
For four-dimensional files, a temporal zoom (repetition time, or TR, in
ms) may be provided as a fourth sequence element.
For four-dimensional files, a temporal zoom (repetition time, or TR)
may be provided as a fourth sequence element.

TR is stored in milliseconds (see `The MGH/MGZ Volume Format
<mghformat>`_),
so if ``units == 'raw'``, the fourth zoom will be interpreted as ms
and stored unmodified.
If ``units == 'norm'`` (default), the fourth zoom will be interpreted
as seconds, and converted to ms before storing in the header.

Parameters
----------
zooms : sequence
sequence of floats specifying spatial and (optionally) temporal
zooms
units : {'norm', 'raw'}, optional
Zooms are specified in normalized units of mm/sec for
spatial/temporal dimensions or as raw values to be stored in
header.

.. _mghformat: https://surfer.nmr.mgh.harvard.edu/fswiki/FsTutorial/MghFormat#line-82
"""
if units not in ('norm', 'raw'):
raise ValueError("`units` parameter must be 'norm' or 'raw'")
Copy link
Member

Choose a reason for hiding this comment

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

too frequent of a code pattern. In current design I guess the only way to mitigate is to come up with self._check_units(units) to centralize it

Copy link
Member Author

Choose a reason for hiding this comment

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

We could do something like that.

hdr = self._structarr
zooms = np.asarray(zooms)
ndims = self._ndims()
Expand All @@ -283,7 +318,13 @@ def set_zooms(self, zooms):
if len(zooms) == 4:
if zooms[3] < 0:
raise HeaderDataError(f'TR must be non-negative; got {zooms[3]}')
hdr['tr'] = zooms[3]
if units == 'norm':
tfactor = 1000
elif units == 'raw':
tfactor = 1
else:
raise ValueError("`units` parameter must be 'norm' or 'raw'")
hdr['tr'] = zooms[3] * tfactor

def get_data_shape(self):
""" Get shape of data
Expand Down
71 changes: 67 additions & 4 deletions nibabel/freesurfer/tests/test_mghformat.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ def test_read_mgh():
assert_almost_equal(h['flip_angle'], 0.0)
assert_almost_equal(h['te'], 0.0)
assert_almost_equal(h['ti'], 0.0)
assert_array_almost_equal(h.get_zooms(), [1, 1, 1, 2])
assert_array_almost_equal(h.get_zooms(units='raw'), [1, 1, 1, 2])
assert_array_almost_equal(h.get_zooms(units='norm'), [1, 1, 1, 0.002])
assert_array_almost_equal(h.get_vox2ras(), v2r)
assert_array_almost_equal(h.get_vox2ras_tkr(), v2rtkr)

Expand Down Expand Up @@ -147,9 +148,14 @@ def test_write_noaffine_mgh():
def test_set_zooms():
mgz = load(MGZ_FNAME)
h = mgz.header
assert_array_almost_equal(h.get_zooms(), [1, 1, 1, 2])
h.set_zooms([1, 1, 1, 3])
assert_array_almost_equal(h.get_zooms(), [1, 1, 1, 3])
assert_array_almost_equal(h.get_zooms(units='raw'), [1, 1, 1, 2])
assert_array_almost_equal(h.get_zooms(units='norm'), [1, 1, 1, 0.002])
h.set_zooms([1, 1, 1, 3], units='raw')
assert_array_almost_equal(h.get_zooms(units='raw'), [1, 1, 1, 3])
assert_array_almost_equal(h.get_zooms(units='norm'), [1, 1, 1, 0.003])
h.set_zooms([1, 1, 1, 3], units='norm')
assert_array_almost_equal(h.get_zooms(units='raw'), [1, 1, 1, 3000])
assert_array_almost_equal(h.get_zooms(units='norm'), [1, 1, 1, 3])
for zooms in ((-1, 1, 1, 1),
(1, -1, 1, 1),
(1, 1, -1, 1),
Expand Down Expand Up @@ -351,6 +357,63 @@ def check_dtypes(self, expected, actual):
# MGH requires the actual to be a big endian version of expected
assert expected.newbyteorder('>') == actual

def test_zooms_edge_cases(self):
img_klass = self.image_class
aff = np.eye(4)
arr = np.arange(120, dtype=np.int16).reshape((2, 3, 4, 5))
img = img_klass(arr, aff)

assert_array_almost_equal(img.header.get_zooms(units='raw'),
(1, 1, 1, 0))
assert_array_almost_equal(img.header.get_zooms(units='norm'),
(1, 1, 1, 0))

img.header.set_zooms((1, 1, 1, 2000), units='raw')
assert_array_almost_equal(img.header.get_zooms(units='raw'),
(1, 1, 1, 2000))
assert_array_almost_equal(img.header.get_zooms(units='norm'),
(1, 1, 1, 2))
assert_array_almost_equal(img.header.get_zooms(), (1, 1, 1, 2))

img.header.set_zooms((2, 2, 2, 3), units='norm')
assert_array_almost_equal(img.header.get_zooms(units='raw'),
(2, 2, 2, 3000))
assert_array_almost_equal(img.header.get_zooms(units='norm'),
(2, 2, 2, 3))
assert_array_almost_equal(img.header.get_zooms(), (2, 2, 2, 3))

# It's legal to set zooms for spatial dimensions only
img.header.set_zooms((3, 3, 3), units='norm')
assert_array_almost_equal(img.header.get_zooms(units='raw'),
(3, 3, 3, 3000))
assert_array_almost_equal(img.header.get_zooms(units='norm'),
(3, 3, 3, 3))
assert_array_almost_equal(img.header.get_zooms(), (3, 3, 3, 3))

arr = np.arange(24, dtype=np.int16).reshape((2, 3, 4))
img = img_klass(arr, aff)

assert_array_almost_equal(img.header.get_zooms(units='raw'),
(1, 1, 1))
assert_array_almost_equal(img.header.get_zooms(units='norm'),
(1, 1, 1))

img.header.set_zooms((2, 2, 2), units='raw')
assert_array_almost_equal(img.header.get_zooms(units='raw'),
(2, 2, 2))
assert_array_almost_equal(img.header.get_zooms(units='norm'),
(2, 2, 2))

img.header.set_zooms((3, 3, 3), units='norm')
assert_array_almost_equal(img.header.get_zooms(units='raw'),
(3, 3, 3))
assert_array_almost_equal(img.header.get_zooms(units='norm'),
(3, 3, 3))

# Cannot set TR as zoom for 3D image
assert_raises(HeaderDataError, img.header.set_zooms, (4, 4, 4, 5), 'raw')
assert_raises(HeaderDataError, img.header.set_zooms, (4, 4, 4, 5), 'norm')


class TestMGHHeader(tws._TestLabeledWrapStruct):
header_class = MGHHeader
Expand Down
4 changes: 3 additions & 1 deletion nibabel/minc1.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,10 @@ def get_data_dtype(self):
def get_data_shape(self):
return self._image.data.shape

def get_zooms(self):
def get_zooms(self, units='norm', raise_unknown=False):
""" Get real-world sizes of voxels """
if units not in ('norm', 'raw'):
raise ValueError("`units` parameter must be 'norm' or 'raw'")
# zooms must be positive; but steps in MINC can be negative
return tuple([abs(float(dim.step)) if hasattr(dim, 'step') else 1.0
for dim in self._dims])
Expand Down
Loading