diff --git a/TODO b/TODO index 6ad55537b0534bb6d2a08428e1ec5b78505bfc43..c35621f2281a33bc435310ae4414e3b059b295e7 100644 --- a/TODO +++ b/TODO @@ -1,3 +1,4 @@ + TODO LIST -*- mode: org; -*- A mixture of things to do, things which might be nice to do, and things which @@ -6,7 +7,16 @@ https://internal.fmrib.ox.ac.uk/i-wiki/Analysis/Todos/FSLView regularly, too. * Bugs to fix -** 'World location' should always be defined by the NIFTI affine transformation +** Canvases are not displayed properly when labels are shown. +** World location is clamped to voxel centre when voxel location changes. Not sure if this really needs to be fixed ... +** Linux/Jalapeno Bad window crash +** No worky in VNC session. +** WX Bugs +*** Floating panels get moved about when opening a new floating panel. +*** Make range widget limit buttons expand properly. Ugh. Works fine under linux. This looks like an OSX Mavericks bug. +*** Wild cards in image file load (WX/OSX issue) +** OSMesa render doesn't like circle voxels +** DONE 'World location' should always be defined by the NIFTI affine transformation Currently your 'world coordinate system is equivalent to the display coordinate system. You need to ensure that all displayed 'world coordinates' are the ones generated by the image affine transformation matrix. @@ -15,13 +25,6 @@ You should remove the 'transform' property from the Image class. This should only be a property on the ImageDisplay class. This means that it might be worth moving the transformation matrix stuff out of the Image class, and perhaps into a standalone module... -** Linux/Jalapeno Bad window crash -** No worky in VNC session. -** WX Bugs -*** Floating panels get moved about when opening a new floating panel. -*** Make range widget limit buttons expand properly. Ugh. Works fine under linux. This looks like an OSX Mavericks bug. -*** Wild cards in image file load (WX/OSX issue) -** OSMesa render doesn't like circle voxels ** DONE Ortho anatomical labels not being refreshed ** DONE Cursor location in lightbox can be out of the slices ** DONE LightBox screwy on image transformation change @@ -59,15 +62,19 @@ perhaps into a standalone module... ** DONE Graceful handling of bad input filenames ** DONE Aspect ratio on slicecanvas zoom, and panning is broken. * Little things +** Allow display resolution greater than image resolution ** CLI options *** Show/hide labels *** Ortho layout (horiz/vert/grid) *** Check that show/hide cursor is being applied correctly +*** A switch to automatically choose colour maps/clipping thresholds for a provided set of images (like Eugene's fast fslview startup script) ** Scale values in colour map according to largest value? ** ColourBarPanel should only do stuff if it is being displayed +** ImageDisplay objects could be managed by DisplayContetx, rather than being tacked on as an image attribute. The same could be said of GLObjects. ** Arrow keys on image list change selected image ** 'Centre view' option on slice canvases ** Props package - add a setting on HasProperties instances to allow validation of all properties to be disabled for instances which don't need it (see comments at properties.py:264). +** Props package - floating point property equality - if difference is < some small value, the property should be considered unchanged, and listeners should not be notified. ** Optimisations - tune image._transform, and glimage.genVertexData functions. Go back to using triangle strips? ** Get rid of imagefile module, put functionality in image module. ** View/view config panels persist across shutdown/restart @@ -93,6 +100,7 @@ Display either the cbar for the current image, or cbars for all images. ** IN PROGRESS 'Actions'? - Make 'Add File' and 'Add Std' menu items actions. Need to be able to specify the order that actions appear in the menu - perhaps just hard code in fsl/fslview/actions/__init__.py +** DONE Preserve image world location when transformation changed. ** DONE Orientation labels on slice canvas. ** DONE Display image space code in cursor panel ** DONE Turn your test_gl14_coords.py script into a fslview ViewPanel; it might come in handy. diff --git a/fsl/data/image.py b/fsl/data/image.py index 00bfd8e4fe59d0e16ac9c24e0e1ffa626db2cbe9..398fa221ef89098ceda15b9cb6bf208dc4f4eb67 100644 --- a/fsl/data/image.py +++ b/fsl/data/image.py @@ -8,26 +8,24 @@ """Classes for representing 3D/4D images and collections of said images.""" import os -import sys import logging import tempfile import collections -import subprocess as sp -import os.path as op +import subprocess as sp +import os.path as op -import numpy as np -import numpy.linalg as linalg -import nibabel as nib +import numpy as np +import nibabel as nib import props - -import fsl.data.imagefile as imagefile +import fsl.data.imagefile as imagefile +import fsl.utils.transform as transform log = logging.getLogger(__name__) -# Constants which represent the orientation of an axis, -# in either voxel or world space. +# Constants which represent the orientation +# of an axis, in either voxel or world space. ORIENT_UNKNOWN = -1 ORIENT_L2R = 0 ORIENT_R2L = 1 @@ -44,9 +42,6 @@ NIFTI_XFORM_ALIGNED_ANAT = 2 NIFTI_XFORM_TALAIRACH = 3 NIFTI_XFORM_MNI_152 = 4 -# My own code, used to indicate that the -# image is being displayed in voxel space -NIFTI_XFORM_VOXEL = 5 def _loadImageFile(filename): """Given the name of an image file, loads it using nibabel. @@ -120,30 +115,30 @@ class Image(props.HasProperties): The following attributes are present on an :class:`Image` object: - :ivar nibImage: The :mod:`nibabel` image object. - - :ivar data: A reference to the image data, stored as a - :mod`numpy` array. - - :ivar shape: A list/tuple containing the number of voxels - along each image dimension. + :ivar nibImage: The :mod:`nibabel` image object. - :ivar pixdim: A list/tuple containing the size of one voxel - along each image dimension. + :ivar data: A reference to the image data, stored as a + :mod`numpy` array. - :ivar voxToWorldMat: A 4*4 array specifying the affine transformation - for transforming voxel coordinates into real world - coordinates. + :ivar shape: A list/tuple containing the number of voxels + along each image dimension. - :ivar worldToVoxMat: A 4*4 array specifying the affine transformation - for transforming real world coordinates into voxel - coordinates. + :ivar pixdim: A list/tuple containing the size of one voxel + along each image dimension. - :ivar imageFile: The name of the file that the image was loaded from. + :ivar voxToWorldMat: A 4*4 array specifying the affine transformation + for transforming voxel coordinates into real world + coordinates. + + :ivar worldToVoxMat: A 4*4 array specifying the affine transformation + for transforming real world coordinates into voxel + coordinates. + + :ivar imageFile: The name of the file that the image was loaded from. - :ivar tempFile: The name of the temporary file which was created (in - the event that the image was large and was gzipped - - see :func:`_loadImageFile`). + :ivar tempFile: The name of the temporary file which was created (in + the event that the image was large and was gzipped - + see :func:`_loadImageFile`). """ @@ -156,27 +151,6 @@ class Image(props.HasProperties): """This property defines the type of image data.""" - transform = props.Choice( - collections.OrderedDict([ - ('affine', 'Use qform/sform transformation matrix'), - ('pixdim', 'Use pixdims only'), - ('id', 'Do not use qform/sform or pixdims')]), - default='pixdim') - """This property defines how the image should be transformd into real world - space. - - - ``affine``: Use the affine transformation matrix stored in the image - (the ``qform``/``sform`` fields in NIFTI1 headers). - - - ``pixdim``: Scale voxel sizes by the ``pixdim`` fields in the image - header. - - - ``id``: Perform no scaling or transformation - voxels will be - interpreted as :math:`1mm^3` isotropic, with the origin at voxel - (0,0,0). - """ - - name = props.String() """The name of this image.""" @@ -221,16 +195,11 @@ class Image(props.HasProperties): self.tempFile = None self.imageFile = None - self.data = self.nibImage.get_data() - self.shape = self.nibImage.get_shape() - self.pixdim = self.nibImage.get_header().get_zooms() - - self.addListener( - 'transform', - '{}_{}'.format(self.__class__.__name__, self.name), - self._transformChanged) - - self._transformChanged() + self.data = self.nibImage.get_data() + self.shape = self.nibImage.get_shape() + self.pixdim = self.nibImage.get_header().get_zooms() + self.voxToWorldMat = np.array(self.nibImage.get_affine()) + self.worldToVoxMat = transform.invert(self.voxToWorldMat) if len(self.shape) < 3 or len(self.shape) > 4: raise RuntimeError('Only 3D or 4D images are supported') @@ -253,140 +222,7 @@ class Image(props.HasProperties): def is4DImage(self): """Returns ``True`` if this image is 4D, ``False`` otherwise. """ - return len(self.shape) > 3 and self.shape[3] > 1 - - - def _transformChanged(self, *a): - """This method is called when the :attr:`transform` property value - changes. It updates the :attr:`voxToWorldMat`, :attr:`worldToVoxMat`, - and :attr:`pixdim` attributes to reflect the new transformation - type. - """ - - if self.transform == 'affine': - voxToWorldMat = self.nibImage.get_affine() - elif self.transform == 'pixdim': - pixdim = self.nibImage.get_header().get_zooms() - voxToWorldMat = np.diag([pixdim[0], pixdim[1], pixdim[2], 1.0]) - elif self.transform == 'id': - voxToWorldMat = np.identity(4) - - self.voxToWorldMat = np.array(voxToWorldMat, dtype=np.float32) - self.worldToVoxMat = linalg.inv(self.voxToWorldMat) - - self.voxToWorldMat = self.voxToWorldMat.transpose() - self.worldToVoxMat = self.worldToVoxMat.transpose() - - if self.transform == 'affine': - pixdim = [self.axisLength(ax) / self.shape[ax] for ax in range(3)] - elif self.transform == 'pixdim': - pixdim = self.nibImage.get_header().get_zooms() - elif self.transform == 'id': - pixdim = [1.0, 1.0, 1.0] - - self.pixdim = pixdim - - # for pixdim/identity transformations, we want the world - # location (0, 0, 0) to map to voxel location (0, 0, 0) - if self.transform in ['pixdim', 'id']: - for i in range(3): - self.voxToWorldMat[3, i] = self.pixdim[i] * 0.5 - self.worldToVoxMat[3, i] = -0.5 - - log.debug('Image {} transformation matrix changed: {}'.format( - self.name, self.voxToWorldMat)) - log.debug('Inverted matrix: {}'.format(self.worldToVoxMat)) - - - def imageBounds(self, axis): - """Return the bounds (min, max) of the image, in real world - - - The returned bounds give the coordinates, along the specified axis, of - a bounding box which contains the entire image. - """ - - x, y, z = self.shape[:3] - - x -= 0.5 - y -= 0.5 - z -= 0.5 - - points = np.zeros((8, 3), dtype=np.float32) - - points[0, :] = [-0.5, -0.5, -0.5] - points[1, :] = [-0.5, -0.5, z] - points[2, :] = [-0.5, y, -0.5] - points[3, :] = [-0.5, y, z] - points[4, :] = [x, -0.5, -0.5] - points[5, :] = [x, -0.5, z] - points[6, :] = [x, y, -0.5] - points[7, :] = [x, y, z] - - - tx = self.voxToWorld(points) - - lo = tx[:, axis].min() - hi = tx[:, axis].max() - - return (lo, hi) - - - def axisLength(self, axis): - """Return the length, in real world units, of the specified axis. - """ - - points = np.zeros((2, 3), dtype=np.float32) - points[:] = [-0.5, -0.5, -0.5] - points[1, axis] = self.shape[axis] - 0.5 - - tx = self.voxToWorld(points) - - # euclidean distance between each boundary point - return sum((tx[0, :] - tx[1, :]) ** 2) ** 0.5 - - - def worldToVox(self, p, axes=None): - """Transforms the given set of points in voxel coordinates to points - in world coordinates, according to the current :attr:`transform`. - - The returned array is either a :class:`numpy.float64` array, or a - single ``float`` value, depending on the input. There is no guarantee - that the returned array of voxel coordinates is within the bounds of - the image shape. Parameters: - - :arg p: N*A array, where N is the number of points, and A - is the number of axes to consider (default: 3). - - :arg axes: If ``None``, it is assumed that the input p is a N*3 - array, with each point being specified by x,y,z - coordinates. If a single value in the range (0-2), - it is assumed that p is a 1D array. Or, if a - sequence of 2 or 3 values, p must be an array of - N*2 or N*3, respectively. - """ - - voxp = self._transform(p, self.worldToVoxMat, axes) - voxp = np.array(voxp, dtype=np.float64) - - if voxp.size == 1: return voxp[0] - else: return voxp - - - def voxToWorld(self, p, axes=None): - """Transforms the given set of points in world coordinates to - points in voxel coordinates, according to the current - :attr:`transform`. - - The returned array is either a :class:`numpy.float64` array, - or a single ``float`` value, depending on the input. See the - :meth:`worldToVox` method for more details. - """ - - worldp = self._transform(p, self.voxToWorldMat, axes) - - if worldp.size == 1: return float(worldp) - else: return worldp + return len(self.shape) > 3 and self.shape[3] > 1 def getXFormCode(self): @@ -459,64 +295,7 @@ class Image(props.HasProperties): (ORIENT_S2I, ORIENT_I2S)))[axis] return code - - def _transform(self, p, a, axes): - """Used by the :meth:`worldToVox` and :meth:`voxToWorld` methods. - - Transforms the given set of points ``p`` according to the given affine - transformation ``a``. The transformed points are returned as a - :class:``numpy.float64`` array. - """ - - p = self._fillPoints(p, axes) - t = np.zeros((len(p), 3), dtype=np.float64) - - x = p[:, 0] - y = p[:, 1] - z = p[:, 2] - - t[:, 0] = x * a[0, 0] + y * a[1, 0] + z * a[2, 0] + a[3, 0] - t[:, 1] = x * a[0, 1] + y * a[1, 1] + z * a[2, 1] + a[3, 1] - t[:, 2] = x * a[0, 2] + y * a[1, 2] + z * a[2, 2] + a[3, 2] - - if axes is None: axes = [0, 1, 2] - - return t[:, axes] - - - def _fillPoints(self, p, axes): - """Used by the :meth:`_transform` method. Turns the given array p into - a N*3 array of x,y,z coordinates. The array p may be a 1D array, or an - N*2 or N*3 array. - """ - - if not isinstance(p, collections.Iterable): p = [p] - - p = np.array(p) - - if axes is None: return p - - if not isinstance(axes, collections.Iterable): axes = [axes] - - if p.ndim == 1: - p = p.reshape((len(p), 1)) - - if p.ndim != 2: - raise ValueError('Points array must be either one or two ' - 'dimensions') - - if len(axes) != p.shape[1]: - raise ValueError('Points array shape does not match specified ' - 'number of axes') - - newp = np.zeros((len(p), 3), dtype=p.dtype) - - for i, ax in enumerate(axes): - newp[:, ax] = p[:, i] - - return newp - - + def getAttribute(self, name): """Retrieve the attribute with the given name. @@ -524,6 +303,7 @@ class Image(props.HasProperties): """ return self._attributes[name] + def delAttribute(self, name): """Delete and return the value of the attribute with the given name. @@ -544,9 +324,7 @@ class ImageList(props.HasProperties): """Class representing a collection of images to be displayed together. Contains a :class:`props.properties_types.List` property containing - :class:`Image` objects, and some other properties on which listeners may - register themselves to be notified when the properties of the image - collection changes (e.g. image bounds). + :class:`Image` objects. An :class:`ImageList` object has a few wrapper methods around the :attr:`images` property, allowing the :class:`ImageList` to be used @@ -564,16 +342,6 @@ class ImageList(props.HasProperties): """A list of :class:`Image` objects. to be displayed""" - bounds = props.Bounds(ndims=3) - """This property contains the min/max values of - a bounding box (in real world coordinates) which - is big enough to contain all of the images in the - :attr:`images` list. This property shouid be - read-only, but I don't have a way to enforce it - (yet). - """ - - def __init__(self, images=None): """Create an ImageList object from the given sequence of :class:`Image` objects.""" @@ -582,14 +350,6 @@ class ImageList(props.HasProperties): self.images = images - self.addListener( - 'images', - self.__class__.__name__, - self._imageListChanged) - - # initialise image bounds - self._imageListChanged() - # set the _lastDir attribute, # used by the addImages method if len(images) == 0: self._lastDir = os.getcwd() @@ -655,55 +415,6 @@ class ImageList(props.HasProperties): return True - def _imageListChanged(self, *a): - """Called whenever an item is added or removed from the :attr:`images` - list. Registers listeners with the properties of each image, and - calls the :meth:`_updateImageBounds` method. - """ - - for img in self.images: - - # This may be called multiple times on each image, - # but it doesn't matter, as any listener which has - # previously been registered with an image will - # just be replaced by the new one here. - img.addListener( - 'transform', - self.__class__.__name__, - self._updateImageBounds, - overwrite=True) - - self._updateImageBounds() - - - def _updateImageBounds(self, *a): - """Called whenever an item is added or removed from the - :attr:`images` list, or an image property changes. Updates - the :attr:`bounds` property. - """ - - if len(self.images) == 0: - minBounds = [0.0, 0.0, 0.0] - maxBounds = [0.0, 0.0, 0.0] - - else: - minBounds = 3 * [ sys.float_info.max] - maxBounds = 3 * [-sys.float_info.max] - - for img in self.images: - - for ax in range(3): - - lo, hi = img.imageBounds(ax) - - if lo < minBounds[ax]: minBounds[ax] = lo - if hi > maxBounds[ax]: maxBounds[ax] = hi - - self.bounds[:] = [minBounds[0], maxBounds[0], - minBounds[1], maxBounds[1], - minBounds[2], maxBounds[2]] - - # Wrappers around the images list property, allowing this # ImageList object to be used as if it is actually a list. def __len__( self): return self.images.__len__() diff --git a/fsl/fslview/controls/locationpanel.py b/fsl/fslview/controls/locationpanel.py index 4a51a898aacf1c170713bb8b7a709d6990aac0ea..8f17b82ad662c2ecdda1f0c3cad2e312f1c12bf3 100644 --- a/fsl/fslview/controls/locationpanel.py +++ b/fsl/fslview/controls/locationpanel.py @@ -1,22 +1,31 @@ #!/usr/bin/env python # # locationpanel.py - Panel which displays controls allowing the user to change -# the currently displayed location in both real world and voxel coordinates -# (with the latter in terms of the currently selected image) +# the currently displayed location in both real world and voxel coordinates, +# both in the space of the currently selected image. These changes are +# propagated to the current display coordinate system location, managed by the +# display context (and external changes to the display context location are +# propagated back to the voxel/world location properties managed by a +# Location Panel). # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # +import logging + import wx import numpy as np import props -import fsl.data.image as fslimage +import fsl.utils.transform as transform import fsl.fslview.controlpanel as controlpanel import imageselectpanel as imageselect +log = logging.getLogger(__name__) + + class LocationPanel(controlpanel.ControlPanel, props.HasProperties): """ A wx.Panel which contains widgets for changing the currently displayed @@ -26,8 +35,12 @@ class LocationPanel(controlpanel.ControlPanel, props.HasProperties): currently selected voxel. """ + voxelLocation = props.Point(ndims=3, real=False, labels=('X', 'Y', 'Z')) + + worldLocation = props.Point(ndims=3, real=True, labels=('X', 'Y', 'Z')) + def _adjustFont(self, label, by, weight): """ @@ -50,46 +63,46 @@ class LocationPanel(controlpanel.ControlPanel, props.HasProperties): controlpanel.ControlPanel.__init__(self, parent, imageList, displayCtx) - self._voxelPanel = wx.Panel(self) - self._locationPanel = wx.Panel(self) + self._voxelPanel = wx.Panel(self) + self._worldPanel = wx.Panel(self) self._imageSelect = imageselect.ImageSelectPanel( self, imageList, displayCtx) - self._locationLabel = wx.StaticText( self._locationPanel, - style=wx.ALIGN_LEFT) - self._spaceLabel = wx.StaticText( self._locationPanel, - style=wx.ALIGN_LEFT) - self._locationWidget = props.makeWidget(self, displayCtx, 'location') + self._worldLabel = wx.StaticText( self._worldPanel, + style=wx.ALIGN_LEFT) + self._spaceLabel = wx.StaticText( self._worldPanel, + style=wx.ALIGN_LEFT) + self._worldWidget = props.makeWidget(self, self, 'worldLocation') - self._dividerLine1 = wx.StaticLine( self, style=wx.LI_HORIZONTAL) - self._voxelWidget = props.makeWidget(self, self, 'voxelLocation') + self._dividerLine1 = wx.StaticLine( self, style=wx.LI_HORIZONTAL) + self._voxelWidget = props.makeWidget(self, self, 'voxelLocation') - self._dividerLine2 = wx.StaticLine( self, style=wx.LI_HORIZONTAL) - self._volumeLabel = wx.StaticText( self, style=wx.ALIGN_LEFT) - self._volumeWidget = props.makeWidget(self, displayCtx, 'volume') + self._dividerLine2 = wx.StaticLine( self, style=wx.LI_HORIZONTAL) + self._volumeLabel = wx.StaticText( self, style=wx.ALIGN_LEFT) + self._volumeWidget = props.makeWidget(self, displayCtx, 'volume') self._voxelLabel = wx.StaticText(self._voxelPanel, style=wx.ALIGN_LEFT) self._valueLabel = wx.StaticText(self._voxelPanel, style=wx.ALIGN_RIGHT) - self._adjustFont(self._locationLabel, -2, wx.FONTWEIGHT_LIGHT) - self._adjustFont(self._spaceLabel, -2, wx.FONTWEIGHT_LIGHT) - self._adjustFont(self._volumeLabel, -2, wx.FONTWEIGHT_LIGHT) - self._adjustFont(self._voxelLabel, -2, wx.FONTWEIGHT_LIGHT) - self._adjustFont(self._valueLabel, -2, wx.FONTWEIGHT_LIGHT) + self._adjustFont(self._worldLabel, -2, wx.FONTWEIGHT_LIGHT) + self._adjustFont(self._spaceLabel, -2, wx.FONTWEIGHT_LIGHT) + self._adjustFont(self._volumeLabel, -2, wx.FONTWEIGHT_LIGHT) + self._adjustFont(self._voxelLabel, -2, wx.FONTWEIGHT_LIGHT) + self._adjustFont(self._valueLabel, -2, wx.FONTWEIGHT_LIGHT) - self._locationLabel.SetLabel(strings.locationPanelLocationLabel) - self._voxelLabel .SetLabel(strings.locationPanelVoxelLabel) - self._volumeLabel .SetLabel(strings.locationPanelVolumeLabel) + self._worldLabel .SetLabel(strings.locationPanelWorldLabel) + self._voxelLabel .SetLabel(strings.locationPanelVoxelLabel) + self._volumeLabel.SetLabel(strings.locationPanelVolumeLabel) - self._locationSizer = wx.BoxSizer(wx.HORIZONTAL) - self._locationPanel.SetSizer(self._locationSizer) + self._worldSizer = wx.BoxSizer(wx.HORIZONTAL) + self._worldPanel.SetSizer(self._worldSizer) - self._locationSizer.Add(self._locationLabel, flag=wx.EXPAND) - self._locationSizer.Add((1, 1), flag=wx.EXPAND, proportion=1) - self._locationSizer.Add(self._spaceLabel, flag=wx.EXPAND) + self._worldSizer.Add(self._worldLabel, flag=wx.EXPAND) + self._worldSizer.Add((1, 1), flag=wx.EXPAND, proportion=1) + self._worldSizer.Add(self._spaceLabel, flag=wx.EXPAND) self._voxelSizer = wx.BoxSizer(wx.HORIZONTAL) self._voxelPanel.SetSizer(self._voxelSizer) @@ -101,15 +114,15 @@ class LocationPanel(controlpanel.ControlPanel, props.HasProperties): self._sizer = wx.BoxSizer(wx.VERTICAL) self.SetSizer(self._sizer) - self._sizer.Add(self._imageSelect, flag=wx.EXPAND) - self._sizer.Add(self._locationPanel, flag=wx.EXPAND) - self._sizer.Add(self._locationWidget, flag=wx.EXPAND) - self._sizer.Add(self._dividerLine1, flag=wx.EXPAND) - self._sizer.Add(self._voxelPanel, flag=wx.EXPAND) - self._sizer.Add(self._voxelWidget, flag=wx.EXPAND) - self._sizer.Add(self._dividerLine2, flag=wx.EXPAND) - self._sizer.Add(self._volumeLabel, flag=wx.EXPAND) - self._sizer.Add(self._volumeWidget, flag=wx.EXPAND) + self._sizer.Add(self._imageSelect, flag=wx.EXPAND) + self._sizer.Add(self._worldPanel, flag=wx.EXPAND) + self._sizer.Add(self._worldWidget, flag=wx.EXPAND) + self._sizer.Add(self._dividerLine1, flag=wx.EXPAND) + self._sizer.Add(self._voxelPanel, flag=wx.EXPAND) + self._sizer.Add(self._voxelWidget, flag=wx.EXPAND) + self._sizer.Add(self._dividerLine2, flag=wx.EXPAND) + self._sizer.Add(self._volumeLabel, flag=wx.EXPAND) + self._sizer.Add(self._volumeWidget, flag=wx.EXPAND) self._voxelPanel.Layout() self.Layout() @@ -124,25 +137,28 @@ class LocationPanel(controlpanel.ControlPanel, props.HasProperties): self._name, self._volumeChanged) self._displayCtx.addListener('location', - '{}_worldToVox'.format(self._name), - self._worldLocationChanged) + self._name, + self._displayLocationChanged) self.addListener( 'voxelLocation', - '{}_voxToWorld'.format(self._name), + self._name, self._voxelLocationChanged) + self.addListener( 'worldLocation', + self._name, + self._worldLocationChanged) def onDestroy(ev): ev.Skip() self._imageList.removeListener( 'images', self._name) self._displayCtx.removeListener('selectedImage', self._name) self._displayCtx.removeListener('volume', self._name) - self._displayCtx.removeListener('location', - '{}_worldToVox'.format(self._name)) + self._displayCtx.removeListener('location', self._name) self.Bind(wx.EVT_WINDOW_DESTROY, onDestroy) - + + self._internalLocationChange = False self._selectedImageChanged() + self._displayLocationChanged() self._volumeChanged() - self._worldLocationChanged() def _updateVoxelValue(self, voxVal=None): @@ -153,34 +169,47 @@ class LocationPanel(controlpanel.ControlPanel, props.HasProperties): the value at the current voxel location is displayed. """ + import fsl.fslview.strings as strings + if voxVal is not None: self._valueLabel.SetLabel('{}'.format(voxVal)) self._voxelPanel.Layout() return - - image = self._imageList[self._displayCtx.selectedImage] + + image = self._imageList[self._displayCtx.selectedImage] + display = image.getAttribute('display') + + dloc = self._displayCtx.location.xyz + vloc = transform.transform([dloc], display.displayToVoxMat)[0] + vloc = np.round(vloc) volume = self._displayCtx.volume - voxLoc = self.voxelLocation.xyz - - if voxVal is None: - # There's a chance that the voxel location will temporarily - # be out of bounds when the selected image is changed. - # So we'll be safe and check them. - for i in range(3): - if voxLoc[i] < 0 or voxLoc[i] >= image.shape[i]: - return + # Test to see if the voxel + # location/volume is out of bounds + inBounds = True + for i in range(3): + if vloc[i] < 0 or vloc[i] >= image.shape[i]: + inBounds = False + + if image.is4DImage(): + if volume >= image.shape[3]: + inBounds = False + # If the value is out of the voxel bounds, + # display some appropriate text + if not inBounds: + voxVal = strings.locationPanelOutOfBounds + + else: + # 3D image if len(image.shape) == 3: - voxVal = image.data[voxLoc[0], voxLoc[1], voxLoc[2]] + voxVal = image.data[vloc[0], vloc[1], vloc[2]] - # 4D image. This will crash on non-4D images, - # which is intentional for the time being. + # No support for images of more + # than 4 dimensions at the moment else: - if volume >= image.shape[3]: - return - voxVal = image.data[voxLoc[0], voxLoc[1], voxLoc[2], volume] + voxVal = image.data[vloc[0], vloc[1], vloc[2], volume] if np.isnan(voxVal): voxVal = 'NaN' elif np.isinf(voxVal): voxVal = 'Inf' @@ -194,24 +223,26 @@ class LocationPanel(controlpanel.ControlPanel, props.HasProperties): :attr:`fsl.fslview.displaycontext.DisplayContext.volume` property changes. Updates the voxel value label. """ - import fsl.fslview.strings as strings + self._updateVoxelValue() + - volume = self._displayCtx.volume + def _displayLocationChanged(self, *a): - if len(self._imageList) == 0: - return + if len(self._imageList) == 0: return + if self._internalLocationChange: return - image = self._imageList[self._displayCtx.selectedImage] - voxVal = None - - if image.is4DImage(): - if volume >= image.shape[3]: - voxVal = strings.locationPanelOutOfBounds - - elif volume > 0: - voxVal = strings.locationPanelOutOfBounds + image = self._imageList[self._displayCtx.selectedImage] + display = image.getAttribute('display') - self._updateVoxelValue(voxVal) + dloc = self._displayCtx.location.xyz + vloc = transform.transform([dloc], display.displayToVoxMat)[ 0] + wloc = transform.transform([dloc], display.displayToWorldMat)[0] + + self._internalLocationChange = True + self.voxelLocation.xyz = np.round(vloc) + self.worldLocation.xyz = wloc + self._internalLocationChange = False + self._updateVoxelValue() def _voxelLocationChanged(self, *a): @@ -220,26 +251,22 @@ class LocationPanel(controlpanel.ControlPanel, props.HasProperties): change on to the display context world location. """ - image = self._imageList[self._displayCtx.selectedImage] - voxLoc = self.voxelLocation.xyz - worldLoc = image.voxToWorld([voxLoc])[0] - worldVoxLoc = image.worldToVox([self._displayCtx.location.xyz])[0] - worldVoxLoc = np.round(worldVoxLoc) - + if len(self._imageList) == 0: return + if self._internalLocationChange: return + + image = self._imageList[self._displayCtx.selectedImage] + display = image.getAttribute('display') + + vloc = self.voxelLocation.xyz + dloc = transform.transform([vloc], display.voxToDisplayMat)[0] + wloc = transform.transform([vloc], display.voxToWorldMat)[ 0] + + self._internalLocationChange = True + self._displayCtx.location.xyz = dloc + self.worldLocation .xyz = wloc + self._internalLocationChange = False self._updateVoxelValue() - # if the current image list location is already equal to the - # new voxel location, don't change it. The voxel location, - # transformed to world coordinates, will be in the centre of - # voxel. But the world location can be anywhere within a - # voxel. So if the world location is already in the correct - # voxel, we don't want it to be shifted to the voxel centre. - diffs = map(lambda vl, wvl: vl - wvl, voxLoc, worldVoxLoc) - - if not any(map(lambda d: d > 0.001, diffs)): return - - self._displayCtx.location.xyz = worldLoc - def _worldLocationChanged(self, *a): """ @@ -247,33 +274,22 @@ class LocationPanel(controlpanel.ControlPanel, props.HasProperties): Propagates the change on to the voxel location in the currently selected image. """ - import fsl.fslview.strings as strings - - if len(self._imageList) == 0: return - - image = self._imageList[self._displayCtx.selectedImage] - loc = self._displayCtx.location.xyz - voxLoc = np.round(image.worldToVox([loc]))[0] - - inBounds = True - - # If the selected world location is not within the selected - # image, we're going to temporarily disable notification on - # the voxel location property, because this would otherwise - # cause some infinite-property-listener-callback-recursion - # nastiness. - for i in range(3): - - # allow the voxel location values to be equal to the image - if voxLoc[i] < 0 or voxLoc[i] > image.shape[i]: - inBounds = False - if not inBounds: - self._updateVoxelValue(voxVal=strings.locationPanelOutOfBounds) - self.voxelLocation.disableNotification() + if len(self._imageList) == 0: return + if self._internalLocationChange: return - self.voxelLocation.xyz = voxLoc - self.voxelLocation.enableNotification() + image = self._imageList[self._displayCtx.selectedImage] + display = image.getAttribute('display') + + wloc = self.worldLocation.xyz + dloc = transform.transform([wloc], display.worldToDisplayMat)[0] + vloc = transform.transform([wloc], display.worldToVoxMat)[ 0] + + self._internalLocationChange = True + self._displayCtx.location.xyz = dloc + self.voxelLocation .xyz = np.round(vloc) + self._internalLocationChange = False + self._updateVoxelValue() def _selectedImageChanged(self, *a): @@ -282,59 +298,35 @@ class LocationPanel(controlpanel.ControlPanel, props.HasProperties): (which contains the image name), and sets the voxel location limits. """ - # Make sure that a listener is registered on the - # selected image, so that the space label can be - # updated when its transformation matrix is changed - for i, img in enumerate(self._imageList): - img.removeListener('transform', self._name) - if i == self._displayCtx.selectedImage: - img.addListener('transform', self._name, self._spaceChanged) - - self._spaceChanged() - - if len(self._imageList) == 0: - self._updateVoxelValue('') - self._voxelPanel.Layout() - return - - image = self._imageList[self._displayCtx.selectedImage] - - oldLoc = self._displayCtx.location.xyz - voxLoc = np.round(image.worldToVox([oldLoc]))[0] - - for i in range(3): - self.voxelLocation.setLimits(i, 0, image.shape[i] - 1) - - self.voxelLocation.xyz = voxLoc - - # The voxel coordinates may have inadvertently been - # changed due to a change in their limits. So we'll - # restore the old location from the real world - # coordinates. - self._displayCtx.location.xyz = oldLoc - - - def _spaceChanged(self, *a): - """Called when the transformation matrix of the currently selected - image changes. Updates the 'space' label to reflect the change. - """ - import fsl.fslview.strings as strings if len(self._imageList) == 0: + self._updateVoxelValue( '') self._spaceLabel.SetLabel('') - self._locationPanel.Layout() + self._worldPanel.Layout() return - image = self._imageList[self._displayCtx.selectedImage] + image = self._imageList[self._displayCtx.selectedImage] + display = image.getAttribute('display') - if image.transform == 'affine': - spaceLabel = strings.imageSpaceLabels[image.getXFormCode()] - else: - spaceLabel = strings.imageSpaceLabels[fslimage.NIFTI_XFORM_VOXEL] - + # Update the label which + # displays the image space + spaceLabel = strings.imageSpaceLabels[image.getXFormCode()] spaceLabel = strings.locationPanelSpaceLabel.format(spaceLabel) - self._spaceLabel.SetLabel(spaceLabel) + self._worldPanel.Layout() - self._locationPanel.Layout() + # Update the voxel and world location limits, + # but don't trigger a listener callback, as + # this would change the display location + self._internalLocationChange = True + for i in range(3): + vlo, vhi = 0, image.shape[i] - 1 + wlo, whi = transform.axisBounds(image.shape, + display.voxToWorldMat, + i) + + self.voxelLocation.setLimits(i, vlo, vhi) + self.worldLocation.setLimits(i, wlo, whi) + self._internalLocationChange = False + self._updateVoxelValue() diff --git a/fsl/fslview/displaycontext.py b/fsl/fslview/displaycontext.py index 60af89a5e5c0ce990d6596b9fbf5be0a106e7cdd..3f0d17db577457a6862375a8daad972f451b5e71 100644 --- a/fsl/fslview/displaycontext.py +++ b/fsl/fslview/displaycontext.py @@ -5,13 +5,16 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -from collections import OrderedDict +import sys +import collections +from collections import OrderedDict import numpy as np import props -import fsl.data.image as fslimage +import fsl.data.image as fslimage +import fsl.utils.transform as transform import fsl.fslview.colourmaps as fslcm class ImageDisplay(props.HasProperties): @@ -76,11 +79,25 @@ class ImageDisplay(props.HasProperties): volume = props.Int(minval=0, maxval=0, default=0, clamped=True) """If a 4D image, the current volume to display.""" - - transform = fslimage.Image.transform - """How the image is transformed from voxel to real world coordinates. - This property is bound to the :attr:`~fsl.data.image.Image.transform` - property of the image associated with this :class:`ImageDisplay`. + + transform = props.Choice( + collections.OrderedDict([ + ('affine', 'Use qform/sform transformation matrix'), + ('pixdim', 'Use pixdims only'), + ('id', 'Do not use qform/sform or pixdims')]), + default='pixdim') + """This property defines how the image should be transformd into the display + coordinate system. + + - ``affine``: Use the affine transformation matrix stored in the image + (the ``qform``/``sform`` fields in NIFTI1 headers). + + - ``pixdim``: Scale voxel sizes by the ``pixdim`` fields in the image + header. + + - ``id``: Perform no scaling or transformation - voxels will be + interpreted as :math:`1mm^3` isotropic, with the origin at voxel + (0,0,0). """ @@ -241,11 +258,9 @@ class ImageDisplay(props.HasProperties): self.image = image - # bind self.transform and self.name to - # image.transform/image.name, so changes + # bind self.name to # image.name, so changes # in one are propagated to the other - self.bindProps('transform', image) - self.bindProps('name', image) + self.bindProps('name', image) # Attributes controlling image display. Only # determine the real min/max for small images - @@ -271,19 +286,95 @@ class ImageDisplay(props.HasProperties): self.setConstraint('worldResolution', 'minval', min(image.pixdim[:3])) self.worldResolution = min(image.pixdim[:3]) + # The display<->* transformation matrices + # are created in the _transformChanged method + self.voxToWorldMat = image.voxToWorldMat.transpose() + self.worldToVoxMat = image.worldToVoxMat.transpose() + self.voxToDisplayMat = None + self.displayToVoxMat = None + self.worldToDisplayMat = None + self.displayToWorldMat = None + # is this a 4D volume? if image.is4DImage(): self.setConstraint('volume', 'maxval', image.shape[3] - 1) + # Update transformation matrices when + # the transform property changes + self.addListener( + 'transform', + 'ImageDisplay_{}'.format(id(self)), + self._transformChanged) + + self._transformChanged() + # When the transform property changes, + # the display<->* transformation matrices + # are recalculated. References to the + # previous matrices are stored here, just + # in case anything (hint the DisplayContext + # object) needs them for any particular + # reason (hint: so the DisplayContext can + # preserve the current display location, + # in terms of image world space, when the + # transform of the selected changes) + self._oldVoxToDisplayMat = self.voxToDisplayMat + self._oldDisplayToVoxMat = self.displayToVoxMat + self._oldWorldToDisplayMat = self.worldToDisplayMat + self._oldDisplayToWorldMat = self.displayToWorldMat + + + def _transformChanged(self, *a): + """Called when the :attr:`transform` property is changed. + + Generates transformation matrices for transforming between voxel and + display coordinate space. + + If :attr:`transform` is set to ``affine``, the :attr:`interpolation` + property is changed to ``spline. Otherwise, it is set to ``none``. + """ + + # Store references to the previous display related + # transformation matrices (see comments in __init__) + self._oldVoxToDisplayMat = self.voxToDisplayMat + self._oldDisplayToVoxMat = self.displayToVoxMat + self._oldWorldToDisplayMat = self.worldToDisplayMat + self._oldDisplayToWorldMat = self.displayToWorldMat + + # The transform property defines the way + # in which image voxel coordinates map + # to the display coordinate system + if self.transform == 'id': + pixdim = [1.0, 1.0, 1.0] + voxToDisplayMat = np.eye(4) + + elif self.transform == 'pixdim': + pixdim = self.image.pixdim + voxToDisplayMat = np.diag([pixdim[0], pixdim[1], pixdim[2], 1.0]) + + elif self.transform == 'affine': + voxToDisplayMat = self.voxToWorldMat + + # for pixdim/identity transformations, we want the world + # location (0, 0, 0) to map to voxel location (0, 0, 0) + if self.transform in ('id', 'pixdim'): + for i in range(3): + voxToDisplayMat[3, i] = pixdim[i] * 0.5 + + # Transformation matrices for moving between the voxel + # coordinate system and the display coordinate system + self.voxToDisplayMat = np.array(voxToDisplayMat, dtype=np.float32) + self.displayToVoxMat = transform.invert(self.voxToDisplayMat) + + # Matrices for moving between the display coordinate + # system, and the image world coordinate system + self.displayToWorldMat = transform.concat(self.displayToVoxMat, + self.voxToWorldMat) + self.worldToDisplayMat = transform.invert(self.displayToWorldMat) + # When transform is changed to 'affine', enable interpolation # and, when changed to 'pixdim' or 'id', disable interpolation - def changeInterp(*a): - if self.transform == 'affine': self.interpolation = 'spline' - else: self.interpolation = 'none' - - self.addListener('transform', - 'ImageDisplay_{}'.format(id(self)), - changeInterp) + if self.transform == 'affine': self.interpolation = 'spline' + else: self.interpolation = 'none' class DisplayContext(props.HasProperties): @@ -307,6 +398,14 @@ class DisplayContext(props.HasProperties): 3D location (xyz) in the image list space. """ + + bounds = props.Bounds(ndims=3) + """This property contains the min/max values of a bounding box (in display + coordinates) which is big enough to contain all of the images in the + :attr:`images` list. This property shouid be read-only, but I don't have a + way to enforce it (yet). + """ + volume = props.Int(minval=0, maxval=0, default=0, clamped=True) """The volume property contains the currently selected volume @@ -325,12 +424,14 @@ class DisplayContext(props.HasProperties): self._imageList = imageList self._name = '{}_{}'.format(self.__class__.__name__, id(self)) + # Ensure that an ImageDisplay object exists for + # every image, and that the display bounds + # property is initialised self._imageListChanged() - self._imageListBoundsChanged() # initialise the location to be # the centre of the image world - b = imageList.bounds + b = self.bounds self.location.xyz = [ b.xlo + b.xlen / 2.0, b.ylo + b.ylen / 2.0, @@ -339,9 +440,9 @@ class DisplayContext(props.HasProperties): imageList.addListener('images', self._name, self._imageListChanged) - imageList.addListener('bounds', + self.addListener( 'bounds', self._name, - self._imageListBoundsChanged) + self._boundsChanged) self.addListener( 'volume', self._name, self._volumeChanged) @@ -361,9 +462,32 @@ class DisplayContext(props.HasProperties): # Ensure that an ImageDisplay # object exists for every image for image in self._imageList: - try: image.getAttribute('display') - except KeyError: image.setAttribute('display', ImageDisplay(image)) - + try: + display = image.getAttribute('display') + except KeyError: + display = ImageDisplay(image) + image.setAttribute('display', display) + + # Register a listener with the transform property + # of every image display so that when they change, + # we can update the display bounds, and preserve + # the current display location so that it is in + # terms of the world location of the currently + # selected image + # + # This may be called multiple times on each image, + # but it doesn't matter, as any listener which has + # previously been registered with an image will + # just be replaced by the new one here. + display.addListener( + 'transform', + self.__class__.__name__, + self._transformChanged, + overwrite=True) + + # Ensure that the bounds property is accurate + self._updateBounds() + # Limit the selectedImage property # so it cannot take a value greater # than len(imageList)-1 @@ -391,6 +515,66 @@ class DisplayContext(props.HasProperties): self.setConstraint('volume', 'maxval', 0) + def _transformChanged(self, xform, valid, display): + """Called when the + :attr:`~fsl.fslview.displaycontext.ImageDisplay.transform property + changes on any image in the :attr:`images` list. Sets the + :attr:`location` property, so that the selected image world location + is preserved, in the new display coordinate system. + """ + + if display.image != self._imageList[self.selectedImage]: + self._updateBounds() + return + + # Calculate the image world location using + # the old display<-> world transform, then + # transform it back to the new world->display + # transform + imgWorldLoc = transform.transform([self.location.xyz], + display._oldDisplayToWorldMat)[0] + newDispLoc = transform.transform([imgWorldLoc], + display.worldToDisplayMat)[0] + + # Update the display coordinate system bounds - + # this will also update the constraints on the + # location property, so we have to do this first + # before setting said location property. + self._updateBounds() + + self.location.xyz = newDispLoc + + + def _updateBounds(self, *a): + """Called when the image list changes, or when any image display + transform is changed. Updates the :attr:`bounds` property. + """ + + if len(self._imageList) == 0: + minBounds = [0.0, 0.0, 0.0] + maxBounds = [0.0, 0.0, 0.0] + + else: + minBounds = 3 * [ sys.float_info.max] + maxBounds = 3 * [-sys.float_info.max] + + for img in self._imageList.images: + + display = img.getAttribute('display') + xform = display.voxToDisplayMat + + for ax in range(3): + + lo, hi = transform.axisBounds(img.shape, xform, ax) + + if lo < minBounds[ax]: minBounds[ax] = lo + if hi > maxBounds[ax]: maxBounds[ax] = hi + + self.bounds[:] = [minBounds[0], maxBounds[0], + minBounds[1], maxBounds[1], + minBounds[2], maxBounds[2]] + + def _volumeChanged(self, *a): """Called when the :attr:`volume` property changes. @@ -406,14 +590,12 @@ class DisplayContext(props.HasProperties): image.getAttribute('display').volume = self.volume - def _imageListBoundsChanged(self, *a): - """Called when the :attr:`fsl.data.image.ImageList.bounds` property - changes. + def _boundsChanged(self, *a): + """Called when the :attr:`bounds` property changes. Updates the constraints on the :attr:`location` property. """ - bounds = self._imageList.bounds - self.location.setLimits(0, bounds.xlo, bounds.xhi) - self.location.setLimits(1, bounds.ylo, bounds.yhi) - self.location.setLimits(2, bounds.zlo, bounds.zhi) + self.location.setLimits(0, self.bounds.xlo, self.bounds.xhi) + self.location.setLimits(1, self.bounds.ylo, self.bounds.yhi) + self.location.setLimits(2, self.bounds.zlo, self.bounds.zhi) diff --git a/fsl/fslview/gl/gl14/glcircle_funcs.py b/fsl/fslview/gl/gl14/glcircle_funcs.py index 0ffc8d483e5cfb0a4a40761604d0097f90a11777..25e2de8afa40b381ed7cb459f344ac7009c3c470 100644 --- a/fsl/fslview/gl/gl14/glcircle_funcs.py +++ b/fsl/fslview/gl/gl14/glcircle_funcs.py @@ -8,15 +8,16 @@ import logging log = logging.getLogger(__name__) -import scipy.ndimage as ndi -import OpenGL.GL as gl +import scipy.ndimage as ndi +import OpenGL.GL as gl + +import fsl.utils.transform as transform def draw(glimg, zpos, xform=None): """Draws a slice of the image at the given Z location using immediate mode rendering. """ - image = glimg.image display = glimg.display # Don't draw the slice if this @@ -31,7 +32,7 @@ def draw(glimg, zpos, xform=None): # Transform world texture coordinates # to (floating point) voxel coordinates - voxCoords = image.worldToVox(texCoords) + voxCoords = transform.transform(texCoords, display.displayToVoxMat) imageData = glimg.imageData texCoordXform = glimg.texCoordXform colourTexture = glimg.colourTexture diff --git a/fsl/fslview/gl/gl14/glimage_funcs.py b/fsl/fslview/gl/gl14/glimage_funcs.py index 8fec7cfec923e3a45e0b3de483d940b3b7a8aa4e..837aa687a13c87a54729ffbbb9f20df2629f0a37 100644 --- a/fsl/fslview/gl/gl14/glimage_funcs.py +++ b/fsl/fslview/gl/gl14/glimage_funcs.py @@ -34,9 +34,11 @@ This module provides the following functions: import logging log = logging.getLogger(__name__) -import numpy as np -import scipy.ndimage as ndi -import OpenGL.GL as gl +import numpy as np +import scipy.ndimage as ndi +import OpenGL.GL as gl + +import fsl.utils.transform as transform def init(glimg, xax, yax): @@ -86,7 +88,6 @@ def draw(glimg, zpos, xform=None): mode rendering. """ - image = glimg.image display = glimg.display # Don't draw the slice if this @@ -101,7 +102,7 @@ def draw(glimg, zpos, xform=None): # Transform world texture coordinates # to (floating point) voxel coordinates - voxCoords = image.worldToVox(texCoords) + voxCoords = transform.transform(texCoords, display.displayToVoxMat) imageData = glimg.imageData texCoordXform = glimg.texCoordXform colourTexture = glimg.colourTexture diff --git a/fsl/fslview/gl/gl21/glcircle_funcs.py b/fsl/fslview/gl/gl21/glcircle_funcs.py index 36e68f70709f425532ea01b0137a0732a75cb483..f2d3f258b6872a95b7985d7d5ead2f54bb761319 100644 --- a/fsl/fslview/gl/gl21/glcircle_funcs.py +++ b/fsl/fslview/gl/gl21/glcircle_funcs.py @@ -39,9 +39,9 @@ def draw(glimg, zpos, xform=None): # to the shader variable if xform is None: xform = np.identity(4) - w2w = np.array(xform, dtype=np.float32).ravel('C') - w2v = np.array(image.worldToVoxMat, dtype=np.float32).ravel('C') - tcx = np.array(glimg.texCoordXform, dtype=np.float32).ravel('C') + w2w = np.array(xform, dtype=np.float32).ravel('C') + w2v = np.array(display.displayToVoxMat, dtype=np.float32).ravel('C') + tcx = np.array(glimg.texCoordXform, dtype=np.float32).ravel('C') gl.glUniformMatrix4fv(glimg.worldToVoxMatPos, 1, False, w2v) gl.glUniformMatrix4fv(glimg.worldToWorldMatPos, 1, False, w2w) diff --git a/fsl/fslview/gl/gl21/glimage_funcs.py b/fsl/fslview/gl/gl21/glimage_funcs.py index 58c9f84b94f1fac5f0e86d0083f96652b36a0c9d..9d6e3af221bc64f6d685e7fc855a99d5ff38092d 100644 --- a/fsl/fslview/gl/gl21/glimage_funcs.py +++ b/fsl/fslview/gl/gl21/glimage_funcs.py @@ -47,8 +47,6 @@ This module provides the following functions: import logging log = logging.getLogger(__name__) -import os.path as op - import numpy as np import OpenGL.GL as gl import OpenGL.arrays.vbo as vbo @@ -397,9 +395,9 @@ def draw(glimg, zpos, xform=None): # to the shader variable if xform is None: xform = np.identity(4) - w2w = np.array(xform, dtype=np.float32).ravel('C') - w2v = np.array(image.worldToVoxMat, dtype=np.float32).ravel('C') - tcx = np.array(glimg.texCoordXform, dtype=np.float32).ravel('C') + w2w = np.array(xform, dtype=np.float32).ravel('C') + w2v = np.array(display.displayToVoxMat, dtype=np.float32).ravel('C') + tcx = np.array(glimg.texCoordXform, dtype=np.float32).ravel('C') gl.glUniformMatrix4fv(glimg.worldToVoxMatPos, 1, False, w2v) gl.glUniformMatrix4fv(glimg.worldToWorldMatPos, 1, False, w2w) diff --git a/fsl/fslview/gl/glcircle.py b/fsl/fslview/gl/glcircle.py index 4be7ff3c3393c2fd9bd7c8dab43141646770991f..16f8d030d47fe757cf98927e1edeb8825e6a74c9 100644 --- a/fsl/fslview/gl/glcircle.py +++ b/fsl/fslview/gl/glcircle.py @@ -10,6 +10,8 @@ import logging log = logging.getLogger(__name__) import numpy as np + +import fsl.utils.transform as transform import fsl.fslview.gl as fslgl import fsl.fslview.gl.glimage as glimage @@ -27,21 +29,22 @@ class GLCircle(glimage.GLImage): def genVertexData(image, display, xax, yax): - zax = 3 - xax - yax - worldRes = display.worldResolution - voxelRes = display.voxelResolution - transform = display.transform + zax = 3 - xax - yax + worldRes = display.worldResolution + voxelRes = display.voxelResolution + transformCode = display.transform + transformMat = display.voxToDisplayMat # These values give the min/max x/y values # of a bounding box which encapsulates # the entire image - xmin, xmax = image.imageBounds(xax) - ymin, ymax = image.imageBounds(yax) + xmin, xmax = transform.axisBounds(image.shape, transformMat, xax) + ymin, ymax = transform.axisBounds(image.shape, transformMat, yax) # The width/height of a displayed voxel. # If we are displaying in real world space, # we use the world display resolution - if transform in ('affine'): + if transformCode in ('affine'): xpixdim = worldRes ypixdim = worldRes @@ -64,7 +67,7 @@ def genVertexData(image, display, xax, yax): log.debug('Generating coordinate buffers for {} ' '({} resolution {}/{}, num samples {})'.format( - image.name, transform, worldRes, voxelRes, + image.name, transformCode, worldRes, voxelRes, xNumSamples * yNumSamples)) # The location of every displayed diff --git a/fsl/fslview/gl/globject.py b/fsl/fslview/gl/globject.py index 43022578080a49174e97d08c56f62928d66a7c7b..b7d3c094ace72d6522d971830ee44b4154f2aa8a 100644 --- a/fsl/fslview/gl/globject.py +++ b/fsl/fslview/gl/globject.py @@ -18,7 +18,8 @@ GL Objects must have the following methods: import logging log = logging.getLogger(__name__) -import numpy as np +import numpy as np +import fsl.utils.transform as transform def createGLObject(image, display): @@ -68,20 +69,21 @@ def calculateSamplePoints(image, display, xax, yax): """ - worldRes = display.worldResolution - voxelRes = display.voxelResolution - transform = display.transform + worldRes = display.worldResolution + voxelRes = display.voxelResolution + transformCode = display.transform + transformMat = display.voxToDisplayMat # These values give the min/max x/y values # of a bounding box which encapsulates # the entire image - xmin, xmax = image.imageBounds(xax) - ymin, ymax = image.imageBounds(yax) + xmin, xmax = transform.axisBounds(image.shape, transformMat, xax) + ymin, ymax = transform.axisBounds(image.shape, transformMat, yax) # The width/height of a displayed voxel. # If we are displaying in real world space, # we use the world display resolution - if transform in ('affine'): + if transformCode == 'affine': xpixdim = worldRes ypixdim = worldRes @@ -89,9 +91,13 @@ def calculateSamplePoints(image, display, xax, yax): # But if we're just displaying the data (the # transform is 'id' or 'pixdim'), we display # it in the resolution of said data. - else: + elif transformCode == 'pixdim': xpixdim = image.pixdim[xax] * voxelRes ypixdim = image.pixdim[yax] * voxelRes + + elif transformCode == 'id': + xpixdim = 1.0 * voxelRes + ypixdim = 1.0 * voxelRes # Number of samples across each dimension, # given the current sample rate @@ -104,7 +110,7 @@ def calculateSamplePoints(image, display, xax, yax): log.debug('Generating coordinate buffers for {} ' '({} resolution {}/{}, num samples {})'.format( - image.name, transform, worldRes, voxelRes, + image.name, transformCode, worldRes, voxelRes, xNumSamples * yNumSamples)) # The location of every displayed diff --git a/fsl/fslview/gl/gltensorline.py b/fsl/fslview/gl/gltensorline.py index 0f9653b82b8277e6182f7cf7022ca55c4f3869bd..007ec2d23b22735c0d6d3ebf5874f94c8bc5ec50 100644 --- a/fsl/fslview/gl/gltensorline.py +++ b/fsl/fslview/gl/gltensorline.py @@ -18,6 +18,8 @@ import scipy.ndimage as ndi import globject +import fsl.utils.transform as transform + class GLTensorLine(object): """The :class:`GLTensorLine` class encapsulates the data and logic required to render 2D slices of a X*Y*Z*3 image as tensor lines. @@ -104,7 +106,9 @@ class GLTensorLine(object): # Transform the world coordinates to # floating point voxel coordinates - voxCoords = image.worldToVox(worldCoords).transpose() + dToVMat = display.displayToVoxMat + + voxCoords = transform.transform(worldCoords, dToVMat).transpose() imageData = image.data nVoxels = worldCoords.shape[0] diff --git a/fsl/fslview/gl/lightboxcanvas.py b/fsl/fslview/gl/lightboxcanvas.py index 5d5141c9ba91de7ecad732b3d3bed00e955241e1..272b61b5f5e7ca9ad3a21701a1fd52c1e417d8d0 100644 --- a/fsl/fslview/gl/lightboxcanvas.py +++ b/fsl/fslview/gl/lightboxcanvas.py @@ -101,8 +101,8 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): """ sliceno = int(np.floor((zpos - self.zrange.xlo) / self.sliceSpacing)) - xlen = self.imageList.bounds.getLen(self.xax) - ylen = self.imageList.bounds.getLen(self.yax) + xlen = self.displayCtx.bounds.getLen(self.xax) + ylen = self.displayCtx.bounds.getLen(self.yax) row = self._totalRows - int(np.floor(sliceno / self.ncols)) - 1 col = int(np.floor(sliceno % self.ncols)) @@ -130,10 +130,10 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): screenx, screeny = slicecanvas.SliceCanvas.canvasToWorld( self, xpos, ypos) - xmin = self.imageList.bounds.getLo( self.xax) - ymin = self.imageList.bounds.getLo( self.yax) - xlen = self.imageList.bounds.getLen(self.xax) - ylen = self.imageList.bounds.getLen(self.yax) + xmin = self.displayCtx.bounds.getLo( self.xax) + ymin = self.displayCtx.bounds.getLo( self.yax) + xlen = self.displayCtx.bounds.getLen(self.xax) + ylen = self.displayCtx.bounds.getLen(self.yax) xmax = xmin + ncols * xlen ymax = ymin + nrows * ylen @@ -168,15 +168,19 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): return self._totalRows - def __init__(self, imageList, zax=0): + def __init__(self, imageList, displayCtx, zax=0): """Create a :class:`LightBoxCanvas` object. - :arg imageList: a :class:`~fsl.data.image.ImageList` object which - contains, or will contain, a list of images to be - displayed. + :arg imageList: a :class:`~fsl.data.image.ImageList` object which + contains, or will contain, a list of images to be + displayed. + + :arg displayCtx: A :class:`~fsl.fslview.displaycontext.DisplayContext` + object which defines how that image list is to be + displayed. - :arg zax: Image axis to be used as the 'depth' axis. Can be - changed via the :attr:`LightBoxCanvas.zax` property. + :arg zax: Image axis to be used as the 'depth' axis. Can be + changed via the :attr:`LightBoxCanvas.zax` property. """ # These attributes are used to keep track of @@ -185,10 +189,10 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): self._nslices = 0 self._totalRows = 0 - slicecanvas.SliceCanvas.__init__(self, imageList, zax) + slicecanvas.SliceCanvas.__init__(self, imageList, displayCtx, zax) # default to showing the entire slice range - self.zrange.x = imageList.bounds.getRange(self.zax) + self.zrange.x = displayCtx.bounds.getRange(self.zax) self._slicePropsChanged() @@ -237,8 +241,8 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): the total number of rows. """ - xlen = self.imageList.bounds.getLen(self.xax) - ylen = self.imageList.bounds.getLen(self.yax) + xlen = self.displayCtx.bounds.getLen(self.xax) + ylen = self.displayCtx.bounds.getLen(self.yax) zlen = self.zrange.xlen width, height = self._getSize() @@ -318,7 +322,7 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): transformation matrices being changed). """ - newZRange = self.imageList.bounds.getRange(self.zax) + newZRange = self.displayCtx.bounds.getRange(self.zax) newZGap = self.sliceSpacing # Pick a sensible default for the @@ -351,7 +355,7 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): slicecanvas.SliceCanvas._imageBoundsChanged(self) - zmin, zmax = self.imageList.bounds.getRange(self.zax) + zmin, zmax = self.displayCtx.bounds.getRange(self.zax) oldzmin, oldzmax = self.zrange.getLimits(0) self.zrange.setLimits(0, zmin, zmax) @@ -380,10 +384,10 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): be displayed, in real world coordinates. """ - xmin = self.imageList.bounds.getLo( self.xax) - ymin = self.imageList.bounds.getLo( self.yax) - xlen = self.imageList.bounds.getLen(self.xax) - ylen = self.imageList.bounds.getLen(self.yax) + xmin = self.displayCtx.bounds.getLo( self.xax) + ymin = self.displayCtx.bounds.getLo( self.yax) + xlen = self.displayCtx.bounds.getLen(self.xax) + ylen = self.displayCtx.bounds.getLen(self.yax) off = self._totalRows - self.nrows - self.topRow ymin = ymin + ylen * off @@ -453,8 +457,8 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): row = int(np.floor(sliceno / ncols)) col = int(np.floor(sliceno % ncols)) - xlen = self.imageList.bounds.getLen(self.xax) - ylen = self.imageList.bounds.getLen(self.yax) + xlen = self.displayCtx.bounds.getLen(self.xax) + ylen = self.displayCtx.bounds.getLen(self.yax) translate = np.identity(4, dtype=np.float32) translate[3, self.xax] = xlen * col @@ -467,10 +471,10 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): def _drawGridLines(self): """Draws grid lines between all the displayed slices.""" - xlen = self.imageList.bounds.getLen(self.xax) - ylen = self.imageList.bounds.getLen(self.yax) - xmin = self.imageList.bounds.getLo( self.xax) - ymin = self.imageList.bounds.getLo( self.yax) + xlen = self.displayCtx.bounds.getLen(self.xax) + ylen = self.displayCtx.bounds.getLen(self.yax) + xmin = self.displayCtx.bounds.getLo( self.xax) + ymin = self.displayCtx.bounds.getLo( self.yax) rowLines = np.zeros(((self.nrows - 1) * 2, 3), dtype=np.float32) colLines = np.zeros(((self.ncols - 1) * 2, 3), dtype=np.float32) @@ -511,10 +515,10 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): sliceno = int(np.floor((self.pos.z - self.zrange.xlo) / self.sliceSpacing)) - xlen = self.imageList.bounds.getLen(self.xax) - ylen = self.imageList.bounds.getLen(self.yax) - xmin = self.imageList.bounds.getLo( self.xax) - ymin = self.imageList.bounds.getLo( self.yax) + xlen = self.displayCtx.bounds.getLen(self.xax) + ylen = self.displayCtx.bounds.getLen(self.yax) + xmin = self.displayCtx.bounds.getLo( self.xax) + ymin = self.displayCtx.bounds.getLo( self.yax) row = int(np.floor(sliceno / self.ncols)) col = int(np.floor(sliceno % self.ncols)) diff --git a/fsl/fslview/gl/osmesalightboxcanvas.py b/fsl/fslview/gl/osmesalightboxcanvas.py index 9f824ba6ad2ceaf1200279382404a3d9d500a965..410a0f2de78a362d50fa840556d024655dd67d8d 100644 --- a/fsl/fslview/gl/osmesalightboxcanvas.py +++ b/fsl/fslview/gl/osmesalightboxcanvas.py @@ -25,6 +25,7 @@ class OSMesaLightBoxCanvas(lightboxcanvas.LightBoxCanvas, def __init__(self, imageList, + displayCtx, zax=0, width=0, height=0, diff --git a/fsl/fslview/gl/osmesaslicecanvas.py b/fsl/fslview/gl/osmesaslicecanvas.py index 2ef36fe8a2f22e7c25a1403dea51f38a09d36ef1..60e12bf18723dacc3a6216432453e501f262bfc8 100644 --- a/fsl/fslview/gl/osmesaslicecanvas.py +++ b/fsl/fslview/gl/osmesaslicecanvas.py @@ -25,6 +25,7 @@ class OSMesaSliceCanvas(sc.SliceCanvas, def __init__(self, imageList, + displayCtx, zax=0, width=0, height=0, diff --git a/fsl/fslview/gl/slicecanvas.py b/fsl/fslview/gl/slicecanvas.py index eeff9594c307a6f1e99ed16348d363d6118e6d4c..81ccc0b489e4c1d92eb466fd670ec9bfd5a5b6f6 100644 --- a/fsl/fslview/gl/slicecanvas.py +++ b/fsl/fslview/gl/slicecanvas.py @@ -41,10 +41,11 @@ class SliceCanvas(props.HasProperties): """The currently displayed position. The ``pos.x`` and ``pos.y`` positions denote the position of a 'cursor', which is highlighted with green crosshairs. The ``pos.z`` position specifies the currently displayed - slice. While the values of this point are in the image list world - coordinates, the dimension ordering may not be the same as the image list - dimension ordering. For this position, the x and y dimensions correspond - to horizontal and vertical on the screen, and the z dimension to 'depth'. + slice. While the values of this point are in the display coordinate + system, the dimension ordering may not be the same as the display + coordinate dimension ordering. For this position, the x and y dimensions + correspond to horizontal and vertical on the screen, and the z dimension + to 'depth'. """ @@ -59,7 +60,7 @@ class SliceCanvas(props.HasProperties): displayBounds = props.Bounds(ndims=2) """The display bound x/y values specify the horizontal/vertical display - range of the canvas, in world coordinates. This may be a larger area + range of the canvas, in display coordinates. This may be a larger area than the size of the displayed images, as it is adjusted to preserve the aspect ratio. """ @@ -107,11 +108,11 @@ class SliceCanvas(props.HasProperties): def calcPixelDims(self): """Calculate and return the approximate size (width, height) of one - pixel in world space. + pixel in display space. """ - xmin, xmax = self.imageList.bounds.getRange(self.xax) - ymin, ymax = self.imageList.bounds.getRange(self.yax) + xmin, xmax = self.displayCtx.bounds.getRange(self.xax) + ymin, ymax = self.displayCtx.bounds.getRange(self.yax) w, h = self._getSize() pixx = (xmax - xmin) / float(w) @@ -122,7 +123,7 @@ class SliceCanvas(props.HasProperties): def canvasToWorld(self, xpos, ypos): """Given pixel x/y coordinates on this canvas, translates them - into the real world coordinates of the displayed slice. + into the display coordinates of the displayed slice. """ realWidth = self.displayBounds.xlen @@ -146,7 +147,7 @@ class SliceCanvas(props.HasProperties): def panDisplayBy(self, xoff, yoff): """Pans the canvas display by the given x/y offsets (specified in - world coordinates). + display coordinates). """ bounds = self.displayBounds @@ -178,7 +179,7 @@ class SliceCanvas(props.HasProperties): def panDisplayToShow(self, xpos, ypos): - """Pans the display so that the given x/y position (in world + """Pans the display so that the given x/y position (in display coordinates) is visible. """ @@ -200,7 +201,7 @@ class SliceCanvas(props.HasProperties): self.panDisplayBy(xoff, yoff) - def __init__(self, imageList, zax=0): + def __init__(self, imageList, displayCtx, zax=0): """Creates a canvas object. .. note:: It is assumed that each :class:`~fsl.data.image.Image` @@ -208,7 +209,10 @@ class SliceCanvas(props.HasProperties): which refers to an :class:`~fsl.fslview.displaycontext.ImageDisplay` instance defining how that image is to be displayed. - :arg imageList: An :class:`~fsl.data.image.ImageList` object. + :arg imageList: An :class:`~fsl.data.image.ImageList` object. + + :arg displayCtx: A :class:`~fsl.fslview.displaycontext.DisplayContext` + object. :arg zax: Image axis perpendicular to the plane to be displayed (the 'depth' axis), default 0. @@ -220,8 +224,9 @@ class SliceCanvas(props.HasProperties): props.HasProperties.__init__(self) - self.imageList = imageList - self.name = '{}_{}'.format(self.__class__.__name__, id(self)) + self.imageList = imageList + self.displayCtx = displayCtx + self.name = '{}_{}'.format(self.__class__.__name__, id(self)) # The zax property is the image axis which maps to the # 'depth' axis of this canvas. The _zAxisChanged method @@ -247,12 +252,12 @@ class SliceCanvas(props.HasProperties): # When the image list changes, refresh the # display, and update the display bounds - self.imageList.addListener('images', - self.name, - self._imageListChanged) - self.imageList.addListener('bounds', - self.name, - self._imageBoundsChanged) + self.imageList.addListener( 'images', + self.name, + self._imageListChanged) + self.displayCtx.addListener('bounds', + self.name, + self._imageBoundsChanged) def _initGL(self): @@ -359,14 +364,14 @@ class SliceCanvas(props.HasProperties): def _imageBoundsChanged(self, *a): - """Called when the image list bounds are changed. + """Called when the display bounds are changed. Updates the constraints on the :attr:`pos` property so it is limited to stay within a valid range, and then calls the :meth:`_updateDisplayBounds` method. """ - imgBounds = self.imageList.bounds + imgBounds = self.displayCtx.bounds self.pos.setMin(0, imgBounds.getLo(self.xax)) self.pos.setMax(0, imgBounds.getHi(self.xax)) @@ -438,10 +443,10 @@ class SliceCanvas(props.HasProperties): :arg ymax: Maximum y value to be in the display bounds. """ - if xmin is None: xmin = self.imageList.bounds.getLo(self.xax) - if xmax is None: xmax = self.imageList.bounds.getHi(self.xax) - if ymin is None: ymin = self.imageList.bounds.getLo(self.yax) - if ymax is None: ymax = self.imageList.bounds.getHi(self.yax) + if xmin is None: xmin = self.displayCtx.bounds.getLo(self.xax) + if xmax is None: xmax = self.displayCtx.bounds.getHi(self.xax) + if ymin is None: ymin = self.displayCtx.bounds.getLo(self.yax) + if ymax is None: ymax = self.displayCtx.bounds.getHi(self.yax) log.debug('Required display bounds: X: ({}, {}) Y: ({}, {})'.format( xmin, xmax, ymin, ymax)) @@ -515,8 +520,8 @@ class SliceCanvas(props.HasProperties): if xmax is None: xmax = self.displayBounds.xhi if ymin is None: ymin = self.displayBounds.ylo if ymax is None: ymax = self.displayBounds.yhi - if zmin is None: zmin = self.imageList.bounds.getLo(self.zax) - if zmax is None: zmax = self.imageList.bounds.getHi(self.zax) + if zmin is None: zmin = self.displayCtx.bounds.getLo(self.zax) + if zmax is None: zmax = self.displayCtx.bounds.getHi(self.zax) # If there are no images to be displayed, # or no space to draw, do nothing @@ -582,8 +587,8 @@ class SliceCanvas(props.HasProperties): xverts = np.zeros((2, 3)) yverts = np.zeros((2, 3)) - xmin, xmax = self.imageList.bounds.getRange(self.xax) - ymin, ymax = self.imageList.bounds.getRange(self.yax) + xmin, xmax = self.displayCtx.bounds.getRange(self.xax) + ymin, ymax = self.displayCtx.bounds.getRange(self.yax) x = self.pos.x y = self.pos.y diff --git a/fsl/fslview/gl/wxgllightboxcanvas.py b/fsl/fslview/gl/wxgllightboxcanvas.py index 40515d92947d0d9c9f1f27b13dd4b307e8a65858..bb723428151543e2352806737f90298355535eeb 100644 --- a/fsl/fslview/gl/wxgllightboxcanvas.py +++ b/fsl/fslview/gl/wxgllightboxcanvas.py @@ -26,22 +26,25 @@ class WXGLLightBoxCanvas(lightboxcanvas.LightBoxCanvas, interactive 2D slice rendering from a collection of 3D images. """ - def __init__(self, parent, imageList, zax=0): + def __init__(self, parent, imageList, displayCtx, zax=0): """Configures a few event handlers for cleaning up property listeners when the canvas is destroyed, and for redrawing on paint/resize events. """ wxgl.GLCanvas .__init__(self, parent) - lightboxcanvas.LightBoxCanvas.__init__(self, imageList, zax) + lightboxcanvas.LightBoxCanvas.__init__(self, + imageList, + displayCtx, + zax) fslgl.WXGLCanvasTarget .__init__(self) # the image list is probably going to outlive # this SliceCanvas object, so we do the right # thing and remove our listeners when we die def onDestroy(ev): - self.imageList.removeListener('images', self.name) - self.imageList.removeListener('bounds', self.name) + self.imageList .removeListener('images', self.name) + self.displayCtx.removeListener('bounds', self.name) for image in self.imageList: disp = image.getAttribute('display') disp.removeListener('imageType', self.name) diff --git a/fsl/fslview/gl/wxglslicecanvas.py b/fsl/fslview/gl/wxglslicecanvas.py index 9b0c118e86cb154355dc7f2a8087316f60755129..d7c587588ac59c343213c6fa5394185df55b9230 100644 --- a/fsl/fslview/gl/wxglslicecanvas.py +++ b/fsl/fslview/gl/wxglslicecanvas.py @@ -31,22 +31,22 @@ class WXGLSliceCanvas(slicecanvas.SliceCanvas, interactive 2D slice rendering from a collection of 3D images. """ - def __init__(self, parent, imageList, zax=0): + def __init__(self, parent, imageList, displayCtx, zax=0): """Configures a few event handlers for cleaning up property listeners when the canvas is destroyed, and for redrawing on paint/resize events. """ wxgl.GLCanvas .__init__(self, parent) - slicecanvas.SliceCanvas.__init__(self, imageList, zax) + slicecanvas.SliceCanvas.__init__(self, imageList, displayCtx, zax) fslgl.WXGLCanvasTarget .__init__(self) # the image list is probably going to outlive # this SliceCanvas object, so we do the right # thing and remove our listeners when we die def onDestroy(ev): - self.imageList.removeListener('images', self.name) - self.imageList.removeListener('bounds', self.name) + self.imageList .removeListener('images', self.name) + self.displayCtx.removeListener('bounds', self.name) for image in self.imageList: disp = image.getAttribute('display') disp.removeListener('imageType', self.name) diff --git a/fsl/fslview/strings.py b/fsl/fslview/strings.py index 19afa635c65d637362b79021959efa5b48f6817f..dc395e16d05f5f52dde005bf59eff15be0d58421 100644 --- a/fsl/fslview/strings.py +++ b/fsl/fslview/strings.py @@ -50,11 +50,11 @@ viewPanelConfigMenuText = { orthoConfigMenu = '{} display' lightBoxConfigMenu = '{} display' -locationPanelOutOfBounds = 'Out of bounds' -locationPanelSpaceLabel = '{} space' -locationPanelLocationLabel = 'World location (mm)' -locationPanelVoxelLabel = 'Voxel coordinates' -locationPanelVolumeLabel = 'Volume (index)' +locationPanelOutOfBounds = 'Out of bounds' +locationPanelSpaceLabel = '{} space' +locationPanelWorldLabel = 'World location (mm)' +locationPanelVoxelLabel = 'Voxel coordinates' +locationPanelVolumeLabel = 'Volume (index)' imageAxisLowLongLabels = { @@ -94,7 +94,6 @@ imageAxisHighShortLabels = { fslimage.ORIENT_UNKNOWN : '?'} imageSpaceLabels = { - fslimage.NIFTI_XFORM_VOXEL : 'Voxel', fslimage.NIFTI_XFORM_UNKNOWN : 'Unknown', fslimage.NIFTI_XFORM_SCANNER_ANAT : 'Scanner anatomical', fslimage.NIFTI_XFORM_ALIGNED_ANAT : 'Aligned anatomical', diff --git a/fsl/fslview/views/lightboxpanel.py b/fsl/fslview/views/lightboxpanel.py index bf01aaffff6135d1f2924aaa5b8a64931c45685c..bcd3d2c7adec713d9fdd2c9b2c29b4196b56f8ed 100644 --- a/fsl/fslview/views/lightboxpanel.py +++ b/fsl/fslview/views/lightboxpanel.py @@ -90,7 +90,8 @@ class LightBoxPanel(canvaspanel.CanvasPanel): self._scrollbar = wx.ScrollBar(self, style=wx.SB_VERTICAL) self._lbCanvas = lightboxcanvas.LightBoxCanvas(self.getCanvasPanel(), - imageList) + imageList, + displayCtx) # My properties are the canvas properties self.bindProps('sliceSpacing', self._lbCanvas) diff --git a/fsl/fslview/views/orthopanel.py b/fsl/fslview/views/orthopanel.py index 4e6c11d3c1a0b1973df11eae2da49072e5c53a89..783859d1b9a4dbd3cb550396ef7916e3dca995c9 100644 --- a/fsl/fslview/views/orthopanel.py +++ b/fsl/fslview/views/orthopanel.py @@ -113,11 +113,17 @@ class OrthoPanel(canvaspanel.CanvasPanel): # The canvases themselves - each one displays a # slice along each of the three world axes self._xcanvas = slicecanvas.WXGLSliceCanvas(self._xCanvasPanel, - imageList, zax=0) + imageList, + displayCtx, + zax=0) self._ycanvas = slicecanvas.WXGLSliceCanvas(self._yCanvasPanel, - imageList, zax=1) + imageList, + displayCtx, + zax=1) self._zcanvas = slicecanvas.WXGLSliceCanvas(self._zCanvasPanel, - imageList, zax=2) + imageList, + displayCtx, + zax=2) # Attach each canvas as an attribute of its parent - # see the _configureGridLayout/_configureFlatLayout @@ -284,18 +290,24 @@ class OrthoPanel(canvaspanel.CanvasPanel): for changes on its affine transformation matrix. """ - self._refreshLabels() - - if len(self._imageList) == 0: return - for i, img in enumerate(self._imageList): - img.removeListener('transform', self._name) + display = img.getAttribute('display') + + display.removeListener('transform', self._name) # Update anatomy labels when the image # transformation matrix changes if i == self._displayCtx.selectedImage: - img.addListener('transform', self._name, self._refreshLabels) + display.addListener('transform', + self._name, + self._refreshLabels) + + # _refreshLabels will in turn call + # _layoutChanged, which is needed + # in case the display bounds have + # changed + self._refreshLabels() def _onDestroy(self, ev): @@ -319,7 +331,8 @@ class OrthoPanel(canvaspanel.CanvasPanel): # listeners to individual images, # so we have to remove them too for img in self._imageList: - img.removeListener('transform', self._name) + display = img.getAttribute('display') + display.removeListener('transform', self._name) def _resize(self, ev): @@ -348,8 +361,9 @@ class OrthoPanel(canvaspanel.CanvasPanel): # Are we showing or hiding the labels? - if self.showLabels: show = True - else: show = False + if len(self._imageList) == 0: show = False + elif self.showLabels: show = True + else: show = False for lbl in allLabels: lbl.Show(show) @@ -364,25 +378,24 @@ class OrthoPanel(canvaspanel.CanvasPanel): # changed to red colour = 'white' - if len(self._imageList) > 0: - image = self._imageList[self._displayCtx.selectedImage] + image = self._imageList[self._displayCtx.selectedImage] + display = image.getAttribute('display') - # The image is being displayed as it is stored on - # disk - the image.getOrientation method calculates - # and returns labels for each voxelwise axis. - if image.transform in ('pixdim', 'id'): - xorient = image.getVoxelOrientation(0) - yorient = image.getVoxelOrientation(1) - zorient = image.getVoxelOrientation(2) - - # The image is being displayed in 'real world' space - - # the definition of this space may be present in the - # image meta data - else: - xorient = image.getWorldOrientation(0) - yorient = image.getWorldOrientation(1) - zorient = image.getWorldOrientation(2) + # The image is being displayed as it is stored on + # disk - the image.getOrientation method calculates + # and returns labels for each voxelwise axis. + if display.transform in ('pixdim', 'id'): + xorient = image.getVoxelOrientation(0) + yorient = image.getVoxelOrientation(1) + zorient = image.getVoxelOrientation(2) + # The image is being displayed in 'real world' space - + # the definition of this space may be present in the + # image meta data + else: + xorient = image.getWorldOrientation(0) + yorient = image.getWorldOrientation(1) + zorient = image.getWorldOrientation(2) if fslimage.ORIENT_UNKNOWN in (xorient, yorient, zorient): colour = 'red' @@ -428,7 +441,7 @@ class OrthoPanel(canvaspanel.CanvasPanel): self._configureFlatLayout(width, height, canvases, False) return - bounds = self._imageList.bounds + bounds = self._displayCtx.bounds canvasWidths = [bounds.getLen(c._canvas.xax) for c in canvases] canvasHeights = [bounds.getLen(c._canvas.yax) for c in canvases] @@ -458,7 +471,7 @@ class OrthoPanel(canvaspanel.CanvasPanel): ratio is maintained across them when laid out vertically (``vert=True``) or horizontally (``vert=False``). """ - bounds = self._imageList.bounds + bounds = self._displayCtx.bounds # Get the canvas dimensions in world space canvasWidths = [bounds.getLen(c._canvas.xax) for c in canvases] @@ -556,7 +569,10 @@ class OrthoPanel(canvaspanel.CanvasPanel): self._zCanvasPanel] show = [self.showXCanvas, self.showYCanvas, - self.showZCanvas] + self.showZCanvas] + + if len(self._imageList) == 0: + show = [False] * 3 # Pick out the canvases for which # the 'show*Canvas' property is true @@ -572,6 +588,9 @@ class OrthoPanel(canvaspanel.CanvasPanel): self._zCanvasPanel.Show(self.showZCanvas) if len(canvases) == 0: + self.Layout() + self.getCanvasPanel().Layout() + self.Refresh() return # Regardless of the layout, we use a diff --git a/fsl/fslview/views/spacepanel.py b/fsl/fslview/views/spacepanel.py index d85d751b364026afca295ac2454406d27d967716..9afabd7f4ecd3ba1c70bdad82009a6ee46759957 100644 --- a/fsl/fslview/views/spacepanel.py +++ b/fsl/fslview/views/spacepanel.py @@ -17,6 +17,7 @@ from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as Canvas from mpl_toolkits.mplot3d import Axes3D import fsl.fslview.viewpanel as viewpanel +import fsl.utils.transform as transform class SpacePanel(viewpanel.ViewPanel): @@ -60,7 +61,7 @@ class SpacePanel(viewpanel.ViewPanel): def _onDestroy(self, ev): ev.Skip() - self._iamgeList.removeListener('images', self._name) + self._imageList.removeListener('images', self._name) self._iamgeList.removeListener('selectedImage', self._name) @@ -74,12 +75,13 @@ class SpacePanel(viewpanel.ViewPanel): self._canvas.draw() return - image = self._imageList[self._displayCtx.selectedImage] + image = self._imageList[self._displayCtx.selectedImage] + display = image.getAttribute('display') - image.addListener('transform', - self._name, - self._selectedImageChanged, - overwrite=True) + display.addListener('transform', + self._name, + self._selectedImageChanged, + overwrite=True) self._axis.set_title(image.name) self._axis.set_xlabel('X') @@ -97,11 +99,13 @@ class SpacePanel(viewpanel.ViewPanel): def _plotImageBounds(self): - image = self._imageList[self._displayCtx.selectedImage] + image = self._imageList[self._displayCtx.selectedImage] + display = image.getAttribute('display') + v2DMat = display.voxToDisplayMat - xlo, xhi = image.imageBounds(0) - ylo, yhi = image.imageBounds(1) - zlo, zhi = image.imageBounds(2) + xlo, xhi = transform.axisBounds(image.shape, v2DMat, 0) + ylo, yhi = transform.axisBounds(image.shape, v2DMat, 1) + zlo, zhi = transform.axisBounds(image.shape, v2DMat, 2) points = np.zeros((8, 3), dtype=np.float32) points[0, :] = [xlo, ylo, zlo] @@ -122,7 +126,8 @@ class SpacePanel(viewpanel.ViewPanel): # Imported here to avoid circular import issues import fsl.fslview.strings as strings - image = self._imageList[self._displayCtx.selectedImage] + image = self._imageList[self._displayCtx.selectedImage] + display = image.getAttribute('display') centre = np.array(image.shape) / 2.0 @@ -138,7 +143,7 @@ class SpacePanel(viewpanel.ViewPanel): lblLo = strings.imageAxisLowShortLabels[ orient] lblHi = strings.imageAxisHighShortLabels[orient] - wldSpan = image.voxToWorld(voxSpan) + wldSpan = transform.transform(voxSpan, display.voxToDisplayMat) self._axis.plot(wldSpan[:, 0], wldSpan[:, 1], @@ -152,7 +157,8 @@ class SpacePanel(viewpanel.ViewPanel): def _plotAxisLengths(self): - image = self._imageList[self._displayCtx.selectedImage] + image = self._imageList[self._displayCtx.selectedImage] + display = image.getAttribute('display') for ax, colour, label in zip(range(3), ['r', 'g', 'b'], @@ -162,7 +168,11 @@ class SpacePanel(viewpanel.ViewPanel): points[:] = [-0.5, -0.5, -0.5] points[1, ax] = image.shape[ax] - 0.5 - tx = image.voxToWorld(points) + tx = transform.transform(points, display.voxToDisplayMat) + + axlen = transform.axisLength(image.shape, + display.voxToDisplayMat, + ax) self._axis.plot(tx[:, 0], tx[:, 1], @@ -170,13 +180,14 @@ class SpacePanel(viewpanel.ViewPanel): lw=1, color=colour, alpha=0.5, - label='Axis {} (length {:0.2f})'.format( - label, image.axisLength(ax))) + label='Axis {} (length {:0.2f})'.format(label, + axlen)) def _plotImageCorners(self): - image = self._imageList[self._displayCtx.selectedImage] + image = self._imageList[self._displayCtx.selectedImage] + display = image.getAttribute('display') x, y, z = image.shape[:3] @@ -195,7 +206,7 @@ class SpacePanel(viewpanel.ViewPanel): points[6, :] = [x, y, -0.5] points[7, :] = [x, y, z] - points = image.voxToWorld(points) + points = transform.transform(points, display.voxToDisplayMat) self._axis.scatter(points[:, 0], points[:, 1], points[:, 2], color='b', s=40) diff --git a/fsl/fslview/views/timeseriespanel.py b/fsl/fslview/views/timeseriespanel.py index 190e5fa26505439a97198dd33c0fd1f4a9361d9b..b4438c670630c08382c825faf90383c909286a84 100644 --- a/fsl/fslview/views/timeseriespanel.py +++ b/fsl/fslview/views/timeseriespanel.py @@ -25,6 +25,7 @@ import matplotlib.pyplot as plt from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as Canvas import fsl.fslview.viewpanel as viewpanel +import fsl.utils.transform as transform class TimeSeriesPanel(viewpanel.ViewPanel): @@ -125,7 +126,10 @@ class TimeSeriesPanel(viewpanel.ViewPanel): for image in self._imageList: - ix, iy, iz = image.worldToVox([[x, y, z]])[0] + display = image.getAttribute('display') + xform = display.displayToVoxMat + + ix, iy, iz = transform.transform([[x, y, z]], xform)[0] ix = round(ix) iy = round(iy) diff --git a/fsl/tools/bet.py b/fsl/tools/bet.py index 8962109db14757f337ba0986bb6ddd7d299a29fa..7c8553eff2e8a694e69339795fe036a98de11b48 100644 --- a/fsl/tools/bet.py +++ b/fsl/tools/bet.py @@ -11,6 +11,7 @@ import props import fsl.data.imagefile as imagefile import fsl.data.image as fslimage +import fsl.utils.transform as transform import fsl.fslview.displaycontext as displaycontext runChoices = OrderedDict(( @@ -189,6 +190,7 @@ def selectHeadCentre(opts, button): image = fslimage.Image(opts.inputImage) imageList = fslimage.ImageList([image]) displayCtx = displaycontext.DisplayContext(imageList) + display = image.getAttribute('display') parent = button.GetTopLevelParent() frame = orthopanel.OrthoDialog(parent, imageList, @@ -196,11 +198,13 @@ def selectHeadCentre(opts, button): opts.inputImage, style=wx.RESIZE_BORDER) panel = frame.panel + v2dMat = display.voxToDisplayMat + d2vMat = display.displayToVoxMat # Whenever the x/y/z coordinates change on # the ortho panel, update the option values. def updateOpts(*a): - x, y, z = image.worldToVox([displayCtx.location])[0] + x, y, z = transform.transform([displayCtx.location], d2vMat)[0] if x >= image.shape[0]: x = image.shape[0] - 1 elif x < 0: x = 0 @@ -222,7 +226,7 @@ def selectHeadCentre(opts, button): # done after the frame has been displayed, i.e # via wx.CallAfter or similar. voxCoords = [opts.xCoordinate, opts.yCoordinate, opts.zCoordinate] - worldCoords = image.voxToWorld([voxCoords])[0] + worldCoords = transform.transform([voxCoords], v2dMat)[0] panel.pos = worldCoords # Position the dialog by the button that was clicked diff --git a/fsl/tools/fslview_parseargs.py b/fsl/tools/fslview_parseargs.py index c07bc0e7e9f6dfa8f5319c11e10bbb43d0ed7b94..8964286f56cf5b423d9fe1d9f0a5cce7b6e9c639 100644 --- a/fsl/tools/fslview_parseargs.py +++ b/fsl/tools/fslview_parseargs.py @@ -18,6 +18,7 @@ import argparse import props import fsl.data.image as fslimage +import fsl.utils.transform as transform import fsl.fslview.displaycontext as displaycontext @@ -252,12 +253,14 @@ def handleImageArgs(args): if args.worldloc: loc = args.worldloc elif args.voxelloc: - loc = imageList[0].voxToWorld([args.voxelloc])[0] + display = imageList[0].getAttribute('display') + xform = display.voxToDisplayMat + loc = transform.transform([[args.voxelloc]], xform)[0] else: - loc = [imageList.bounds.xlo + 0.5 * imageList.bounds.xlen, - imageList.bounds.ylo + 0.5 * imageList.bounds.ylen, - imageList.bounds.zlo + 0.5 * imageList.bounds.zlen] + loc = [displayCtx.bounds.xlo + 0.5 * displayCtx.bounds.xlen, + displayCtx.bounds.ylo + 0.5 * displayCtx.bounds.ylen, + displayCtx.bounds.zlo + 0.5 * displayCtx.bounds.zlen] displayCtx.location.xyz = loc diff --git a/fsl/tools/render.py b/fsl/tools/render.py index dae08ad4ed67d79be7818a2a9b5799207ccbae58..12c8cdb9a6d41060f11a0da978fe4575b4402cb9 100644 --- a/fsl/tools/render.py +++ b/fsl/tools/render.py @@ -162,6 +162,7 @@ def run(args, context): c = slicecanvas.OSMesaSliceCanvas( imageList, + displayCtx, zax=i, width=width, height=height, diff --git a/fsl/utils/transform.py b/fsl/utils/transform.py new file mode 100644 index 0000000000000000000000000000000000000000..6304e6b186fb5c06b7835317b3ecb6c1c0108691 --- /dev/null +++ b/fsl/utils/transform.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python +# +# transform.py - Functions for working with affine transformation matrices. +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# +"""This module provides functions related to 3D image transformations and +spaces. +""" + +import numpy as np +import numpy.linalg as linalg +import collections + + +def invert(x): + """Inverts the given matrix. """ + return linalg.inv(x) + + +def concat(x1, x2): + """Combines the two matrices (returns the dot product).""" + return np.dot(x1, x2) + + +def axisBounds(shape, xform, axis): + """Returns the (lo, hi) bounds of the specified axis.""" + x, y, z = shape + + x -= 0.5 + y -= 0.5 + z -= 0.5 + + points = np.zeros((8, 3), dtype=np.float32) + + points[0, :] = [-0.5, -0.5, -0.5] + points[1, :] = [-0.5, -0.5, z] + points[2, :] = [-0.5, y, -0.5] + points[3, :] = [-0.5, y, z] + points[4, :] = [x, -0.5, -0.5] + points[5, :] = [x, -0.5, z] + points[6, :] = [x, y, -0.5] + points[7, :] = [x, y, z] + + tx = transform(points, xform) + + lo = tx[:, axis].min() + hi = tx[:, axis].max() + + return (lo, hi) + + +def axisLength(shape, xform, axis): + """Return the length, in real world units, of the specified axis. + """ + + points = np.zeros((2, 3), dtype=np.float32) + points[:] = [-0.5, -0.5, -0.5] + points[1, axis] = shape[axis] - 0.5 + + tx = transform(points, xform) + + # euclidean distance between each boundary point + return sum((tx[0, :] - tx[1, :]) ** 2) ** 0.5 + + +def transform(p, xform, axes=None): + """Transforms the given set of points ``p`` according to the given affine + transformation ``x``. The transformed points are returned as a + :class:``numpy.float64`` array. + """ + + p = _fillPoints(p, axes) + t = np.zeros((len(p), 3), dtype=np.float64) + + x = p[:, 0] + y = p[:, 1] + z = p[:, 2] + + t[:, 0] = x * xform[0, 0] + y * xform[1, 0] + z * xform[2, 0] + xform[3, 0] + t[:, 1] = x * xform[0, 1] + y * xform[1, 1] + z * xform[2, 1] + xform[3, 1] + t[:, 2] = x * xform[0, 2] + y * xform[1, 2] + z * xform[2, 2] + xform[3, 2] + + if axes is None: axes = [0, 1, 2] + + tx = np.array(t[:, axes], dtype=np.float64) + + if tx.size == 1: return tx[0] + else: return tx + + +def _fillPoints(p, axes): + """Used by the :func:`transform` function. Turns the given array p into + a N*3 array of x,y,z coordinates. The array p may be a 1D array, or an + N*2 or N*3 array. + """ + + if not isinstance(p, collections.Iterable): p = [p] + + p = np.array(p) + + if axes is None: return p + + if not isinstance(axes, collections.Iterable): axes = [axes] + + if p.ndim == 1: + p = p.reshape((len(p), 1)) + + if p.ndim != 2: + raise ValueError('Points array must be either one or two ' + 'dimensions') + + if len(axes) != p.shape[1]: + raise ValueError('Points array shape does not match specified ' + 'number of axes') + + newp = np.zeros((len(p), 3), dtype=p.dtype) + + for i, ax in enumerate(axes): + newp[:, ax] = p[:, i] + + return newp