diff --git a/fsl/fsleyes/displaycontext/display.py b/fsl/fsleyes/displaycontext/display.py index b85ae494c753e146a5cab1b0663aaeb1765bb1ab..f0a0effa49f49b244d0d39c255fbb7bf60ed86da 100644 --- a/fsl/fsleyes/displaycontext/display.py +++ b/fsl/fsleyes/displaycontext/display.py @@ -406,9 +406,6 @@ class DisplayOpts(props.SyncableHasProperties, actions.ActionProvider): nounbind = kwargs.get('nounbind', []) nounbind.append('bounds') kwargs['nounbind'] = nounbind - - props.SyncableHasProperties.__init__(self, **kwargs) - actions.ActionProvider .__init__(self) self.overlay = overlay self.display = display @@ -417,6 +414,9 @@ class DisplayOpts(props.SyncableHasProperties, actions.ActionProvider): self.overlayType = display.overlayType self.name = '{}_{}'.format(type(self).__name__, id(self)) + props.SyncableHasProperties.__init__(self, **kwargs) + actions.ActionProvider .__init__(self) + log.memory('{}.init ({})'.format(type(self).__name__, id(self))) diff --git a/fsl/fsleyes/gl/glvolume.py b/fsl/fsleyes/gl/glvolume.py index b79e9c65fea2a0c19879173cda02f1cef8bf9c26..1b93a440c8a8f2743548f82c858bfba952bb1d12 100644 --- a/fsl/fsleyes/gl/glvolume.py +++ b/fsl/fsleyes/gl/glvolume.py @@ -12,12 +12,13 @@ encapsulates the data and logic required to render 2D slice of an import logging -import OpenGL.GL as gl +import OpenGL.GL as gl -import fsl.fsleyes.gl as fslgl -import textures -import globject -import resources as glresources +import fsl.fsleyes.gl as fslgl +import fsl.utils.async as async +import textures +import globject +import resources as glresources log = logging.getLogger(__name__) @@ -159,6 +160,11 @@ class GLVolume(globject.GLImageObject): # Create an image texture, clip texture, and a colour map texture self.texName = '{}_{}'.format(type(self).__name__, id(self.image)) + # Ref to an OpenGL shader program - + # the glvolume_funcs module will + # create this for us. + self.shader = None + # References to the clip image and # associated DisplayOpts instance, # if it is set. @@ -177,12 +183,21 @@ class GLVolume(globject.GLImageObject): # make sure we're registered with it. if self.displayOpts.clipImage is not None: self.registerClipImage() - - self.refreshImageTexture() - self.refreshClipTexture() + self.refreshColourTextures() + + # We can't initialise the shaders until + # the image textures are ready to go. + # The image textures are created + # asynchronously, so we need to wait + # for them to finish before initialising + # the shaders. + def onTextureReady(): + fslgl.glvolume_funcs.init(self) + self.notify() - fslgl.glvolume_funcs.init(self) + async.wait((self.refreshImageTexture(), self.refreshClipTexture()), + onTextureReady) def destroy(self): @@ -220,10 +235,12 @@ class GLVolume(globject.GLImageObject): """ if self.displayOpts.clipImage is None: - return (self.imageTexture is not None and + return (self.shader is not None and + self.imageTexture is not None and self.imageTexture.ready()) else: - return (self.imageTexture is not None and + return (self.shader is not None and + self.imageTexture is not None and self.clipTexture is not None and self.imageTexture.ready() and self.clipTexture .ready()) @@ -250,26 +267,22 @@ class GLVolume(globject.GLImageObject): def colourUpdate(*a): self.refreshColourTextures() - fslgl.glvolume_funcs.updateShaderState(self) - self.notify() + if self.ready(): + fslgl.glvolume_funcs.updateShaderState(self) + self.notify() def shaderUpdate(*a): - fslgl.glvolume_funcs.updateShaderState(self) - self.notify() + if self.ready(): + fslgl.glvolume_funcs.updateShaderState(self) + self.notify() + + def onTextureRefresh(): + if self.ready(): + fslgl.glvolume_funcs.updateShaderState(self) + self.notify() def imageRefresh(*a): - texChange = self.refreshImageTexture() - fslgl.glvolume_funcs.updateShaderState(self) - - # If the texture settings have been changed, - # the texture will asynchronously notify - # this GLVolume (which is registered in the - # refreshImageTexture method). If texture - # settings have not been changed, the async - # notify will not occur, so we have to do it - # here. - if not texChange: - self.notify() + async.wait([self.refreshImageTexture()], onTextureRefresh) def imageUpdate(*a): volume = opts.volume @@ -278,23 +291,25 @@ class GLVolume(globject.GLImageObject): if opts.interpolation == 'none': interp = gl.GL_NEAREST else: interp = gl.GL_LINEAR - texChange = self.imageTexture.set(volume=volume, - interp=interp, - resolution=resolution) + self.imageTexture.set(volume=volume, + interp=interp, + resolution=resolution, + notify=False) + + waitfor = [self.imageTexture.refreshThread()] if self.clipTexture is not None: - self.clipTexture.set(interp=interp, resolution=resolution) + self.clipTexture.set(interp=interp, + resolution=resolution, + notify=False) + waitfor.append(self.clipTexture.refreshThread()) - fslgl.glvolume_funcs.updateShaderState(self) - - if not texChange: - self.notify() + async.wait(waitfor, onTextureRefresh) def clipUpdate(*a): self.deregisterClipImage() self.registerClipImage() - self.refreshClipTexture() - fslgl.glvolume_funcs.updateShaderState(self) + async.wait([self.refreshClipTexture()], onTextureRefresh) display.addListener('alpha', lName, colourUpdate, weak=False) opts .addListener('displayRange', lName, colourUpdate, weak=False) @@ -394,7 +409,7 @@ class GLVolume(globject.GLImageObject): if self.imageTexture is not None: if self.imageTexture.getTextureName() == texName: - return + return None self.imageTexture.deregister(self.name) glresources.delete(self.imageTexture.getTextureName()) @@ -407,10 +422,13 @@ class GLVolume(globject.GLImageObject): textures.ImageTexture, texName, self.image, - interp=interp) + interp=interp, + notify=False) self.imageTexture.register(self.name, self.__textureChanged) + return self.imageTexture.refreshThread() + def registerClipImage(self): """Called whenever the :attr:`.VolumeOpts.clipImage` property changes. @@ -430,7 +448,8 @@ class GLVolume(globject.GLImageObject): def updateClipTexture(*a): self.clipTexture.set(volume=clipOpts.volume) - self.notify() + async.wait([self.clipTexture.refreshThread()], + fslgl.glvolume_funcs.updateShaderState, self) clipOpts.addListener('volume', self.name, @@ -469,7 +488,7 @@ class GLVolume(globject.GLImageObject): self.clipTexture = None if clipImage is None: - return + return None if opts.interpolation == 'none': interp = gl.GL_NEAREST else: interp = gl.GL_LINEAR @@ -481,10 +500,13 @@ class GLVolume(globject.GLImageObject): clipImage, interp=interp, resolution=opts.resolution, - volume=clipOpts.volume) + volume=clipOpts.volume, + notify=False) self.clipTexture.register(self.name, self.__textureChanged) + return self.clipTexture.refreshThread() + def refreshColourTextures(self): """Refreshes the :class:`.ColourMapTexture` instances used to colour diff --git a/fsl/fsleyes/gl/textures/imagetexture.py b/fsl/fsleyes/gl/textures/imagetexture.py index b79894acfcc5b5729a05fd1b964162e85856e29b..4ba9c2b69a89a7a883f67f024cd2cfb4adbf63be 100644 --- a/fsl/fsleyes/gl/textures/imagetexture.py +++ b/fsl/fsleyes/gl/textures/imagetexture.py @@ -69,7 +69,8 @@ class ImageTexture(texture.Texture, notifier.Notifier): prefilter=None, interp=gl.GL_NEAREST, resolution=None, - volume=None): + volume=None, + notify=True): """Create an ``ImageTexture``. A listener is added to the :attr:`.Image.data` property, so that the texture data can be refreshed whenever the image data changes - see the @@ -89,6 +90,8 @@ class ImageTexture(texture.Texture, notifier.Notifier): :arg prefilter: An optional function which may perform any pre-processing on the data before it is copied to the GPU - see the :meth:`__prepareTextureData` method. + + :arg notify: Passed to the initial call to :meth:`refresh`. """ texture.Texture.__init__(self, name, 3) @@ -114,13 +117,13 @@ class ImageTexture(texture.Texture, notifier.Notifier): self.__volume = None self.__normalise = None - # The dataMin/dataMax/ready attributes - # are modified in the refresh method - # (which is called via the set method - # below). - self.__dataMin = None - self.__dataMax = None - self.__ready = False + # These attributes are modified + # in the refresh method (which is + # called via the set method below). + self.__dataMin = None + self.__dataMax = None + self.__ready = False + self.__refreshThread = None self.image.addListener('data', self.__name, self.refresh) @@ -130,14 +133,8 @@ class ImageTexture(texture.Texture, notifier.Notifier): volume=volume, normalise=normalise, refresh=False) - self.__refresh() - - - def ready(self): - """Returns ``True`` if this ``ImageTexture`` is ready to be used, - ``False`` otherwise. - """ - return self.__ready + + self.__refresh(notify=notify) def destroy(self): @@ -149,6 +146,21 @@ class ImageTexture(texture.Texture, notifier.Notifier): texture.Texture.destroy(self) self.image.removeListener('data', self.__name) + + def ready(self): + """Returns ``True`` if this ``ImageTexture`` is ready to be used, + ``False`` otherwise. + """ + return self.__ready + + + def refreshThread(self): + """If this ``ImageTexture`` is in the process of being refreshed + on another thread, this method returns a reference to the ``Thread`` + object. Otherwise, this method returns ``None``. + """ + return self.__refreshThread + def setInterp(self, interp): """Sets the texture interpolation - either ``GL_NEAREST`` or @@ -197,6 +209,7 @@ class ImageTexture(texture.Texture, notifier.Notifier): ``normalise`` See :meth:`setNormalise`. ``refresh`` If ``True`` (the default), the :meth:`refresh` function is called (but only if a setting has changed). + ``notify`` Passed through to the :meth:`refresh` method. ============== ======================================================= :returns: ``True`` if any settings have changed and the @@ -208,6 +221,7 @@ class ImageTexture(texture.Texture, notifier.Notifier): volume = kwargs.get('volume', self.__volume) normalise = kwargs.get('normalise', self.__normalise) refresh = kwargs.get('refresh', True) + notify = kwargs.get('notify', True) changed = {'interp' : interp != self.__interp, 'prefilter' : prefilter != self.__prefilter, @@ -241,7 +255,9 @@ class ImageTexture(texture.Texture, notifier.Notifier): changed['normalise'])) if refresh: - self.refresh(refreshData=refreshData, refreshRange=refreshRange) + self.refresh(refreshData=refreshData, + refreshRange=refreshRange, + notify=notify) return True @@ -269,12 +285,19 @@ class ImageTexture(texture.Texture, notifier.Notifier): :arg refreshRange: If ``True`` (the default), the data range is re-calculated. + :arg notify: If ``True`` (the default), a notification is + triggered via the :class:`.Notifier` base-class, + when ``ImageTexture`` has been refreshed, and is + ready to use. Otherwise, the notification is + suppressed. + .. note:: The texture data is generated on a separate thread, using the :func:`.async.run` function. """ refreshData = kwargs.get('refreshData', True) refreshRange = kwargs.get('refreshRange', True) + notify = kwargs.get('notify', True) self.__ready = False @@ -377,10 +400,14 @@ class ImageTexture(texture.Texture, notifier.Notifier): self.unbindTexture() log.debug('{}({}) is ready to use'.format( type(self).__name__, self.image)) - self.__ready = True - self.notify() + + self.__refreshThread = None + self.__ready = True + + if notify: + self.notify() - async.run( + self.__refreshThread = async.run( genData, onFinish=configTexture, name='{}.genData({})'.format(type(self).__name__, self.image)) diff --git a/fsl/utils/async.py b/fsl/utils/async.py index 6d098f30db021a03d8bbbc33545f06f7ab62e56a..005d44b142cfcf8d8785a0e0f49ba83ecb6d3279 100644 --- a/fsl/utils/async.py +++ b/fsl/utils/async.py @@ -4,8 +4,7 @@ # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -"""This module provides a couple of functions for running tasks -asynchronously - :func:`run` and :func:`idle`. +"""This module provides functions for running tasks asynchronously. .. note:: The functions in this module are intended to be run from within a @@ -26,6 +25,10 @@ The :func:`idle` function is a simple way to run a task on an ``wx`` ``EVT_IDLE`` event handler. This effectively performs the same job as the :func:`run` function, but is more suitable for short tasks which do not warrant running in a separate thread. + +The :func:`wait` function is given one or more ``Thread`` instances, and a +task to run. It waits until all the threads have finished, and then runs +the task (via :func:`idle`). """ import Queue @@ -85,6 +88,7 @@ def run(task, onFinish=None, name=None): log.debug('Scheduling task "{}" finish handler ' 'on wx.MainLoop'.format(name)) + # Should I use the idle function here? wx.CallAfter(onFinish) if haveWX: @@ -122,7 +126,8 @@ def _wxIdleLoop(ev): try: task, args, kwargs = _idleQueue.get_nowait() except Queue.Empty: return - + + log.debug('Running function on wx idle loop') task(*args, **kwargs) if _idleQueue.qsize() > 0: @@ -135,6 +140,8 @@ def idle(task, *args, **kwargs): :arg task: The task to run. All other arguments are passed through to the task function. + + If a ``wx.App`` is not running, the task is called directly. """ global _idleRegistered @@ -154,3 +161,39 @@ def idle(task, *args, **kwargs): else: log.debug('Running idle task directly') task(*args, **kwargs) + + +def wait(threads, task, *args, **kwargs): + """Creates and starts a new ``Thread`` which waits for all of the ``Thread`` + instances to finsih (by ``join``ing them), and then runs the given + ``task`` via :func:`idle`. + + If a ``wx.App`` is not running, this function ``join``s the threads + directly instead of creating a new ``Thread`` to do so. + + :arg threads: A sequence of ``Thread`` instances to join. Elements in the + sequence may be ``None``. + + :arg task: The task to run. + + All other arguments are passed to the ``task`` function. + """ + haveWX = _haveWX() + + def joinAll(): + log.debug('Wait thread joining on all targets') + for t in threads: + if t is not None: + t.join() + + log.debug('Wait thread scheduling task on idle loop') + idle(task, *args, **kwargs) + + if haveWX: + thread = threading.Thread(target=joinAll) + thread.start() + return thread + + else: + joinAll() + return None