diff --git a/fsl/data/fslimage.py b/fsl/data/fslimage.py index 9e4b846729969cd3ea6fc0a5085b9f4cb151f378..e8e0bf4f80027a3b739393099ab6081003cd295f 100644 --- a/fsl/data/fslimage.py +++ b/fsl/data/fslimage.py @@ -6,8 +6,9 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -import collections +import sys import logging +import collections import os.path as op @@ -18,7 +19,6 @@ import matplotlib.colors as mplcolors import fsl.props as props import fsl.data.imagefile as imagefile -import fsl.utils.notifylist as notifylist log = logging.getLogger(__name__) @@ -146,7 +146,7 @@ class ImageDisplay(props.HasProperties): self.displayMax = self.dataMax -class ImageList(notifylist.NotifyList): +class ImageList(object): """ Class representing a collection of images to be displayed together. Provides basic list-like functionality, and a listener interface @@ -154,6 +154,7 @@ class ImageList(notifylist.NotifyList): changes (e.g. an image is added or removed). """ + def __init__(self, images=None): """ Create an ImageList object from the given sequence of Image objects. @@ -164,8 +165,102 @@ class ImageList(notifylist.NotifyList): if not isinstance(images, collections.Iterable): raise TypeError('images must be a sequence of images') - def validate(img): - if not isinstance(img, Image): - raise TypeError('images must be a sequence of images') + map(self._validate, images) + + self._items = images + self._listeners = [] + + self._updateImageAttributes() + + + def _updateImageAttributes(self): + """ + Called whenever an item is added or removed from the list. + Updates the xyz bounds. + """ + + # TODO support negative space + + maxBounds = 3 * [-sys.float_info.max] + minBounds = [0.0, 0.0, 0.0] + + for img in self._items: + + if img.shape[0] > maxBounds[0]: maxBounds[0] = img.shape[0] + if img.shape[1] > maxBounds[1]: maxBounds[1] = img.shape[1] + if img.shape[2] > maxBounds[2]: maxBounds[2] = img.shape[2] + + self.minBounds = minBounds + self.maxBounds = maxBounds + + + def _validate(self, img): + """ + Called whenever an item is added to the list. Raises + a TypeError if said item is not an Image object. + """ + if not isinstance(img, Image): + raise TypeError('item must be a fsl.data.fslimage.Image') + + + def __len__ (self): return self._items.__len__() + def __getitem__ (self, key): return self._items.__getitem__(key) + def __iter__ (self): return self._items.__iter__() + def __contains__(self, item): return self._items.__contains__(item) + def __eq__ (self, other): return self._items.__eq__(other) + def __str__ (self): return self._items.__str__() + def __repr__ (self): return self._items.__repr__() + + + def append(self, item): + self._validate(item) + log.debug('Item appended: {}'.format(item)) + self._items.append(item) + self._updateImageAttributes() + self._notify() + + + def pop(self, index=-1): + item = self._items.pop(index) + log.debug('Item popped: {} (index {})'.format(item, index)) + self._updateImageAttributes() + self._notify() + return item + + + def insert(self, index, item): + self._validate(item) + self._items.insert(index, item) + log.debug('Item inserted: {} (index {})'.format(item, index)) + self._updateImageAttributes() + self._notify() + + + def extend(self, items): + map(self._validate, items) + self._items.extend(items) + log.debug('List extended: {}'.format(', '.join([str(i) for i in item]))) + self._updateImageAttributes() + self._notify() + + + def move(self, from_, to): + """ + Move the item from 'from_' to 'to'. + """ + + item = self._items.pop(from_) + self._items.insert(to, item) + log.debug('Image moved: {} (from: {} to: {})'.format(item, from_, to)) + self._notify() + - notifylist.NotifyList.__init__(self, images, validate) + def addListener (self, listener): self._listeners.append(listener) + def removeListener(self, listener): self._listeners.remove(listener) + def _notify (self): + for listener in self._listeners: + try: + listener(self) + except e: + log.debug('Listener raised exception: {}'.format(e.message)) + diff --git a/fsl/fslview/orthopanel.py b/fsl/fslview/orthopanel.py index 0a06e317fce85e82c5fcbbc48971a33e1543ec05..89158a42690316b1ebebf52c9f8b3202e7694c5c 100644 --- a/fsl/fslview/orthopanel.py +++ b/fsl/fslview/orthopanel.py @@ -48,8 +48,6 @@ class OrthoPanel(wx.Panel): wx.Panel.__init__(self, parent) self.SetMinSize((300,100)) - self.shape = imageList[0].data.shape - self.xcanvas = slicecanvas.SliceCanvas(self, imageList, zax=0) self.ycanvas = slicecanvas.SliceCanvas(self, imageList, zax=1, context=self.xcanvas.context) @@ -127,33 +125,12 @@ class OrthoPanel(wx.Panel): y = self.ycanvas.zpos z = self.zcanvas.zpos - if source == self.xcanvas: - - mx = mx * self.shape[1] / float(w) - my = my * self.shape[2] / float(h) - y,z = mx,my - - elif source == self.ycanvas: - mx = mx * self.shape[0] / float(w) - my = my * self.shape[2] / float(h) - x,z = mx,my - - elif source == self.zcanvas: - mx = mx * self.shape[0] / float(w) - my = my * self.shape[1] / float(h) - x,y = mx,my - - x = int(x) - y = int(y) - z = int(z) - - if x < 0: x = 0 - if y < 0: y = 0 - if z < 0: z = 0 + mx = mx * (source.xmax - source.xmin) / float(w) + my = my * (source.ymax - source.ymin) / float(h) - if x >= self.shape[0]: x = self.shape[0]-1 - if y >= self.shape[1]: y = self.shape[1]-1 - if z >= self.shape[2]: z = self.shape[2]-1 + if source == self.xcanvas: y,z = mx,my + elif source == self.ycanvas: x,z = mx,my + elif source == self.zcanvas: x,y = mx,my self.setLocation(x,y,z) diff --git a/fsl/fslview/slicecanvas.py b/fsl/fslview/slicecanvas.py index bdda0dada2811e867defef5011de427cd41bdf1d..f3142eeee2d2c7baf911daf75b905a2df000adaf 100644 --- a/fsl/fslview/slicecanvas.py +++ b/fsl/fslview/slicecanvas.py @@ -6,7 +6,7 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -import itertools as it +import sys import numpy as np import matplotlib.colors as mplcolors @@ -78,6 +78,8 @@ class GLImageData(object): self.ylen = image.pixdim[canvas.yax] self.zlen = image.pixdim[canvas.zax] + # TODO origin + dsize = image.data.dtype.itemsize self.xstride = image.data.strides[canvas.xax] / dsize @@ -339,10 +341,8 @@ class SliceCanvas(wxgl.GLCanvas): Refresh() after changing the zpos. """ - zpos = int(round(zpos)) - -# if zpos >= self.zdim: zpos = self.zdim - 1 -# elif zpos < 0: zpos = 0 + if zpos > self.zmax: zpos = self.zmax + elif zpos < self.zmin: zpos = self.zmin self._zpos = zpos @@ -360,10 +360,8 @@ class SliceCanvas(wxgl.GLCanvas): Refresh() after changing the xpos. """ - xpos = int(round(xpos)) - -# if xpos >= self.xdim: xpos = self.xdim - 1 -# elif xpos < 0: xpos = 0 + if xpos > self.xmax: xpos = self.xmax + elif xpos < self.xmin: xpos = self.xmin self._xpos = xpos @@ -380,20 +378,18 @@ class SliceCanvas(wxgl.GLCanvas): Change the y cursor position. You will need to manually call Refresh() after changing the ypos. """ - - ypos = int(round(ypos)) - -# if ypos >= self.ydim: ypos = self.ydim - 1 -# elif ypos < 0: ypos = 0 + + if ypos > self.ymax: ypos = self.ymax + elif ypos < self.ymin: ypos = self.ymin self._ypos = ypos - def __init__( - self, parent, imageList, zax=0, zpos=None, context=None, **kwargs): + def __init__(self, parent, imageList, zax=0, context=None): """ - Creates a canvas object. The OpenGL data buffers are set up in - _initGLData the first time that the canvas is displayed/drawn. + Creates a canvas object. The OpenGL data buffers are set up the + first time that the canvas is displayed/drawn. + Parameters: parent - WX parent object @@ -404,57 +400,109 @@ class SliceCanvas(wxgl.GLCanvas): (the 'depth' axis), default 0. context - wx.glcanvas.GLContext object. If None, one is created. - - zpos - Initial slice to be displayed. If not provided, the - middle slice is used. """ - wxgl.GLCanvas.__init__(self, parent, **kwargs) + if not isinstance(imageList, fslimage.ImageList): + raise TypeError( + 'imageList must be a fsl.data.fslimage.ImageList instance') - self.name = 'SliceCanvas_{}'.format(id(self)) + wxgl.GLCanvas.__init__(self, parent) # 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 not isinstance(imageList, fslimage.ImageList): - raise TypeError( - 'imageList must be a fsl.data.fslimage.ImageList instance') + self.imageList = imageList + self.name = 'SliceCanvas_{}'.format(id(self)) + # These attributes map from the image axes to + # the display axes. xax is horizontal, yax + # is vertical, and zax is depth. + # # TODO Currently, the displayed x/horizontal and # y/vertical axes are defined by their order in - # the image. Allow the caller to specify which - # axes should be horizontal/vertical. + # the image. We could allow the caller to specify + # which axes should be horizontal/vertical. dims = range(3) dims.pop(zax) - - self.imageList = imageList - self.xax = dims[0] - self.yax = dims[1] - self.zax = zax - # This flag is set by the _initGLData method when it - # has finished initialising the OpenGL data buffers + self.xax = dims[0] + self.yax = dims[1] + self.zax = zax + + # These attributes define the current location + # of the cursor, and the displayed slice. They + # are initialised in the _imageListChanged + # method. + self._xpos = None + self._ypos = None + self._zpos = None + + # These attributes define the spatial data + # limits of all displayed images. They are + # set by the _imageListChanged method, and + # updated whenever an image is added/removed + # from the list. + self.xmin = None + self.xmax = None + self.ymin = None + self.ymax = None + self.zmin = None + self.zmax = None + + # This flag is set by the _initGLData method + # when it has finished initialising the OpenGL + # shaders self.glReady = False # All the work is done by the draw method self.Bind(wx.EVT_PAINT, self.draw) - # TODO Fix these numbers - self._xpos = 200 - self._ypos = 200 - self._zpos = 200 + # When the image list changes, refresh the + # display, and update the display bounds + self.imageList.addListener(lambda il: self._imageListChanged()) - # When the image list changes, refresh the display - # - # TODO When image list changes, update local attributes - # xdim and ydim, so we know how big to set the viewport - # in the resize method - # - self.imageList.addListener(lambda il: self.Refresh()) + def _imageListChanged(self): + """ + This method is called once by _initGLData on the first draw, and + then again every time an image is added or removed from the + image list. For newly added images, it creates a GLImageData + object, which initialises the OpenGL data necessary to render + the image. This method also updates the canvas bounds (i.e. + the min/max x/y/z coordinates across all images being displayed). + """ + + # Create a GLImageData object + # for any new images + for image in self.imageList: + try: + glData = image.getAttribute(self.name) + except: + glData = GLImageData(image, self) + image.setAttribute(self.name, glData) + + # Update the minimum/maximum + # image bounds along each axis + self.xmin = self.imageList.minBounds[self.xax] + self.ymin = self.imageList.minBounds[self.yax] + self.zmin = self.imageList.minBounds[self.zax] + + self.xmax = self.imageList.maxBounds[self.xax] + self.ymax = self.imageList.maxBounds[self.yax] + self.zmax = self.imageList.maxBounds[self.zax] + + # initialise the cursor location and displayed + # slice if they do not yet have values + if not all((self._xpos, self._ypos, self._zpos)): + self.xpos = (self.xmax - self.xmin) / 2 + self.ypos = (self.ymax - self.ymin) / 2 + self.zpos = (self.zmax - self.zmin) / 2 - def _initGLShaders(self): + self.Refresh() + + + def _initGLData(self): """ Compiles the vertex and fragment shader programs, and stores references to the shader variables as attributes @@ -464,13 +512,13 @@ class SliceCanvas(wxgl.GLCanvas): # A bit hacky. We can only set the GL context (and create # the GL data) once something is actually displayed on the - # screen. The _initGLShaders method is called (asynchronously) + # screen. The _initGLData method is called (asynchronously) # by the draw() method if it sees that the glReady flag has # not yet been set. But draw() may be called mored than once - # before _initGLShaders is called. Here, to prevent - # _initGLShaders from running more than once, the first time + # before _initGLData is called. Here, to prevent + # _initGLData from running more than once, the first time # it is called it simply overwrites itself with a dummy method. - self._initGLShaders = lambda s: s + self._initGLData = lambda s: s self.context.SetCurrent(self) @@ -485,6 +533,10 @@ class SliceCanvas(wxgl.GLCanvas): self.alphaPos = gl.glGetUniformLocation(self.shaders, 'alpha') self.colourMapPos = gl.glGetUniformLocation(self.shaders, 'colourMap') + # Initialise data for the images that + # are already in the image list + self._imageListChanged() + self.glReady = True @@ -501,8 +553,7 @@ class SliceCanvas(wxgl.GLCanvas): gl.glViewport(0, 0, size.width, size.height) gl.glMatrixMode(gl.GL_PROJECTION) gl.glLoadIdentity() - # TODO fix these numbers (see notes in __init__) - gl.glOrtho(0, 450, 0, 450, 0, 1) + gl.glOrtho(self.xmin, self.xmax, self.ymin, self.ymax, 0, 1) gl.glMatrixMode(gl.GL_MODELVIEW) gl.glLoadIdentity() @@ -514,7 +565,7 @@ class SliceCanvas(wxgl.GLCanvas): # image data has not been initialised. if not self.glReady: - wx.CallAfter(self._initGLShaders) + wx.CallAfter(self._initGLData) return self.context.SetCurrent(self) @@ -529,15 +580,15 @@ 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.imageList)): - - image = self.imageList[i] + for image in self.imageList: - try: - glImageData = image.getAttribute(self.name) - except: - glImageData = GLImageData(image, self) - image.setAttribute(glImageData, self.name) + # The GL data is stored as an attribute of the image, + # and is created in the _imageListChanged method when + # images are added to the image. If there's no data + # here, ignore it; hopefully by the time draw() is + # called again, it will have been created. + try: glImageData = image.getAttribute(self.name) + except: continue imageDisplay = image.display @@ -551,9 +602,16 @@ class SliceCanvas(wxgl.GLCanvas): zdim = glImageData.zdim xstride = glImageData.xstride ystride = glImageData.ystride - zstride = glImageData.zstride + zstride = glImageData.zstride + + # Figure out which slice we are drawing + # TODO origin and scaling by zlen + zi = int(round(self.zpos)) if not imageDisplay.enabled: + continue + + if zi < 0 or zi >= zdim: continue # Set up the colour buffer @@ -575,7 +633,8 @@ class SliceCanvas(wxgl.GLCanvas): # drawing columns, for reasons unknown to me. for yi in range(ydim): - imageOffset = self.zpos * zstride + yi * ystride + # TODO zpos is not necessarily in image coords + imageOffset = zi * zstride + yi * ystride imageStride = xstride posOffset = yi * xdim * 4 @@ -631,17 +690,15 @@ class SliceCanvas(wxgl.GLCanvas): gl.glUseProgram(0) # A vertical line at xpos, and a horizontal line at ypos - x = self.xpos + 0.5 - y = self.ypos + 0.5 + x = self.xpos + y = self.ypos - # TODO Fix these numbers (see __init__ notes) - gl.glBegin(gl.GL_LINES) gl.glColor3f(0, 1, 0) - gl.glVertex2f(x, 0) - gl.glVertex2f(x, 450) - gl.glVertex2f(0, y) - gl.glVertex2f(450, y) + gl.glVertex2f(x, self.ymin) + gl.glVertex2f(x, self.ymax) + gl.glVertex2f(self.xmin, y) + gl.glVertex2f(self.xmax, y) gl.glEnd() self.SwapBuffers()