Source code for fsleyes.gl.glimageobject

#
# glimageobject.py - The GLImageObject class.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`GLImageObject` class, a sub-class of
:class:`.GLObject`, and the base class for all OpenGL objects which display
data from :class:`.Nifti` overlays.
"""


import numpy     as np
import OpenGL.GL as gl

import fsl.transform.affine                as affine
import fsl.utils.memoize                   as memoize

import fsleyes.displaycontext.volume3dopts as volume3dopts
import fsleyes.colourmaps                  as fslcmaps
import fsleyes.gl.globject                 as globject
import fsleyes.gl.routines                 as glroutines


[docs]class GLImageObject(globject.GLObject): """The ``GLImageObject`` class is the base class for all GL representations of :class:`.Nifti` instances. It contains some convenience methods for drawing volumetric image data. Some useful methods for 2D rendering:: .. autsummary:: :nosignatures: frontFace generateVertices2D generateVoxelCoordinates2D Some useful methods for 3D rendering:: .. autsummary:: :nosignatures: generateVertices3D generateVoxelCoordinates3D get3DClipPlane clipPlaneVertices drawClipPlanes """
[docs] def __init__(self, overlay, overlayList, displayCtx, canvas, threedee): """Create a ``GLImageObject``. :arg image: A :class:`.Nifti` object. :arg overlayList: The :class`.OverlayList`. :arg displayCtx: The :class:`.DisplayContext` object managing the scene. :arg canvas: The canvas doing the drawing. :arg threedee: Set up for 2D or 3D rendering. """ globject.GLObject.__init__( self, overlay, overlayList, displayCtx, canvas, threedee) self.__name = 'GLImageObject_{}'.format(self.name) name = self.__name opts = self.opts # In 3D mode, when Volume3DOpts.showClipPlanes # is on, we create a unique random colour for # each displayed clipping plane. self.__clipPlaneColours = {} # If this is being shown in 3D, # we add some listeners. Some # geometry methods are memoized, # and we need to invalidate the # memoize cache when certain # display properties change. if self.threedee and isinstance(opts, volume3dopts.Volume3DOpts): kwargs = {'callback' : self.__boundsChanged, 'immediate' : True} # Invalidate vertices on bounds change opts.addListener('bounds', name, **kwargs) opts.addListener('transform', name, **kwargs) opts.addListener('displayXform', name, **kwargs) # Invalidate 3Dclipping on any clip chagnes kwargs['callback'] = self.__clip3DChanged opts.addListener('clipPosition', name, **kwargs) opts.addListener('clipAzimuth', name, **kwargs) opts.addListener('clipInclination', name, **kwargs)
@property def image(self): """The :class:`.Nifti` being rendered by this ``GLImageObject``. This is equivalent to :meth:`.GLObject.overlay`. """ return self.overlay
[docs] def destroy(self): """Must be called when this ``GLImageObject`` is no longer needed. Removes some property listeners. """ name = self.__name opts = self.opts opts.removeListener('bounds', name) opts.removeListener('transform', name) opts.removeListener('displayXform', name) if self.threedee and isinstance(opts, volume3dopts.Volume3DOpts): opts.removeListener('clipPosition', name) opts.removeListener('clipAzimuth', name) opts.removeListener('clipInclination', name) globject.GLObject.destroy(self)
@property def destroyed(self): """Returns ``True`` if :meth:`destroy` has been called, ``False`` otherwise. """ return self.image is None
[docs] def getDisplayBounds(self): """Returns the bounds of the :class:`.Image` (see the :meth:`.DisplayOpts.bounds` property). """ return (self.opts.bounds.getLo(), self.opts.bounds.getHi())
[docs] def getDataResolution(self, xax, yax): """Returns a suitable screen resolution for rendering this ``GLImageObject`` in 2D. """ import nibabel as nib image = self.image opts = self.opts # Figure out a good display resolution # along each voxel dimension shape = np.array(image.shape[:3]) # Figure out an approximate # correspondence between the # voxel axes and the display # coordinate system axes. xform = opts.getTransform('id', 'display') axes = nib.orientations.aff2axcodes( xform, ((0, 0), (1, 1), (2, 2))) # Re-order the voxel resolutions # in the display space res = [shape[axes[0]], shape[axes[1]], shape[axes[2]]] return res
[docs] def frontFace(self): """Convenience method for 2D rendering. Image slices are generally drawn onto a 2D plane which is parallel to the viewing plane (see the :class:`.GLVolume` class). If the canvas that is drawing this ``GLImageObject`` has adjusted the projection matrix (e.g. via the :attr:`.SliceCanvas.invertX` or :attr:`.SliceCanvas.invertY` properties), the front or back face of this plane may be facing the viewing plane. So if face-culling is desired, this method returns the face that is facing away from the viewing plane, i.e. the face that can safely be culled. .. note:: This will raise an error if called on a ``GLImageObject`` which is being drawn by anything other than a :class:`.SliceCanvas` or :class:`.LightBoxCanvas`. """ front = gl.GL_CCW back = gl.GL_CW numInverts = 0 copts = self.canvas.opts # No flips if rendering # to an offscreen texture if copts.renderMode != 'onscreen': return front if copts.invertX: numInverts += 1 if copts.invertY: numInverts += 1 if numInverts == 1: front, back = back, front return front
[docs] def generateVertices2D(self, zpos, axes, bbox=None): """Generates vertex coordinates for a 2D slice of the :class:`.Image`, through the given ``zpos``, with the optional ``bbox`` applied to the coordinates. This is a convenience method for generating vertices which can be used to render a slice through a 3D texture. It is used by the :mod:`.gl14.glvolume_funcs` and :mod:`.gl21.glvolume_funcs` (and other) modules. A tuple of three values is returned, containing: - A ``6*3 numpy.float32`` array containing the vertex coordinates - A ``6*3 numpy.float32`` array containing the voxel coordinates corresponding to each vertex - A ``6*3 numpy.float32`` array containing the texture coordinates corresponding to each vertex """ opts = self.opts v2dMat = opts.getTransform('voxel', 'display') d2vMat = opts.getTransform('display', 'voxel') v2tMat = opts.getTransform('voxel', 'texture') xax, yax, zax = axes vertices, voxCoords = glroutines.slice2D( self.image.shape[:3], xax, yax, zpos, v2dMat, d2vMat, bbox=bbox) # If not interpolating, centre the # voxel coordinates on the Z/depth # axis. We do this to avoid rounding # bias when the display Z position is # on a voxel boundary. if not hasattr(opts, 'interpolation') or opts.interpolation == 'none': voxCoords = opts.roundVoxels(voxCoords, daxes=[zax]) texCoords = affine.transform(voxCoords, v2tMat) return vertices, voxCoords, texCoords
@memoize.Instanceify(memoize.memoize) def generateVertices3D(self, bbox=None): """Generates vertex coordinates defining the 3D bounding box of the :class:`.Image`, with the optional ``bbox`` applied to the coordinates. See the :func:`.routines.boundingBox` function. A tuple of three values is returned, containing: - A ``36*3 numpy.float32`` array containing the vertex coordinates - A ``36*3 numpy.float32`` array containing the voxel coordinates corresponding to each vertex - A ``36*3 numpy.float32`` array containing the texture coordinates corresponding to each vertex """ opts = self.opts v2dMat = opts.getTransform('voxel', 'display') d2vMat = opts.getTransform('display', 'voxel') v2tMat = opts.getTransform('voxel', 'texture') vertices, voxCoords = glroutines.boundingBox( self.image.shape[:3], v2dMat, d2vMat, bbox=bbox) texCoords = affine.transform(voxCoords, v2tMat) return vertices, voxCoords, texCoords
[docs] def generateVoxelCoordinates2D( self, zpos, axes, bbox=None, space='voxel'): """Generates a 2D grid of voxel coordinates along the XY display coordinate system plane, at the given ``zpos``. :arg zpos: Position along the display coordinate system Z axis. :arg axes: Axis indices. :arg bbox: Limiting bounding box. :arg space: Either ``'voxel'`` (the default) or ``'display'``. If the latter, the returned coordinates are in terms of the display coordinate system. Otherwise, the returned coordinates are integer voxel coordinates. :returns: A ``numpy.float32`` array of shape ``(N, 3)``, containing the coordinates for ``N`` voxels. See the :func:`.pointGrid` function. """ if space not in ('voxel', 'display'): raise ValueError('Unknown value for space ("{}")'.format(space)) image = self.image opts = self.opts v2dMat = opts.getTransform('voxel', 'display') d2vMat = opts.getTransform('display', 'voxel') xax, yax, zax = axes # TODO If space == voxel, you should call # pointGrid to generate voxels, and # avoid the subsequent transform back # from display to voxel space. if opts.transform == 'id': resolution = [1, 1, 1] elif opts.transform in ('pixdim', 'pixdim-flip'): resolution = image.pixdim[:3] else: resolution = [min(image.pixdim[:3])] * 3 voxels = glroutines.pointGrid( image.shape, resolution, v2dMat, xax, yax, bbox=bbox)[0] voxels[:, zax] = zpos if space == 'voxel': voxels = affine.transform(voxels, d2vMat) voxels = opts.roundVoxels(voxels, daxes=[zax], roundOther=False) return voxels
[docs] def generateVoxelCoordinates3D(self, bbox, space='voxel'): """ See the :func:`.pointGrid3D` function. note: Not implemented properly yet. """ if space not in ('voxel', 'display'): raise ValueError('Unknown value for space ("{}")'.format(space)) # TODO image = self.image opts = self.opts v2dMat = opts.getTransform('voxel', 'display') d2vMat = opts.getTransform('display', 'voxel') voxels = glroutines.pointGrid3D(image.shape[:3]) if space == 'voxel': pass # voxels = affine.transform(voxels, d2vMat) # voxels = opts.roundVoxels(voxels) return voxels
@memoize.Instanceify(memoize.memoize) def get3DClipPlane(self, *args, **kwargs): """Memoized wrapper around the :meth:`.Volume3DOpts.get3DClipPlane` method. """ return self.opts.get3DClipPlane(*args, **kwargs) @memoize.Instanceify(memoize.memoize) def clipPlaneVertices(self, planeIdx, bbox=None): """A convenience method for use with overlays being displayed in terms of a :class:`.Volume3DOpts` instance. Generates vertices for the clipping plane specified by ``planeIdx`` (an index into the ``Volume3DOpts.clip*`` lists). Returns a ``(N, 3)`` ``numpy`` array containing the vertices, and a 1D ``numpy`` array containing vertex indices. See the :meth:`get3DClipPlane` and :meth:`drawClipPlanes` methods. .. note:: This method depends on the ``trimesh`` library - if it is not present, two empty arrays are returned. """ origin, normal = self.get3DClipPlane(planeIdx) vertices = self.generateVertices3D(bbox=bbox)[0] indices = np.arange(vertices.shape[0]).reshape(-1, 3) try: import trimesh import trimesh.intersections as tmint except ImportError: return np.zeros((0, 3)), np.zeros((0,)) # Calculate the intersection of the clip # plane with the image bounding box lines = tmint.mesh_plane( trimesh.Trimesh(vertices, indices), plane_normal=normal, plane_origin=origin) # I'm assuming that the # returned lines are sorted vertices = np.array(lines.reshape(-1, 3), dtype=np.float32) if vertices.shape[0] < 3: return np.zeros((0, 3)), np.zeros((0,)) indices = glroutines.polygonIndices(vertices.shape[0]) return vertices, indices
[docs] def drawClipPlanes(self, xform=None, bbox=None): """A convenience method for use with overlays being displayed in terms of a :class:`.Volume3DOpts` instance. Draws the active clipping planes, as specified by the :class:`.Volume3DOpts` clipping properties. :arg xform: A transformation matrix to apply to the clip plane vertices before drawing them. :arg bbox: A bounding box by which the clip planes can be limited (not currently honoured). """ if not self.opts.showClipPlanes: return for i in range(self.opts.numClipPlanes): verts, idxs = self.clipPlaneVertices(i, bbox) if len(idxs) == 0: continue if xform is not None: verts = affine.transform(verts, xform) verts = np.array(verts.ravel('C'), dtype=np.float32, copy=False) # A consistent colour for # each clipping plane rgb = self.__clipPlaneColours.get(i, None) if rgb is None: rgb = fslcmaps.randomBrightColour()[:3] self.__clipPlaneColours[i] = rgb r, g, b = rgb with glroutines.enabled(gl.GL_VERTEX_ARRAY): gl.glColor4f(r, g, b, 0.3) gl.glVertexPointer(3, gl.GL_FLOAT, 0, verts) gl.glDrawElements(gl.GL_TRIANGLES, len(idxs), gl.GL_UNSIGNED_INT, idxs)
def __boundsChanged(self, *a): """Called when any change to the overlay bounds change. Some of the methods on this class use the :func:`fsl.utils.memoize.memoize` decorator to cache previously calculated values. When certain :class:`.DisplayOpts` properties change, these cached values need to be invalidated. This method does that. """ self.generateVertices3D.invalidate() self.__clip3DChanged() def __clip3DChanged(self, *a): """Called when any change to the 3D clipping properties change. See the :meth:`__boundsChanged` method. """ self.get3DClipPlane .invalidate() self.clipPlaneVertices.invalidate()