Commit abf10fd4 authored by Paul McCarthy's avatar Paul McCarthy 🚵
Browse files

Merge branch 'enh/scaledvoxels' into 'master'

Display all images in scaled voxels

See merge request fsl/fsleyes/fsleyes!286
parents 93a119d4 d5a73c28
......@@ -9,6 +9,17 @@ This document contains the ``fsleyes`` release history in reverse
chronological order.
1.2.0 (Under development)
-------------------------
* The **Display space** setting can now be set to *Scaled voxel coordinates*
on ortho and lightbox views. This causes all images to be displayed in
scaled voxels, with the origin for each image set to the centre of voxel
``(0, 0, 0)`` (!286).
1.1.0 (Friday 6th August 2021)
------------------------------
......
......@@ -165,7 +165,7 @@ class DisplayContext(props.SyncableHasProperties):
"""
displaySpace = props.Choice(('world', ))
displaySpace = props.Choice(('world', 'scaledVoxel'))
"""The *space* in which overlays are displayed. This property defines the
display coordinate system for this ``DisplayContext``. When it is changed,
the :attr:`.NiftiOpts.transform` property of all :class:`.Nifti` overlays
......@@ -179,7 +179,15 @@ class DisplayContext(props.SyncableHasProperties):
their affine transformation matrix - the :attr:`.NiftiOpts.transform`
property for every ``Nifti`` overlay is set to ``affine``.
2. **Reference image** space
2. **Scaled-voxel** space (a.k.a. ``'scaledVoxel'``)
All :class:`.Nifti` overlays are displayed in a scaled-voxel coordinate
system, with origin set to the centre of voxel ``(0, 0, 0)``, and
voxels scaled by image pixdims. This is accomplished by setting the
:attr:`.NiftiOpts.transform` property for every ``Nifti`` overlay to
``pixdim-flip``.
3. **Reference image** space
A single :class:`.Nifti` overlay is selected as a *reference* image,
and is displayed in scaled voxel space (with a potential L/R flip for
......@@ -495,50 +503,6 @@ class DisplayContext(props.SyncableHasProperties):
return self.getOpts(overlay).referenceImage
def displayToWorld(self, dloc, *args, **kwargs):
"""Transforms the given coordinates from the display coordinate
system into the world coordinate system.
.. warning:: If any :attr:`.NiftiOpts.transform` properties have
been modified manually, this method will return invalid
results.
All other arguments are passed to the
:meth:`.NiftiOpts.transformCoords` method.
"""
displaySpace = self.displaySpace
if displaySpace == 'world' or len(self.__overlayList) == 0:
return dloc
opts = self.getOpts(displaySpace)
return opts.transformCoords(dloc, 'display', 'world', *args, **kwargs)
def worldToDisplay(self, wloc, *args, **kwargs):
"""Transforms the given coordinates from the world coordinate
system into the display coordinate system.
.. warning:: If any :attr:`.NiftiOpts.transform` properties have
been modified manually, this method will return invalid
results.
All other arguments are passed to the
:meth:`.NiftiOpts.transformCoords` method.
"""
displaySpace = self.displaySpace
if displaySpace == 'world' or len(self.__overlayList) == 0:
return wloc
opts = self.getOpts(displaySpace)
return opts.transformCoords(wloc, 'world', 'display', *args, **kwargs)
def displaySpaceIsRadiological(self):
"""Returns ``True`` if the current :attr:`displaySpace` aligns with
a radiological orientation. A radiological orientation is one in
......@@ -552,6 +516,7 @@ class DisplayContext(props.SyncableHasProperties):
if len(self.__overlayList) == 0:
return True
opts = None
space = self.displaySpace
# Display space is either 'world', or an image.
......@@ -562,11 +527,26 @@ class DisplayContext(props.SyncableHasProperties):
# right).
if space == 'world':
return False
else:
opts = self.getOpts(space)
xform = opts.getTransform('pixdim-flip', 'display')
return npla.det(xform) > 0
# If space == scaledVoxel, we decide based on
# the currently selected overlay. Otherwise
# (reference image), we decide based on the ref
# image.
elif space == 'scaledVoxel':
space = self.getSelectedOverlay()
# Use the FSL / FLIRT convention - if the affine
# determinant is negative, assume neurological
# storage order.
if space is not None:
ref = self.getOpts(space).referenceImage
if ref is not None:
opts = self.getOpts(space)
xform = opts.getTransform('pixdim-flip', 'display')
return npla.det(xform) > 0
# no nifti overlays loaded
return False
def selectOverlay(self, overlay):
......@@ -691,9 +671,9 @@ class DisplayContext(props.SyncableHasProperties):
def defaultDisplaySpace(self, ds):
"""Sets the :meth:`defaultDisplaySpace`.
:arg ds: Either ``'ref'`` or ``'world'``.
:arg ds: Either ``'ref'``, ``'scaledVoxel'``, or ``'world'``.
"""
if ds not in ('world', 'ref'):
if ds not in ('world', 'scaledVoxel', 'ref'):
raise ValueError('Invalid default display space: {}'.format(ds))
self.__defaultDisplaySpace = ds
......@@ -893,7 +873,7 @@ class DisplayContext(props.SyncableHasProperties):
if isinstance(overlay, fslimage.Nifti):
choices.append(overlay)
choices.append('world')
choices.extend(('world', 'scaledVoxel'))
choiceProp.setChoices(choices, instance=self)
......@@ -920,9 +900,10 @@ class DisplayContext(props.SyncableHasProperties):
# get called before we have registered a
# listener on the bounds property.
with props.skip(opts, 'bounds', self.__name, ignoreInvalid=True):
if space == 'world': opts.transform = 'affine'
elif image is space: opts.transform = 'pixdim-flip'
else: opts.transform = 'reference'
if space == 'world': opts.transform = 'affine'
elif space == 'scaledVoxel': opts.transform = 'pixdim-flip'
elif image is space: opts.transform = 'pixdim-flip'
else: opts.transform = 'reference'
def __displaySpaceChanged(self, *a):
......@@ -1185,14 +1166,23 @@ class DisplayContext(props.SyncableHasProperties):
self.location = self.worldLocation
return
ref = self.displaySpace
if self.displaySpace == 'scaledVoxel':
ref = self.getSelectedOverlay()
srcSpace = 'pixdim-flip'
else:
ref = self.displaySpace
srcSpace = 'display'
if ref is None:
return
opts = self.getOpts(ref)
if dest == 'world':
with props.skip(self, 'location', self.__name):
self.worldLocation = opts.transformCoords(
self.location, 'display', 'world')
self.location, srcSpace, 'world')
else:
with props.skip(self, 'worldLocation', self.__name):
self.location = opts.transformCoords(
self.worldLocation, 'world', 'display')
self.worldLocation, 'world', srcSpace)
......@@ -27,15 +27,16 @@ in one of several ways:
**scaled voxels** (a.k.a. ``pixdim``) The image data voxel
coordinates are scaled by the ``pixdim`` values
stored in the NIFTI header.
stored in the NIFTI header. The origin is
fixed at the centre of voxel ``(0, 0, 0)``.
**radioloigcal scaled voxels** (a.k.a. ``pixdim-flip``) The image data voxel
coordinates are scaled by the ``pixdim`` values
stored in the NIFTI header and, if the image
appears to be stored in neurological order,
the X (left-right) axis is inverted.
coordinates are scaled by the ``pixdim``
values stored in the NIFTI header and, if the
image appears to be stored in neurological
order, the X (left-right) axis is
inverted. The origin is fixed at the centre of
voxel ``(0, 0, 0)``.
**world** (a.k.a. ``affine``) The image data voxel
coordinates are transformed by the
......@@ -443,6 +444,8 @@ class NiftiOpts(fsldisplay.DisplayOpts):
# on the value of displaySpace
if ds == 'world':
voxToRefMat = voxToWorldMat
elif ds == 'scaledVoxel':
voxToRefMat = voxToPixFlipMat
elif ds is self.overlay:
voxToRefMat = voxToPixFlipMat
else:
......@@ -455,7 +458,7 @@ class NiftiOpts(fsldisplay.DisplayOpts):
# the note on coordinate systems at
# the top of this file).
voxToTexMat = affine.scaleOffsetXform(tuple(1.0 / shape),
tuple(0.5 / shape))
tuple(0.5 / shape))
idToVoxMat = affine.invert(voxToIdMat)
idToPixdimMat = affine.concat(voxToPixdimMat, idToVoxMat)
......
......@@ -2607,6 +2607,8 @@ def applySceneArgs(args, overlayList, displayCtx, sceneOpts):
if args.displaySpace == 'world':
displaySpace = 'world'
elif args.displaySpace == 'scaledVoxel':
displaySpace = 'scaledVoxel'
elif args.displaySpace is not None:
try:
......
......@@ -345,11 +345,11 @@ class OverlayInfoPanel(ctrlpanel.ControlPanel):
dsDisplay = self.displayCtx.getDisplay(dsImg)
displaySpace = displaySpace.format(dsDisplay.name)
else:
log.warn('{} transform ({}) seems to be out '
'of date (display space: {})'.format(
overlay,
opts.transform,
self.displayCtx.displaySpace))
log.warning('{} transform ({}) seems to be out '
'of date (display space: {})'.format(
overlay,
opts.transform,
self.displayCtx.displaySpace))
dataType = strings.nifti.get(('datatype', int(hdr['datatype'])),
'Unknown')
......
......@@ -1274,7 +1274,9 @@ properties = TypeDict({
choices = TypeDict({
'DisplayContext.displaySpace' : {'world' : 'World coordinates'},
'DisplayContext.displaySpace' : {'world' : 'World coordinates',
'scaledVoxel' : 'Scaled voxel coordinates',
},
'SceneOpts.colourBarLocation' : {'top' : 'Top',
'bottom' : 'Bottom',
......
......@@ -32,6 +32,7 @@ import matplotlib.image as mplimg
import fsleyes_props as props
from fsl.utils.tempdir import tempdir
import fsl.utils.idle as idle
import fsl.utils.image.resample as resample
import fsl.transform.affine as affine
import fsl.data.image as fslimage
import fsleyes
......@@ -693,6 +694,18 @@ def roi(fname, roi):
return outfile
def resampled(fname, fac):
base = fslimage.removeExt(op.basename(fname))
outfile = '{}_resampled_{}'.format(base, fac)
img = fslimage.Image(fname)
pix = np.array(img.pixdim[:3]) * fac
data, xform = resample.resampleToPixdims(img, pix)
fslimage.Image(data, header=img.header, xform=xform).save(outfile)
return outfile
def asrgb(infile):
basename = fslimage.removeExt(op.basename(infile))
outfile = '{}_asrgb.nii.gz'.format(basename)
......
#!/usr/bin/env python
#
# test_displayspace.py -
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
import pytest
from fsleyes.tests import run_cli_tests, resampled, roi, rotate
pytestmark = pytest.mark.clitest
tests = """
-vl 10 8 8 -o 0 3d {{roi('3d', (8, 13, 6, 10, 6, 10))}} -cm red-yellow
-vl 10 8 8 -o 0 3d {{resampled(roi('3d', (8, 13, 6, 10, 6, 10)), 0.5)}} -cm red-yellow
-ds scaledVoxel -vl 0 0 0 -o 0 3d {{roi('3d', (8, 13, 6, 10, 6, 10))}} -cm red-yellow
-ds scaledVoxel -vl 0 0 0 -o 0 3d {{resampled(roi('3d', (8, 13, 6, 10, 6, 10)), 0.5)}} -cm red-yellow
3d {{rotate('3d', 20, 20, 20)}} -cm red-yellow -a 50
-ds 3d 3d {{rotate('3d', 20, 20, 20)}} -cm red-yellow -a 50
-ds 3d_rotated_20_20_20 3d {{rotate('3d', 20, 20, 20)}} -cm red-yellow -a 50
-ds world 3d {{rotate('3d', 20, 20, 20)}} -cm red-yellow -a 50
-ds scaledVoxel 3d {{rotate('3d', 20, 20, 20)}} -cm red-yellow -a 50
"""
def test_displayspace():
extras = {
'roi' : roi,
'resampled' : resampled,
'rotate' : rotate
}
run_cli_tests('test_displayspace', tests, extras=extras)
......@@ -159,6 +159,21 @@ coordinate system <display_space_world_coordinate_system>` of the images you
are viewing.
.. _display_space_scaled_voxel_space:
Scaled voxel space
^^^^^^^^^^^^^^^^^^
FSLeyes is also capable of displaying all images in their respective scaled
voxel coordinate system - this is achieved by setting the **Display space** to
*Scaled voxel coordinates*. Each image is positioned relative to an origin at
the centre of voxel ``(0, 0, 0)``.
.. note:: This is an advanced option that should not normally be needed, but
can sometimes be useful for troubleshooting problematic images.
.. _display_space_nifti_image_orientation:
NIFTI image orientation
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment