From 249a335739b029127130247e1fa8c9518f45f9fa Mon Sep 17 00:00:00 2001 From: Paul McCarthy <pauld.mccarthy@gmail.com> Date: Tue, 22 Apr 2014 12:20:31 +0100 Subject: [PATCH] SliceCanvas supports multiple images. Well, it looks like it should, but I haven't yet tested it with more than one. --- fsl/data/fslimage.py | 4 +- fsl/utils/imageview.py | 8 +- fsl/utils/slicecanvas.py | 422 ++++++++++++++++++++------------------- 3 files changed, 225 insertions(+), 209 deletions(-) diff --git a/fsl/data/fslimage.py b/fsl/data/fslimage.py index 75bcecd34..3717e76a9 100644 --- a/fsl/data/fslimage.py +++ b/fsl/data/fslimage.py @@ -118,5 +118,5 @@ class ImageList(object): if images is None: images = [] if displays is None: displays = [] - self._images = images - self._displays = displays + self.images = images + self.displays = displays diff --git a/fsl/utils/imageview.py b/fsl/utils/imageview.py index a62bcf33e..d651bb597 100644 --- a/fsl/utils/imageview.py +++ b/fsl/utils/imageview.py @@ -40,6 +40,8 @@ class ImageView(wx.Panel): self.imageDisplay = fslimage.ImageDisplay(image) + imageList = fslimage.ImageList([image], [self.imageDisplay]) + wx.Panel.__init__(self, parent, *args, **kwargs) self.SetMinSize((300,100)) @@ -49,11 +51,11 @@ class ImageView(wx.Panel): self.controlPanel = props.buildGUI(self, self.imageDisplay) self.xcanvas = slicecanvas.SliceCanvas( - self.canvasPanel, self.imageDisplay, zax=0) + self.canvasPanel, imageList, zax=0) self.ycanvas = slicecanvas.SliceCanvas( - self.canvasPanel, self.imageDisplay, zax=1, context=self.xcanvas.context) + self.canvasPanel, imageList, zax=1, context=self.xcanvas.context) self.zcanvas = slicecanvas.SliceCanvas( - self.canvasPanel, self.imageDisplay, zax=2, context=self.xcanvas.context) + self.canvasPanel, imageList, zax=2, context=self.xcanvas.context) self.mainSizer = wx.BoxSizer(wx.VERTICAL) self.canvasSizer = wx.BoxSizer(wx.HORIZONTAL) diff --git a/fsl/utils/slicecanvas.py b/fsl/utils/slicecanvas.py index 25ddc6e20..4f052fba6 100644 --- a/fsl/utils/slicecanvas.py +++ b/fsl/utils/slicecanvas.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # slicecanvas.py - A wx.GLCanvas canvas which displays a single -# slice from a 3D image. +# slice from a collection of 3D images. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # @@ -27,22 +27,45 @@ import OpenGL.GL.ARB.draw_instanced as arbdi import fsl.data.fslimage as fslimage -# A slice is rendered using three buffers and one texture. The first -# buffer, the 'geometry buffer' simply contains four vertices, which -# define the geometry of a single voxel (using triangle strips). -# The second buffer, the 'position buffer', contains the location of -# every voxel in one slice of the image (these locations are identical -# for every slice of the image, so we can re-use the location -# information for every slice). +class GLImageData(object): + """ + A GLImageData object encapsulates the OpenGL information necessary + to render an image. + + A slice from one image is rendered using three buffers and one + texture. The first buffer, the 'geometry buffer' simply contains four + vertices, which define the geometry of a single voxel (using triangle + strips). + + The second buffer, the 'position buffer', contains the location of every + voxel in one slice of the image (these locations are identical for every + slice of the image, so we can re-use the location information for every + slice). + + The third buffer, the 'image buffer' contains the image data itself, + scaled to lie between 0.0 and 1.0. It is used to calculate voxel colours. + + Finally, the texture, the 'colour buffer', is used to store a lookup table + containing colours. + """ + + def __init__( + self, + image, + imageBuffer, + colourBuffer, + positionBuffer, + geomBuffer): + """ + Parameters. + """ -# The third buffer, the 'image buffer' contains the image data itself, -# scaled to lie between 0.0 and 1.0. It is used to calculate voxel -# colours, and may be shared between multiple SliceCanvas objects -# which are displaying the same image. -# -# Finally, the texture, the 'colour buffer', is used to store a -# lookup table containing colours. + self.image = image + self.imageBuffer = imageBuffer + self.colourBuffer = colourBuffer + self.positionBuffer = positionBuffer + self.geomBuffer = geomBuffer # The vertex shader positions and colours a single vertex. @@ -96,11 +119,10 @@ void main(void) { } """ - class SliceCanvas(wxgl.GLCanvas): """ - A wx.glcanvas.GLCanvas which may be used to display a single - 2D slice from a 3D image (see fsl.data.fslimage.Image). + A wx.glcanvas.GLCanvas which may be used to display a single 2D slice from + a collection of 3D images (see fsl.data.fslimage.ImageList). """ @property @@ -167,70 +189,30 @@ class SliceCanvas(wxgl.GLCanvas): self._ypos = ypos - @property - def colourResolution(self): - """ - Total number of possible colours that will be used when rendering - a slice. - """ - return self._colourResolution - - @colourResolution.setter - def colourResolution(self, colourResolution): - """ - Updates the colour resolution. You will need to manually call - updateColourBuffer(), and then Refresh(), after changing the - colour resolution. - """ - - if colourResolution <= 0: return - if colourResolution > 4096: return # this upper limit is arbitrary. - - self._colourResolution = colourResolution - - def __init__( - self, parent, image, zax=0, zpos=None, context=None, **kwargs): + self, parent, imageList, zax=0, zpos=None, context=None, **kwargs): """ Creates a canvas object. The OpenGL data buffers are set up in _initGLData the first time that the canvas is displayed/drawn. Parameters: - parent - WX parent object + parent - WX parent object - image - A fsl.data.fslimage.Image object, or a - fsl.data.fslimage.ImageDisplay object, or a 3D numpy - array. + imageList - a fslimage.ImageList object. - zax - Axis perpendicular to the plane to be displayed - (the 'depth' axis), default 0. + zax - Axis perpendicular to the plane to be displayed + (the 'depth' axis), default 0. - context - + context - wx.glcanvas.GLContext object. If None, one is created. - zpos - Initial slice to be displayed. If not provided, the - middle slice is used. + zpos - Initial slice to be displayed. If not provided, the + middle slice is used. """ - realImage = None - imageDisplay = None - - if isinstance(image, fslimage.ImageDisplay): - realImage = image.image - imageDisplay = image - - elif isinstance(image, fslimage.Image): - realImage = image - imageDisplay = fslimage.ImageDisplay(image) - - elif isinstance(image, np.ndarray): - realImage = fslimage.Image(image) - imageDisplay = fslimage.ImageDisplay(realImage) - wxgl.GLCanvas.__init__(self, parent, **kwargs) - if context is None: context = wxgl.GLContext(self) - - self.context = context + if context is None: self.context = wxgl.GLContext(self) + else: self.context = context # TODO Currently, the displayed x/horizontal and # y/vertical axes are defined by their order in @@ -239,21 +221,23 @@ class SliceCanvas(wxgl.GLCanvas): dims = range(3) dims.pop(zax) - self.image = realImage - self.imageDisplay = imageDisplay - self.xax = dims[0] - self.yax = dims[1] - self.zax = zax + self.imageList = imageList + self.xax = dims[0] + self.yax = dims[1] + self.zax = zax + + # Currently all images must be of the same dimensions + ref = self.imageList.images[0] - self.xdim = self.image.data.shape[self.xax] - self.ydim = self.image.data.shape[self.yax] - self.zdim = self.image.data.shape[self.zax] + self.xdim = ref.data.shape[self.xax] + self.ydim = ref.data.shape[self.yax] + self.zdim = ref.data.shape[self.zax] - dsize = self.image.data.dtype.itemsize + dsize = ref.data.dtype.itemsize - self.xstride = self.image.data.strides[self.xax] / dsize - self.ystride = self.image.data.strides[self.yax] / dsize - self.zstride = self.image.data.strides[self.zax] / dsize + self.xstride = ref.data.strides[self.xax] / dsize + self.ystride = ref.data.strides[self.yax] / dsize + self.zstride = ref.data.strides[self.zax] / dsize if zpos is None: zpos = self.zdim / 2 @@ -264,38 +248,48 @@ class SliceCanvas(wxgl.GLCanvas): self._colourResolution = 256 - # This flag is set by the _initGLData method - # when it has finished initialising the OpenGL - # data buffers + # This flag is set by the _initGLData method when it + # has finished initialising the OpenGL data buffers self.glReady = False 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, so we can update the view when image - # display properties are changed. + # 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() + self.updateColourBuffer(idx) self.Refresh() lnrName = 'SliceCanvas_{{}}_{}'.format(id(self)) - self.imageDisplay.addListener( + display.addListener( 'alpha', lnrName.format('alpha'), refreshNeeded) - - self.imageDisplay.addListener( + + display.addListener( 'displayMin', lnrName.format('displayMin'), colourUpdateNeeded) - - self.imageDisplay.addListener( + + display.addListener( 'displayMax', lnrName.format('displayMax'), colourUpdateNeeded) - - self.imageDisplay.addListener( + + display.addListener( 'rangeClip', lnrName.format('rangeClip'), colourUpdateNeeded) - - self.imageDisplay.addListener( + + display.addListener( 'cmap', lnrName.format('cmap'), colourUpdateNeeded) @@ -328,38 +322,48 @@ class SliceCanvas(wxgl.GLCanvas): self.alphaPos = gl.glGetUniformLocation(self.shaders, 'alpha') self.colourMapPos = gl.glGetUniformLocation(self.shaders, 'colourMap') - # 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) - - # 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() - - self.geomBuffer = vbo.VBO(geomData, gl.GL_STATIC_DRAW) - self.positionBuffer = vbo.VBO(positionData, gl.GL_STATIC_DRAW) - - # The image buffer, containing the image data itself - self.imageBuffer = self._initImageBuffer() - - # The colour buffer, containing a map of - # colours (stored on the GPU as a 1D texture) - self.colourBuffer = gl.glGenTextures(1) - self.updateColourBuffer() + self.imageData = [] + + for idx,image in enumerate(self.imageList.images): + + # 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) + + # 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) + + # The image buffer, containing the image data itself + imageBuffer = self._initImageBuffer(image) + + # The colour buffer, containing a map of + # colours (stored on the GPU as a 1D texture) + colourBuffer = gl.glGenTextures(1) + + imageData = GLImageData( + image, imageBuffer, colourBuffer, positionBuffer, geomBuffer) + + self.imageData.append(imageData) + + self.updateColourBuffer(idx) self.glReady = True - def _initImageBuffer(self): + def _initImageBuffer(self, image): """ Initialises the buffer used to store the image data. If a 'master' canvas was set when this SliceCanvas object was constructed, its @@ -368,12 +372,12 @@ class SliceCanvas(wxgl.GLCanvas): # If a master canvas was passed to the # constructor, let's share its image data. - if self.image.glBuffer is not None: - return self.image.glBuffer + if image.glBuffer is not None: + return image.glBuffer # The image data is cast to single precision floating # point, and normalised to lie between 0.0 and 1.0 - imageData = np.array(self.image.data, dtype=np.float32) + imageData = np.array(image.data, dtype=np.float32) imageData = (imageData - imageData.min()) / \ (imageData.max() - imageData.min()) @@ -383,19 +387,20 @@ class SliceCanvas(wxgl.GLCanvas): imageData = imageData.ravel(order='F') imageBuffer = vbo.VBO(imageData, gl.GL_STATIC_DRAW) - self.image.glBuffer = imageBuffer + image.glBuffer = imageBuffer return imageBuffer - def updateColourBuffer(self): + 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. """ - iDisplay = self.imageDisplay + iDisplay = self.imageList.displays[idx] + colourBuffer = self.imageData[idx].colourBuffer # Here we are creating a range of values to be passed # to the matplotlib.colors.Colormap instance of the @@ -406,8 +411,8 @@ class SliceCanvas(wxgl.GLCanvas): # generate appropriate colours for these out-of-range # values. - normalRange = np.linspace(0.0, 1.0, self.colourResolution) - normalStep = 1.0 / (self.colourResolution - 1) + normalRange = np.linspace(0.0, 1.0, self._colourResolution) + normalStep = 1.0 / (self._colourResolution - 1) normMin = (iDisplay.displayMin - iDisplay.dataMin) / \ (iDisplay.dataMax - iDisplay.dataMin) @@ -429,7 +434,7 @@ class SliceCanvas(wxgl.GLCanvas): colourmap = colourmap.ravel(order='C') # GL texture creation stuff - gl.glBindTexture(gl.GL_TEXTURE_1D, self.colourBuffer) + gl.glBindTexture(gl.GL_TEXTURE_1D, colourBuffer) gl.glTexParameteri( gl.GL_TEXTURE_1D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_NEAREST) gl.glTexParameteri( @@ -440,7 +445,7 @@ class SliceCanvas(wxgl.GLCanvas): gl.glTexImage1D(gl.GL_TEXTURE_1D, 0, gl.GL_RGBA8, - self.colourResolution, + self._colourResolution, 0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, @@ -489,77 +494,86 @@ class SliceCanvas(wxgl.GLCanvas): gl.glEnable(gl.GL_BLEND) gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) - # Set up the colour buffer - gl.glEnable(gl.GL_TEXTURE_1D) - gl.glActiveTexture(gl.GL_TEXTURE0) - gl.glBindTexture(gl.GL_TEXTURE_1D, self.colourBuffer) - gl.glUniform1i(self.colourMapPos, 0) - - gl.glUniform1f(self.alphaPos, self.imageDisplay.alpha) - - # We draw each horizontal row of voxels one at a time. - # This is necessary because, in order to allow image - # buffers to be shared between different SliceCanvas - # objects, we cannot re-arrange the image data, as - # stored in GPU memory. So while the memory offset - # between values in the same row (or column) is - # consistent, the offset between rows (columns) is - # not. And drawing rows seems to be faster than - # drawing columns, for reasons unknown to me. - for yi in range(self.ydim): - - imageOffset = self.zpos * self.zstride + yi * self.ystride - imageStride = self.xstride - posOffset = yi * self.xdim * 4 - - # The geometry buffer, which defines the geometry of a - # single vertex (4 vertices, drawn as a triangle strip) - self.geomBuffer.bind() - gl.glVertexAttribPointer( - self.inVertexPos, - 2, - gl.GL_UNSIGNED_BYTE, - gl.GL_FALSE, - 0, - None) - gl.glEnableVertexAttribArray(self.inVertexPos) - arbia.glVertexAttribDivisorARB(self.inVertexPos, 0) - - # The position buffer, which defines - # the location of every voxel - self.positionBuffer.bind() - gl.glVertexAttribPointer( - self.inPositionPos, - 2, - gl.GL_UNSIGNED_SHORT, - gl.GL_FALSE, - 0, - self.positionBuffer + posOffset) - gl.glEnableVertexAttribArray(self.inPositionPos) - arbia.glVertexAttribDivisorARB(self.inPositionPos, 1) - - # The image buffer, which defines - # the colour value at each voxel. - self.imageBuffer.bind() - gl.glVertexAttribPointer( - self.voxelValuePos, - 1, - gl.GL_FLOAT, - gl.GL_FALSE, - imageStride*4, - self.imageBuffer + imageOffset*4) - - gl.glEnableVertexAttribArray(self.voxelValuePos) - arbia.glVertexAttribDivisorARB(self.voxelValuePos, 1) - - # Draw all of the triangles! - arbdi.glDrawArraysInstancedARB( - gl.GL_TRIANGLE_STRIP, 0, 4, self.xdim) - - gl.glDisableVertexAttribArray(self.inVertexPos) - gl.glDisableVertexAttribArray(self.inPositionPos) - gl.glDisableVertexAttribArray(self.voxelValuePos) - gl.glDisable(gl.GL_TEXTURE_1D) + for i in range(len(self.imageData)): + + 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 + + # Set up the colour buffer + gl.glEnable(gl.GL_TEXTURE_1D) + gl.glActiveTexture(gl.GL_TEXTURE0) + gl.glBindTexture(gl.GL_TEXTURE_1D, colourBuffer) + gl.glUniform1i(self.colourMapPos, 0) + + gl.glUniform1f(self.alphaPos, imageDisplay.alpha) + + # We draw each horizontal row of voxels one at a time. + # This is necessary because, in order to allow image + # buffers to be shared between different SliceCanvas + # objects, we cannot re-arrange the image data, as + # stored in GPU memory. So while the memory offset + # between values in the same row (or column) is + # consistent, the offset between rows (columns) is + # not. And drawing rows seems to be faster than + # drawing columns, for reasons unknown to me. + for yi in range(self.ydim): + + imageOffset = self.zpos * self.zstride + yi * self.ystride + imageStride = self.xstride + posOffset = yi * self.xdim * 4 + + # The geometry buffer, which defines the geometry of a + # single vertex (4 vertices, drawn as a triangle strip) + geomBuffer.bind() + gl.glVertexAttribPointer( + self.inVertexPos, + 2, + gl.GL_UNSIGNED_BYTE, + gl.GL_FALSE, + 0, + None) + gl.glEnableVertexAttribArray(self.inVertexPos) + arbia.glVertexAttribDivisorARB(self.inVertexPos, 0) + + # The position buffer, which defines + # the location of every voxel + positionBuffer.bind() + gl.glVertexAttribPointer( + self.inPositionPos, + 2, + gl.GL_UNSIGNED_SHORT, + gl.GL_FALSE, + 0, + positionBuffer + posOffset) + gl.glEnableVertexAttribArray(self.inPositionPos) + arbia.glVertexAttribDivisorARB(self.inPositionPos, 1) + + # The image buffer, which defines + # the colour value at each voxel. + imageBuffer.bind() + gl.glVertexAttribPointer( + self.voxelValuePos, + 1, + gl.GL_FLOAT, + gl.GL_FALSE, + imageStride*4, + imageBuffer + imageOffset*4) + + gl.glEnableVertexAttribArray(self.voxelValuePos) + arbia.glVertexAttribDivisorARB(self.voxelValuePos, 1) + + # Draw all of the triangles! + arbdi.glDrawArraysInstancedARB( + gl.GL_TRIANGLE_STRIP, 0, 4, self.xdim) + + gl.glDisableVertexAttribArray(self.inVertexPos) + gl.glDisableVertexAttribArray(self.inPositionPos) + gl.glDisableVertexAttribArray(self.voxelValuePos) + gl.glDisable(gl.GL_TEXTURE_1D) gl.glUseProgram(0) -- GitLab