diff --git a/fsl/data/fslimage.py b/fsl/data/fslimage.py index 217903edac89ae7df300aa8b1757c48005ea1f8e..9c24bc971330a52a7bf80b86cb5520b7f4e4dee7 100644 --- a/fsl/data/fslimage.py +++ b/fsl/data/fslimage.py @@ -6,6 +6,8 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # +import os.path as op + import numpy as np import nibabel as nib import matplotlib.cm as mplcm @@ -39,7 +41,7 @@ class Image(object): # otherwise, we assume that it is a nibabel image self.nibImage = image self.data = image.get_data() - self.name = image.get_filename() + self.name = op.basename(image.get_filename()) xdim,ydim,zdim = self.nibImage.get_shape() xlen,ylen,zlen = self.nibImage.get_header().get_zooms() @@ -52,6 +54,10 @@ class Image(object): self.ylen = ylen self.zlen = zlen + # ImageDisplay instance used to describe + # how this image is to be displayed + self.display = ImageDisplay(self) + # This attribute may be used to point to an OpenGL # buffer which is to be shared between multiple users # (e.g. two SliceCanvas instances which are displaying @@ -61,7 +67,9 @@ class Image(object): class ImageDisplay(props.HasProperties): """ - A class which describes how an image should be displayed. + A class which describes how an image should be displayed. There should + be no need to manually instantiate ImageDisplay objects - one is created + for each Image object, and is accessed via the Image.display attribute. """ def updateColourMap(self, newVal): @@ -81,7 +89,7 @@ class ImageDisplay(props.HasProperties): cmap.set_over( cmap(1.0), alpha=1.0) # The display properties of an image - enabled = props.Boolean() + enabled = props.Boolean(default=True) alpha = props.Double(minval=0.0, maxval=1.0, default=1.0) displayMin = props.Double() displayMax = props.Double() @@ -125,13 +133,51 @@ class ImageDisplay(props.HasProperties): class ImageList(object): """ Class representing a collection of images to be displayed together. + Provides basic list-like functionality, and a listener interface + allowing callback functions to be notified when the image collection + changes (e.g. an image is added or removed). """ - def __init__(self, images=None, displays=None): - if images is None: images = [] - if displays is None: displays = [] + def __init__(self, images=None): + """ + """ + + if images is None: images = [] + + self._images = images + self._listeners = [] + + + def __len__ (self): return self._images.__len__() + def __getitem__ (self, key): return self._images.__getitem__(key) + def __iter__ (self): return self._images.__iter__() + def __contains__(self, image): return self._images.__contains__(image) + + + def append(self, image): + self._images.append(image) + self.notify() + + + def pop(self, index=-1): + self._images.pop(index) + self.notify() + + + def insert(self, index, image): + self._images.insert(index, image) + self.notify() + + + def extend(self, images): + self._images.extend(images) + self.notify() + - self.images = images - self.displays = displays + def addListener (self, listener): self._listeners.append(listener) + def removeListener(self, listener): self._listeners.remove(listener) + def notify (self): + for listener in self._listeners: + listener(self) diff --git a/fsl/props/widgets.py b/fsl/props/widgets.py index 349bd768489a4b14fec8f584ccce7a8e5cb76b9b..a37ccd7f09288e7aea68627ccbdf00890878ceb0 100644 --- a/fsl/props/widgets.py +++ b/fsl/props/widgets.py @@ -315,6 +315,7 @@ def _Number(parent, hasProps, propObj, propVal): params['min'] = minval params['max'] = maxval params['initial'] = value + params['value'] = '{}'.format(value) # The minval and maxval attributes have not both # been set, so we create a spinbox instead of a slider. diff --git a/fsl/utils/imageview.py b/fsl/utils/imageview.py index 5e75097c264dfc8bad2661d138ecdabb44912e9c..4e5ccd459461bee6e5aacdcaa3c58a74262a2bef 100644 --- a/fsl/utils/imageview.py +++ b/fsl/utils/imageview.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # imgshow.py - A wx/OpenGL widget for displaying and interacting with a -# collection of 3D image. Displays three canvases, each of which shows +# collection of 3D images. Displays three canvases, each of which shows # a slice of the images along each dimension. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> @@ -9,14 +9,15 @@ import sys -# import logging -# logging.basicConfig( -# format='%(levelname)8s '\ -# '%(filename)20s '\ -# '%(lineno)4d: '\ -# '%(funcName)s - '\ -# '%(message)s', -# level=logging.DEBUG) +if False: + import logging + logging.basicConfig( + format='%(levelname)8s '\ + '%(filename)20s '\ + '%(lineno)4d: '\ + '%(funcName)s - '\ + '%(message)s', + level=logging.DEBUG) import wx import wx.lib.newevent as wxevent @@ -32,15 +33,22 @@ class ImageView(wx.Panel): def __init__(self, parent, imageList, *args, **kwargs): """ Creates three SliceCanvas objects, each displaying a - different axis of the given 3D numpy image. + different axis of the given image list. """ + + if isinstance(imageList, fslimage.Image): + imageList = fslimage.ImageList(imageList) + + if not isinstance(imageList, fslimage.ImageList): + raise TypeError( + 'imageList must be a fsl.data.fslimage.ImageList instance') self.imageList = imageList wx.Panel.__init__(self, parent, *args, **kwargs) self.SetMinSize((300,100)) - self.shape = imageList.images[0].data.shape + self.shape = imageList[0].data.shape self.canvasPanel = wx.Panel(self) @@ -51,12 +59,12 @@ class ImageView(wx.Panel): self.zcanvas = slicecanvas.SliceCanvas( self.canvasPanel, imageList, zax=2, context=self.xcanvas.context) - self.controlPanel = wx.Notebook(self) - for i in range(len(imageList.images)): + + for image in imageList: - controlPanel = props.buildGUI(self.controlPanel, self.imageList.displays[i]) - self.controlPanel.AddPage(controlPanel, '{}'.format(i)) + displayProps = props.buildGUI(self.controlPanel, image.display) + self.controlPanel.AddPage(displayProps, image.name) self.mainSizer = wx.BoxSizer(wx.VERTICAL) self.canvasSizer = wx.BoxSizer(wx.HORIZONTAL) @@ -178,8 +186,7 @@ class ImageFrame(wx.Frame): def __init__(self, parent, imageList, title=None): wx.Frame.__init__(self, parent, title=title) - self.imageList = imageList - self.panel = ImageView(self, imageList) + self.panel = ImageView(self, imageList) self.Layout() @@ -191,8 +198,7 @@ if __name__ == '__main__': app = wx.App() images = map(fslimage.Image, sys.argv[1:]) - displays = map(fslimage.ImageDisplay, images) - imageList = fslimage.ImageList(images, displays) + imageList = fslimage.ImageList(images) frame = ImageFrame( None, diff --git a/fsl/utils/slicecanvas.py b/fsl/utils/slicecanvas.py index 30de77b4ccd625c3b51272b4ea2699e7e5a99c71..5216b48f032910a2fea92c3a2f43e65c5eef6f80 100644 --- a/fsl/utils/slicecanvas.py +++ b/fsl/utils/slicecanvas.py @@ -211,9 +211,20 @@ class SliceCanvas(wxgl.GLCanvas): wxgl.GLCanvas.__init__(self, parent, **kwargs) + # Use the provided shared GL + # context, or create a new one if context is None: self.context = wxgl.GLContext(self) else: self.context = context + # If a single image has been provided, + # make it into a list of length 1 + if isinstance(imageList, fslimage.Image): + imageList = fslimage.ImageList(imageList) + + if not isinstance(imageList, fslimage.ImageList): + raise TypeError( + 'imageList must be a fsl.data.fslimage.ImageList instance') + # TODO Currently, the displayed x/horizontal and # y/vertical axes are defined by their order in # the image. Allow the caller to specify which @@ -227,7 +238,7 @@ class SliceCanvas(wxgl.GLCanvas): self.zax = zax # Currently all images must be of the same dimensions - ref = self.imageList.images[0] + ref = self.imageList[0] self.xdim = ref.data.shape[self.xax] self.ydim = ref.data.shape[self.yax] @@ -248,55 +259,19 @@ class SliceCanvas(wxgl.GLCanvas): self._colourResolution = 256 - # This flag is set by the _initGLData method when it + # These fields are set by the _initGLData method when it # has finished initialising the OpenGL data buffers - self.glReady = False + self.glReady = False + self.glImageData = None self.Bind(wx.EVT_PAINT, self.draw) - for i in range(len(self.imageList.images)): - self._configDisplayListeners(i) - - - def _configDisplayListeners(self, idx): - """ - """ - - image = self.imageList.images [idx] - display = self.imageList.displays[idx] - - # Add a bunch of listeners to the image display - # object for each image, so we can update the - # view when image display properties are changed. - def refreshNeeded(*a): - self.Refresh() - - def colourUpdateNeeded(*a): - self.updateColourBuffer(idx) - self.Refresh() - - lnrName = 'SliceCanvas_{{}}_{}'.format(id(self)) - - display.addListener( - 'alpha', lnrName.format('alpha'), refreshNeeded) - - display.addListener( - 'displayMin', lnrName.format('displayMin'), colourUpdateNeeded) - - display.addListener( - 'displayMax', lnrName.format('displayMax'), colourUpdateNeeded) - - display.addListener( - 'rangeClip', lnrName.format('rangeClip'), colourUpdateNeeded) - - display.addListener( - 'cmap', lnrName.format('cmap'), colourUpdateNeeded) - def _initGLData(self): """ Initialises the GL buffers which are copied to the GPU, - and used to render the voxel data. + and used to render the voxel data. This method is only + called once, on the first draw. """ # A bit hacky. We can only set the GL context (and create @@ -322,54 +297,65 @@ class SliceCanvas(wxgl.GLCanvas): self.alphaPos = gl.glGetUniformLocation(self.shaders, 'alpha') self.colourMapPos = gl.glGetUniformLocation(self.shaders, 'colourMap') - self.imageData = [] + self.glImageData = [] - for idx,image in enumerate(self.imageList.images): + for idx in range(len(self.imageList)): + self.glImageData.append(self._initGLImageData(idx)) + self._configDisplayListeners(idx) + self.updateColourBuffer(idx) - # Data stored in the geometry buffer. Defines - # the geometry of a single voxel, rendered as - # a triangle strip. - geomData = np.array([0, 0, - 1, 0, - 0, 1, - 1, 1], dtype=np.uint8) + self.glReady = True - # Data stored in the position buffer. Defines - # the location of every voxel in a single slice. - positionData = np.zeros((self.xdim*self.ydim, 2), dtype=np.uint16) - yidxs,xidxs = np.meshgrid(np.arange(self.ydim), - np.arange(self.xdim), - indexing='ij') - positionData[:,0] = xidxs.ravel() - positionData[:,1] = yidxs.ravel() - geomBuffer = vbo.VBO(geomData, gl.GL_STATIC_DRAW) - positionBuffer = vbo.VBO(positionData, gl.GL_STATIC_DRAW) + def _initGLImageData(self, idx): + """ + """ - # The image buffer, containing the image data itself - imageBuffer = self._initImageBuffer(image) + image = self.imageList[idx] + + # Data stored in the geometry buffer. Defines + # the geometry of a single voxel, rendered as + # a triangle strip. + geomData = np.array([0, 0, + 1, 0, + 0, 1, + 1, 1], dtype=np.uint8) - # The colour buffer, containing a map of - # colours (stored on the GPU as a 1D texture) - colourBuffer = gl.glGenTextures(1) + # Data stored in the position buffer. Defines + # the location of every voxel in a single slice. + positionData = np.zeros((self.xdim*self.ydim, 2), dtype=np.uint16) + yidxs,xidxs = np.meshgrid(np.arange(self.ydim), + np.arange(self.xdim), + indexing='ij') + positionData[:,0] = xidxs.ravel() + positionData[:,1] = yidxs.ravel() - imageData = GLImageData( - image, imageBuffer, colourBuffer, positionBuffer, geomBuffer) + geomBuffer = vbo.VBO(geomData, gl.GL_STATIC_DRAW) + positionBuffer = vbo.VBO(positionData, gl.GL_STATIC_DRAW) - self.imageData.append(imageData) + # The image buffer, containing the image data itself + imageBuffer = self._initImageBuffer(idx) - self.updateColourBuffer(idx) + # The colour buffer, containing a map of + # colours (stored on the GPU as a 1D texture) + colourBuffer = gl.glGenTextures(1) + + glImageData = GLImageData( + image, imageBuffer, colourBuffer, positionBuffer, geomBuffer) - self.glReady = True + + return glImageData - def _initImageBuffer(self, image): + def _initImageBuffer(self, idx): """ Initialises the buffer used to store the image data. If a 'master' canvas was set when this SliceCanvas object was constructed, its image buffer is used instead. """ + image = self.imageList[idx] + # If a master canvas was passed to the # constructor, let's share its image data. if image.glBuffer is not None: @@ -394,15 +380,52 @@ class SliceCanvas(wxgl.GLCanvas): return imageBuffer + def _configDisplayListeners(self, idx): + """ + """ + + image = self.imageList[idx] + display = image.display + + # Add a bunch of listeners to the image display + # object for each image, so we can update the + # view when image display properties are changed. + def refreshNeeded(*a): + self.Refresh() + + def colourUpdateNeeded(*a): + self.updateColourBuffer(idx) + self.Refresh() + + lnrName = 'SliceCanvas_{{}}_{}'.format(id(self)) + + display.addListener( + 'alpha', lnrName.format('alpha'), refreshNeeded) + display.addListener( + 'enabled', lnrName.format('enabled'), refreshNeeded) + + display.addListener( + 'displayMin', lnrName.format('displayMin'), colourUpdateNeeded) + + display.addListener( + 'displayMax', lnrName.format('displayMax'), colourUpdateNeeded) + + display.addListener( + 'rangeClip', lnrName.format('rangeClip'), colourUpdateNeeded) + + display.addListener( + 'cmap', lnrName.format('cmap'), colourUpdateNeeded) + + def updateColourBuffer(self, idx): """ - Regenerates the colour buffer used to colour a slice. After - calling this method, you will need to call Refresh() for the - change to take effect. + Regenerates the colour buffer used to colour a slice of the + specified image. After calling this method, you will need to + call Refresh() for the change to take effect. """ - iDisplay = self.imageList.displays[idx] - colourBuffer = self.imageData[idx].colourBuffer + iDisplay = self.imageList [idx].display + colourBuffer = self.glImageData[idx].colourBuffer # Here we are creating a range of values to be passed # to the matplotlib.colors.Colormap instance of the @@ -496,18 +519,21 @@ class SliceCanvas(wxgl.GLCanvas): gl.glEnable(gl.GL_BLEND) gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) - for i in range(len(self.imageData)): + for i in range(len(self.glImageData)): + + image = self.imageList[i] + imageDisplay = image.display + geomBuffer = self.glImageData[i].geomBuffer + imageBuffer = self.glImageData[i].imageBuffer + positionBuffer = self.glImageData[i].positionBuffer + colourBuffer = self.glImageData[i].colourBuffer - image = self.imageList.images[i] - imageDisplay = self.imageList.displays[i] - geomBuffer = self.imageData[i].geomBuffer - imageBuffer = self.imageData[i].imageBuffer - positionBuffer = self.imageData[i].positionBuffer - colourBuffer = self.imageData[i].colourBuffer + if not imageDisplay.enabled: + continue # Set up the colour buffer gl.glEnable(gl.GL_TEXTURE_1D) - gl.glActiveTexture(gl.GL_TEXTURE0) + gl.glActiveTexture(gl.GL_TEXTURE0) gl.glBindTexture(gl.GL_TEXTURE_1D, colourBuffer) gl.glUniform1i(self.colourMapPos, 0)