From 4759dd32501fdcd8dea8c758adbab77cd712d548 Mon Sep 17 00:00:00 2001 From: Paul McCarthy <pauld.mccarthy@gmail.com> Date: Wed, 21 May 2014 15:39:20 +0100 Subject: [PATCH] GLImageData stuff separated from SliceCanvas. --- fsl/fslview/glimagedata.py | 296 +++++++++++++++++++++++++++++++++++ fsl/fslview/slicecanvas.py | 306 +++---------------------------------- 2 files changed, 316 insertions(+), 286 deletions(-) create mode 100644 fsl/fslview/glimagedata.py diff --git a/fsl/fslview/glimagedata.py b/fsl/fslview/glimagedata.py new file mode 100644 index 000000000..177eb10b6 --- /dev/null +++ b/fsl/fslview/glimagedata.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python +# +# glimagedata.py - Create OpenGL data to render 2D slices of a 3D image. + +# A GLImageData object encapsulates the OpenGL information necessary +# to render 2D slices of a 3D image. +# +# A slice from one image is rendered using four buffers and two textures. + +# The first buffer, the 'geometry buffer' simply contains the 3D +# coordinates (single precision floating point) of four vertices, which +# define the geometry of a single voxel (using triangle strips). + +# The remaining buffers contain the X, Y, and Z coordinates of the voxels +# in the slice to be displayed. These coordinates are stored as single +# precision floating points, and used both to position a voxel, and to +# look up its value in the 3D data texture (see below). + +# The image data itself is stored as a 3D texture, with each voxel value +# stored as a single unsigned byte in the range 0-255. + +# Finally, a 1D texture is used is used to store a lookup table containing +# an RGBA8 colour map, to colour each voxel according to its value. +# +# All of these things are created when a GLImageData object is +# instantiated. They are available as attributes of the object: +# +# - dataBuffer +# - xBuffer +# - yBuffer +# - zBuffer +# - geomBuffer +# - colourBuffer +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import numpy as np + +import OpenGL.GL as gl +import OpenGL.arrays.vbo as vbo + +# This extension provides the GL_R8 constant, +# which is built into modern versions of OpenGL. +import OpenGL.GL.ARB.texture_rg as arbrg + +class GLImageData(object): + + def __init__(self, image, xax, yax, imageDisplay=None): + """ + Initialise the OpenGL data buffers required to render the given image. + Parameters: + + - image: A fsl.data.fslimage.Image object. + + - xax: The image axis which maps to the screen x axis. + + - yax: The image axis which maps to the screen y axis. + + - imageDisplay: Optional. A fsl.data.fslimage.ImageDisplay object + which describes how the image is to be displayed. + If not provided, the default image.display instance + is used (see fsl.data.fslimage.ImageDisplay for + details). + """ + + self.image = image + self.xax = xax + self.yax = yax + + if imageDisplay is not None: self.display = imageDisplay + else: self.display = image.display + + # Maximum number of colours used to draw image data. + # Keep this to a power of two, as some GL implementations + # will complain/misbehave if it isn't. + self.colourResolution = 256 + + self.initGLImageData() + + + def initGLImageData(self): + """ + Creates and initialises the OpenGL data for the fslimage.Image + object that was passed to the GLImageData constructor. + """ + + image = self.image + xax = self.xax + yax = self.yax + + # The geometry buffer defines the geometry of + # a single voxel, rendered as a triangle strip. + geomData = np.zeros((4, 3), dtype=np.float32) + geomData[:, [xax, yax]] = [[-0.5, -0.5], + [ 0.5, -0.5], + [-0.5, 0.5], + [ 0.5, 0.5]] + + geomData = geomData.ravel('C') + geomBuffer = vbo.VBO(geomData, gl.GL_STATIC_DRAW) + + # x/y/z coordinates are stored as VBO arrays + voxData = [] + for dim in image.shape: + data = np.arange(0, dim, dtype=np.float32) + voxData.append(data) + + # the screen x coordinate data has to be repeated (ydim) + # times - we are drawing row-wise, and opengl does not + # allow us to loop over a VBO in a single instance + # rendering call + voxData[xax] = np.tile(voxData[xax], image.shape[yax]) + + xBuffer = vbo.VBO(voxData[0], gl.GL_STATIC_DRAW) + yBuffer = vbo.VBO(voxData[1], gl.GL_STATIC_DRAW) + zBuffer = vbo.VBO(voxData[2], gl.GL_STATIC_DRAW) + + # The colour buffer, containing a map of + # colours (stored on the GPU as a 1D texture) + # This is initialised in the updateColourBuffer + # method + colourBuffer = gl.glGenTextures(1) + + self.dataBuffer = self.initImageBuffer() + self.voxXBuffer = xBuffer + self.voxYBuffer = yBuffer + self.voxZBuffer = zBuffer + self.geomBuffer = geomBuffer + self.colourBuffer = colourBuffer + + # Add listeners to this image so the view can be + # updated when its display properties are changed + self.configDisplayListeners() + + # Create the colour buffer for the given image + self.updateColourBuffer() + + + def initImageBuffer(self): + """ + Initialises the OpenGL buffer used to store the data for the given + image. The buffer is stored as an attribute of the image and, if it + has already been created (e.g. by another SliceCanvas object), the + existing buffer is returned. + """ + + image = self.image + + texShape = 2 ** (np.ceil(np.log2(image.shape))) + pad = [(0, l - s) for (l, s) in zip(texShape, image.shape)] + self.imageTexShape = texShape + + try: imageBuffer = image.getAttribute('glImageBuffer') + except: imageBuffer = None + + if imageBuffer is not None: + return imageBuffer + + # The image data is normalised to lie + # between 0 and 255, and cast to uint8 + imageData = np.array(image.data, dtype=np.float32) + imageData = 255.0 * (imageData - imageData.min()) / \ + (imageData.max() - imageData.min()) + + # and each dimension is padded so it has a + # power-of-two length. Ugh. This is a horrible, + # but as far as I'm aware, necessary hack. At + # least it's necessary using the OpenGL 2.1 + # API on OSX mavericks. It massively increases + # image load time, too. + imageData = np.pad(imageData, pad, 'constant', constant_values=0) + imageData = np.array(imageData, dtype=np.uint8) + + # Then flattened, with fortran dimension ordering, + # so the data, as stored on the GPU, has its first + # dimension as the fastest changing. + imageData = imageData.ravel(order='F') + + # Image data is stored on the GPU as a 3D texture + imageBuffer = gl.glGenTextures(1) + gl.glBindTexture(gl.GL_TEXTURE_3D, imageBuffer) + gl.glTexParameteri(gl.GL_TEXTURE_3D, + gl.GL_TEXTURE_MAG_FILTER, + gl.GL_NEAREST) + gl.glTexParameteri(gl.GL_TEXTURE_3D, + gl.GL_TEXTURE_MIN_FILTER, + gl.GL_NEAREST) + gl.glTexParameteri(gl.GL_TEXTURE_3D, + gl.GL_TEXTURE_WRAP_S, + gl.GL_CLAMP_TO_EDGE) + gl.glTexParameteri(gl.GL_TEXTURE_3D, + gl.GL_TEXTURE_WRAP_T, + gl.GL_CLAMP_TO_EDGE) + gl.glTexParameteri(gl.GL_TEXTURE_3D, + gl.GL_TEXTURE_WRAP_R, + gl.GL_CLAMP_TO_EDGE) + + gl.glTexImage3D(gl.GL_TEXTURE_3D, + 0, + arbrg.GL_R8, + texShape[0], + texShape[1], + texShape[2], + 0, + gl.GL_RED, + gl.GL_UNSIGNED_BYTE, + imageData) + + # And added as an attribute of the image, so + # other things which want to render the image + # don't need to duplicate all of that data. + image.setAttribute('glImageBuffer', imageBuffer) + + return imageBuffer + + + def updateColourBuffer(self): + """ + Regenerates the colour buffer used to colour image voxels. + """ + + display = self.display + colourBuffer = self.colourBuffer + + # Here we are creating a range of values to be passed + # to the matplotlib.colors.Colormap instance of the + # image display. We scale this range such that data + # values which lie outside the configured display range + # will map to values below 0.0 or above 1.0. It is + # assumed that the Colormap instance is configured to + # generate appropriate colours for these out-of-range + # values. + + normalRange = np.linspace(0.0, 1.0, self.colourResolution) + normalStep = 1.0 / (self.colourResolution - 1) + + normMin = (display.displayMin - display.dataMin) / \ + (display.dataMax - display.dataMin) + normMax = (display.displayMax - display.dataMin) / \ + (display.dataMax - display.dataMin) + + newStep = normalStep / (normMax - normMin) + newRange = (normalRange - normMin) * (newStep / normalStep) + + # Create [self.colourResolution] rgb values, + # spanning the entire range of the image + # colour map + colourmap = display.cmap(newRange) + + # The colour data is stored on + # the GPU as 8 bit rgba tuples + colourmap = np.floor(colourmap * 255) + colourmap = np.array(colourmap, dtype=np.uint8) + colourmap = colourmap.ravel(order='C') + + # GL texture creation stuff + gl.glBindTexture(gl.GL_TEXTURE_1D, colourBuffer) + gl.glTexParameteri(gl.GL_TEXTURE_1D, + gl.GL_TEXTURE_MAG_FILTER, + gl.GL_NEAREST) + gl.glTexParameteri(gl.GL_TEXTURE_1D, + gl.GL_TEXTURE_MIN_FILTER, + gl.GL_NEAREST) + gl.glTexParameteri(gl.GL_TEXTURE_1D, + gl.GL_TEXTURE_WRAP_S, + gl.GL_CLAMP_TO_EDGE) + + gl.glTexImage1D(gl.GL_TEXTURE_1D, + 0, + gl.GL_RGBA8, + self.colourResolution, + 0, + gl.GL_RGBA, + gl.GL_UNSIGNED_BYTE, + colourmap) + + + def configDisplayListeners(self): + """ + Adds a bunch of listeners to the fslimage.ImageDisplay object which + defines how the given image is to be displayed. This is done so we + can update the colour texture when image display properties are + changed. + """ + + def colourUpdateNeeded(*a): + self.updateColourBuffer() + + display = self.display + lnrName = 'GlImageData_{}'.format(id(self)) + + display.addListener('displayMin', lnrName, colourUpdateNeeded) + display.addListener('displayMax', lnrName, colourUpdateNeeded) + display.addListener('rangeClip', lnrName, colourUpdateNeeded) + display.addListener('cmap', lnrName, colourUpdateNeeded) diff --git a/fsl/fslview/slicecanvas.py b/fsl/fslview/slicecanvas.py index 1cb2d23e2..1c15f308f 100644 --- a/fsl/fslview/slicecanvas.py +++ b/fsl/fslview/slicecanvas.py @@ -6,298 +6,21 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -import numpy as np +import numpy as np -import wx -import wx.glcanvas as wxgl +import wx +import wx.glcanvas as wxgl -import OpenGL.GL as gl -import OpenGL.arrays.vbo as vbo +import OpenGL.GL as gl # Under OS X, I don't think I can request an OpenGL 3.2 core profile # using wx - I'm stuck with OpenGL 2.1 I'm using these ARB extensions # for functionality which is standard in 3.2. import OpenGL.GL.ARB.instanced_arrays as arbia import OpenGL.GL.ARB.draw_instanced as arbdi -import OpenGL.GL.ARB.texture_rg as arbrg - -import fsl.data.fslimage as fslimage - - -class GLImageData(object): - """ - A GLImageData object encapsulates the OpenGL information necessary - to render 2D slices of a 3D image. - - A slice from one image is rendered using four buffers and two textures. - - The first buffer, the 'geometry buffer' simply contains the 3D - coordinates (single precision floating point) of four vertices, which - define the geometry of a single voxel (using triangle strips). - - The remaining buffers contain the X, Y, and Z coordinates of the voxels - in the slice to be displayed. These coordinates are stored as single - precision floating points, and used both to position a voxel, and to - look up its value in the 3D data texture (see below). - - The image data itself is stored as a 3D texture, with each voxel value - stored as a single unsigned byte in the range 0-255. - - Finally, a 1D texture is used is used to store a lookup table containing - an RGBA8 colour map, to colour each voxel according to its value. - """ - - def __init__(self, image, canvas): - """ - Initialise the OpenGL data buffers required to render the given image. - Parameters: - - image: A fsl.data.fslimage.Image object. - - canvas: The SliceCanvas object which is rendering the image. - """ - - self.image = image - self.canvas = canvas - - # Maximum number of colours used to draw image data. - # Keep this to a power of two, as some GL implementations - # will complain/misbehave if it isn't. - self.colourResolution = 256 - - self.initGLImageData() - - - def initGLImageData(self): - """ - Creates and initialises the OpenGL data for the fslimage.Image - object that was passed to the GLImageData constructor. - """ - - image = self.image - canvas = self.canvas - - # The geometry buffer defines the geometry of - # a single voxel, rendered as a triangle strip. - geomData = np.zeros((4, 3), dtype=np.float32) - geomData[:, [canvas.xax, canvas.yax]] = [[-0.5, -0.5], - [ 0.5, -0.5], - [-0.5, 0.5], - [ 0.5, 0.5]] - - geomData = geomData.ravel('C') - geomBuffer = vbo.VBO(geomData, gl.GL_STATIC_DRAW) - - # x/y/z coordinates are stored as VBO arrays - voxData = [] - for dim in image.shape: - data = np.arange(0, dim, dtype=np.float32) - voxData.append(data) - - # the screen x coordinate data has to be repeated (ydim) - # times - we are drawing row-wise, and opengl does not - # allow us to loop over a VBO in a single instance - # rendering call - voxData[canvas.xax] = np.tile(voxData[ canvas.xax], - image.shape[canvas.yax]) - - xBuffer = vbo.VBO(voxData[0], gl.GL_STATIC_DRAW) - yBuffer = vbo.VBO(voxData[1], gl.GL_STATIC_DRAW) - zBuffer = vbo.VBO(voxData[2], gl.GL_STATIC_DRAW) - - # The colour buffer, containing a map of - # colours (stored on the GPU as a 1D texture) - # This is initialised in the updateColourBuffer - # method - colourBuffer = gl.glGenTextures(1) - - self.dataBuffer = self.initImageBuffer() - self.voxXBuffer = xBuffer - self.voxYBuffer = yBuffer - self.voxZBuffer = zBuffer - self.geomBuffer = geomBuffer - self.colourBuffer = colourBuffer - - # Add listeners to this image so the view can be - # updated when its display properties are changed - self.configDisplayListeners() - - # Create the colour buffer for the given image - self.updateColourBuffer() - - - def initImageBuffer(self): - """ - Initialises the OpenGL buffer used to store the data for the given - image. The buffer is stored as an attribute of the image and, if it - has already been created (e.g. by another SliceCanvas object), the - existing buffer is returned. - """ - - image = self.image - - texShape = 2 ** (np.ceil(np.log2(image.shape))) - pad = [(0, l - s) for (l, s) in zip(texShape, image.shape)] - self.imageTexShape = texShape - - try: imageBuffer = image.getAttribute('glBuffers') - except: imageBuffer = None - - if imageBuffer is not None: - return imageBuffer - - # The image data is normalised to lie - # between 0 and 255, and cast to uint8 - imageData = np.array(image.data, dtype=np.float32) - imageData = 255.0 * (imageData - imageData.min()) / \ - (imageData.max() - imageData.min()) - - # and each dimension is padded so it has a - # power-of-two length. Ugh. This is a horrible, - # but as far as I'm aware necessary hack. At - # least it's necessary using the OpenGL 2.1 - # API on OSX mavericks. It massively increases - # image load time, too. - imageData = np.pad(imageData, pad, 'constant', constant_values=0) - imageData = np.array(imageData, dtype=np.uint8) - - # Then flattened, with fortran dimension ordering, - # so the data, as stored on the GPU, has its first - # dimension as the fastest changing. - imageData = imageData.ravel(order='F') - - # Image data is stored on the GPU as a 3D texture - imageBuffer = gl.glGenTextures(1) - gl.glBindTexture(gl.GL_TEXTURE_3D, imageBuffer) - gl.glTexParameteri(gl.GL_TEXTURE_3D, - gl.GL_TEXTURE_MAG_FILTER, - gl.GL_NEAREST) - gl.glTexParameteri(gl.GL_TEXTURE_3D, - gl.GL_TEXTURE_MIN_FILTER, - gl.GL_NEAREST) - gl.glTexParameteri(gl.GL_TEXTURE_3D, - gl.GL_TEXTURE_WRAP_S, - gl.GL_CLAMP_TO_EDGE) - gl.glTexParameteri(gl.GL_TEXTURE_3D, - gl.GL_TEXTURE_WRAP_T, - gl.GL_CLAMP_TO_EDGE) - gl.glTexParameteri(gl.GL_TEXTURE_3D, - gl.GL_TEXTURE_WRAP_R, - gl.GL_CLAMP_TO_EDGE) - - gl.glTexImage3D(gl.GL_TEXTURE_3D, - 0, - arbrg.GL_R8, - texShape[0], - texShape[1], - texShape[2], - 0, - gl.GL_RED, - gl.GL_UNSIGNED_BYTE, - imageData) - - # And added as an attribute of the image, so - # other things which want to render the image - # don't need to recreate all of those buffers. - image.setAttribute('glBuffers', imageBuffer) - - return imageBuffer - - - def updateColourBuffer(self): - """ - Regenerates the colour buffer used to colour image voxels. - """ - - display = self.image.display - colourBuffer = self.colourBuffer - - # Here we are creating a range of values to be passed - # to the matplotlib.colors.Colormap instance of the - # image display. We scale this range such that data - # values which lie outside the configured display range - # will map to values below 0.0 or above 1.0. It is - # assumed that the Colormap instance is configured to - # generate appropriate colours for these out-of-range - # values. - - normalRange = np.linspace(0.0, 1.0, self.colourResolution) - normalStep = 1.0 / (self.colourResolution - 1) - - normMin = (display.displayMin - display.dataMin) / \ - (display.dataMax - display.dataMin) - normMax = (display.displayMax - display.dataMin) / \ - (display.dataMax - display.dataMin) - - newStep = normalStep / (normMax - normMin) - newRange = (normalRange - normMin) * (newStep / normalStep) - - # Create [self.colourResolution] rgb values, - # spanning the entire range of the image - # colour map - colourmap = display.cmap(newRange) - - # The colour data is stored on - # the GPU as 8 bit rgba tuples - colourmap = np.floor(colourmap * 255) - colourmap = np.array(colourmap, dtype=np.uint8) - colourmap = colourmap.ravel(order='C') - - # GL texture creation stuff - gl.glBindTexture(gl.GL_TEXTURE_1D, colourBuffer) - gl.glTexParameteri(gl.GL_TEXTURE_1D, - gl.GL_TEXTURE_MAG_FILTER, - gl.GL_NEAREST) - gl.glTexParameteri(gl.GL_TEXTURE_1D, - gl.GL_TEXTURE_MIN_FILTER, - gl.GL_NEAREST) - gl.glTexParameteri(gl.GL_TEXTURE_1D, - gl.GL_TEXTURE_WRAP_S, - gl.GL_CLAMP_TO_EDGE) - - gl.glTexImage1D(gl.GL_TEXTURE_1D, - 0, - gl.GL_RGBA8, - self.colourResolution, - 0, - gl.GL_RGBA, - gl.GL_UNSIGNED_BYTE, - colourmap) - - - def configDisplayListeners(self): - """ - Adds a bunch of listeners to the fslimage.ImageDisplay object - (accessible as an attribute, called 'display', of the given image), - which defines how the given image is to be displayed. This is done - so we can refresh the image view when image display properties are - changed. - """ - - def refreshNeeded(*a): - """ - The view just needs to be refreshed (e.g. the alpha property - has changed). - """ - self.canvas.Refresh() - - def colourUpdateNeeded(*a): - """ - The colour map for this image needs to be recreated (e.g. the - colour map has been changed). - """ - self.updateColourBuffer() - self.canvas.Refresh() - - display = self.image.display - lnrName = 'SliceCanvas_{{}}_{}'.format(id(self)) - refreshProps = ['alpha', 'enabled'] - colourUpdateProps = ['displayMin', 'displayMax', 'rangeClip', 'cmap'] - - for prop in refreshProps: - display.addListener(prop, lnrName.format(prop), refreshNeeded) - - for prop in colourUpdateProps: - display.addListener(prop, lnrName.format(prop), colourUpdateNeeded) +import fsl.data.fslimage as fslimage +import fsl.fslview.glimagedata as glimagedata # The vertex shader positions and colours a single vertex. @@ -577,15 +300,26 @@ class SliceCanvas(wxgl.GLCanvas): self.ypos = self.ypos self.zpos = self.zpos - # Create a GLImageData object - # for any new images + # Create a GLImageData object for any new images, + # and attach a listener to their display properties + # so we know when to refresh the canvas. for image in self.imageList: try: glData = image.getAttribute(self.name) except: - glData = GLImageData(image, self) + glData = glimagedata.GLImageData(image, self.xax, self.yax) image.setAttribute(self.name, glData) + def refresh(*a): + self.Refresh() + + image.display.addListener('enabled', self.name, refresh) + image.display.addListener('alpha', self.name, refresh) + image.display.addListener('displayMin', self.name, refresh) + image.display.addListener('displayMax', self.name, refresh) + image.display.addListener('rangeClip', self.name, refresh) + image.display.addListener('cmap', self.name, refresh) + self.Refresh() -- GitLab