diff --git a/TODO b/TODO index 7796c8ed3074c7a116120b4168bb1e76125c85e0..d409a3e558ad12035ae225893100348727ebd172 100644 --- a/TODO +++ b/TODO @@ -1,70 +1,65 @@ - TODO LIST -*- mode: org; -*- A mixture of things to do, things which might be nice to do, and things which have been done. Order is arbitrary. Check https://internal.fmrib.ox.ac.uk/i-wiki/Analysis/Todos/FSLView regularly, too. -* Feedback -** (Sean) In location panel, ability to display intensity for all images, not just the currently selected one - -* February/March 2015 internal release -** DONE Installation instructions on wiki -*** Make sure they're up to date - test in a VM -*** Usage - keyboard shortcuts -** DONE OSX installer -*** Make sure you disable GL error checking/logging -*** Test screenshots -*** Test GL14 and GL21 (shaders) -*** Test two-stage rendering -*** Test installing colour maps -*** Test saving edited images -*** Test atlas tools (this will only be available when it is run from command line) -** DONE Linux compatibility -*** DONE Combobox drop down lists in dialogs are shown beneath the dialog?!? This may only be occurring under X11/SSH/OSX -*** WONTFIX Toolbars not being resized appropriately when the parent viewpanel is resized? -Can't reproduce right now .. -** WONTFIX Ludoweird interpolation effect - wtf? -It's an artifact of the fact that interpolation is applied in voxel space, rather than mm space. -** DONE Fix all 'listener still registered' warnings. -** DONE Progress dialog during screen shot -** DONE VNC/X11 ortho panning -** DONE Change 'profile' to 'mode' -** DONE Volume Option to invert colour map -** DONE Make ImageDisplayPanel change interpolation when transformation is changed -** DONE Disable image display widgets for disabled images -** DONE Rename 'sync image order' -> 'sync overlay order' -** DONE Remove 'sync volume' option -** DONE -ISH Make VNC two stage render approach stablish -** DONE Add 'blue-lightblue' colour map -** DONE Rename 'autumn' to 'red-yellow' (change colour map) -** DONE Colour map ordering -** DONE Change 'ss' back to 'twostage' -** DONE Command line -** DONE? Little things -*** DONE ViewPanel: New toolbars in a lower layer -*** DONE Number spinboxes are no longer clamped -*** Keyboard on number widgets .. Should be working by default? -** DONE Default layout -** DONE UI design -Good enough for the time being -** DONE Support for double precision images -** DONE Histogram -** DONE Atlas tools -** DONE Keyboard shortcut for pan mode - can't use middle click when using a shitty laptop trackpad -** DONE Git release pipeline -We now have an 'oxford' branch, which is linked to the jalapeno installation. -When you want to 'release' something, merge from the master branch to the -oxford branch, and push to jalapeno - -** DONE Offscreen rendering -** Other things/stretch objectives: -*** Tooltips -*** Save/restore window layout -*** Movie mode -*** Document all the code -*** Fix all the bugs +* Required feature list before next internal release +** DONE Label support +** DONE VTK support +** DONE Improve time series +** DONE Improve histogram +** DONE Feat mode +** DONE Cluster browser +** DONE Overlay groups +** DONE Movie mode +** Iconify/clean up interface +*** Icons for buttons +*** Fix toolbar refresh behaviour +*** Fix location panel size. +*** Sensible layout when new views are opened +*** Global font settings +*** A 'WidgetList'/'PropertyList' panel +which displays a list of control widgets, each with a name, in a scrollable +panel. You could use the wx.CollabisblePane to group properties/widgets +sensibly. +** Rename to FSLEyes +** Application icon (for OSX) +** Startup time +** Fix command line for models and labels +** Complete code documentation, commenting and clean-up +** Move TODO list to Gitlab +** Other +*** Software/GL14/Offscreen rendering issues +*** Control panel for image header information +*** Profile actions/settings in canvas settings panel +*** 'Reset display range' option on overlay display toolbar/settings panel +*** SliceCanvas - do not re-centre on current location when zoomed in, and displayed slice changes +*** Remove Colour map name from toolbar +I think I need to write my own combobox implementation (subclass +OwnerDrawnComboBox) in order to achieve this. This would also allow me to +properly disable the colour map combo box. +*** Make double click on overlay list toggle visibility, rather than edit name +*** Unavailable options (e.g. spline interp) shown but disabled? +*** Persist properties when overlay type changes? E.g. Change to a different type, then change back again - restore previous settings. +*** Refresh when numeric control value changes, not when focus lost +*** Combo box mouse selection +*** OSX Installer +*** Start up menu visibility thing (PyInstaller bug) +*** DONE CanvasPanel menu option to generate command line for current view +*** DONE Check/fix Display range for 4D images +*** DONE Check/fix Bricon <-> display range linking +*** DONE Add display range (spinctrls) to toolbar +*** DONE FSLDIR management - prompt user at startup? Configurable menu option? +*** DONE Open file dialog should use last directory +* Things to do once the feature list is complete +** Combine SliceCanvas offscreen+prerender modes +** A better way of managing colour maps/luts +** Text annotations for models and labels +** A pre-processor for GL14 shader programs +** Unify/remove duplication across gl*_funcs modules +** Unify/remove duplication across texture classes - the set(**kwargs) method, and all the GL texture function calls * Things to do in props package ** Refactor/rewrite constraints/attributes. Clean up HasProps class - remove related unnecessary methods. Is there anything stopping me from making the constraints, for each property, properties themselves? @@ -82,6 +77,8 @@ Also, redo the way that global HasProps validation works - it is currently perfo ** PyInstaller menu bar not showing http://dvitonis.net/blog/2015/01/07/menu-bar-not-visible-when-building-pyqt-app-bundle-pyinstaller-mac-osx-mavericks-yosemite/ * Bugs to fix +** Link/unlink Volume brightness/contrast is a bit funky +** Toolbar labels don't refresh properly on selected overlay change ** Clipping range not working for some volumes in ~/analysis_prac_2015/rest/ICA/Group/groupmelodic_fix.ica/melodic_IC.nii.gz ** LightBox - grid lines are drawn below canvas area where slices are drawn ** LocationPanel in lightbox view - voxel value lookup is wrong? @@ -161,41 +158,42 @@ perhaps into a standalone module... ** DONE Graceful handling of bad input filenames ** DONE Aspect ratio on slicecanvas zoom, and panning is broken. * Little things +** A 'FeatModel' control widget, allowing user to look at design matrix, stats, whatever .. +** A 'MELODICTimeSeries' thingo, just like the FEATTimeSeries - plot component time courses alongside a voxel's time course. +** Colour command line (e.g. renderpy background) should be floating point in the range [0, 1] +** GLModel - annotate view with model name +** LineVector - Threshold on vector length +** Colour bar for other image types (e.g. vectors) ** Arrow keys on number widgets ** Buffer sharing A gl/buffers.py module which checks for buffer capability. If buffer-capable, vertex data is copied to a buffer, and the buffer name/ID returned. Otherwise, the vertex data is returned unmodified. -** Absolute clipping (e.g. clip [-3, 3]) ** Make zoom more flexible ** Make ortho aspect ratio/zoom thing better ** RunWindow should print to stdout ** Startup - ability to set $FSLDIR ** Time Series - option to 'hold' voxel time course You could have a list panel which lists the image and voxel coordinates of the -time courses that are currently plotted. +time courses that are currently plotted. Allow voxels to be held, and images +to be held. ** Histogram - mouse drag should zoom into plot but not change data range. ** A proper GLMask class - deriving from GLVolume is inefficient ** A top-level class, from which all things inherit, which has a destroy method that must be called when the object is destroy4ed ** Editor - apply fill/mask/ROI to all volumes of a 4D image ** Option to centre display range for volume images ** Spline interpolation in GL14 -** Ability to display vector data as directional (i.e. starting from voxel centre) -** 'Label' image type, which allows user to change colour for each label. -Ability to draw region outlines instead of full label overlays ** 'Annotation' image type that allows the user to highlight regions in different colours. Is this the same as the 'label' image above? ** Support ANALYZE75 Pixdims stored in A75 header should be used, unchanged, as the affine diagonals (i.e. negative pixdims supported). ** EListBox - Mouse drag to move items -** VNC - using software rendering (wrong LD_LIBARY_PATH ?!?) ** CLI options *** DONE Show/hide labels *** DONE Ortho layout (horiz/vert/grid) *** DONE Check that show/hide cursor is being applied correctly *** A switch to automatically choose colour maps/clipping thresholds for a provided set of images (like Eugene's fast fslview startup script) ** Scale values in colour map according to largest value? -** ColourBarPanel should only do stuff if it is being displayed ** View/view config panels persist across shutdown/restart This is tough, because the old agw.AUINotebook implementation does not have any ability to query layout. @@ -219,6 +217,15 @@ Need to be able to specify the order that actions appear in the menu - perhaps just hard code in fsl/fslview/actions/__init__.py ** WONTFIX Allow display resolution greater than image resolution? Now that I'm smoothing over voxels (when interpolation is on), I don't think this is necessary +** DONE Absolute clipping (e.g. clip [-3, 3]) +** DONE Add 'load colour map' button to VolumeOpts settings panel. If you do this, you could probably remove the option from the file menu. +** DONE 'Label' image type, which allows user to change colour for each label. +Ability to draw region outlines instead of full label overlays +** DONE ColourBarPanel should only do stuff if it is being displayed +** DONE Ability to display vector data as directional (i.e. starting from voxel centre) +** DONE LineVector - modulate line length +** DONE OrthoViewProfile - mouse wheel on canvas scrolls through displayed slice +** DONE (Sean) In location panel, ability to display intensity for all images, not just the currently selected one ** DONE EListBox - Up/down keys change selected image ** DONE On startup, check to see if cached display location is valid (e.g. external display has been unplugged). Revert to default location/size if not. ** DONE 'Create mask image' option @@ -290,22 +297,43 @@ that need it (e.g. SliceCanvas, LightBoxCanvas, etc) ** DONE Enable/disable ImageList<->Canvas position sync - 'link' property on slice canvas/orthopanel, or something. ** DONE SliceCanvas initial location set from image list * Medium things +** PlotPanel - Mouse click interaction: when clicking on a plot, the current overlay/volume should be selected +** HistogramListPanel +*** When a histogram is added for a 4D image, prompt user to choose one of + - Histogram for current volume + - Histogram across all volumes + - Histogram for every volume +** Save animated gif from movie mode +** Lightbox - ability to specify exactly which slices are displayed +** Lightbox/SliceCanvas - annotate displayed slice with Z coordinates +** Lightbox - Ability to show cutting planes from specified locations on an orthogonal axis ** Editor features *** Select all above/below a specified intensity *** Honour clipping range (i.e. don't select clipped voxels) -** Ability to unlink image volume across displays -** Link display properties across images ** Custom colour select widget - the OSX one is rubbish ** Transfer editor selection on selected image changes ** Option in ortho panel/lightbox (or CanvasPanel superclass) to set display to radiological/neurological/first angle/third angle orthographic/etc. ** Tensors -*** RGB display -*** Tensor angles are in real world space, so are currently being displayed incorrectly for non isotropic voxels +*** DONE RGB display +*** DONE Tensor angles are in real world space, so are currently being displayed incorrectly for non isotropic voxels *** Option to make displayed tensor lines all the same length -*** 'Modulate' one image by another (e.g. modulate DTI tensors by FA values) +*** DONE 'Modulate' one image by another (e.g. modulate DTI tensors by FA values) *** Option to show tensors as ellipsoids ** Display fnirt warp images ** Panel/profile mode which allows mouse control of brightness/contrast (e.g. a 2D canvas where the vertical axis corresponds to brightness, and the horizontal to contrast). +** DONE Link display properties across images +** DONE Edge detection algorithm +For drawing outline images (both model and volumetric). A Sobel filter looks +perfect, but I think I would need to be able to run it within fragment +shader. This means that I would have to render an overlay to an off-screen +texture, and run the shader on said texture ... + + - A fragment shader which just runs a Sobel filter on a 2D texture. + - A new GLImageObject subclass, GLOutline, which renders to offscreen + texture, and applies Sobel filter to said texture before rendering to + screen. + - Build same functionality into the GLModel class. +** DONE Ability to unlink image volume across displays ** DONE Different settings for each view Keep a 'master' image list, but mainain an index list in separate view panels - make it editable as a view panel property. diff --git a/fsl/__init__.py b/fsl/__init__.py index d606065cae9af155919747d27c2b6808faeec32a..3bcf0bb9c8e757dd6535f7392cf9fb240a3d24ea 100644 --- a/fsl/__init__.py +++ b/fsl/__init__.py @@ -59,26 +59,40 @@ import argparse import subprocess -log = logging.getLogger(__name__) - - # make matplotlib quiet warnings.filterwarnings('ignore', module='matplotlib') warnings.filterwarnings('ignore', module='mpl_toolkits') +# My own custom logging level for tracing memory related events +logging.MEMORY = 15 +def logmemory(self, message, *args, **kwargs): + if self.isEnabledFor(logging.MEMORY): + self._log(logging.MEMORY, message, args, **kwargs) + +logging.Logger.memory = logmemory +logging.addLevelName(logging.MEMORY, 'MEMORY') + # There's a bug in OpenGL.GL.shaders (which has been fixed in # the latest version) - it calls logging.basicConfig(), and # thus screws up our own logging. We overcome this by configuring # the root logger before OpenGL.GL.shaders is imported (which # occurs when fsl.fslview.gl.slicecanvas.SliceCanvas is imported). -logging.basicConfig( - format='%(levelname)8.8s ' - '%(filename)20.20s ' - '%(lineno)4d: ' - '%(funcName)-15.15s - ' - '%(message)s') -log = logging.getLogger('fsl') + +logFormatter = logging.Formatter('%(levelname)8.8s ' + '%(filename)20.20s ' + '%(lineno)4d: ' + '%(funcName)-15.15s - ' + '%(message)s') +logHandler = logging.StreamHandler() +logHandler.setFormatter(logFormatter) + + +# We want the root logger +log = logging.getLogger() + + +log.addHandler(logHandler) import fsl.tools as tools @@ -176,6 +190,10 @@ def parseArgs(argv, allTools): parser.add_argument( '-n', '--noisy', metavar='MODULE', action='append', help='Make the specified module noisy') + + parser.add_argument( + '-m', '--memory', action='store_true', + help='Output memory events (implied if -v is set)') parser.add_argument( '-w', '--wxinspect', action='store_true', @@ -206,6 +224,9 @@ def parseArgs(argv, allTools): namespace = parser.parse_args(fslArgv) + if namespace.noisy is None: + namespace.noisy = [] + # if the specified tool is 'help', it should be followed by # one more argument, the name of the tool to print help for if namespace.tool == 'help': @@ -242,12 +263,26 @@ def parseArgs(argv, allTools): # Configure any logging verbosity # settings specified by the user + if namespace.verbose is None: + if namespace.memory: + class MemFilter(object): + def filter(self, record): + if record.name in namespace.noisy: return 1 + elif record.levelno == logging.MEMORY: return 1 + else: return 0 + + log.setLevel(logging.MEMORY) + log.handlers[0].addFilter(MemFilter()) + log.memory('Added filter for MEMORY messages') + logging.getLogger('props') .setLevel(logging.WARNING) + logging.getLogger('pwidgets').setLevel(logging.WARNING) + if namespace.verbose == 1: log.setLevel(logging.DEBUG) # make some noisy things quiet - logging.getLogger('fsl.fslview.gl') .setLevel(logging.WARNING) - logging.getLogger('fsl.fslview.views').setLevel(logging.WARNING) + logging.getLogger('fsl.fslview.gl') .setLevel(logging.MEMORY) + logging.getLogger('fsl.fslview.views').setLevel(logging.MEMORY) logging.getLogger('props') .setLevel(logging.WARNING) logging.getLogger('pwidgets') .setLevel(logging.WARNING) elif namespace.verbose == 2: @@ -259,9 +294,14 @@ def parseArgs(argv, allTools): logging.getLogger('props') .setLevel(logging.DEBUG) logging.getLogger('pwidgets').setLevel(logging.DEBUG) - if namespace.noisy is not None: - for mod in namespace.noisy: - logging.getLogger(mod).setLevel(logging.DEBUG) + for mod in namespace.noisy: + logging.getLogger(mod).setLevel(logging.DEBUG) + + # The trace module monkey-patches some + # things if its logging level has been + # set to DEBUG, so we import it now so + # it can set itself up. + import fsl.utils.trace # otherwise, give the remaining arguments to the tool parser fslTool = allTools[namespace.tool] @@ -280,17 +320,22 @@ def fslDirWarning(frame, toolName, fslEnvActive): if fslEnvActive: return - msg = 'The FSLDIR environment variable is not set - '\ - '{} may not behave correctly.'.format(toolName) + warnmsg = 'The FSLDIR environment variable is not set - '\ + '{} may not behave correctly.'.format(toolName) if frame is not None: import wx - wx.MessageDialog( - frame, - message=msg, - style=wx.OK | wx.ICON_EXCLAMATION).ShowModal() + from fsl.utils.fsldirdlg import FSLDirDialog + + dlg = FSLDirDialog(frame, toolName) + + if dlg.ShowModal() == wx.ID_OK: + fsldir = dlg.GetFSLDir() + log.debug('Setting $FSLDIR to {} (specified ' + 'by user)'.format(fsldir)) + os.environ['FSLDIR'] = fsldir else: - log.warn(msg) + log.warn(warnmsg) def buildGUI(args, fslTool, toolCtx, fslEnvActive): diff --git a/fsl/data/atlases.py b/fsl/data/atlases.py index 32e633f7edaa81a4a7ec806c548271039f06c2c7..dd5165a7bbda47f5b2f44cbd6f2490859491cc83 100644 --- a/fsl/data/atlases.py +++ b/fsl/data/atlases.py @@ -61,13 +61,18 @@ import fsl.utils.transform as transform log = logging.getLogger(__name__) +ATLAS_DIR = None -if os.environ.get('FSLDIR', None) is None: - log.warn('$FSLDIR is not set - atlases are not available') +def _setAtlasDir(): + global ATLAS_DIR - ATLAS_DIR = None -else: - ATLAS_DIR = op.join(os.environ['FSLDIR'], 'data', 'atlases') + if ATLAS_DIR is not None: + return + + if os.environ.get('FSLDIR', None) is None: + log.warn('$FSLDIR is not set - atlases are not available') + else: + ATLAS_DIR = op.join(os.environ['FSLDIR'], 'data', 'atlases') ATLAS_DESCRIPTIONS = collections.OrderedDict() @@ -84,6 +89,8 @@ def listAtlases(refresh=False): :attr:`ATLAS_DESCRIPTIONS`). """ + _setAtlasDir() + if ATLAS_DIR is None: return [] @@ -112,6 +119,8 @@ def getAtlasDescription(atlasID): atlas with the given ``atlasID``. """ + _setAtlasDir() + if ATLAS_DIR is None: return None @@ -130,6 +139,8 @@ def loadAtlas(atlasID, loadSummary=False): a 4D :class:`ProbabilisticAtlas` image is loaded. """ + _setAtlasDir() + if ATLAS_DIR is None: return None @@ -297,6 +308,6 @@ class ProbabilisticAtlas(Atlas): voxelLoc[0] >= self.shape[0] or \ voxelLoc[1] >= self.shape[1] or \ voxelLoc[2] >= self.shape[2]: - return np.nan + return [] return self.data[voxelLoc[0], voxelLoc[1], voxelLoc[2], :] diff --git a/fsl/data/featimage.py b/fsl/data/featimage.py new file mode 100644 index 0000000000000000000000000000000000000000..c87121f211815e6d6580a8221455159e1962cc37 --- /dev/null +++ b/fsl/data/featimage.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python +# +# featimage.py - An Image subclass which has some FEAT-specific functionality. +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# +"""This module provides the :class:`FEATImage` class, a subclass of +:class:`.Image` designed for the ``filtered_func_data`` file of a FEAT +analysis. +""" + +import os.path as op + +import numpy as np + +import image as fslimage +import featresults + + +class FEATImage(fslimage.Image): + + def __init__(self, path, **kwargs): + """ + The specified ``path`` may be a FEAT analysis directory, or the model + data input file (e.g. ``analysis.feat/filtered_func_data.nii.gz``). + """ + + featDir = featresults.getFEATDir(path) + + if featDir is None: + raise ValueError('{} does not appear to be data from a ' + 'FEAT analysis'.format(path)) + + if op.isdir(path): + path = op.join(featDir, 'filtered_func_data') + + settings = featresults.loadSettings( featDir) + design = featresults.loadDesign( featDir) + names, cons = featresults.loadContrasts(featDir) + + fslimage.Image.__init__(self, path, **kwargs) + + self.__analysisName = op.splitext(op.basename(featDir))[0] + self.__featDir = featDir + self.__design = design + self.__contrastNames = names + self.__contrasts = cons + self.__settings = settings + self.__evNames = featresults.getEVNames(settings) + + self.__residuals = None + self.__pes = [None] * self.numEVs() + self.__copes = [None] * self.numContrasts() + self.__zstats = [None] * self.numContrasts() + self.__clustMasks = [None] * self.numContrasts() + + if 'name' not in kwargs: + self.name = '{}: {}'.format(self.__analysisName, self.name) + + + def getFEATDir(self): + return self.__featDir + + + def getAnalysisName(self): + return self.__analysisName + + + def getDesign(self): + return np.array(self.__design) + + + def numPoints(self): + return self.__design.shape[0] + + + def numEVs(self): + return self.__design.shape[1] + + + def evNames(self): + return list(self.__evNames) + + + def numContrasts(self): + return len(self.__contrasts) + + + def contrastNames(self): + return list(self.__contrastNames) + + + def contrasts(self): + return [list(c) for c in self.__contrasts] + + + def thresholds(self): + return featresults.getThresholds(self.__settings) + + + def clusterResults(self, contrast): + + return featresults.loadClusterResults(self.__featDir, + self.__settings, + contrast) + + + def getPE(self, ev): + + if self.__pes[ev] is None: + pefile = featresults.getPEFile(self.__featDir, ev) + self.__pes[ev] = FEATImage( + pefile, + name='{}: PE{} ({})'.format( + self.__analysisName, + ev + 1, + self.evNames()[ev])) + + return self.__pes[ev] + + + def getResiduals(self): + + if self.__residuals is None: + resfile = featresults.getResidualFile(self.__featDir) + self.__residuals = FEATImage( + resfile, + name='{}: residuals'.format(self.__analysisName)) + + return self.__residuals + + + def getCOPE(self, con): + + if self.__copes[con] is None: + copefile = featresults.getPEFile(self.__featDir, con) + self.__copes[con] = FEATImage( + copefile, + name='{}: COPE{} ({})'.format( + self.__analysisName, + con + 1, + self.contrastNames()[con])) + + return self.__copes[con] + + + def getZStats(self, con): + + if self.__zstats[con] is None: + zfile = featresults.getZStatFile(self.__featDir, con) + + self.__zstats[con] = FEATImage( + zfile, + name='{}: zstat{} ({})'.format( + self.__analysisName, + con + 1, + self.contrastNames()[con])) + + return self.__zstats[con] + + + def getClusterMask(self, con): + + if self.__clustMasks[con] is None: + mfile = featresults.getClusterMaskFile(self.__featDir, con) + + self.__clustMasks[con] = FEATImage( + mfile, + name='{}: cluster mask for zstat{} ({})'.format( + self.__analysisName, + con + 1, + self.contrastNames()[con])) + + return self.__clustMasks[con] + + + def fit(self, contrast, xyz, fullmodel=False): + """ + + Passing in a contrast of all 1s, and ``fullmodel=True`` will + get you the full model fit. Pass in ``fullmodel=False`` for + all other contrasts, otherwise the model fit values will not + be scaled correctly. + """ + + if not fullmodel: + contrast = np.array(contrast) + contrast /= np.sqrt((contrast ** 2).sum()) + + x, y, z = xyz + numEVs = self.numEVs() + + if len(contrast) != numEVs: + raise ValueError('Contrast is wrong length') + + X = self.__design + data = self.data[x, y, z, :] + modelfit = np.zeros(len(data)) + + for i in range(numEVs): + + pe = self.getPE(i).data[x, y, z] + modelfit += X[:, i] * pe * contrast[i] + + return modelfit + data.mean() + + + def reducedData(self, xyz, contrast, fullmodel=False): + """ + + Passing in a contrast of all 1s, and ``fullmodel=True`` will + get you the model fit residuals. + """ + + x, y, z = xyz + residuals = self.getResiduals().data[x, y, z, :] + modelfit = self.fit(contrast, xyz, fullmodel) + + return residuals + modelfit diff --git a/fsl/data/featresults.py b/fsl/data/featresults.py new file mode 100644 index 0000000000000000000000000000000000000000..4d3ae675f55fd62f516e0d0ede4e28b7a3537223 --- /dev/null +++ b/fsl/data/featresults.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python +# +# featresults.py - Utility functions for loading/querying the contents of +# a FEAT analysis directory. +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# +"""This module provides a few utility functions for loading/querying the +contents of a FEAT analysis directory. +""" + + +import logging +import glob +import os.path as op +import numpy as np + +import fsl.data.image as fslimage +import fsl.utils.transform as transform + + +log = logging.getLogger(__name__) + + +def isFEATDir(path): + """Returns ``True`` if the given path looks like a FEAT directory, or + looks like the input data for a FEAT analysis, ``False`` otherwise. + """ + + + dirname, filename = op.split(path) + + featDir = getFEATDir(dirname) + isfeatdir = featDir is not None + + try: + hasdesfsf = op.exists(op.join(featDir, 'design.fsf')) + hasdesmat = op.exists(op.join(featDir, 'design.mat')) + hasdescon = op.exists(op.join(featDir, 'design.con')) + + isfeat = (isfeatdir and + hasdesmat and + hasdescon and + hasdesfsf) + + return isfeat + + except: + return False + + +def getFEATDir(path): + + sufs = ['.feat', '.gfeat'] + idxs = [(path.rfind(s), s) for s in sufs] + idx, suf = max(idxs, key=lambda (i, s): i) + + if idx == -1: + return None + + idx += len(suf) + path = path[:idx] + + if path.endswith(suf) or path.endswith('{}{}'.format(suf, op.sep)): + return path + + return None + + +def loadDesign(featdir): + """Loads the design matrix from a FEAT folder. + + Returns a ``numpy`` array containing the design matrix data, where the + first dimension corresponds to the data points, and the second to the EVs. + """ + + matrix = None + designmat = op.join(featdir, 'design.mat') + + log.debug('Loading FEAT design matrix from {}'.format(designmat)) + + with open(designmat, 'rt') as f: + + while True: + line = f.readline() + if line.strip() == '/Matrix': + break + + matrix = np.loadtxt(f) + + if matrix is None or matrix.size == 0: + raise RuntimeError('{} does not appear to be a ' + 'valid design.mat file'.format(designmat)) + + return matrix + + +def loadContrasts(featdir): + """Loads the contrasts from a FEAT folder. Returns a tuple containing: + + - A dictionary of ``{contrastnum : name}`` mappings + + - A list of contrast vectors (each of which is a list itself). + """ + + matrix = None + numContrasts = 0 + names = {} + designcon = op.join(featdir, 'design.con') + + log.debug('Loading FEAT contrasts from {}'.format(designcon)) + + with open(designcon, 'rt') as f: + + while True: + line = f.readline().strip() + + if line.startswith('/ContrastName'): + tkns = line.split(None, 1) + num = [c for c in tkns[0] if c.isdigit()] + num = int(''.join(num)) + name = tkns[1].strip() + names[num] = name + + elif line.startswith('/NumContrasts'): + numContrasts = int(line.split()[1]) + + elif line == '/Matrix': + break + + matrix = np.loadtxt(f) + + if matrix is None or \ + numContrasts != matrix.shape[0]: + raise RuntimeError('{} does not appear to be a ' + 'valid design.con file'.format(designcon)) + + # Fill in any missing contrast names + if len(names) != numContrasts: + for i in range(numContrasts): + if i + 1 not in names: + names[i + 1] = str(i + 1) + + names = [names[c + 1] for c in range(numContrasts)] + contrasts = [] + + for row in matrix: + contrasts.append(list(row)) + + return names, contrasts + + +def loadSettings(featdir): + """Loads the analysis settings from a a FEAT folder. + + Returns a dict containing the settings specified in the given file. + """ + + settings = {} + designfsf = op.join(featdir, 'design.fsf') + + log.debug('Loading FEAT settings from {}'.format(designfsf)) + + with open(designfsf, 'rt') as f: + + for line in f.readlines(): + line = line.strip() + + if not line.startswith('set '): + continue + + tkns = line.split(None, 2) + + key = tkns[1].strip() + val = tkns[2].strip().strip("'").strip('"') + + if key.startswith('fmri(') and key.endswith(')'): + key = key[5:-1] + + settings[key] = val + + return settings + + +def getThresholds(settings): + return { + 'p' : settings.get('prob_thresh', None), + 'z' : settings.get('z_thresh', None) + } + + +def loadClusterResults(featdir, settings, contrast): + """If cluster thresholding was used in the FEAT analysis, this function + will load and return the cluster results for the specified contrast + (which is assumed to be 0-indexed). + + If there are no cluster results for the given contrast, + ``None`` is returned. + + An error will be raised if the cluster file cannot be parsed. + """ + + # Cluster files are named like + # 'cluster_zstatX.txt', where + # X is the COPE number. And + # the ZMax/COG etc coordinates + # are usually in voxel coordinates + coordXform = np.eye(4) + clusterFile = op.join( + featdir, 'cluster_zstat{}.txt'.format(contrast + 1)) + + + if not op.exists(clusterFile): + + # If the analysis was performed in standard + # space (e.g. a higher level group analysis), + # the cluster file will instead be called + # 'cluster_zstatX_std.txt', so we'd better + # check for that too. + clusterFile = op.join( + featdir, 'cluster_zstat{}_std.txt'.format(contrast + 1)) + + # In higher levle analysis run in some standard + # space, the cluster coordinates are in standard + # space. We transform them to voxel coordinates. + # later on. + coordXform = fslimage.Image( + getDataFile(featdir), + loadData=False).worldToVoxMat.T + + if not op.exists(clusterFile): + return None + + log.debug('Loading cluster results for contrast {} from {}'.format( + contrast, clusterFile)) + + # The cluster.txt file is converted + # into a list of Cluster objects, + # each of which encapsulates + # information about one cluster. + class Cluster(object): + def __init__(self, **kwargs): + for name, val in kwargs.items(): + + attrName, atype = colmap[name] + if val is not None: + val = atype(val) + + setattr(self, attrName, val) + + # This dict provides a mapping between + # Cluster object attribute names, and + # the corresponding column name in the + # cluster.txt file. And the value type + # is thrown in as well, for good measure. + colmap = { + 'Cluster Index' : ('index', int), + 'Voxels' : ('nvoxels', int), + 'P' : ('p', float), + '-log10(P)' : ('logp', float), + 'Z-MAX' : ('zmax', float), + 'Z-MAX X (vox)' : ('zmaxx', int), + 'Z-MAX Y (vox)' : ('zmaxy', int), + 'Z-MAX Z (vox)' : ('zmaxz', int), + 'Z-COG X (vox)' : ('zcogx', float), + 'Z-COG Y (vox)' : ('zcogy', float), + 'Z-COG Z (vox)' : ('zcogz', float), + 'Z-MAX X (mm)' : ('zmaxx', int), + 'Z-MAX Y (mm)' : ('zmaxy', int), + 'Z-MAX Z (mm)' : ('zmaxz', int), + 'Z-COG X (mm)' : ('zcogx', float), + 'Z-COG Y (mm)' : ('zcogy', float), + 'Z-COG Z (mm)' : ('zcogz', float), + 'COPE-MAX' : ('copemax', float), + 'COPE-MAX X (vox)' : ('copemaxx', int), + 'COPE-MAX Y (vox)' : ('copemaxy', int), + 'COPE-MAX Z (vox)' : ('copemaxz', int), + 'COPE-MAX X (mm)' : ('copemaxx', int), + 'COPE-MAX Y (mm)' : ('copemaxy', int), + 'COPE-MAX Z (mm)' : ('copemaxz', int), + 'COPE-MEAN' : ('copemean', float)} + + # An error will be raised if the + # cluster file does not exist (e.g. + # if the specified contrast index + # is invalid) + with open(clusterFile, 'rt') as f: + + # Get every line in the file, + # removing leading/trailing + # whitespace, and discarding + # empty lines + lines = f.readlines() + lines = [l.strip() for l in lines] + lines = filter(lambda l: l != '', lines) + + # the first line should contain column + # names, and each other line should + # contain the data for one cluster + colNames = lines[0] + clusterLines = lines[1:] + + # each line should be tab-separated + colNames = colNames.split('\t') + clusterLines = [cl .split('\t') for cl in clusterLines] + + # No clusters + if len(clusterLines) == 0: + return None + + # Turn each cluster line into a + # Cluster instance. An error will + # be raised if the columm names + # are unrecognised (i.e. not in + # the colmap above), or if the + # file is poorly formed. + clusters = [Cluster(**dict(zip(colNames, cl))) for cl in clusterLines] + + # Make sure all coordinates are in voxels - + # for first level analyses, the coordXform + # will be an identity transform (the coords + # are already in voxels). But for higher + # level, the coords are in mm, and need to + # be transformed to voxels. + for c in clusters: + c.zmaxx, c.zmaxy, c.zmaxz = transform.transform( + [[c.zmaxx, c.zmaxy, c.zmaxz]], coordXform)[0] + c.zcogx, c.zcogy, c.zcogz = transform.transform( + [[c.zcogx, c.zcogy, c.zcogz]], coordXform)[0] + c.copemaxx, c.copemaxy, c.copemaxz = transform.transform( + [[c.copemaxx, c.copemaxy, c.copemaxz]], coordXform)[0] + + return clusters + + +def getDataFile(featdir): + """Returns the name of the file in the FEAT results which contains + the model input data (typically called ``filtered_func_data.nii.gz``). + """ + + # Assuming here that there is only + # one file called filtered_func_data.* + return glob.glob((op.join(featdir, 'filtered_func_data.*')))[0] + + +def getResidualFile(featdir): + """Returns the name of the file in the FEAT results which contains + the model fit residuals (typically called ``res4d.nii.gz``). + """ + + # Assuming here that there is only + # one file called stats/res4d.* + return glob.glob((op.join(featdir, 'stats', 'res4d.*')))[0] + + +def getPEFile(featdir, ev): + """Returns the path of the PE file for the specified ``ev``, which is + assumed to be 0-indexed. + """ + + pefile = op.join(featdir, 'stats', 'pe{}.*'.format(ev + 1)) + return glob.glob(pefile)[0] + + +def getCOPEFile(featdir, contrast): + """Returns the path of the COPE file for the specified ``contrast``, which + is assumed to be 0-indexed. + """ + copefile = op.join(featdir, 'stats', 'cope{}.*'.format(contrast + 1)) + return glob.glob(copefile)[0] + + +def getZStatFile(featdir, contrast): + """Returns the path of the Z-statistic file for the specified + ``contrast``, which is assumed to be 0-indexed. + """ + zfile = op.join(featdir, 'stats', 'zstat{}.*'.format(contrast + 1)) + return glob.glob(zfile)[0] + + +def getClusterMaskFile(featdir, contrast): + """Returns the path of the cluster mask file for the specified + ``contrast``, which is assumed to be 0-indexed. + """ + mfile = op.join(featdir, 'cluster_mask_zstat{}.*'.format(contrast + 1)) + return glob.glob(mfile)[0] + + +def getEVNames(settings): + """Returns the names of every EV in the FEAT analysis which has the given + ``settings`` (see the :func:`loadSettings` function). + + An error of some sort will be raised if the EV names cannot be determined + from the FEAT settings. + """ + + numEVs = int(settings['evs_real']) + + titleKeys = filter(lambda s: s.startswith('evtitle'), settings.keys()) + derivKeys = filter(lambda s: s.startswith('deriv_yn'), settings.keys()) + + def _cmp(key1, key2): + key1 = ''.join([c for c in key1 if c.isdigit()]) + key2 = ''.join([c for c in key2 if c.isdigit()]) + + return cmp(int(key1), int(key2)) + + titleKeys = sorted(titleKeys, cmp=_cmp) + derivKeys = sorted(derivKeys, cmp=_cmp) + evnames = [] + + for titleKey, derivKey in zip(titleKeys, derivKeys): + + # Figure out the ev number from + # the design.fsf key - skip over + # 'evtitle' (an offset of 7) + evnum = int(titleKey[7:]) + + # Sanity check - the evnum + # for the deriv_yn key matches + # that for the evtitle key + if evnum != int(derivKey[8:]): + raise RuntimeError('design.fsf seem to be corrupt') + + title = settings[titleKey] + deriv = settings[derivKey] + + if deriv == '0': + evnames.append(title) + else: + evnames.append(title) + evnames.append('{} - {}'.format(title, 'temporal derivative')) + + if len(evnames) != numEVs: + raise RuntimeError('The number of EVs in design.fsf does not ' + 'match the number of EVs in design.mat') + + return evnames diff --git a/fsl/data/image.py b/fsl/data/image.py index e757d020deb6a2f6d83fc2ca8c06bdba0b17bb12..f27dfdb404a63b25ddad4bdd7e43fc52ea17fcd8 100644 --- a/fsl/data/image.py +++ b/fsl/data/image.py @@ -1,20 +1,18 @@ #!/usr/bin/env python # -# image.py - Classes for representing 3D/4D images and collections of said -# images. +# image.py - Provides the :class:`Image` class, for representing 3D/4D NIFTI +# images. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -"""Classes for representing 3D/4D images and collections of said images. - -See the :mod:`fsl.data.imageio` module for image loading/saving -functionality. - +"""Provides the :class:`Image` class, for representing 3D/4D NIFTI images. """ -import logging -import collections -import os.path as op +import logging +import tempfile +import os +import os.path as op +import subprocess as sp import numpy as np import nibabel as nib @@ -22,22 +20,17 @@ import nibabel as nib import props import fsl.utils.transform as transform -import fsl.data.imageio as iio +import fsl.data.strings as strings import fsl.data.constants as constants log = logging.getLogger(__name__) - class Image(props.HasProperties): """Class which represents a 3D/4D image. Internally, the image is loaded/stored using :mod:`nibabel`. - Arbitrary data may be associated with an :class:`Image` object, via the - :meth:`getAttribute` and :meth:`setAttribute` methods (which are just - front end wrappers around an internal ``dict`` object). - In addition to the class-level properties defined below, the following attributes are present on an :class:`Image` object: @@ -57,7 +50,7 @@ class Image(props.HasProperties): for transforming real world coordinates into voxel coordinates. - :ivar imageFile: The name of the file that the image was loaded from. + :ivar dataSource: The name of the file that the image was loaded from. :ivar tempFile: The name of the temporary file which was created (in the event that the image was large and was gzipped - @@ -65,16 +58,6 @@ class Image(props.HasProperties): """ - imageType = props.Choice( - collections.OrderedDict([ - ('volume', '3D/4D volume'), - ('mask', '3D/4D mask image'), - ('rgbvector', '3-direction vector image (RGB)'), - ('linevector', '3-direction vector image (Line)')]), - default='volume') - """This property defines the type of image data.""" - - name = props.String() """The name of this image.""" @@ -120,9 +103,9 @@ class Image(props.HasProperties): via the :meth:`loadData` method. """ - self.nibImage = None - self.imageFile = None - self.tempFile = None + self.nibImage = None + self.dataSource = None + self.tempFile = None if header is not None: header = header.copy() @@ -130,19 +113,23 @@ class Image(props.HasProperties): # The image parameter may be the name of an image file if isinstance(image, basestring): - nibImage, filename = iio.loadImage(iio.addExt(image)) + nibImage, filename = loadImage(addExt(image)) self.nibImage = nibImage - self.imageFile = image + self.dataSource = op.abspath(image) # if the returned file name is not the same as # the provided file name, that means that the # image was opened from a temporary file if filename != image: - self.name = iio.removeExt(op.basename(self.imageFile)) + filepref = removeExt(op.basename(self.dataSource)) self.tempFile = nibImage.get_filename() else: - self.name = iio.removeExt(op.basename(self.imageFile)) + filepref = removeExt(op.basename(self.dataSource)) + if name is None: + name = filepref + + self.name = name self.saved = True # Or a numpy array - we wrap it in a nibabel image, @@ -181,22 +168,12 @@ class Image(props.HasProperties): if len(self.shape) < 3 or len(self.shape) > 4: raise RuntimeError('Only 3D or 4D images are supported') - # This dictionary may be used to store - # arbitrary data associated with this image. - self._attributes = {} - - # update the available image type(s) - imageTypeProp = self.getProp('imageType') + log.memory('{}.init ({})'.format(type(self).__name__, id(self))) - # the vector type is only - # applicable to X*Y*Z*3 images - if len(self.shape) != 4 or self.shape[3] != 3: - - log.debug('Disabling vector type for {} ({})'.format( - self, self.shape)) - imageTypeProp.disableChoice('vector', self) - + def __del__(self): + log.memory('{}.del ({})'.format(type(self).__name__, id(self))) + def loadData(self): """Loads the image data from the file. This method only needs to @@ -270,9 +247,9 @@ class Image(props.HasProperties): """Convenience method to save any changes made to the :attr:`data` of this :class:`Image` instance. - See the :func:`fsl.data.imageio.save` function. + See the :func:`saveImage` function. """ - return iio.saveImage(self) + return saveImage(self) def __hash__(self): @@ -286,7 +263,7 @@ class Image(props.HasProperties): """Return a string representation of this :class:`Image`.""" return '{}({}, {})'.format(self.__class__.__name__, self.name, - self.imageFile) + self.dataSource) def __repr__(self): @@ -370,98 +347,305 @@ class Image(props.HasProperties): (constants.ORIENT_S2I, constants.ORIENT_I2S)))[axis] return code + +# TODO The wx.FileDialog does not +# seem to handle wildcards with +# multiple suffixes (e.g. '.nii.gz'), +# so i'm just providing '*.gz'for now +ALLOWED_EXTENSIONS = ['.nii.gz', '.nii', '.img', '.hdr', '.img.gz', '.gz'] +"""The file extensions which we understand. This list is used as the default +if if the ``allowedExts`` parameter is not passed to any of the functions in +this module. +""" + +EXTENSION_DESCRIPTIONS = ['Compressed NIFTI1 images', + 'NIFTI1 images', + 'ANALYZE75 images', + 'NIFTI1/ANALYZE75 headers', + 'Compressed NIFTI1/ANALYZE75 images', + 'Compressed images'] +"""Descriptions for each of the extensions in :data:`ALLOWED_EXTENSIONS`. """ + + +DEFAULT_EXTENSION = '.nii.gz' +"""The default file extension (TODO read this from ``$FSLOUTPUTTYPE``).""" + + +def isSupported(filename, allowedExts=None): + """ + Returns ``True`` if the given file has a supported extension, ``False`` + otherwise. + + :arg filename: The file name to test. - def getAttribute(self, name): - """Retrieve the attribute with the given name. + :arg allowedExts: A list of strings containing the allowed file + extensions. + """ - :raise KeyError: if there is no attribute with the given name. - """ - return self._attributes[name] + if allowedExts is None: allowedExts = ALLOWED_EXTENSIONS + + return any(map(lambda ext: filename.endswith(ext), allowedExts)) + + +def removeExt(filename, allowedExts=None): + """ + Removes the extension from the given file name. Returns the filename + unmodified if it does not have a supported extension. + :arg filename: The file name to strip. - def delAttribute(self, name): - """Delete and return the value of the attribute with the given name. + :arg allowedExts: A list of strings containing the allowed file + extensions. + """ - :raise KeyError: if there is no attribute with the given name. - """ - return self._attributes.pop(name) + if allowedExts is None: allowedExts = ALLOWED_EXTENSIONS - - def setAttribute(self, name, value): - """Set an attribute with the given name and the given value.""" - self._attributes[name] = value - - log.debug('Attribute set on {}: {} = {}'.format( - self.name, name, str(value))) + # figure out the extension of the given file + extMatches = map(lambda ext: filename.endswith(ext), allowedExts) + + # the file does not have a supported extension + if not any(extMatches): + return filename + + # figure out the length of the matched extension + extIdx = extMatches.index(True) + extLen = len(allowedExts[extIdx]) + + # and trim it from the file name + return filename[:-extLen] + + +def addExt( + prefix, + mustExist=True, + allowedExts=None, + defaultExt=None): + """Adds a file extension to the given file ``prefix``. + + If ``mustExist`` is False, and the file does not already have a + supported extension, the default extension is appended and the new + file name returned. If the prefix already has a supported extension, + it is returned unchanged. + + If ``mustExist`` is ``True`` (the default), the function checks to see + if any files exist that have the given prefix, and a supported file + extension. A :exc:`ValueError` is raised if: + + - No files exist with the given prefix and a supported extension. + - More than one file exists with the given prefix, and a supported + extension. + + Otherwise the full file name is returned. + + :arg prefix: The file name refix to modify. + :arg mustExist: Whether the file must exist or not. + :arg allowedExts: List of allowed file extensions. + :arg defaultExt: Default file extension to use. + """ + if allowedExts is None: allowedExts = ALLOWED_EXTENSIONS + if defaultExt is None: defaultExt = DEFAULT_EXTENSION -class ImageList(props.HasProperties): - """Class representing a collection of images to be displayed together. + if not mustExist: - Contains a :class:`props.properties_types.List` property containing - :class:`Image` objects. + # the provided file name already + # ends with a supported extension + if any(map(lambda ext: prefix.endswith(ext), allowedExts)): + return prefix - An :class:`ImageList` object has a few wrapper methods around the - :attr:`images` property, allowing the :class:`ImageList` to be used - as if it were a list itself. + return prefix + defaultExt + + # If the provided prefix already ends with a + # supported extension , check to see that it exists + if any(map(lambda ext: prefix.endswith(ext), allowedExts)): + extended = [prefix] + + # Otherwise, make a bunch of file names, one per + # supported extension, and test to see if exactly + # one of them exists. + else: + extended = map(lambda ext: prefix + ext, allowedExts) + + exists = map(op.isfile, extended) + + # Could not find any supported file + # with the specified prefix + if not any(exists): + raise ValueError( + 'Could not find a supported file with prefix {}'.format(prefix)) + + # Ambiguity! More than one supported + # file with the specified prefix + if len(filter(bool, exists)) > 1: + raise ValueError('More than one file with prefix {}'.format(prefix)) + + # Return the full file name of the + # supported file that was found + extIdx = exists.index(True) + return extended[extIdx] + + +def loadImage(filename): + """Given the name of an image file, loads it using nibabel. + + If the file is large, and is gzipped, it is decompressed to a temporary + location, so that it can be memory-mapped. A tuple is returned, + consisting of the nibabel image object, and the name of the file that it + was loaded from (either the passed-in file name, or the name of the + temporary decompressed file). """ + # If we have a GUI, we can display a dialog + # message. Otherwise we print a log message + haveGui = False + try: + import wx + if wx.GetApp() is not None: + haveGui = True + except: + pass + + realFilename = filename + mbytes = op.getsize(filename) / 1048576.0 + + # The mbytes limit is arbitrary + if filename.endswith('.nii.gz') and mbytes > 512: + + unzipped, filename = tempfile.mkstemp(suffix='.nii') + + unzipped = os.fdopen(unzipped) + + msg = strings.messages['image.loadImage.decompress'] + msg = msg.format(realFilename, mbytes, filename) + + if not haveGui: + log.info(msg) + else: + busyDlg = wx.BusyInfo(msg, wx.GetTopLevelWindows()[0]) + + gzip = ['gzip', '-d', '-c', realFilename] + log.debug('Running {} > {}'.format(' '.join(gzip), filename)) + + # If the gzip call fails, revert to loading from the gzipped file + try: + sp.call(gzip, stdout=unzipped) + unzipped.close() + + except OSError as e: + log.warn('gzip call failed ({}) - cannot memory ' + 'map file: {}'.format(e, realFilename), + exc_info=True) + unzipped.close() + os.remove(filename) + filename = realFilename + + if haveGui: + busyDlg.Destroy() + + log.debug('Loading image from {}'.format(filename)) - def _validateImage(self, atts, images): - """Returns ``True`` if all objects in the given ``images`` list are - :class:`Image` objects, ``False`` otherwise. - """ - return all(map(lambda img: isinstance(img, Image), images)) + return nib.load(filename), filename - images = props.List(validateFunc=_validateImage, allowInvalid=False) - """A list of :class:`Image` objects. to be displayed""" +def saveImage(image, fromDir=None): + """Convenience function for interactively saving changes to an image. + If the :mod:`wx` package is available, a dialog is popped up, prompting + the user to select a destination. Or, if the image has been loaded + from a file, the user is prompted to confirm that they want to overwrite + the image. + + + :param image: The :class:`.Image` instance to be saved. + + :param str fromDir: Directory in which the file dialog should start. + If ``None``, the most recently visited directory + (via this method) is used, or the directory from + the given image, or the current working directory. + + :raise ImportError: if :mod:`wx` is not present. + :raise RuntimeError: if a :class:`wx.App` has not been created. + """ + + if image.saved: + return - def __init__(self, images=None): - """Create an ImageList object from the given sequence of - :class:`Image` objects.""" - - if images is None: images = [] - self.images.extend(images) + import wx + app = wx.GetApp() - def addImages(self, fromDir=None, addToEnd=True): - """Convenience method for interactively adding images to this - :class:`ImageList`. + if app is None: + raise RuntimeError('A wx.App has not been created') - See the :func:`fsl.data.imageio.addImages` function. - """ - return iio.addImages(self, fromDir, addToEnd) + lastDir = getattr(saveImage, 'lastDir', None) + if lastDir is None: + if image.dataSource is None: lastDir = os.getcwd() + else: lastDir = op.dirname(image.dataSource) - def find(self, name): - """Returns the first image with the given name, or ``None`` if - there is no image with said name. - """ - for image in self.images: - if image.name == name: - return image - return None + # TODO make image.name safe (spaces to + # underscores, filter non-alphanumeric) + if image.dataSource is None: filename = image.name + else: filename = op.basename(image.dataSource) + + filename = removeExt(filename) + + saveLastDir = False + if fromDir is None: + fromDir = lastDir + saveLastDir = True + + dlg = wx.FileDialog(app.GetTopWindow(), + message=strings.titles['image.saveImage.dialog'], + defaultDir=fromDir, + defaultFile=filename, + style=wx.FD_SAVE) + + if dlg.ShowModal() != wx.ID_OK: return False + + if saveLastDir: saveImage.lastDir = lastDir + + path = dlg.GetPath() + nibImage = image.nibImage + + if not isSupported(path): + path = addExt(path, False) + + # this is an image which has been + # loaded from a file, and ungzipped + # to a temporary location + try: + if image.tempFile is not None: + + # if selected path is same as original path, + # save to both temp file and to path + + # else, if selected path is different from + # original path, save to temp file and to + # new path, and update the path + + # actually, the two behaviours just described + # are identical + log.warn('Saving large images is not yet functional') + pass + + # this is just a normal image + # which has been loaded from + # a file, or an in-memory image + else: + + log.debug('Saving image ({}) to {}'.format(image, path)) + + nib.save(nibImage, path) + image.dataSource = path + except Exception as e: + + msg = strings.messages['image.saveImage.error'].format(e.msg) + log.warn(msg) + wx.MessageDialog(app.GetTopWindow(), + message=msg, + style=wx.OK | wx.ICON_ERROR).ShowModal() + return - # Wrappers around the images list property, allowing this - # ImageList object to be used as if it is actually a list. - 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, item): return self.images.__contains__(item) - def __setitem__( self, key, val): return self.images.__setitem__(key, - val) - def __delitem__( self, key): return self.images.__delitem__(key) - def index( self, item): return self.images.index(item) - def count( self, item): return self.images.count(item) - def append( self, item): return self.images.append(item) - def extend( self, iterable): return self.images.extend(iterable) - def pop( self, index=-1): return self.images.pop(index) - def move( self, from_, to): return self.images.move(from_, to) - def remove( self, item): return self.images.remove(item) - def insert( self, index, item): return self.images.insert(index, - item) - def insertAll( self, index, items): return self.images.insertAll(index, - items) + image.saved = True diff --git a/fsl/data/imageio.py b/fsl/data/imageio.py deleted file mode 100644 index 69ac1399c64e3ef6d2175ce1dd44a2cb4fb1e108..0000000000000000000000000000000000000000 --- a/fsl/data/imageio.py +++ /dev/null @@ -1,509 +0,0 @@ -#!/usr/bin/env python -# -# imageio.py - Utility functions for loading/saving images. -# -# Author: Paul McCarthy <pauldmccarthy@gmail.com> -# - -import logging -import os -import os.path as op -import subprocess as sp -import tempfile - -import nibabel as nib - -import fsl.data.strings as strings -import image as fslimage - - -log = logging.getLogger(__name__) - - -# TODO The wx.FileDialog does not -# seem to handle wildcards with -# multiple suffixes (e.g. '.nii.gz'), -# so i'm just providing '*.gz'for now -ALLOWED_EXTENSIONS = ['.nii.gz', '.nii', '.img', '.hdr', '.img.gz', '.gz'] -"""The file extensions which we understand. This list is used as the default -if if the ``allowedExts`` parameter is not passed to any of the functions in -this module. -""" - -EXTENSION_DESCRIPTIONS = ['Compressed NIFTI1 images', - 'NIFTI1 images', - 'ANALYZE75 images', - 'NIFTI1/ANALYZE75 headers', - 'Compressed NIFTI1/ANALYZE75 images', - 'Compressed images'] -"""Descriptions for each of the extensions in :data:`ALLOWED_EXTENSIONS`. """ - - -DEFAULT_EXTENSION = '.nii.gz' -"""The default file extension (TODO read this from ``$FSLOUTPUTTYPE``).""" - - -def makeWildcard(allowedExts=None): - """Returns a wildcard string for use in a file dialog, to limit - the acceptable file types. - - :arg allowedExts: A list of strings containing the allowed file - extensions. - """ - - if allowedExts is None: - allowedExts = ALLOWED_EXTENSIONS - descs = EXTENSION_DESCRIPTIONS - else: - descs = allowedExts - - exts = ['*{}'.format(ext) for ext in allowedExts] - exts = [';'.join(exts)] + exts - descs = ['All supported files'] + descs - - wcParts = ['|'.join((desc, ext)) for (desc, ext) in zip(descs, exts)] - - return '|'.join(wcParts) - - -def isSupported(filename, allowedExts=None): - """ - Returns ``True`` if the given file has a supported extension, ``False`` - otherwise. - - :arg filename: The file name to test. - - :arg allowedExts: A list of strings containing the allowed file - extensions. - """ - - if allowedExts is None: allowedExts = ALLOWED_EXTENSIONS - - return any(map(lambda ext: filename.endswith(ext), allowedExts)) - - -def removeExt(filename, allowedExts=None): - """ - Removes the extension from the given file name. Returns the filename - unmodified if it does not have a supported extension. - - :arg filename: The file name to strip. - - :arg allowedExts: A list of strings containing the allowed file - extensions. - """ - - if allowedExts is None: allowedExts = ALLOWED_EXTENSIONS - - # figure out the extension of the given file - extMatches = map(lambda ext: filename.endswith(ext), allowedExts) - - # the file does not have a supported extension - if not any(extMatches): - return filename - - # figure out the length of the matched extension - extIdx = extMatches.index(True) - extLen = len(allowedExts[extIdx]) - - # and trim it from the file name - return filename[:-extLen] - - -def addExt( - prefix, - mustExist=True, - allowedExts=None, - defaultExt=None): - """Adds a file extension to the given file ``prefix``. - - If ``mustExist`` is False, and the file does not already have a - supported extension, the default extension is appended and the new - file name returned. If the prefix already has a supported extension, - it is returned unchanged. - - If ``mustExist`` is ``True`` (the default), the function checks to see - if any files exist that have the given prefix, and a supported file - extension. A :exc:`ValueError` is raised if: - - - No files exist with the given prefix and a supported extension. - - More than one file exists with the given prefix, and a supported - extension. - - Otherwise the full file name is returned. - - :arg prefix: The file name refix to modify. - :arg mustExist: Whether the file must exist or not. - :arg allowedExts: List of allowed file extensions. - :arg defaultExt: Default file extension to use. - """ - - if allowedExts is None: allowedExts = ALLOWED_EXTENSIONS - if defaultExt is None: defaultExt = DEFAULT_EXTENSION - - if not mustExist: - - # the provided file name already - # ends with a supported extension - if any(map(lambda ext: prefix.endswith(ext), allowedExts)): - return prefix - - return prefix + defaultExt - - # If the provided prefix already ends with a - # supported extension , check to see that it exists - if any(map(lambda ext: prefix.endswith(ext), allowedExts)): - extended = [prefix] - - # Otherwise, make a bunch of file names, one per - # supported extension, and test to see if exactly - # one of them exists. - else: - extended = map(lambda ext: prefix + ext, allowedExts) - - exists = map(op.isfile, extended) - - # Could not find any supported file - # with the specified prefix - if not any(exists): - raise ValueError( - 'Could not find a supported file with prefix {}'.format(prefix)) - - # Ambiguity! More than one supported - # file with the specified prefix - if len(filter(bool, exists)) > 1: - raise ValueError('More than one file with prefix {}'.format(prefix)) - - # Return the full file name of the - # supported file that was found - extIdx = exists.index(True) - return extended[extIdx] - - -def loadImage(filename): - """Given the name of an image file, loads it using nibabel. - - If the file is large, and is gzipped, it is decompressed to a temporary - location, so that it can be memory-mapped. A tuple is returned, - consisting of the nibabel image object, and the name of the file that it - was loaded from (either the passed-in file name, or the name of the - temporary decompressed file). - """ - - # If we have a GUI, we can display a dialog - # message. Otherwise we print a log message - haveGui = False - try: - import wx - if wx.GetApp() is not None: - haveGui = True - except: - pass - - realFilename = filename - mbytes = op.getsize(filename) / 1048576.0 - - # The mbytes limit is arbitrary - if filename.endswith('.nii.gz') and mbytes > 512: - - unzipped, filename = tempfile.mkstemp(suffix='.nii') - - unzipped = os.fdopen(unzipped) - - msg = strings.messages['imageio.loadImage.decompress'] - msg = msg.format(realFilename, mbytes, filename) - - if not haveGui: - log.info(msg) - else: - busyDlg = wx.BusyInfo(msg, wx.GetTopLevelWindows()[0]) - - gzip = ['gzip', '-d', '-c', realFilename] - log.debug('Running {} > {}'.format(' '.join(gzip), filename)) - - # If the gzip call fails, revert to loading from the gzipped file - try: - sp.call(gzip, stdout=unzipped) - unzipped.close() - - except OSError as e: - log.warn('gzip call failed ({}) - cannot memory ' - 'map file: {}'.format(e, realFilename), - exc_info=True) - unzipped.close() - os.remove(filename) - filename = realFilename - - if haveGui: - busyDlg.Destroy() - - log.debug('Loading image from {}'.format(filename)) - - return nib.load(filename), filename - - -def saveImage(image, imageList=None, fromDir=None): - """Convenience method for interactively saving changes to an image. - - If the :mod:`wx` package is available, a dialog is popped up, prompting - the user to select a destination. Or, if the image has been loaded - from a file, the user is prompted to confirm that they want to overwrite - the image. - - - :param image: The :class:`~fsl.data.image.Image` instance to - be saved. - - - :param imageList: The :class:`~fsl.data.image.ImageList` instance - which contains the given image. - - - :param str fromDir: Directory in which the file dialog should start. - If ``None``, the most recently visited directory - (via this method) is used, or the directory from - the given image, or the current working directory. - - :raise ImportError: if :mod:`wx` is not present. - :raise RuntimeError: if a :class:`wx.App` has not been created. - """ - - if image.saved: - return - - import wx - - app = wx.GetApp() - - if app is None: - raise RuntimeError('A wx.App has not been created') - - lastDir = getattr(saveImage, 'lastDir', None) - - if lastDir is None: - if image.imageFile is None: lastDir = os.getcwd() - else: lastDir = op.dirname(image.imageFile) - - # TODO make image.name safe (spaces to - # underscores, filter non-alphanumeric) - if image.imageFile is None: filename = image.name - else: filename = op.basename(image.imageFile) - - filename = removeExt(filename) - - saveLastDir = False - if fromDir is None: - fromDir = lastDir - saveLastDir = True - - dlg = wx.FileDialog(app.GetTopWindow(), - message=strings.titles['imageio.saveImage.dialog'], - defaultDir=fromDir, - defaultFile=filename, - style=wx.FD_SAVE) - - if dlg.ShowModal() != wx.ID_OK: return False - - if saveLastDir: saveImage.lastDir = lastDir - - path = dlg.GetPath() - nibImage = image.nibImage - - if not isSupported(path): - path = addExt(path, False) - - # this is an image which has been - # loaded from a file, and ungzipped - # to a temporary location - try: - if image.tempFile is not None: - - # if selected path is same as original path, - # save to both temp file and to path - - # else, if selected path is different from - # original path, save to temp file and to - # new path, and update the path - - # actually, the two behaviours just described - # are identical - log.warn('Saving large images is not yet functional') - pass - - # this is just a normal image - # which has been loaded from - # a file, or an in-memory image - else: - - log.debug('Saving image ({}) to {}'.format(image, path)) - - nib.save(nibImage, path) - image.imageFile = path - - except Exception as e: - - msg = strings.messages['imageio.saveImage.error'].format(e.msg) - log.warn(msg) - wx.MessageDialog(app.GetTopWindow(), - message=msg, - style=wx.OK | wx.ICON_ERROR).ShowModal() - return - - image.saved = True - - -def loadImages(paths, loadFunc='default', errorFunc='default'): - """Loads all of the images specified in the sequence of image files - contained in ``paths``. - - :param loadFunc: A function which is called just before each image - is loaded, and is passed the image path. The default - load function uses a :mod:`wx` popup frame to display - the name of the image currently being loaded. Pass in - ``None`` to disable this default behaviour. - - :param errorFunc: A function which is called if an error occurs while - loading an image, being passed the name of the image, - and the :class:`Exception` which occurred. The - default function pops up a :class:`wx.MessageBox` - with an error message. Pass in ``None`` to disable - this default behaviour. - - :Returns a list of :class:`~fsl.data.image.Image` instances, the - images that were loaded. - """ - - defaultLoad = loadFunc == 'default' - - # If the default load function is - # being used, create a dialog window - # to show the currently loading image - if defaultLoad: - import wx - loadDlg = wx.Frame(wx.GetApp().GetTopWindow(), style=0) - loadDlgStatus = wx.StaticText(loadDlg, style=wx.ST_ELLIPSIZE_MIDDLE) - - sizer = wx.BoxSizer(wx.HORIZONTAL) - sizer.Add(loadDlgStatus, - border=25, - proportion=1, - flag=wx.EXPAND | wx.ALL | wx.ALIGN_CENTRE) - loadDlg.SetSizer(sizer) - - loadDlg.SetSize((400, 100)) - loadDlg.Layout() - - # The default load function updates - # the dialog window created above - def defaultLoadFunc(s): - msg = strings.messages['imageio.loadImages.loading'].format(s) - loadDlgStatus.SetLabel(msg) - loadDlg.Layout() - loadDlg.Refresh() - loadDlg.Update() - - # The default error function - # shows an error dialog - def defaultErrorFunc(s, e): - import wx - e = str(e) - msg = strings.messages['imageio.loadImages.error'].format(s, e) - title = strings.titles[ 'imageio.loadImages.error'] - wx.MessageBox(msg, title, wx.ICON_ERROR | wx.OK) - - # If loadFunc or errorFunc are explicitly set to - # None, use these no-op load/error functions - if loadFunc is None: loadFunc = lambda s: None - if errorFunc is None: errorFunc = lambda s, e: None - - # Or if not provided, use the - # default functions defined above - if loadFunc == 'default': loadFunc = defaultLoadFunc - if errorFunc == 'default': errorFunc = defaultErrorFunc - - images = [] - - # If using the default load - # function, show the dialog - if defaultLoad: - loadDlg.CentreOnParent() - loadDlg.Show(True) - loadDlg.Update() - - # Load the images - for path in paths: - - loadFunc(path) - - try: images.append(fslimage.Image(path)) - except Exception as e: errorFunc(path, e) - - if defaultLoad: - loadDlg.Close() - - return images - - -def addImages(imageList, - fromDir=None, - addToEnd=True, - **kwargs): - """Convenience method for interactively adding images to an - :class:`fsl.data.image.ImageList`. - If the :mod:`wx` package is available, pops up a file dialog - prompting the user to select one or more images to append to the - image list. - - :param str fromDir: Directory in which the file dialog should start. - If ``None``, the most recently visited directory - (via this method) is used, or a directory from - an already loaded image, or the current working - directory. - - :param bool addToEnd: If True (the default), the new images are added - to the end of the list. Otherwise, they are added - to the beginning of the list. - - Returns: True if images were successfully added, False if no images - were added. - - :raise ImportError: if :mod:`wx` is not present. - :raise RuntimeError: if a :class:`wx.App` has not been created. - """ - import wx - - app = wx.GetApp() - - if app is None: - raise RuntimeError('A wx.App has not been created') - - lastDir = getattr(addImages, 'lastDir', None) - - if lastDir is None: - if len(imageList) > 0 and imageList[-1].imageFile is not None: - lastDir = op.dirname(imageList[-1].imageFile) - else: - lastDir = os.getcwd() - - saveLastDir = False - if fromDir is None: - fromDir = lastDir - saveLastDir = True - - dlg = wx.FileDialog(app.GetTopWindow(), - message=strings.titles['imageio.addImages.dialog'], - defaultDir=fromDir, - wildcard=makeWildcard(), - style=wx.FD_OPEN | wx.FD_MULTIPLE) - - if dlg.ShowModal() != wx.ID_OK: return False - - paths = dlg.GetPaths() - images = loadImages(paths, **kwargs) - - if saveLastDir: addImages.lastDir = op.dirname(paths[-1]) - - if addToEnd: imageList.extend( images) - else: imageList.insertAll(0, images) - - return True diff --git a/fsl/data/model.py b/fsl/data/model.py new file mode 100644 index 0000000000000000000000000000000000000000..542ed38d65a4ad900823f198ca304d1ae417b056 --- /dev/null +++ b/fsl/data/model.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python +# +# model.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import logging + +import os.path as op +import numpy as np + + +log = logging.getLogger(__name__) + + +ALLOWED_EXTENSIONS = ['.vtk'] +EXTENSION_DESCRIPTIONS = ['VTK polygon model file'] + + +def loadVTKPolydataFile(infile): + + lines = None + + with open(infile, 'rt') as f: + lines = f.readlines() + + lines = [l.strip() for l in lines] + + if lines[3] != 'DATASET POLYDATA': + raise ValueError('') + + nVertices = int(lines[4].split()[1]) + nPolygons = int(lines[5 + nVertices].split()[1]) + nIndices = int(lines[5 + nVertices].split()[2]) - nPolygons + + vertices = np.zeros((nVertices, 3), dtype=np.float32) + polygonLengths = np.zeros( nPolygons, dtype=np.uint32) + indices = np.zeros( nIndices, dtype=np.uint32) + + for i in range(nVertices): + vertLine = lines[i + 5] + vertices[i, :] = map(float, vertLine.split()) + + indexOffset = 0 + for i in range(nPolygons): + + polyLine = lines[6 + nVertices + i].split() + polygonLengths[i] = int(polyLine[0]) + + start = indexOffset + end = indexOffset + polygonLengths[i] + indices[start:end] = map(int, polyLine[1:]) + + indexOffset += polygonLengths[i] + + + return vertices, polygonLengths, indices + + +class Model(object): + + def __init__(self, data, indices=None): + """ + """ + + if isinstance(data, basestring): + infile = data + data, lengths, indices = loadVTKPolydataFile(infile) + + if np.any(lengths != 3): + raise RuntimeError('All polygons in VTK file must be ' + 'triangles ({})'.format(infile)) + + self.name = op.basename(infile) + self.dataSource = infile + else: + self.name = 'Model' + self.dataSource = 'Model' + + if indices is None: + indices = np.arange(data.shape[0], dtype=np.uint32) + + self.vertices = np.array(data, dtype=np.float32) + self.indices = indices + + log.memory('{}.init ({})'.format(type(self).__name__, id(self))) + + + def __del__(self): + log.memory('{}.del ({})'.format(type(self).__name__, id(self))) + + + def __repr__(self): + return '{}({}, {})'.format(type(self).__name__, + self.name, + self.dataSource) + + def __str__(self): + return self.__repr__() + + + def getBounds(self): + return (self.vertices.min(axis=0), + self.vertices.max(axis=0)) diff --git a/fsl/data/strings.py b/fsl/data/strings.py index 187056ab5d7bbca42f2d181360b41a3382f9d461..ece2469269a8ec182ed4ba263d1d23a2f1744340 100644 --- a/fsl/data/strings.py +++ b/fsl/data/strings.py @@ -10,18 +10,30 @@ import fsl.data.constants as constants messages = TypeDict({ - 'fslview.loading' : 'Loading {} ...', - 'FSLViewSplash.default' : 'Loading ...', + 'FSLDirDialog.FSLDirNotSet' : 'The $FSLDIR environment variable ' + 'is not set - \n{} may not behave ' + 'correctly.', + 'FSLDirDialog.selectFSLDir' : 'Select the directory in which ' + 'FSL is installed', - 'imageio.saveImage.error' : 'An error occurred saving the file. ' - 'Details: {}', - 'imageio.loadImage.decompress' : '{} is a large file ({} MB) - ' - 'decompressing to {}, to allow memory ' - 'mapping...', + 'fslview.loading' : 'Loading {}', + 'FSLViewSplash.default' : 'Loading ...', - 'imageio.loadImages.loading' : 'Loading {} ...', - 'imageio.loadImages.error' : 'An error occurred loading the image {}\n\n' + 'image.saveImage.error' : 'An error occurred saving the file. ' 'Details: {}', + + 'image.loadImage.decompress' : '{} is a large file ({} MB) - ' + 'decompressing to {}, to allow memory ' + 'mapping...', + + 'ProcessingDialog.error' : 'An error has occurred: {}' + '\n\nDetails: {}', + + 'overlay.loadOverlays.loading' : 'Loading {} ...', + 'overlay.loadOverlays.error' : 'An error occurred loading the image ' + '{}\n\nDetails: {}', + + 'overlay.loadOverlays.unknownType' : 'Unknown data type', 'actions.loadcolourmap.loadcmap' : 'Open colour map file', 'actions.loadcolourmap.namecmap' : 'Enter a name for the colour map - ' @@ -37,14 +49,18 @@ messages = TypeDict({ 'actions.loadcolourmap.installerror' : 'An error occurred while ' 'installing the colour map', + 'AtlasOverlayPanel.loadRegions' : 'Loading region descriptions for {} ...', + 'AtlasInfoPanel.notMNISpace' : 'Atlas lookup can only be performed on ' - 'images registered to MNI152 space', + 'images oriented to MNI152 space', + + 'AtlasInfoPanel.noReference' : 'No reference image available', 'AtlasInfoPanel.chooseAnAtlas' : 'Choose an atlas!', 'AtlasInfoPanel.atlasDisabled' : 'Atlases are not available', 'CanvasPanel.screenshot' : 'Save screenshot', - 'CanvasPanel.screenshot.notSaved' : 'Image {} needs saving before a ' + 'CanvasPanel.screenshot.notSaved' : 'Overlay {} needs saving before a ' 'screenshot can be taken.', 'CanvasPanel.screenshot.pleaseWait' : 'Saving screenshot - ' 'please wait ...', @@ -52,72 +68,121 @@ messages = TypeDict({ 'saving the screenshot. Try ' 'calling render directly with ' 'this command: \n{}', + + 'CanvasPanel.showCommandLineArgs.title' : 'Scene parameters', + 'CanvasPanel.showCommandLineArgs.message' : 'Use these parameters on the ' + 'command line to recreate ' + 'the current scene', + + 'PlotPanel.screenshot' : 'Save screenshot', + + 'PlotPanel.screenshot.error' : 'An error occurred while saving the ' + 'screenshot.\n\n' + 'Details: {}', + + 'HistogramPanel.calcHist' : 'Calculating histogram for {} ...', + + + 'LookupTablePanel.notLutOverlay' : 'Choose an overlay which ' + 'uses a lookup table', + + 'LookupTablePanel.labelExists' : 'The {} LUT already contains a ' + 'label with value {}', + + 'NewLutDialog.newLut' : 'Enter a name for the new LUT', + + 'ClusterPanel.noOverlays' : 'Add a FEAT overlay', + 'ClusterPanel.notFEAT' : 'Choose a FEAT overlay', + 'ClusterPanel.noClusters' : 'No cluster results exist ' + 'in this FEAT analysis', + 'ClusterPanel.badData' : 'Cluster data could not be parsed - ' + 'check your cluster_*.txt files.', + }) titles = TypeDict({ - 'imageio.saveImage.dialog' : 'Save image file', - 'imageio.addImages.dialog' : 'Open image files', + + 'FSLDirDialog' : '$FSLDIR is not set', + + 'image.saveImage.dialog' : 'Save image file', + + 'ProcessingDialog.error' : 'Error', + + 'overlay.addOverlays.dialog' : 'Open overlay files', - 'imageio.loadImages.error' : 'Error loading image', + 'overlay.loadOverlays.error' : 'Error loading overlay', 'OrthoPanel' : 'Ortho View', 'LightBoxPanel' : 'Lightbox View', 'TimeSeriesPanel' : 'Time series', 'HistogramPanel' : 'Histogram', - 'SpacePanel' : 'Space inspector', 'CanvasPanel.screenshot' : 'Save screenshot', - 'CanvasPanel.screenshot.notSaved' : 'Save image before continuing', + 'CanvasPanel.screenshot.notSaved' : 'Save overlay before continuing', 'CanvasPanel.screenshot.error' : 'Error saving screenshot', + 'PlotPanel.screenshot.error' : 'Error saving screenshot', + 'AtlasInfoPanel' : 'Atlas information', 'AtlasOverlayPanel' : 'Atlas overlays', - 'ImageListPanel' : 'Image list', - 'AtlasPanel' : 'Atlases', - 'LocationPanel' : 'Location', - 'ImageDisplayToolBar' : 'Display toolbar', - 'ImageDisplayPanel' : 'Display settings', - 'OrthoToolBar' : 'Ortho view toolbar', - 'OrthoProfileToolBar' : 'Ortho view mode toolbar', - 'OrthoSettingsPanel' : 'Ortho view settings', - 'LightBoxToolBar' : 'Lightbox view toolbar', - 'LightBoxSettingsPanel' : 'Lightbox view settings', - 'HistogramToolBar' : 'Histogram settings', + 'OverlayListPanel' : 'Overlay list', + 'AtlasPanel' : 'Atlases', + 'LocationPanel' : 'Location', + 'OverlayDisplayToolBar' : 'Display toolbar', + 'CanvasSettingsPanel' : 'View settings', + 'OverlayDisplayPanel' : 'Display settings', + 'OrthoToolBar' : 'Ortho view toolbar', + 'OrthoProfileToolBar' : 'Ortho view mode toolbar', + 'LightBoxToolBar' : 'Lightbox view toolbar', + 'LookupTablePanel' : 'Lookup tables', + 'LutLabelDialog' : 'New LUT label', + 'NewLutDialog' : 'New LUT', + 'TimeSeriesListPanel' : 'Time series list', + 'TimeSeriesControlPanel' : 'Time series control', + 'HistogramListPanel' : 'Histogram list', + 'HistogramControlPanel' : 'Histogram control', + 'ClusterPanel' : 'Cluster browser', + + 'LookupTablePanel.loadLut' : 'Select a lookup table file', + 'LookupTablePanel.labelExists' : 'Label already exists', }) actions = TypeDict({ - 'OpenFileAction' : 'Add image file', + 'OpenFileAction' : 'Add overlay file', 'OpenStandardAction' : 'Add standard', - 'CopyImageAction' : 'Copy image', - 'SaveImageAction' : 'Save image', + 'CopyOverlayAction' : 'Copy overlay', + 'SaveOverlayAction' : 'Save overlay', 'LoadColourMapAction' : 'Load custom colour map', 'CanvasPanel.screenshot' : 'Take screenshot', + 'CanvasPanel.showCommandLineArgs' : 'Show command line for scene', 'CanvasPanel.toggleColourBar' : 'Colour bar', - 'CanvasPanel.toggleImageList' : 'Image list', - 'CanvasPanel.toggleDisplayProperties' : 'Image display properties', + 'CanvasPanel.toggleOverlayList' : 'Overlay list', + 'CanvasPanel.toggleDisplayProperties' : 'Overlay display properties', 'CanvasPanel.toggleLocationPanel' : 'Location panel', 'CanvasPanel.toggleAtlasPanel' : 'Atlas panel', + 'CanvasPanel.toggleLookupTablePanel' : 'Lookup tables', + 'CanvasPanel.toggleClusterPanel' : 'Cluster browser', 'OrthoPanel.toggleOrthoToolBar' : 'View properties', 'OrthoPanel.toggleProfileToolBar' : 'Mode controls', 'OrthoToolBar.more' : 'More settings', 'LightBoxToolBar.more' : 'More settings', - 'ImageDisplayToolBar.more' : 'More settings', + 'OverlayDisplayToolBar.more' : 'More settings', 'LightBoxPanel.toggleLightBoxToolBar' : 'View properties', - - 'PlotPanel.screenshot' : 'Take screenshot', - - 'HistogramPanel.toggleToolbar' : 'Histogram controls', - + 'PlotPanel.screenshot' : 'Take screenshot', + 'TimeSeriesPanel.toggleTimeSeriesList' : 'Time series list', + 'TimeSeriesPanel.toggleTimeSeriesControl' : 'Time series control', + 'HistogramPanel.toggleHistogramList' : 'Histogram list', + 'HistogramPanel.toggleHistogramControl' : 'Histogram control', 'OrthoViewProfile.centreCursor' : 'Centre cursor', 'OrthoViewProfile.resetZoom' : 'Reset zoom', @@ -132,17 +197,100 @@ actions = TypeDict({ }) labels = TypeDict({ - 'LocationPanel.worldLocation' : 'World location (mm)', - 'LocationPanel.voxelLocation' : 'Voxel location', - 'LocationPanel.volume' : 'Volume', - 'LocationPanel.space' : 'Space', - 'LocationPanel.intensity' : 'Intensity', - 'LocationPanel.outOfBounds' : 'Out of bounds', - - 'CanvasPanel.screenshot.notSaved.save' : 'Save image now', - 'CanvasPanel.screenshot.notSaved.skip' : 'Skip image (will not appear ' + + 'FSLDirDialog.locate' : 'Locate $FSLDIR', + 'FSLDirDialog.skip' : 'Skip', + + 'LocationPanel.worldLocation' : 'Coordinates: ', + 'LocationPanel.worldLocation.unknown' : 'Unknown', + 'LocationPanel.voxelLocation' : 'Voxel location', + 'LocationPanel.volume' : 'Volume', + 'LocationPanel.noData' : 'No data', + 'LocationPanel.outOfBounds' : 'Out of bounds', + 'LocationPanel.notAvailable' : 'N/A', + + 'CanvasPanel.screenshot.notSaved.save' : 'Save overlay now', + 'CanvasPanel.screenshot.notSaved.skip' : 'Skip overlay (will not appear ' 'in screenshot)', 'CanvasPanel.screenshot.notSaved.cancel' : 'Cancel screenshot', + + + 'LookupTablePanel.addLabel' : 'Add label', + 'LookupTablePanel.newLut' : 'New', + 'LookupTablePanel.copyLut' : 'Copy', + 'LookupTablePanel.saveLut' : 'Save', + 'LookupTablePanel.loadLut' : 'Load', + + 'LutLabelDialog.value' : 'Value', + 'LutLabelDialog.name' : 'Name', + 'LutLabelDialog.colour' : 'Colour', + 'LutLabelDialog.ok' : 'Ok', + 'LutLabelDialog.cancel' : 'Cancel', + 'LutLabelDialog.newLabel' : 'New label', + + 'NewLutDialog.ok' : 'Ok', + 'NewLutDialog.cancel' : 'Cancel', + 'NewLutDialog.newLut' : 'New LUT', + + 'PlotPanel.plotSettings' : 'General plot settings', + 'PlotPanel.currentSettings' : 'Settings for currently ' + 'selected plot ({})', + 'PlotPanel.xlim' : 'X limits', + 'PlotPanel.ylim' : 'Y limits', + 'PlotPanel.labels' : 'Labels', + 'PlotPanel.xlabel' : 'X', + 'PlotPanel.ylabel' : 'Y', + + 'HistogramControlPanel.histSettings' : 'Histogram plot settings', + + 'TimeSeriesControlPanel.tsSettings' : 'Time series plot settings', + 'TimeSeriesControlPanel.currentSettings' : 'Settings for current ' + 'voxel time course', + 'TimeSeriesControlPanel.currentFEATSettings' : 'FEAT settings for ' + 'selected overlay ({})', + + 'TimeSeriesListPanel.featReduced' : 'Reduced against {}', + + 'FEATModelFitTimeSeries.full' : 'Full model fit', + 'FEATModelFitTimeSeries.cope' : 'COPE{} fit: {}', + 'FEATModelFitTimeSeries.pe' : 'PE{} fit', + + 'FEATReducedTimeSeries.cope' : 'Reduced against COPE{}: {}', + 'FEATReducedTimeSeries.pe' : 'Reduced against PE{}', + + 'FEATResidualTimeSeries' : 'Residuals', + + 'ClusterPanel.clustName' : 'Z statistics for COPE{} ({})', + + 'ClusterPanel.index' : 'Cluster index', + 'ClusterPanel.nvoxels' : 'Size (voxels)', + 'ClusterPanel.p' : 'P', + 'ClusterPanel.logp' : '-log10(P)', + 'ClusterPanel.zmax' : 'Z Max', + 'ClusterPanel.zmaxcoords' : 'Z Max location', + 'ClusterPanel.zcogcoords' : 'Z Max COG location', + 'ClusterPanel.copemax' : 'COPE Max', + 'ClusterPanel.copemaxcoords' : 'COPE Max location', + 'ClusterPanel.copemean' : 'COPE mean', + + 'ClusterPanel.addZStats' : 'Add Z statistics', + 'ClusterPanel.addClustMask' : 'Add cluster mask', + + + 'OverlayDisplayPanel.Display' : 'General display settings', + 'OverlayDisplayPanel.VolumeOpts' : 'Volume settings', + 'OverlayDisplayPanel.MaskOpts' : 'Mask settings', + 'OverlayDisplayPanel.LabelOpts' : 'Label settings', + 'OverlayDisplayPanel.RGBVectorOpts' : 'RGB vector settings', + 'OverlayDisplayPanel.LineVectorOpts' : 'Line vector settings', + 'OverlayDisplayPanel.ModelOpts' : 'Model settings', + + 'OverlayDisplayPanel.loadCmap' : 'Load colour map', + + 'CanvasSettingsPanel.scene' : 'Scene settings', + 'CanvasSettingsPanel.ortho' : 'Ortho view settings', + 'CanvasSettingsPanel.lightbox' : 'Lightbox settings', + }) @@ -151,8 +299,10 @@ properties = TypeDict({ 'Profile.mode' : 'Profile', 'CanvasPanel.syncLocation' : 'Sync location', - 'CanvasPanel.syncImageOrder' : 'Sync overlay order', - 'CanvasPanel.syncVolume' : 'Sync volume', + 'CanvasPanel.syncOverlayOrder' : 'Sync overlay order', + 'CanvasPanel.syncOverlayDisplay' : 'Sync overlay display settings', + 'CanvasPanel.movieMode' : 'Movie mode', + 'CanvasPanel.movieRate' : 'Movie update rate', 'CanvasPanel.profile' : 'Mode', 'SceneOpts.showCursor' : 'Show location cursor', @@ -177,10 +327,45 @@ properties = TypeDict({ 'OrthoOpts.yzoom' : 'Y zoom', 'OrthoOpts.zzoom' : 'Z zoom', - 'HistogramPanel.dataRange' : 'Data range', - 'HistogramPanel.autoHist' : 'Automatic histogram binning', - 'HistogramPanel.nbins' : 'Number of bins', - + 'PlotPanel.legend' : 'Show legend', + 'PlotPanel.ticks' : 'Show ticks', + 'PlotPanel.grid' : 'Show grid', + 'PlotPanel.smooth' : 'Smooth', + 'PlotPanel.autoScale' : 'Auto-scale', + 'PlotPanel.xLogScale' : 'Log scale (x axis)', + 'PlotPanel.yLogScale' : 'Log scale (y axis)', + 'PlotPanel.xlabel' : 'X label', + 'PlotPanel.ylabel' : 'Y label', + + 'TimeSeriesPanel.plotMode' : 'Plotting mode', + 'TimeSeriesPanel.usePixdim' : 'Use pixdims', + 'TimeSeriesPanel.showCurrent' : 'Plot time series for current voxel', + 'TimeSeriesPanel.currentColour' : 'Colour for current time course', + 'TimeSeriesPanel.currentAlpha' : 'Transparency for current ' + 'time course', + 'TimeSeriesPanel.currentLineWidth' : 'Line width for current time course', + 'TimeSeriesPanel.currentLineStyle' : 'Line style for current time course', + 'TimeSeriesPanel.plotFullModelFit' : 'Plot full model fit', + 'TimeSeriesPanel.plotResiduals' : 'Plot residuals', + + 'HistogramPanel.histType' : 'Histogram type', + 'HistogramPanel.autoBin' : 'Automatic histogram binning', + 'HistogramPanel.showCurrent' : 'Plot histogram for current overlay', + + 'HistogramSeries.nbins' : 'Number of bins', + 'HistogramSeries.ignoreZeros' : 'Ignore zeros', + 'HistogramSeries.includeOutliers' : 'Include values out of data range', + 'HistogramSeries.volume' : 'Volume', + 'HistogramSeries.dataRange' : 'Data range', + 'HistogramSeries.showOverlay' : 'Show 3D histogram overlay', + + 'FEATTimeSeries.plotFullModelFit' : 'Plot full model fit', + 'FEATTimeSeries.plotEVs' : 'Plot EV{} ({})', + 'FEATTimeSeries.plotPEFits' : 'Plot PE{} fit ({})', + 'FEATTimeSeries.plotCOPEFits' : 'Plot COPE{} fit ({})', + 'FEATTimeSeries.plotResiduals' : 'Plot residuals', + 'FEATTimeSeries.plotReduced' : 'Plot data reduced against', + 'FEATTimeSeries.plotData' : 'Plot data', 'OrthoEditProfile.selectionSize' : 'Selection size', 'OrthoEditProfile.selectionIs3D' : '3D selection', @@ -191,22 +376,23 @@ properties = TypeDict({ 'OrthoEditProfile.selectionOverlayColour' : 'Selection overlay', 'OrthoEditProfile.selectionCursorColour' : 'Selection cursor', - - 'Display.name' : 'Image name', + 'Display.name' : 'Overlay name', + 'Display.overlayType' : 'Overlay data type', 'Display.enabled' : 'Enabled', 'Display.alpha' : 'Opacity', 'Display.brightness' : 'Brightness', 'Display.contrast' : 'Contrast', - 'Display.interpolation' : 'Interpolation', - 'Display.resolution' : 'Resolution', - 'Display.volume' : 'Volume', - 'Display.transform' : 'Image transform', - 'Display.imageType' : 'Image data type', + + 'ImageOpts.resolution' : 'Resolution', + 'ImageOpts.transform' : 'Image transform', + 'ImageOpts.volume' : 'Volume', - 'VolumeOpts.displayRange' : 'Display range', - 'VolumeOpts.clippingRange' : 'Clipping range', - 'VolumeOpts.cmap' : 'Colour map', - 'VolumeOpts.invert' : 'Invert colour map', + 'VolumeOpts.displayRange' : 'Display range', + 'VolumeOpts.clippingRange' : 'Clipping range', + 'VolumeOpts.cmap' : 'Colour map', + 'VolumeOpts.invert' : 'Invert colour map', + 'VolumeOpts.invertClipping' : 'Invert clipping range', + 'VolumeOpts.interpolation' : 'Interpolation', 'MaskOpts.colour' : 'Colour', 'MaskOpts.invert' : 'Invert', @@ -222,8 +408,22 @@ properties = TypeDict({ 'VectorOpts.modulate' : 'Modulate by', 'VectorOpts.modThreshold' : 'Modulation threshold', + 'RGBVectorOpts.interpolation' : 'Interpolation', + 'LineVectorOpts.directed' : 'Interpret vectors as directed', 'LineVectorOpts.lineWidth' : 'Line width', + + 'ModelOpts.colour' : 'Colour', + 'ModelOpts.outline' : 'Show outline only', + 'ModelOpts.outlineWidth' : 'Outline width', + 'ModelOpts.refImage' : 'Reference image', + 'ModelOpts.coordSpace' : 'Model coordinate space', + 'ModelOpts.showName' : 'Show model name', + + 'LabelOpts.lut' : 'Look-up table', + 'LabelOpts.outline' : 'Show outline only', + 'LabelOpts.outlineWidth' : 'Outline width', + 'LabelOpts.showNames' : 'Show label names', }) @@ -251,7 +451,6 @@ modes = TypeDict({ }) - choices = TypeDict({ 'SceneOpts.colourBarLocation.top' : 'Top', @@ -281,14 +480,31 @@ choices = TypeDict({ 'VectorOpts.displayType.rgb' : 'RGB', 'VectorOpts.modulate.none' : 'No modulation', - - 'Display.transform.affine' : 'Use qform/sform transformation matrix', - 'Display.transform.pixdim' : 'Use pixdims only', - 'Display.transform.id' : 'Do not use qform/sform or pixdims', - 'Display.interpolation.none' : 'No interpolation', - 'Display.interpolation.linear' : 'Linear interpolation', - 'Display.interpolation.spline' : 'Spline interpolation', + 'ImageOpts.transform.affine' : 'Use qform/sform transformation matrix', + 'ImageOpts.transform.pixdim' : 'Use pixdims only', + 'ImageOpts.transform.id' : 'Do not use qform/sform or pixdims', + + 'ModelOpts.refImage.none' : 'None', + + 'VolumeOpts.interpolation.none' : 'No interpolation', + 'VolumeOpts.interpolation.linear' : 'Linear interpolation', + 'VolumeOpts.interpolation.spline' : 'Spline interpolation', + + 'Display.overlayType.volume' : '3D/4D volume', + 'Display.overlayType.mask' : '3D/4D mask image', + 'Display.overlayType.label' : 'Label image', + 'Display.overlayType.rgbvector' : '3-direction vector image (RGB)', + 'Display.overlayType.linevector' : '3-direction vector image (Line)', + 'Display.overlayType.model' : '3D model', + + 'HistogramPanel.histType.probability' : 'Probability', + 'HistogramPanel.histType.count' : 'Count', + + 'TimeSeriesPanel.plotMode.normal' : 'Normal - no scaling/offsets', + 'TimeSeriesPanel.plotMode.demean' : 'Demeaned', + 'TimeSeriesPanel.plotMode.normalise' : 'Normalised', + 'TimeSeriesPanel.plotMode.percentChange' : 'Percent changed', }) diff --git a/fsl/fslview/actions/__init__.py b/fsl/fslview/actions/__init__.py index 5aa3caf9b39d67566b9715ac93b96eaf784488ea..8fd3a86271915f422a5e2fcda861cd11e571f857 100644 --- a/fsl/fslview/actions/__init__.py +++ b/fsl/fslview/actions/__init__.py @@ -12,13 +12,12 @@ performed, enabled and disabled, and may be bound to a GUI menu item or button. Some 'global' actions are provided in this package, for example the -:class:`~fsl.fslview.actions.openfile.OpenFileAction`, and the -:class:`~fsl.fslview.actions.openstandard.OpenStandardAction`. +:class:`.OpenFileAction`, and the :class:`.OpenStandardAction`. -The :class:`ActionProvider` class represents some entity which can perform -one or more actions. As the :class:`~fsl.fslview.panel.FSLViewPanel` class -derives from :class:`ActionProvider` pretty much everything in FSLView is -an :class:`ActionProvider`. +The :class:`ActionProvider` class represents some entity which can perform one +or more actions. As the :class:`.FSLViewPanel` class derives from +:class:`ActionProvider` pretty much everything in FSLView is an +:class:`ActionProvider`. """ @@ -34,20 +33,18 @@ log = logging.getLogger(__name__) def listGlobalActions(): """Convenience function which returns a list containing all - :class:`~fsl.fslview.action.Action` classes in the :mod:`actions` package. + :class:`.Action` classes in the :mod:`actions` package. """ import openfile import openstandard - import copyimage - import saveimage - import loadcolourmap + import copyoverlay + import saveoverlay - return [openfile .OpenFileAction, - openstandard .OpenStandardAction, - copyimage .CopyImageAction, - saveimage .SaveImageAction, - loadcolourmap.LoadColourMapAction] + return [openfile .OpenFileAction, + openstandard.OpenStandardAction, + copyoverlay .CopyOverlayAction, + saveoverlay .SaveOverlayAction] class ActionButton(props.Button): @@ -58,12 +55,13 @@ class ActionButton(props.Button): self.name = actionName - props.Button.__init__(self, - actionName, - text=strings.actions[classType, actionName], - callback=self.__onButton, - setup=self.__setup, - **kwargs) + props.Button.__init__( + self, + actionName, + text=strings.actions.get((classType, actionName), actionName), + callback=self.__onButton, + setup=self.__setup, + **kwargs) def __setup(self, instance, parent, widget, *a): @@ -97,15 +95,15 @@ class Action(props.HasProperties): """ - def __init__(self, imageList, displayCtx, action=None): + def __init__(self, overlayList, displayCtx, action=None): """ - :arg imageList: A :class:`~fsl.data.image.ImageList` instance - containing the list of images being displayed. + :arg overlayList: An :class:`.OverlayList` instance + containing the list of overlays being displayed. - :arg displayCtx: A :class:`~fsl.fslview.displaycontext.DisplayContext` - instance defining how the images are to be displayed. + :arg displayCtx: A :class:`.DisplayContext` instance defining how + the overlays are to be displayed. """ - self._imageList = imageList + self._overlayList = overlayList self._displayCtx = displayCtx self._boundWidgets = [] self._name = '{}_{}'.format(self.__class__.__name__, id(self)) @@ -128,7 +126,25 @@ class Action(props.HasProperties): parent.Bind(evType, wrappedAction, widget) widget.Enable(self.enabled) - self._boundWidgets.append(widget) + self._boundWidgets.append((parent, evType, widget)) + + + def unbindAllWidgets(self): + """Unbinds all widgets which have been bound via :meth:`bindToWidget`. + """ + + import wx + + for parent, evType, widget in self._boundWidgets: + + # Only attempt to unbind if the parent + # and widget have not been destroyed + try: + parent.Unbind(evType, source=widget) + except wx.PyDeadObjectError: + pass + + self._boundWidgets = [] def _enabledChanged(self, *args): @@ -138,7 +154,7 @@ class Action(props.HasProperties): if self.enabled: self.doAction = self.__enabledDoAction else: self.doAction = self.__disabledDoAction - for widget in self._boundWidgets: + for _, _, widget in self._boundWidgets: widget.Enable(self.enabled) @@ -171,18 +187,18 @@ class ActionProvider(props.HasProperties): will ultimately be exposed to the user. """ - def __init__(self, imageList, displayCtx, actions=None): + def __init__(self, overlayList, displayCtx, actions=None): """Create an :class:`ActionProvider` instance. - :arg imageList: A :class:`~fsl.data.image.ImageList` instance - containing the list of images being displayed. + :arg overlayList: An :class:`.OverlayList` instance containing the + list of overlays being displayed. - :arg displayCtx: A :class:`~fsl.fslview.displaycontext.DisplayContext` - instance defining how the images are to be displayed. + :arg displayCtx: A :class:`.DisplayContext` instance defining how + the overlays are to be displayed. - :arg actions: A dictionary containing ``{name -> function}`` - mappings, where each function is an action that - should be made available to the user. + :arg actions: A dictionary containing ``{name -> function}`` + mappings, where each function is an action that + should be made available to the user. """ if actions is None: @@ -191,10 +207,21 @@ class ActionProvider(props.HasProperties): self.__actions = {} for name, func in actions.items(): - act = Action(imageList, displayCtx, action=func) + act = Action(overlayList, displayCtx, action=func) self.__actions[name] = act + def destroy(self): + """This method should be called when this ``ActionProvider`` is + about to be destroyed. It ensures that all ``Action`` instances + are cleared. + """ + for _, act in self.__actions.items(): + act.unbindAllWidgets() + + self.__actions = None + + def addActionToggleListener(self, name, listenerName, func): """Add a listener function which will be called when the named action is enabled or disabled. diff --git a/fsl/fslview/actions/copyimage.py b/fsl/fslview/actions/copyimage.py deleted file mode 100644 index 89fd7c6e3385f93a4355187eb4f098f647073604..0000000000000000000000000000000000000000 --- a/fsl/fslview/actions/copyimage.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python -# -# copyimage.py - -# -# Author: Paul McCarthy <pauldmccarthy@gmail.com> -# - -import logging -log = logging.getLogger(__name__) - -import numpy as np - -import fsl.fslview.actions as actions -import fsl.data.image as fslimage - -class CopyImageAction(actions.Action): - - def __init__(self, *args, **kwargs): - actions.Action.__init__(self, *args, **kwargs) - - self._displayCtx.addListener('selectedImage', - self._name, - self._selectedImageChanged) - self._imageList .addListener('images', - self._name, - self._selectedImageChanged) - - self._selectedImageChanged() - - - def _selectedImageChanged(self, *a): - self.enabled = self._displayCtx.getSelectedImage() is not None - - - def doAction(self): - - imageIdx = self._displayCtx.selectedImage - image = self._imageList[imageIdx] - - if image is None: - return - - data = np.copy(image.data) - header = image.nibImage.get_header() - name = '{}_copy'.format(image.name) - copy = fslimage.Image(data, name=name, header=header) - - # TODO copy display properties - - self._imageList.insert(imageIdx + 1, copy) diff --git a/fsl/fslview/actions/copyoverlay.py b/fsl/fslview/actions/copyoverlay.py new file mode 100644 index 0000000000000000000000000000000000000000..047882342075304cc599af2abde0c9f2b2aa9d32 --- /dev/null +++ b/fsl/fslview/actions/copyoverlay.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python +# +# copyoverlay.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import logging +log = logging.getLogger(__name__) + +import numpy as np + +import fsl.fslview.actions as actions +import fsl.data.image as fslimage + + +class CopyOverlayAction(actions.Action): + + def __init__(self, *args, **kwargs): + actions.Action.__init__(self, *args, **kwargs) + + self._displayCtx .addListener('selectedOverlay', + self._name, + self.__selectedOverlayChanged) + self._overlayList.addListener('overlays', + self._name, + self.__selectedOverlayChanged) + + self.__selectedOverlayChanged() + + + def __selectedOverlayChanged(self, *a): + self.enabled = self._displayCtx.getSelectedOverlay() is not None + + + def doAction(self): + + ovlIdx = self._displayCtx.selectedOverlay + overlay = self._overlayList[ovlIdx] + + if overlay is None: + return + + # TODO support for other overlay types + if not isinstance(overlay, fslimage.Image): + raise RuntimeError('Currently, only {} instances can be ' + 'copied'.format(fslimage.Image.__name__)) + + data = np.copy(overlay.data) + header = overlay.nibImage.get_header() + name = '{}_copy'.format(overlay.name) + copy = fslimage.Image(data, name=name, header=header) + + # TODO copy display properties + + self._overlayList.insert(ovlIdx + 1, copy) diff --git a/fsl/fslview/actions/loadcolourmap.py b/fsl/fslview/actions/loadcolourmap.py index b05490e5e3835842048edf793a4536046bd750eb..976b537706d7d51f1811578163b6c7a639a424c7 100644 --- a/fsl/fslview/actions/loadcolourmap.py +++ b/fsl/fslview/actions/loadcolourmap.py @@ -8,15 +8,11 @@ import logging import os.path as op -import fsl.data.strings as strings -import fsl.fslview.actions as actions -import fsl.fslview.colourmaps as fslcmap -import fsl.fslview.displaycontext.volumeopts as volumeopts +import fsl.data.strings as strings +import fsl.fslview.actions as actions +import fsl.fslview.colourmaps as fslcmap -# TODO You will need to patch ColourMap instances -# for any new display option types - log = logging.getLogger(__name__) @@ -61,7 +57,7 @@ class LoadColourMapAction(actions.Action): cmapName = dlg.GetValue() # a colour map with the specified name already exists - if fslcmap.isRegistered(cmapName): + if fslcmap.isColourMapRegistered(cmapName): cmapNameMsg = strings.messages[_stringID + 'alreadyinstalled'] continue @@ -74,30 +70,16 @@ class LoadColourMapAction(actions.Action): break # register the selected colour map file - fslcmap.registerColourMap(cmapFile, cmapName) - - # update the VolumeOpts colour map property ... - # - # for future images - volumeopts.VolumeOpts.cmap.setConstraint( - None, - 'cmapNames', - fslcmap.getColourMaps()) - - # and for any existing VolumeOpts instances - for image in self._imageList: - display = self._displayCtx.getDisplayProperties(image) - opts = display.getDisplayOpts() - if isinstance(opts, volumeopts.VolumeOpts): - opts.setConstraint('cmap', - 'cmapNames', - fslcmap.getColourMaps()) + fslcmap.registerColourMap(cmapFile, + self._overlayList, + self._displayCtx, + cmapName) # ask the user if they want to install # the colour map for future use dlg = wx.MessageDialog( app.GetTopWindow(), - message=strings.messages['actions.loadcolourmap.installcmap'], + message=strings.messages[_stringID + 'installcmap'], style=wx.YES_NO) if dlg.ShowModal() != wx.ID_YES: diff --git a/fsl/fslview/actions/openfile.py b/fsl/fslview/actions/openfile.py index f3aa2d1bc90251cc592dbef4b09e03b523124935..0c5e741a3ccc029279a0090610452608b5d22584 100644 --- a/fsl/fslview/actions/openfile.py +++ b/fsl/fslview/actions/openfile.py @@ -13,5 +13,6 @@ import fsl.fslview.actions as actions class OpenFileAction(actions.Action): def doAction(self): - if self._imageList.addImages(): - self._displayCtx.selectedImage = self._displayCtx.imageOrder[-1] + if self._overlayList.addOverlays(): + self._displayCtx.selectedOverlay = \ + self._displayCtx.overlayOrder[-1] diff --git a/fsl/fslview/actions/openstandard.py b/fsl/fslview/actions/openstandard.py index da3d16019fe19b7695a6d21a7524234bfc2f16bd..7fb00cfe00843c89924fc6b694307aea94cb9730 100644 --- a/fsl/fslview/actions/openstandard.py +++ b/fsl/fslview/actions/openstandard.py @@ -14,8 +14,8 @@ log = logging.getLogger(__name__) import fsl.fslview.actions as actions class OpenStandardAction(actions.Action): - def __init__(self, imageList, displayCtx): - actions.Action.__init__(self, imageList, displayCtx) + def __init__(self, overlayList, displayCtx): + actions.Action.__init__(self, overlayList, displayCtx) # disable the 'add standard' menu # item if $FSLDIR is not set @@ -29,5 +29,5 @@ class OpenStandardAction(actions.Action): def doAction(self): - if self._imageList.addImages(self._stddir, addToEnd=False): - self._displayCtx.selectedImage = self._displayCtx.imageOrder[0] + if self._overlayList.addOverlays(self._stddir, addToEnd=False): + self._displayCtx.selectedOverlay = self._displayCtx.overlayOrder[0] diff --git a/fsl/fslview/actions/saveimage.py b/fsl/fslview/actions/saveimage.py deleted file mode 100644 index cbd6990e3842d102a6b9853a06ea74dad31dc406..0000000000000000000000000000000000000000 --- a/fsl/fslview/actions/saveimage.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python -# -# savefile.py - -# -# Author: Paul McCarthy <pauldmccarthy@gmail.com> -# - -import logging -log = logging.getLogger(__name__) - -import fsl.fslview.actions as actions - -class SaveImageAction(actions.Action): - - def __init__(self, *args, **kwargs): - actions.Action.__init__(self, *args, **kwargs) - - self._displayCtx.addListener('selectedImage', - self._name, - self._selectedImageChanged) - self._imageList .addListener('images', - self._name, - self._selectedImageChanged) - - self._selectedImageChanged() - - - def _selectedImageChanged(self, *a): - - image = self._displayCtx.getSelectedImage() - - self.enabled = (image is not None) and (not image.saved) - - for i in self._imageList: - i.removeListener('saved', self._name) - - if i is image: - i.addListener('saved', self._name, self._imageSaveStateChanged) - - - def _imageSaveStateChanged(self, *a): - image = self._displayCtx.getSelectedImage() - - if image is None: self.enabled = False - else: self.enabled = not image.saved - - - def doAction(self): - - image = self._displayCtx.getSelectedImage() - if image is None: - return - - image.save() diff --git a/fsl/fslview/actions/saveoverlay.py b/fsl/fslview/actions/saveoverlay.py new file mode 100644 index 0000000000000000000000000000000000000000..f7d92f0954918b8cae1d729772789e978e48ee86 --- /dev/null +++ b/fsl/fslview/actions/saveoverlay.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python +# +# saveoverlay.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import logging + +import fsl.data.image as fslimage +import fsl.fslview.actions as actions + + +log = logging.getLogger(__name__) + + +class SaveOverlayAction(actions.Action): + + def __init__(self, *args, **kwargs): + actions.Action.__init__(self, *args, **kwargs) + + self._displayCtx .addListener('selectedOverlay', + self._name, + self.__selectedOverlayChanged) + self._overlayList.addListener('overlays', + self._name, + self.__selectedOverlayChanged) + + self.__selectedOverlayChanged() + + + def __selectedOverlayChanged(self, *a): + + overlay = self._displayCtx.getSelectedOverlay() + + # TODO Support for other overlay types + + self.enabled = ((overlay is not None) and + isinstance(overlay, fslimage.Image) and + (not overlay.saved)) + + for ovl in self._overlayList: + if not isinstance(ovl, fslimage.Image): + continue + + ovl.removeListener('saved', self._name) + + if ovl is overlay: + ovl.addListener('saved', + self._name, + self.__overlaySaveStateChanged) + + + def __overlaySaveStateChanged(self, *a): + + overlay = self._displayCtx.getSelectedOverlay() + + if overlay is None: + self.enabled = False + + elif not isinstance(overlay, fslimage.Image): + self.enabled = False + else: + self.enabled = not overlay.saved + + + def doAction(self): + + overlay = self._displayCtx.getSelectedOverlay() + + if overlay is None: + return + + # TODO support for other overlay types + if not isinstance(overlay, fslimage.Image): + raise RuntimeError('Non-volumetric types not supported yet') + + overlay.save() diff --git a/fsl/fslview/colourmaps.py b/fsl/fslview/colourmaps.py index d69dca402d452923d91e6079c569c04191db38c5..2b3a6e583d445559118e8bd90a81eb19ac9885cb 100644 --- a/fsl/fslview/colourmaps.py +++ b/fsl/fslview/colourmaps.py @@ -1,58 +1,139 @@ #!/usr/bin/env python # -# colourmaps.py - Manage colour maps for image rendering. +# colourmaps.py - Manage colour maps and lookup tables for overlay rendering. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -"""Module which manages the colour maps available for image rendering. +"""Module which manages the colour maps and lookup tables available for +overlay rendering. -When this module is first initialised, it searches in the -``fsl/fslview/colourmaps/`` directory, and attempts to load all files within -which have the suffix ``.cmap``. Such a file is assumed to contain a list of -RGB colours, one per line, with each colour specified by three space-separated -floating point values in the range 0.0 - 1.0. -This list of RGB values is used to create a -:class:`matplotlib.colors.ListedColormap` object, which is then registered -with the :mod:`matplotlib.cm` module (using the file name prefix as the colour -map name), and thus made available for rendering purposes. +The :func:`init` function must be called before any colour maps or lookup +tables can be accessed. When :func:`init` is called, it searches in the +``fsl/fslview/colourmaps/`` and ``fsl/fslview/luts/``directories, and attempts +to load all files within which have the suffix ``.cmap`` or ``.lut`` +respectively. + + +----------- +Colour maps +----------- + + +A ``.cmap`` file defines a colour map which may be used to display a range of +intensity values - see the :attr:`.VolumeOpts.cmap` property for an example. A +``.cmap`` file must contain a list of RGB colours, one per line, with each +colour specified by three space-separated floating point values in the range +0.0 - 1.0, for example:: + + + 1.000000 0.260217 0.000000 + 0.000000 0.687239 1.000000 + 0.738949 0.000000 1.000000 + + +This list of RGB values is used to create a :class:`.ListedColormap` object, +which is then registered with the :mod:`matplotlib.cm` module (using the file +name prefix as the colour map name), and thus made available for rendering +purposes. + If a file named ``order.txt`` exists in the ``fsl/fslview/colourmaps/`` directory, it is assumed to contain a list of colour map names, and colour map identifiers, defining the order in which the colour maps should be displayed to the user. Any colour maps which are not listed in the ``order.txt`` file -will be appended to the end of the list. +will be appended to the end of the list, and their name will be derived from +the file name. + This module provides a number of functions, the most important of which are: - - :func:`initColourMaps`: This function must be called before any of the - other functions will work. It loads all present - colourmap files. + - :func:`getColourMaps`: Returns a list of the names of all available + colourmaps. + + - :func:`registerColourMap`: Given a text file containing RGB values, + loads the data and registers it with + :mod:`matplotlib`. + + - :func:`installColourMap`: + + - :func:`isColourMapRegistered`: + + - :func:`isColourMapInstalled`: + + +------------- +Lookup tables +------------- - - :func:`getDefault`: Returns the name of the colourmap to be used - as the default. - - :func:`getColourMaps`: Returns a list of the names of all available - colourmaps. +A ``.lut`` file defines a lookup table which may be used to display images +wherein each voxel has a discrete integer label. Each of the possible voxel +values such an image has an associated colour and name. Each line in a +``.lut`` file must specify the label value, RGB colour, and associated name. +The first column (where columns are space-separated) defines the label value, +the second to fourth columns specify the RGB values, and all remaining columns +give the label name. For example:: - - :func:`registerColourMap`: Given a text file containing RGB values, - loads the data and registers it with - :mod:`matplotlib`. + + 1 0.00000 0.93333 0.00000 Frontal Pole + 2 0.62745 0.32157 0.17647 Insular Cortex + 3 1.00000 0.85490 0.72549 Superior Frontal Gyrus + + +This list of label, colour, and name mappings is used to create a +:class:`LookupTable` instance, which can be used to access the colours and +names associated with each label value. + + +Once created, ``LookupTable`` instances may be modified - labels can be +added/removed, and the name/colour of existing labels can be modified. The +:func:`.installLookupTable` method will install a new lookup table, or save +any changes made to an existing one. + + +The following functions are available to access and manage +:class:`LookupTable` instances: + + - :func:`getLookupTables`: + + - :func:`registerLookupTable`: + + - :func:`installLookupTable`: + + - :func:`isLookupTableRegistered`: + + - :func:`isLookupTableInstalled`: + + +------------- +Miscellaneous +------------- Some utility functions are also kept in this module, related to calculating -the relationship between a data display range, and brightness/contrast -scales: +the relationship between a data display range and brightness/contrast scales, +and generating/manipulating colours.: - :func:`displayRangeToBricon`: Given a data range, converts a display range to brightness/contrast values. - :func:`briconToDisplayRange`: Given a data range, converts brigtness/ contrast values to a display range. + + - :func:`applyBricon`: Given a RGB colour, brightness, and contrast + value, scales the colour according to the + brightness and contrast. + + - :func:`randomColour`: Generates a random RGB colour. + + - :func:`randomBrightColour`: Generates a random saturated RGB colour. """ + +import logging import glob -import shutil +import bisect import os.path as op from collections import OrderedDict @@ -61,119 +142,370 @@ import numpy as np import matplotlib.colors as colors import matplotlib.cm as mplcm -import logging - +import props log = logging.getLogger(__name__) _cmapDir = op.join(op.dirname(__file__), 'colourmaps') +_lutDir = op.join(op.dirname(__file__), 'luts') + _cmaps = None +_luts = None -class _ColourMap(object): - """A little struct for storing details on each installed/available - colour map. +class _Map(object): + """A little class for storing details on each installed/available + colour map/lookup table. This class is only used internally. """ - def __init__(self, name, cmfile, installed): + def __init__(self, name, mapObj, mapFile, installed): """ - :arg name: The name of the colour map (as registered with - :mod:`matplotlib.cm`). + :arg name: The name of the colour map/lookup table. + + :arg mapObj: The colourmap/lut object, either a + :class:`matplotlib.col.lors..Colormap`, or a + :class:`LookupTable` instance. - :arg cmfile: The file from which this colour map was loaded, - or ``None`` if this is a built in :mod:`matplotlib` + :arg mapFile: The file from which this map was loaded, + or ``None`` if this cmap/lookup table only + exists in memory, or is a built in :mod:`matplotlib` colourmap. :arg installed: ``True`` if this is a built in :mod:`matplotlib` colourmap or is installed in the - ``fsl/fslview/colourmaps/`` directory, ``False`` - otherwise. + ``fsl/fslview/colourmaps/`` or ``fsl/fslview/luts/`` + directory, ``False`` otherwise. """ self.name = name - self.cmfile = cmfile + self.mapObj = mapObj + self.mapFile = mapFile self.installed = installed + def __str__(self): - if self.cmfile is not None: return self.cmfile - else: return self.name + if self.mapFile is not None: return self.mapFile + else: return self.name + def __repr__(self): return self.__str__() + +class LutLabel(object): + """This class represents a label -> name/colour mapping; a list of + ``LutLabel`` instances is managed by each :class:`LookupTable` instance. + + ``LutLabel`` objects are immutable. + """ + def __init__(self, value, name, colour, enabled): + + if value is None: raise ValueError('LutLabel value cannot be None') + if name is None: name = 'Label' + if colour is None: colour = (0, 0, 0) + if enabled is None: enabled = True -def getDefault(): - """Returns the name of the default colour map.""" - return getColourMaps()[0] + self.__value = value + self.__name = name + self.__colour = colour + self.__enabled = enabled -def getColourMaps(): - """Returns a list containing the names of all available colour maps.""" - return _cmaps.keys() + def value( self): return self.__value + def name( self): return self.__name + def colour( self): return self.__colour + def enabled(self): return self.__enabled -def isRegistered(cmapName): - """Returns ``True`` if the specified colourmap is registered, ``False`` - otherwise. - """ - return cmapName in _cmaps + def __eq__(self, other): + + return (self.__value == other.__value and + self.__name == other.__name and + self.__colour == other.__colour and + self.__enabled == other.__enabled) + + def __str__(self): + return '{}: {} / {} ({})'.format(self.__value, + self.__name, + self.__colour, + self.__enabled) -def isInstalled(cmapName): - """Returns ``True`` if the specified colourmap is installed, ``False`` - otherwise. A ``KeyError`` is raised if the colourmap is not registered. + + def __repr__(self): + return self.__str__() + + + def __cmp__(self, other): + return self.__value.__cmp__(other.__value) + + + def __hash__(self): + return (hash(self.__value) ^ + hash(self.__name) ^ + hash(self.__colour)) + + +class LookupTable(props.HasProperties): + """Class which encapsulates a list of labels and associated colours and + names, defining a lookup table to be used for colouring label images. """ - return _cmaps[cmapName].installed + + name = props.String() + """The name of this lut. """ -def installColourMap(cmapName): - """Attempts to install a previously registered colourmap into the - ``fsl/fslview/colourmaps`` directory. + + labels = props.List() + """A list of :class:`LutLabel` instances, defining the label -> + colour/name mappings. This list is sorted in increasing order + by the label value. - A ``KeyError`` is raised if the colourmap is not registered, a - ``RuntimeError`` if the colourmap cannot be installed, or an - ``IOError`` if the colourmap file cannot be copied. + If you modify this list directly, you will probably break things. Use + the get/set methods instead. """ - # keyerror if not registered - cmap = _cmaps[cmapName] - # built-in, or already installed - if cmap.installed: - return + saved = props.Boolean(default=False) + + + def __init__(self, name, lutFile=None): + + self.name = name + + if lutFile is not None: + self._load(lutFile) + + + def __len__(self): + return len(self.labels) + + + def max(self): + """Returns the maximum label value in this lookup table. """ + if len(self.labels) == 0: return 0 + else: return max([l.value() for l in self.labels]) + + + def __find(self, value): + """Finds the label associated with the specified value. Returns the + index and the label object, or ``(-1, None)`` if there is no label + associated with the given value. + """ + + for i, label in enumerate(self.labels): + if label.value() == value: + return i, label + + return (-1, None) + + + def get(self, value): + """Returns the :class:`LutLabel` instance associated with the given + ``value``, or ``None`` if there is no label. + """ + return self.__find(value)[1] - # cmap has been incorrectly registered - if cmap.cmfile is None: - raise RuntimeError('Colour map {} appears to have been ' - 'incorrectly registered'.format(cmapName)) - destfile = op.join(op.dirname(__file__), - 'colourmaps', - '{}.cmap'.format(cmapName)) + def set(self, value, **kwargs): + """Sets the colour/name/enabled states associated with the given + value. + """ - # destination file already exists - if op.exists(destfile): - raise RuntimeError('Destination file for colour map {} already ' - 'exists: {}'.format(cmapName, destfile)) + # At the moment, we are restricting + # lookup tables to be unsigned 16 bit. + # See gl/textures/lookuptabletexture.py + if not isinstance(value, (int, long)) or \ + value < 0 or value > 65535: + raise ValueError('Lookup table values must be ' + '16 bit unsigned integers.') + + idx, label = self.__find(value) + + # No label exists for the given value, + # so create a new LutLabel instance + # with default values + if idx == -1: + label = LutLabel(value, None, None, None) + + # Create a new LutLabel instance with the + # new, existing, or default label settings + name = kwargs.get('name', label.name()) + colour = kwargs.get('colour', label.colour()) + enabled = kwargs.get('enabled', label.enabled()) + label = LutLabel(value, name, colour, enabled) + + # Use the bisect module to + # maintain the list order + # when inserting new labels + if idx == -1: + + log.debug('Adding new label to {}: {}'.format( + self.name, label)) + + lutChanged = True + bisect.insort(self.labels, label) + else: + lutChanged = not (self.labels[idx].name() == label.name() and + self.labels[idx].colour() == label.colour()) + + log.debug('Updating label in {}: {} -> {} (changed: {})'.format( + self.name, self.labels[idx], label, lutChanged)) + + self.labels[idx] = label + + # Update the saved state if a new label has been added, + # or an existing label name/colour has been changed + if lutChanged: + self.saved = False + + + def delete(self, value): + """Removes the given label value from the lookup table.""" + + idx, label = self.__find(value) + + if idx == -1: + raise ValueError('Value {} is not in lookup table') + + self.labels.pop(idx) + self.saved = False - log.debug('Installing colour map {} to {}'.format(cmapName, destfile)) - shutil.copyfile(cmap.cmfile, destfile) + def _load(self, lutFile): + """Loads a ``LookupTable`` specification from the given file.""" + + with open(lutFile, 'rt') as f: + lines = f.readlines() + + for line in lines: + tkns = line.split() + + label = int( tkns[0]) + r = float( tkns[1]) + g = float( tkns[2]) + b = float( tkns[3]) + lName = ' '.join(tkns[4:]) + + self.set(label, name=lName, colour=(r, g, b), enabled=True) + + + def _save(self, lutFile): + """Saves this ``LookupTable`` instance to the specified ``lutFile``. + """ + + with open(lutFile, 'wt') as f: + for label in self.labels: + value = label.value() + colour = label.colour() + name = label.name() + + tkns = [value, colour[0], colour[1], colour[2], name] + line = ' '.join(map(str, tkns)) + + f.write('{}\n'.format(line)) + + +def init(): + """This function must be called before any of the other functions in this + module can be used. + + It initialises the colour map and lookup table registers, loading all + colour map and lookup table files that exist. + """ + + global _cmaps + global _luts + + registers = [] + + if _cmaps is None: + _cmaps = OrderedDict() + registers.append((_cmaps, _cmapDir, 'cmap')) + + if _luts is None: + _luts = OrderedDict() + registers.append((_luts, _lutDir, 'lut')) + + if len(registers) == 0: + return + + for register, rdir, suffix in registers: + + # Build up a list of key -> name mappings, + # from the order.txt file, and any other + # colour map/lookup table files in the + # cmap/lut directory + allmaps = OrderedDict() + orderFile = op.join(rdir, 'order.txt') + + if op.exists(orderFile): + with open(orderFile, 'rt') as f: + lines = f.read().split('\n') + + for line in lines: + if line.strip() == '': + continue + + # The order.txt file is assumed to + # contain one row per cmap/lut, + # where the first word is the key + # (the cmap/lut file name prefix), + # and the remainder of the line is + # the cmap/lut name + key, name = line.split(' ', 1) + + allmaps[key.strip()] = name.strip() + # Search through all cmap/lut files that exist - + # any which were not specified in order.txt + # are added to the end of the list, and their + # name is just set to the file name prefix + for mapFile in sorted(glob.glob(op.join(rdir, '*.{}'.format(suffix)))): -def registerColourMap(cmapFile, name=None): + name = op.basename(mapFile).split('.')[0] + + if name not in allmaps: + allmaps[name] = name + + # Now load all of the cmaps/luts + for key, name in allmaps.items(): + mapFile = op.join(rdir, '{}.{}'.format(key, suffix)) + + try: + if suffix == 'cmap': registerColourMap( mapFile, name=name) + elif suffix == 'lut': registerLookupTable(mapFile, name=name) + + register[name].installed = True + + except Exception as e: + log.warn('Error processing custom {} ' + 'file {}: {}'.format(suffix, mapFile, str(e))) + + +def registerColourMap(cmapFile, overlayList=None, displayCtx=None, name=None): """Loads RGB data from the given file, and registers it as a :mod:`matplotlib` :class:`~matplotlib.colors.ListedColormap` instance. - :arg cmapFile: Name of a file containing RGB values + :arg cmapFile: Name of a file containing RGB values + + :arg overlayList: A :class:`.OverlayList` instance which contains all + overlays that are being displayed (can be ``None``). - :arg name: Name to give the colour map. If ``None``, defaults - to the file name prefix. + :arg displayCtx: A :class:`.DisplayContext` instance describing how + the overlays in ``overlayList`` are being displayed. + Must be provided if ``overlayList`` is provided. + + :arg name: Name to give the colour map. If ``None``, defaults + to the file name prefix. """ if name is None: name = op.basename(cmapFile).split('.')[0] + if overlayList is None: + overlayList = [] + data = np.loadtxt(cmapFile) cmap = colors.ListedColormap(data, name) @@ -182,72 +514,231 @@ def registerColourMap(cmapFile, name=None): mplcm.register_cmap(name, cmap) - _cmaps[name] = _ColourMap(name, cmapFile, False) + _cmaps[name] = _Map(name, cmap, None, False) - -# Load all custom colour maps from the colourmaps/*.cmap files, -# honouring the naming/order specified in order.txt (if it exists). -def initColourMaps(): - """This function must be called before any of the other functions in this - module can be used. + # TODO Any new Opts types which have a colour + # map will need to be patched here - It initialises the colour map 'register', loading all colour map files - that exist. + log.debug('Patching VolumeOpts instances and class ' + 'to support new colour map {}'.format(name)) + + import fsl.fslview.displaycontext as fsldisplay + + # update the VolumeOpts colour map property + # for any existing VolumeOpts instances + for overlay in overlayList: + opts = displayCtx.getOpts(overlay) + if isinstance(opts, fsldisplay.VolumeOpts): + opts.setConstraint('cmap', + 'cmapNames', + getColourMaps()) + + # and for all future volume overlays + fsldisplay.VolumeOpts.cmap.setConstraint( + None, + 'cmapNames', + getColourMaps()) + + +def registerLookupTable(lut, overlayList=None, displayCtx=None, name=None): + """Registers the given ``LookupTable`` instance (if ``lut`` is a string, + it is assumed to be the name of a ``.lut`` file, which is loaded). + + :arg lut: A :class:`LookupTable` instance, or the name of a + ``.lut`` file. + + :arg overlayList: A :class:`.OverlayList` instance which contains all + overlays that are being displayed (can be ``None``). + + :arg displayCtx: A :class:`.DisplayContext` instance describing how + the overlays in ``overlayList`` are being displayed. + Must be provided if ``overlayList`` is provided. + + :arg name: Name to give the lookup table. If ``None``, defaults + to the file name prefix. """ - global _cmaps + if isinstance(lut, basestring): lutFile = lut + else: lutFile = None - if _cmaps is not None: - return + if overlayList is None: + overlayList = [] + + # lut may be either a file name + # or a LookupTable instance + if lutFile is not None: - _cmaps = OrderedDict() + if name is None: + name = op.basename(lutFile).split('.')[0] - # Build up a list of cmapKey -> cmapName - # mappings, from the order.txt file, and - # any other colour map files in the cmap - # directory - allcmaps = OrderedDict() - orderFile = op.join(_cmapDir, 'order.txt') + log.debug('Loading and registering custom ' + 'lookup table: {}'.format(lutFile)) + + lut = LookupTable(name, lutFile) + else: + if name is None: + name = lut.name + else: + lut.name = name - if op.exists(orderFile): - with open(orderFile, 'rt') as f: - lines = f.read().split('\n') + # Even though the lut may have been loaded from + # a file, it has not necessarily been installed + lut.saved = False + + _luts[name] = _Map(name, lut, None, False) - for line in lines: - if line.strip() == '': - continue - # The order.txt file is assumed to - # contain one row per colour map, - # where the first word is the colour - # map key (the cmap file name prefix), - # and the remainder of the line is - # the colour map name - key, name = line.split(' ', 1) - - allcmaps[key.strip()] = name.strip() - - # Search through all cmap files that exist - - # any which were not specified in order.txt - # are added to the end of the list, and their - # name is just set to the file name prefix - for cmapFile in sorted(glob.glob(op.join(_cmapDir, '*.cmap'))): + log.debug('Patching LabelOpts classes to support ' + 'new LookupTable {}'.format(name)) + + import fsl.fslview.displaycontext as fsldisplay + + # Update the lut property for + # any existing label overlays + for overlay in overlayList: + opts = displayCtx.getOpts(overlay) + + if not isinstance(opts, fsldisplay.LabelOpts): + continue + + lutChoice = opts.getProp('lut') + lutChoice.addChoice(lut, lut.name, opts) + + # and for any future label overlays + fsldisplay.LabelOpts.lut.addChoice(lut, lut.name) + + return lut + + +def getLookupTables(): + """Returns a list containing all available lookup tables.""" + return [_luts[lutName].mapObj for lutName in _luts.keys()] - name = op.basename(cmapFile).split('.')[0] - if name not in allcmaps: - allcmaps[name] = name +def getLookupTable(lutName): + """Returns the :class:`LutTable` instance of the specified name.""" + return _luts[lutName].mapObj - # Now load all of the colour maps - for key, name in allcmaps.items(): - cmapFile = op.join(_cmapDir, '{}.cmap'.format(key)) - try: - registerColourMap(cmapFile, name) - _cmaps[name].installed = True +def getColourMaps(): + """Returns a list containing the names of all available colour maps.""" + return _cmaps.keys() + + +def getColourMap(cmapName): + """Returns the colour map instance of the specified name.""" + return _cmaps[cmapName].mapObj + + +def isColourMapRegistered(cmapName): + """Returns ``True`` if the specified colourmap is registered, ``False`` + otherwise. + """ + return cmapName in _cmaps + + +def isLookupTableRegistered(lutName): + """Returns ``True`` if the specified lookup table is registered, ``False`` + otherwise. + """ + return lutName in _luts + + +def isColourMapInstalled(cmapName): + """Returns ``True`` if the specified colourmap is installed, ``False`` + otherwise. A ``KeyError`` is raised if the colourmap is not registered. + """ + return _cmaps[cmapName].installed + + +def isLookupTableInstalled(lutName): + """Returns ``True`` if the specified loolup table is installed, ``False`` + otherwise. A ``KeyError`` is raised if the lookup tabler is not + registered. + """ + return _luts[lutName].installed + + +def installColourMap(cmapName): + """Attempts to install a previously registered colourmap into the + ``fsl/fslview/colourmaps`` directory. + """ - except: - log.warn('Error processing custom colour ' - 'map file: {}'.format(cmapFile)) + # keyerror if not registered + cmap = _cmaps[cmapName] + + if cmap.mapFile is not None: + destFile = cmap.mapFile + else: + destFile = op.join(op.dirname(__file__), + 'colourmaps', + '{}.cmap'.format(cmapName)) + + log.debug('Installing colour map {} to {}'.format(cmapName, destFile)) + + # I think the colors attribute is only + # available on ListedColormap instances ... + data = cmap.mapObj.colors + np.savetxt(destFile, data, '%0.6f') + + cmap.installed = True + + +def installLookupTable(lutName): + """Attempts to install/save a previously registered lookup table into + the ``fsl/fslview/luts`` directory. + """ + + # keyerror if not registered + lut = _luts[lutName] + + if lut.mapFile is not None: + destFile = lut.mapFile + else: + destFile = op.join( + _lutDir, + '{}.lut'.format(lutName.lower().replace(' ', '_'))) + + log.debug('Installing lookup table {} to {}'.format(lutName, destFile)) + + lut.mapObj._save(destFile) + + lut.mapFile = destFile + lut.installed = True + lut.mapObj.saved = True + + +############### +# Miscellaneous +############### + + +def _briconToScaleOffset(brightness, contrast, drange): + """Used by the :func:`briconToDisplayRange` and the :func:`applyBricon` + functions. + + Calculates a scale and offset which can be used to transform a display + range of the given size so that the given brightness/contrast settings + are applied. + """ + + # The brightness is applied as a linear offset, + # with 0.5 equivalent to an offset of 0.0. + offset = (brightness * 2 - 1) * drange + + # If the contrast lies between 0.0 and 0.5, it is + # applied to the colour as a linear scaling factor. + if contrast <= 0.5: + scale = contrast * 2 + + # If the contrast lies between 0.5 and 1, it + # is applied as an exponential scaling factor, + # so lower values (closer to 0.5) have less of + # an effect than higher values (closer to 1.0). + else: + scale = 20 * contrast ** 4 - 0.25 + + return scale, offset + def briconToDisplayRange(dataRange, brightness, contrast): @@ -271,20 +762,8 @@ def briconToDisplayRange(dataRange, brightness, contrast): drange = dmax - dmin dmid = dmin + 0.5 * drange - # The brightness is applied as a linear offset, - # with 0.5 equivalent to an offset of 0.0. - offset = (brightness * 2 - 1) * drange + scale, offset = _briconToScaleOffset(brightness, contrast, drange) - # If the contrast lies between 0.0 and 0.5, it is - # applied to the colour as a linear scaling factor. - scale = contrast * 2 - - # If the contrast lies between 0.5 and 1, it - # is applied as an exponential scaling factor, - # so lower values (closer to 0.5) have less of - # an effect than higher values (closer to 1.0). - if contrast > 0.5: - scale += np.exp((contrast - 0.5) * 6) - 1 # Calculate the new display range, keeping it # centered in the middle of the data range @@ -311,7 +790,7 @@ def displayRangeToBricon(dataRange, displayRange): dmid = dmin + 0.5 * drange # These are inversions of the equations in - # the briconToDisplayRange function above, + # the briconToScaleOffset function above, # which calculate the display ranges from # the bricon offset/scale offset = dlo + 0.5 * (dhi - dlo) - dmid @@ -320,9 +799,61 @@ def displayRangeToBricon(dataRange, displayRange): brightness = 0.5 * (offset / drange + 1) if scale <= 1: contrast = scale / 2.0 - else: contrast = np.log(scale + 1) / 6.0 + 0.5 + else: contrast = ((scale + 0.25) / 20.0) ** 0.25 brightness = 1.0 - brightness contrast = 1.0 - contrast return brightness, contrast + + +def applyBricon(rgb, brightness, contrast): + """Applies the given ``brightness`` and ``contrast`` levels to + the given ``rgb`` colour(s). + + Passing in ``0.5`` for both the ``brightness`` and ``contrast`` will + result in the colour being returned unchanged. + + :arg rgb: A sequence of three or four floating point numbers in + the range ``[0, 1]`` specifying an RGB(A) value, or a + :mod:`numpy` array of shape ``(n, 3)`` or ``(n, 4)`` + specifying ``n`` colours. If alpha values are passed + in, they are returned unchanged. + + :arg brightness: A brightness level in the range ``[0, 1]``. + + :arg contrast: A contrast level in the range ``[0, 1]``. + """ + rgb = np.array(rgb) + oneColour = len(rgb.shape) == 1 + rgb = rgb.reshape(-1, rgb.shape[-1]) + + scale, offset = _briconToScaleOffset(brightness, contrast, 1) + + # The contrast factor scales the existing colour + # range, but keeps the new range centred at 0.5. + rgb[:, :3] += offset + + rgb[:, :3] = np.clip(rgb[:, :3], 0.0, 1.0) + rgb[:, :3] = (rgb[:, :3] - 0.5) * scale + 0.5 + + rgb[:, :3] = np.clip(rgb[:, :3], 0.0, 1.0) + + if oneColour: return rgb[0] + else: return rgb + + +def randomColour(): + """Generates a random RGB colour. """ + return np.random.random(3) + + +def randomBrightColour(): + """Generates a random saturated RGB colour. """ + colour = np.random.random(3) + colour[colour.argmax()] = 1 + colour[colour.argmin()] = 0 + + np.random.shuffle(colour) + + return colour diff --git a/fsl/fslview/controls/__init__.py b/fsl/fslview/controls/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..a9471fdad551b58a595041c24ae62fc14f19f333 100644 --- a/fsl/fslview/controls/__init__.py +++ b/fsl/fslview/controls/__init__.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +# +# __init__.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +from atlaspanel import AtlasPanel +from overlaydisplaypanel import OverlayDisplayPanel +from overlaylistpanel import OverlayListPanel +from locationpanel import LocationPanel +from lookuptablepanel import LookupTablePanel +from timeserieslistpanel import TimeSeriesListPanel +from timeseriescontrolpanel import TimeSeriesControlPanel +from histogramlistpanel import HistogramListPanel +from histogramcontrolpanel import HistogramControlPanel +from clusterpanel import ClusterPanel +from canvassettingspanel import CanvasSettingsPanel + +from orthotoolbar import OrthoToolBar +from orthoprofiletoolbar import OrthoProfileToolBar +from lightboxtoolbar import LightBoxToolBar +from overlaydisplaytoolbar import OverlayDisplayToolBar diff --git a/fsl/fslview/controls/atlasinfopanel.py b/fsl/fslview/controls/atlasinfopanel.py index ac55f1676db23e63104f01685d4a72d0ae16bad2..8cb248237d46ae9db1298b29a3825b940fbe9c63 100644 --- a/fsl/fslview/controls/atlasinfopanel.py +++ b/fsl/fslview/controls/atlasinfopanel.py @@ -49,76 +49,89 @@ class AtlasListWidget(wx.CheckBox): # that the user can choose from class AtlasInfoPanel(fslpanel.FSLViewPanel): - def __init__(self, parent, imageList, displayCtx, atlasPanel): - fslpanel.FSLViewPanel.__init__(self, parent, imageList, displayCtx) - - self.enabledAtlases = {} - self.atlasPanel = atlasPanel - self.contentPanel = wx.SplitterWindow(self, style=wx.SP_LIVE_UPDATE) - self.infoPanel = wxhtml.HtmlWindow(self.contentPanel) - self.atlasList = elistbox.EditableListBox( - self.contentPanel, + def __init__(self, parent, overlayList, displayCtx, atlasPanel): + fslpanel.FSLViewPanel.__init__(self, parent, overlayList, displayCtx) + + self.__enabledAtlases = {} + self.__atlasPanel = atlasPanel + self.__contentPanel = wx.SplitterWindow(self, + style=wx.SP_LIVE_UPDATE) + self.__infoPanel = wxhtml.HtmlWindow(self.__contentPanel) + self.__atlasList = elistbox.EditableListBox( + self.__contentPanel, style=(elistbox.ELB_NO_ADD | elistbox.ELB_NO_REMOVE | elistbox.ELB_NO_MOVE)) - self.sizer = wx.BoxSizer(wx.HORIZONTAL) - self.sizer.Add(self.contentPanel, flag=wx.EXPAND, proportion=1) - self.SetSizer(self.sizer) + # Force the HTML info panel to + # use the default font size + self.__infoPanel.SetStandardFonts(self.GetFont().GetPointSize()) - self.contentPanel.SetMinimumPaneSize(50) - self.contentPanel.SplitVertically(self.atlasList, self.infoPanel) - self.contentPanel.SetSashGravity(0.4) + self.__sizer = wx.BoxSizer(wx.HORIZONTAL) + self.__sizer.Add(self.__contentPanel, flag=wx.EXPAND, proportion=1) + self.SetSizer(self.__sizer) + + self.__contentPanel.SetMinimumPaneSize(50) + self.__contentPanel.SplitVertically(self.__atlasList, + self.__infoPanel) + self.__contentPanel.SetSashGravity(0.4) for i, atlasDesc in enumerate(atlases.listAtlases()): - self.atlasList.Append(atlasDesc.name, atlasDesc.atlasID) - widget = AtlasListWidget(self.atlasList, self, atlasDesc.atlasID) - self.atlasList.SetItemWidget(i, widget) + self.__atlasList.Append(atlasDesc.name, atlasDesc.atlasID) + widget = AtlasListWidget(self.__atlasList, + self, + atlasDesc.atlasID) + self.__atlasList.SetItemWidget(i, widget) # The info panel contains clickable links # for the currently displayed regions - # when a link is clicked, the location # is centred at the corresponding region - self.infoPanel.Bind(wxhtml.EVT_HTML_LINK_CLICKED, - self._infoPanelLinkClicked) - - displayCtx.addListener('location', - self._name, - self._locationChanged) - displayCtx.addListener('selectedImage', - self._name, - self._locationChanged) - - self._locationChanged() + self.__infoPanel.Bind(wxhtml.EVT_HTML_LINK_CLICKED, + self.__infoPanelLinkClicked) + + overlayList.addListener('overlays', + self._name, + self.__selectedOverlayChanged) + displayCtx .addListener('selectedOverlay', + self._name, + self.__selectedOverlayChanged) + displayCtx .addListener('location', + self._name, + self.__locationChanged) + + self.__selectedOverlayChanged() self.Layout() - self.SetMinSize(self.sizer.GetMinSize()) + self.SetMinSize(self.__sizer.GetMinSize()) def destroy(self): """Must be called when this :class:`AtlasInfoPanel` is to be destroyed. De-registers various property listeners. """ - fslpanel.FSLViewPanel.destroy(self) - self._displayCtx.removeListener('location', self._name) - self._displayCtx.removeListener('selectedImage', self._name) + self._overlayList.removeListener('overlays', self._name) + self._displayCtx .removeListener('location', self._name) + self._displayCtx .removeListener('selectedOverlay', self._name) + + fslpanel.FSLViewPanel.destroy(self) def enableAtlasInfo(self, atlasID): - self.enabledAtlases[atlasID] = self.atlasPanel.loadAtlas(atlasID, - False) - self._locationChanged() + self.__enabledAtlases[atlasID] = self.__atlasPanel.loadAtlas(atlasID, + False) + self.__locationChanged() def disableAtlasInfo(self, atlasID): - self.enabledAtlases.pop(atlasID) - self.atlasPanel.clearAtlas(atlasID, False) - self._locationChanged() + self.__enabledAtlases.pop(atlasID) + self.__atlasPanel.clearAtlas(atlasID, False) + self.__locationChanged() - def _infoPanelLinkClicked(self, ev): + def __infoPanelLinkClicked(self, ev): showType, atlasID, labelIndex = ev.GetLinkInfo().GetHref().split() @@ -131,39 +144,68 @@ class AtlasInfoPanel(fslpanel.FSLViewPanel): # is loaded summary = showType != 'prob' - self.atlasPanel.toggleOverlay(atlasID, labelIndex, summary) + self.__atlasPanel.toggleOverlay(atlasID, labelIndex, summary) - def _locationChanged(self, *a): - - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) - loc = self._displayCtx.location - text = self.infoPanel - loc = transform.transform( - [loc], display.getTransform('display', 'world'))[0] + def __selectedOverlayChanged(self, *a): + """ + """ + + if len(self._overlayList) == 0: + self.__locationChanged() + return + + selOverlay = self._displayCtx.getSelectedOverlay() + + for ovl in self._overlayList: + + opts = self._displayCtx.getOpts(ovl) + + if ovl == selOverlay: + opts.addGlobalListener( self._name, + self.__locationChanged, + overwrite=True) + else: + opts.removeGlobalListener(self._name) + self.__locationChanged() + + def __locationChanged(self, *a): + + text = self.__infoPanel + overlay = self._displayCtx.getReferenceImage( + self._displayCtx.getSelectedOverlay()) + if len(atlases.listAtlases()) == 0: text.SetPage(strings.messages['AtlasInfoPanel.atlasDisabled']) return - if image.getXFormCode() != constants.NIFTI_XFORM_MNI_152: + if overlay is None: + text.SetPage(strings.messages['AtlasInfoPanel.noReference']) + return + + if overlay.getXFormCode() != constants.NIFTI_XFORM_MNI_152: text.SetPage(strings.messages['AtlasInfoPanel.notMNISpace']) return - if len(self.enabledAtlases) == 0: + if len(self.__enabledAtlases) == 0: text.SetPage(strings.messages['AtlasInfoPanel.chooseAnAtlas']) return + opts = self._displayCtx.getOpts(overlay) + loc = self._displayCtx.location + loc = transform.transform( + [loc], opts.getTransform('display', 'world'))[0] + lines = [] titleTemplate = '<b>{}</b> (<a href="summary {} {}">Show/Hide</a>)' labelTemplate = '{} (<a href="label {} {}">Show/Hide</a>)' probTemplate = '{:0.1f}% {} (<a href="prob {} {}">Show/Hide</a>)' - for atlasID in self.enabledAtlases: + for atlasID in self.__enabledAtlases: - atlas = self.enabledAtlases[atlasID] + atlas = self.__enabledAtlases[atlasID] lines.append(titleTemplate.format(atlas.desc.name, atlasID, None)) diff --git a/fsl/fslview/controls/atlasoverlaypanel.py b/fsl/fslview/controls/atlasoverlaypanel.py index ca0216ce501328667d7c0fa9862f2614bc52fc49..e0be7f2c228d859ed64d96a43558a5fb8ffcc109 100644 --- a/fsl/fslview/controls/atlasoverlaypanel.py +++ b/fsl/fslview/controls/atlasoverlaypanel.py @@ -14,6 +14,8 @@ import wx import pwidgets.elistbox as elistbox import fsl.data.atlases as atlases +import fsl.data.strings as strings +import fsl.utils.dialog as dialog import fsl.fslview.panel as fslpanel @@ -59,61 +61,64 @@ class OverlayListWidget(wx.Panel): self.atlasPanel.locateRegion(self.atlasID, self.labelIdx) -# TODO control to locate region (in addition -# to control displaying the region) class AtlasOverlayPanel(fslpanel.FSLViewPanel): - def __init__(self, parent, imageList, displayCtx, atlasPanel): + def __init__(self, parent, overlayList, displayCtx, atlasPanel): - fslpanel.FSLViewPanel.__init__(self, parent, imageList, displayCtx) + fslpanel.FSLViewPanel.__init__(self, parent, overlayList, displayCtx) - self.enabledOverlays = {} - self.atlasPanel = atlasPanel - self.contentPanel = wx.SplitterWindow(self, style=wx.SP_LIVE_UPDATE) - self.atlasList = elistbox.EditableListBox( - self.contentPanel, + self.__enabledOverlays = {} + self.__atlasPanel = atlasPanel + self.__contentPanel = wx.SplitterWindow(self, + style=wx.SP_LIVE_UPDATE) + self.__atlasList = elistbox.EditableListBox( + self.__contentPanel, style=(elistbox.ELB_NO_ADD | elistbox.ELB_NO_REMOVE | elistbox.ELB_NO_MOVE)) - self.regionPanel = wx.Panel( self.contentPanel) - self.regionFilter = wx.TextCtrl(self.regionPanel) + self.__regionPanel = wx.Panel( self.__contentPanel) + self.__regionFilter = wx.TextCtrl(self.__regionPanel) atlasDescs = atlases.listAtlases() - self.regionLists = [None] * len(atlasDescs) + self.__regionLists = [None] * len(atlasDescs) - self.contentPanel.SetMinimumPaneSize(50) - self.contentPanel.SplitVertically(self.atlasList, self.regionPanel) - self.contentPanel.SetSashGravity(0.5) + self.__contentPanel.SetMinimumPaneSize(50) + self.__contentPanel.SplitVertically(self.__atlasList, + self.__regionPanel) + self.__contentPanel.SetSashGravity(0.5) - self.sizer = wx.BoxSizer(wx.HORIZONTAL) - self.regionSizer = wx.BoxSizer(wx.VERTICAL) + self.__sizer = wx.BoxSizer(wx.HORIZONTAL) + self.__regionSizer = wx.BoxSizer(wx.VERTICAL) - self.regionSizer.Add(self.regionFilter, flag=wx.EXPAND) - self.regionSizer.AddStretchSpacer() + self.__regionSizer.Add(self.__regionFilter, flag=wx.EXPAND) + self.__regionSizer.AddStretchSpacer() - self.sizer .Add(self.contentPanel, flag=wx.EXPAND, proportion=1) + self.__sizer .Add(self.__contentPanel, + flag=wx.EXPAND, + proportion=1) - self.regionPanel.SetSizer(self.regionSizer) - self .SetSizer(self.sizer) + self.__regionPanel.SetSizer(self.__regionSizer) + self .SetSizer(self.__sizer) for i, atlasDesc in enumerate(atlasDescs): - self.atlasList.Append(atlasDesc.name, atlasDesc) - self._updateAtlasState(i) - widget = OverlayListWidget(self.atlasList, + self.__atlasList.Append(atlasDesc.name, atlasDesc) + self.__updateAtlasState(i) + widget = OverlayListWidget(self.__atlasList, atlasDesc.atlasID, atlasPanel) - self.atlasList.SetItemWidget(i, widget) + self.__atlasList.SetItemWidget(i, widget) - self.regionFilter.Bind(wx.EVT_TEXT, self._onRegionFilter) - self.atlasList.Bind(elistbox.EVT_ELB_SELECT_EVENT, self._onAtlasSelect) + self.__regionFilter.Bind(wx.EVT_TEXT, self.__onRegionFilter) + self.__atlasList.Bind(elistbox.EVT_ELB_SELECT_EVENT, + self.__onAtlasSelect) - self.regionSizer.Layout() - self.sizer .Layout() + self.__regionSizer.Layout() + self.__sizer .Layout() - self.SetMinSize(self.sizer.GetMinSize()) + self.SetMinSize(self.__sizer.GetMinSize()) def setOverlayState(self, atlasID, labelIdx, summary, state): @@ -123,94 +128,103 @@ class AtlasOverlayPanel(fslpanel.FSLViewPanel): atlasID, labelIdx, state)) if labelIdx is None: - widget = self.atlasList.GetItemWidget(atlasDesc.index) + widget = self.__atlasList.GetItemWidget(atlasDesc.index) widget.enableBox.SetValue(state) else: - regionList = self.regionLists[atlasDesc.index] + regionList = self.__regionLists[atlasDesc.index] if regionList is not None: regionList.GetItemWidget(labelIdx).enableBox.SetValue(state) - def _onRegionFilter(self, ev): + def __onRegionFilter(self, ev): - filterStr = self.regionFilter.GetValue().lower() + filterStr = self.__regionFilter.GetValue().lower().strip() - for i, listBox in enumerate(self.regionLists): + for i, listBox in enumerate(self.__regionLists): - if listBox is None: - continue - - listBox.ApplyFilter(filterStr, ignoreCase=True) - self._updateAtlasState(i) + self.__updateAtlasState(i) + if listBox is not None: + listBox.ApplyFilter(filterStr, ignoreCase=True) - def _updateAtlasState(self, atlasIdx): - listBox = self.regionLists[atlasIdx] - - if listBox is None: - weight = wx.FONTWEIGHT_LIGHT - colour = '#a0a0a0' - - elif listBox.VisibleItemCount() == 0: + def __updateAtlasState(self, atlasIdx): + + filterStr = self.__regionFilter.GetValue().lower().strip() + atlasDesc = self.__atlasList.GetItemData(atlasIdx) + + if filterStr == '': + nhits = 0 + else: + nhits = len(filter( + lambda l: filterStr in l.name.lower(), + atlasDesc.labels)) + + if nhits == 0: weight = wx.FONTWEIGHT_LIGHT - colour = '#303030' + colour = '#404040' else: weight = wx.FONTWEIGHT_BOLD colour = '#000000' - font = self.atlasList.GetItemFont(atlasIdx) + font = self.__atlasList.GetItemFont(atlasIdx) font.SetWeight(weight) - self.atlasList.SetItemFont(atlasIdx, font) - self.atlasList.SetItemForegroundColour(atlasIdx, colour, colour) + self.__atlasList.SetItemFont(atlasIdx, font) + self.__atlasList.SetItemForegroundColour(atlasIdx, colour, colour) - def _onAtlasSelect(self, ev): + def __onAtlasSelect(self, ev): atlasDesc = ev.data atlasIdx = ev.idx - regionList = self.regionLists[atlasIdx] + regionList = self.__regionLists[atlasIdx] if regionList is None: regionList = elistbox.EditableListBox( - self.regionPanel, + self.__regionPanel, style=(elistbox.ELB_NO_ADD | elistbox.ELB_NO_REMOVE | elistbox.ELB_NO_MOVE)) - log.debug('Creating region list for {} ({})'.format( - atlasDesc.atlasID, id(regionList))) - - self.regionLists[atlasIdx] = regionList - for i, label in enumerate(atlasDesc.labels): - regionList.Append(label.name) - widget = OverlayListWidget(regionList, - atlasDesc.atlasID, - self.atlasPanel, - label.index) - regionList.SetItemWidget(i, widget) - + def buildRegionList(): + + log.debug('Creating region list for {} ({})'.format( + atlasDesc.atlasID, id(regionList))) + + self.__regionLists[atlasIdx] = regionList + + for i, label in enumerate(atlasDesc.labels): + regionList.Append(label.name) + widget = OverlayListWidget(regionList, + atlasDesc.atlasID, + self.__atlasPanel, + label.index) + regionList.SetItemWidget(i, widget) + filterStr = self.__regionFilter.GetValue().lower().strip() + regionList.ApplyFilter(filterStr, ignoreCase=True) - filterStr = self.regionFilter.GetValue().lower() - regionList.ApplyFilter(filterStr, ignoreCase=True) + self.__updateAtlasState(atlasIdx) - self._updateAtlasState(atlasIdx) + dialog.ProcessingDialog( + self, + strings.messages[self, 'loadRegions'].format(atlasDesc.name), + buildRegionList).Run() log.debug('Showing region list for {} ({})'.format( atlasDesc.atlasID, id(regionList))) - old = self.regionSizer.GetItem(1).GetWindow() + old = self.__regionSizer.GetItem(1).GetWindow() if old is not None: old.Show(False) regionList.Show(True) - self.regionSizer.Remove(1) + self.__regionSizer.Remove(1) - self.regionSizer.Insert(1, regionList, flag=wx.EXPAND, proportion=1) - self.regionSizer.Layout() + self.__regionSizer.Insert(1, regionList, flag=wx.EXPAND, proportion=1) + self.__regionSizer.Layout() diff --git a/fsl/fslview/controls/atlaspanel.py b/fsl/fslview/controls/atlaspanel.py index 3bab6da0efcafe345381fd0d041b684fbbd4cb3b..9868a3c278d1b30876d8b2855f09125aa8f61e10 100644 --- a/fsl/fslview/controls/atlaspanel.py +++ b/fsl/fslview/controls/atlaspanel.py @@ -10,13 +10,14 @@ import logging import numpy as np import wx -import pwidgets.notebook as notebook +import pwidgets.notebook as notebook -import fsl.data.image as fslimage -import fsl.data.atlases as atlases -import fsl.data.strings as strings -import fsl.utils.transform as transform -import fsl.fslview.panel as fslpanel +import fsl.data.image as fslimage +import fsl.data.atlases as atlases +import fsl.data.strings as strings +import fsl.utils.transform as transform +import fsl.fslview.panel as fslpanel +import fsl.fslview.colourmaps as fslcm log = logging.getLogger(__name__) @@ -25,50 +26,51 @@ log = logging.getLogger(__name__) class AtlasPanel(fslpanel.FSLViewPanel): - def __init__(self, parent, imageList, displayCtx): + def __init__(self, parent, overlayList, displayCtx): import fsl.fslview.controls.atlasoverlaypanel as atlasoverlaypanel import fsl.fslview.controls.atlasinfopanel as atlasinfopanel - fslpanel.FSLViewPanel.__init__(self, parent, imageList, displayCtx) + fslpanel.FSLViewPanel.__init__(self, parent, overlayList, displayCtx) - self.loadedAtlases = {} - self.atlasRefCounts = {} + self.__loadedAtlases = {} + self.__atlasRefCounts = {} - self.notebook = notebook.Notebook(self) + self.__notebook = notebook.Notebook(self) - self.sizer = wx.BoxSizer(wx.HORIZONTAL) - self.sizer.Add(self.notebook, flag=wx.EXPAND, proportion=1) + self.__sizer = wx.BoxSizer(wx.HORIZONTAL) + self.__sizer.Add(self.__notebook, flag=wx.EXPAND, proportion=1) - self.SetSizer(self.sizer) + self.SetSizer(self.__sizer) - self.infoPanel = atlasinfopanel.AtlasInfoPanel( - self.notebook, imageList, displayCtx, self) + self.__infoPanel = atlasinfopanel.AtlasInfoPanel( + self.__notebook, overlayList, displayCtx, self) # Overlay panel, containing a list of regions, # allowing the user to add/remove overlays - self.overlayPanel = atlasoverlaypanel.AtlasOverlayPanel( - self.notebook, imageList, displayCtx, self) + self.__overlayPanel = atlasoverlaypanel.AtlasOverlayPanel( + self.__notebook, overlayList, displayCtx, self) - self.notebook.AddPage(self.infoPanel, - strings.titles[self.infoPanel]) - self.notebook.AddPage(self.overlayPanel, - strings.titles[self.overlayPanel]) + self.__notebook.AddPage(self.__infoPanel, + strings.titles[self.__infoPanel]) + self.__notebook.AddPage(self.__overlayPanel, + strings.titles[self.__overlayPanel]) - # TODO Listen on image list, and update overlay - # panel states when an overlay image is removed + # TODO Listen on overlay list, and update atlas + # overlay panel states when an overlay image is + # removed self.Layout() - self.SetMinSize(self.sizer.GetMinSize()) + self.SetMinSize(self.__sizer.GetMinSize()) def destroy(self): """Must be called on destruction. Performs some necessary clean up when this AtlasPanel is no longer needed. """ + self.__infoPanel .destroy() + self.__overlayPanel .destroy() fslpanel.FSLViewPanel.destroy(self) - self.infoPanel.destroy() - self.overlayPanel.destroy() def loadAtlas(self, atlasID, summary): @@ -78,8 +80,8 @@ class AtlasPanel(fslpanel.FSLViewPanel): if desc.atlasType == 'summary': summary = True - refCount = self.atlasRefCounts.get((atlasID, summary), 0) - atlas = self.loadedAtlases .get((atlasID, summary), None) + refCount = self.__atlasRefCounts.get((atlasID, summary), 0) + atlas = self.__loadedAtlases .get((atlasID, summary), None) if atlas is None: log.debug('Loading atlas {}/{} ({} references)'.format( @@ -88,8 +90,8 @@ class AtlasPanel(fslpanel.FSLViewPanel): refCount + 1)) atlas = atlases.loadAtlas(atlasID, summary) - self.atlasRefCounts[atlasID, summary] = refCount + 1 - self.loadedAtlases[ atlasID, summary] = atlas + self.__atlasRefCounts[atlasID, summary] = refCount + 1 + self.__loadedAtlases[ atlasID, summary] = atlas return atlas @@ -101,19 +103,19 @@ class AtlasPanel(fslpanel.FSLViewPanel): if desc.atlasType == 'summary': summary = True - refCount = self.atlasRefCounts[atlasID, summary] + refCount = self.__atlasRefCounts[atlasID, summary] if refCount == 0: return - self.atlasRefCounts[atlasID, summary] = refCount - 1 + self.__atlasRefCounts[atlasID, summary] = refCount - 1 if refCount - 1 == 0: log.debug('Clearing atlas {}/{} ({} references)'.format( atlasID, 'label' if summary else 'prob', refCount - 1)) - self.loadedAtlases.pop((atlasID, summary)) + self.__loadedAtlases.pop((atlasID, summary)) def getOverlayName(self, atlasID, labelIdx, summary): @@ -138,19 +140,19 @@ class AtlasPanel(fslpanel.FSLViewPanel): def getOverlayState(self, atlasID, labelIdx, summary): name, _ = self.getOverlayName(atlasID, labelIdx, summary) - return self._imageList.find(name) is not None + return self._overlayList.find(name) is not None def toggleOverlay(self, atlasID, labelIdx, summary): atlasDesc = atlases.getAtlasDescription(atlasID) overlayName, summary = self.getOverlayName(atlasID, labelIdx, summary) - overlay = self._imageList.find(overlayName) + overlay = self._overlayList.find(overlayName) if overlay is not None: self.clearAtlas(atlasID, summary) - self._imageList.remove(overlay) - self.overlayPanel.setOverlayState( + self._overlayList.remove(overlay) + self.__overlayPanel.setOverlayState( atlasID, labelIdx, summary, False) log.debug('Removed overlay {}'.format(overlayName)) return @@ -159,8 +161,9 @@ class AtlasPanel(fslpanel.FSLViewPanel): # label image if labelIdx is None: - imageType = 'volume' - data = atlas.data + overlayType = 'label' + data = atlas.data + else: # regional label image @@ -170,53 +173,62 @@ class AtlasPanel(fslpanel.FSLViewPanel): elif atlasDesc.atlasType == 'label': labelVal = labelIdx - imageType = 'mask' - data = np.zeros(atlas.shape, dtype=np.uint16) + overlayType = 'mask' + data = np.zeros(atlas.shape, dtype=np.uint16) data[atlas.data == labelIdx] = labelVal # regional probability image else: - imageType = 'volume' - data = atlas.data[:, :, :, labelIdx] + overlayType = 'volume' + data = atlas.data[:, :, :, labelIdx] overlay = fslimage.Image( data, header=atlas.nibImage.get_header(), name=overlayName) - - overlay.imageType = imageType - self._imageList.append(overlay) + self._overlayList.append(overlay) - self.overlayPanel.setOverlayState( + self.__overlayPanel.setOverlayState( atlasID, labelIdx, summary, True) log.debug('Added overlay {}'.format(overlayName)) - display = self._displayCtx.getDisplayProperties(overlay) + display = self._displayCtx.getDisplay(overlay) + display.overlayType = overlayType + opts = display.getDisplayOpts() - if labelIdx is not None: - if summary: display.getDisplayOpts().colour = np.random.random(3) - else: display.getDisplayOpts().cmap = 'hot' - else: + if overlayType == 'mask': opts.colour = np.random.random(3) + elif overlayType == 'volume': opts.cmap = 'hot' + elif overlayType == 'label': + # The Harvard-Oxford atlases have special colour maps - if atlasID == 'HarvardOxford-Cortical': cmap = 'cortical' - elif atlasID == 'HarvardOxford-Subcortical': cmap = 'subcortical' - else: cmap = 'random' - - display.getDisplayOpts().cmap = cmap + # + # TODO The colourmaps module will (hopefully) soon + # allow me to set an lut by key value, instead + # of having to look up the LUT object by its + # display name + if atlasID == 'HarvardOxford-Cortical': + opts.lut = fslcm.getLookupTable('MGH Cortical') + elif atlasID == 'HarvardOxford-Subcortical': + opts.lut = fslcm.getLookupTable('MGH Sub-cortical') + else: + opts.lut = fslcm.getLookupTable('Random') def locateRegion(self, atlasID, labelIdx): atlasDesc = atlases.getAtlasDescription(atlasID) label = atlasDesc.labels[labelIdx] + overlay = self._displayCtx.getReferenceImage( + self._displayCtx.getSelectedOverlay()) - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) - + if overlay is None: + log.warn('No reference image available - cannot locate region') + + opts = self._displayCtx.getOpts(overlay) worldLoc = (label.x, label.y, label.z) dispLoc = transform.transform( - [worldLoc], display.getTransform('world', 'display'))[0] + [worldLoc], opts.getTransform('world', 'display'))[0] self._displayCtx.location.xyz = dispLoc diff --git a/fsl/fslview/controls/canvassettingspanel.py b/fsl/fslview/controls/canvassettingspanel.py new file mode 100644 index 0000000000000000000000000000000000000000..d336ede8e7d33868386f0b8230ce8424113eb663 --- /dev/null +++ b/fsl/fslview/controls/canvassettingspanel.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python +# +# canvassettingspanel.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import wx + +import props + +import pwidgets.widgetlist as widgetlist + +import fsl.data.strings as strings +import fsl.fslview.panel as fslpanel + + +_CANVASPANEL_PROPS = [ + props.Widget( + 'profile', + visibleWhen=lambda i: len(i.getProp('profile').getChoices(i)) > 1), + props.Widget('syncOverlayOrder'), + props.Widget('syncLocation'), + props.Widget('syncOverlayDisplay'), + props.Widget('movieMode'), + props.Widget('movieRate', spin=False, showLimits=False), +] + +_SCENEOPTS_PROPS = [ + props.Widget('showCursor'), + props.Widget('performance', spin=False, showLimits=False), + props.Widget('showColourBar'), + props.Widget('colourBarLabelSide', enabledWhen=lambda o: o.showColourBar), + props.Widget('colourBarLocation', enabledWhen=lambda o: o.showColourBar) +] + +_ORTHOOPTS_PROPS = [ + props.Widget('layout'), + props.Widget('zoom', spin=False, showLimits=False), + props.Widget('showLabels'), + props.Widget('showXCanvas'), + props.Widget('showYCanvas'), + props.Widget('showZCanvas') +] + +_LIGHTBOXOPTS_PROPS = [ + props.Widget('zax'), + props.Widget('zoom', showLimits=False, spin=False), + props.Widget('sliceSpacing', showLimits=False), + props.Widget('zrange', showLimits=False), + props.Widget('highlightSlice'), + props.Widget('showGridLines') +] + + +class CanvasSettingsPanel(fslpanel.FSLViewPanel): + + def __init__(self, parent, overlayList, displayCtx, canvasPanel): + + fslpanel.FSLViewPanel.__init__(self, parent, overlayList, displayCtx) + + self.__widgets = widgetlist.WidgetList(self) + + self.__sizer = wx.BoxSizer(wx.VERTICAL) + + self.SetSizer(self.__sizer) + + self.__sizer.Add(self.__widgets, flag=wx.EXPAND, proportion=1) + + import fsl.fslview.views.orthopanel as orthopanel + import fsl.fslview.views.lightboxpanel as lightboxpanel + + if isinstance(canvasPanel, orthopanel.OrthoPanel): + panelGroup = 'ortho' + panelProps = _ORTHOOPTS_PROPS + + elif isinstance(canvasPanel, lightboxpanel.LightBoxPanel): + panelGroup = 'lightbox' + panelProps = _LIGHTBOXOPTS_PROPS + + self.__widgets.AddGroup('scene' , strings.labels[self, 'scene']) + self.__widgets.AddGroup( panelGroup, strings.labels[self, panelGroup]) + + for dispProp in _CANVASPANEL_PROPS: + + widget = props.buildGUI(self.__widgets, + canvasPanel, + dispProp, + showUnlink=False) + + self.__widgets.AddWidget( + widget, + strings.properties[canvasPanel, dispProp.key], + groupName='scene') + + opts = canvasPanel.getSceneOptions() + + for dispProp in _SCENEOPTS_PROPS: + + widget = props.buildGUI(self.__widgets, + opts, + dispProp, + showUnlink=False) + + self.__widgets.AddWidget( + widget, + strings.properties[opts, dispProp.key], + groupName='scene') + + for dispProp in panelProps: + + widget = props.buildGUI(self.__widgets, + opts, + dispProp, + showUnlink=False) + + self.__widgets.AddWidget( + widget, + strings.properties[opts, dispProp.key], + groupName=panelGroup) + + self.__widgets.Expand('scene') + self.__widgets.Expand(panelGroup) + + self.SetMinSize((21, 21)) diff --git a/fsl/fslview/controls/clusterpanel.py b/fsl/fslview/controls/clusterpanel.py new file mode 100644 index 0000000000000000000000000000000000000000..b8b65eec97766fcb29c6efb5308aecfe8d839600 --- /dev/null +++ b/fsl/fslview/controls/clusterpanel.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python +# +# clusterpanel.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import logging +import wx + +import pwidgets.widgetgrid as widgetgrid + +import fsl.fslview.panel as fslpanel +import fsl.utils.transform as transform +import fsl.data.strings as strings +import fsl.data.featimage as featimage + + +log = logging.getLogger(__name__) + + +class ClusterPanel(fslpanel.FSLViewPanel): + + def __init__(self, parent, overlayList, displayCtx): + fslpanel.FSLViewPanel.__init__(self, parent, overlayList, displayCtx) + + self.__disabledText = wx.StaticText( + self, + style=(wx.ALIGN_CENTRE_HORIZONTAL | + wx.ALIGN_CENTRE_VERTICAL)) + + self.__overlayName = wx.StaticText(self) + self.__addZStats = wx.Button( self) + self.__addClustMask = wx.Button( self) + self.__statSelect = wx.ComboBox( self, style=wx.CB_READONLY) + self.__clusterList = widgetgrid.WidgetGrid(self) + + self.__addZStats .SetLabel(strings.labels[self, 'addZStats']) + self.__addClustMask.SetLabel(strings.labels[self, 'addClustMask']) + + self.__clusterList.ShowRowLabels(False) + self.__clusterList.ShowColLabels(True) + + self.__sizer = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(self.__sizer) + + self.__topSizer = wx.BoxSizer(wx.HORIZONTAL) + self.__mainSizer = wx.BoxSizer(wx.VERTICAL) + + args = {'flag' : wx.EXPAND, 'proportion' : 1} + + self.__topSizer.Add(self.__statSelect, flag=wx.EXPAND, proportion=1) + self.__topSizer.Add(self.__addZStats, flag=wx.EXPAND, proportion=1) + self.__topSizer.Add(self.__addClustMask, flag=wx.EXPAND, proportion=1) + + self.__mainSizer.Add(self.__overlayName, flag=wx.EXPAND) + self.__mainSizer.Add(self.__topSizer, flag=wx.EXPAND) + self.__mainSizer.Add(self.__clusterList, flag=wx.EXPAND, proportion=1) + + # Only one of the disabledText or + # mainSizer are shown at any one time + self.__sizer.Add(self.__disabledText, **args) + self.__sizer.Add(self.__mainSizer, **args) + + overlayList.addListener('overlays', + self._name, + self.__overlayListChanged) + displayCtx .addListener('selectedOverlay', + self._name, + self.__selectedOverlayChanged) + + self.__statSelect .Bind(wx.EVT_COMBOBOX, self.__statSelected) + self.__addZStats .Bind(wx.EVT_BUTTON, self.__addZStatsClick) + self.__addClustMask.Bind(wx.EVT_BUTTON, self.__addClustMaskClick) + + self.SetMinSize(self.__calcMinSize()) + + self.__selectedOverlay = None + self.__selectedOverlayChanged() + + + def __calcMinSize(self): + """Figures out the minimum size that this ``ClusterPanel`` should + have. + + When the ``ClusterPanel`` is created, the COPE combo box is not + populated, so has no minimum size. Here, we figure out a good minimum + size for it. We can then calculate a good minimum size for the entire + panel. + """ + + dc = wx.ClientDC(self.__statSelect) + + dummyName = strings.labels[self, 'clustName'].format(1, 'WW') + + w, h = dc.GetTextExtent(dummyName) + + self.__statSelect .SetMinSize((w, h)) + self.__sizer.Layout() + + return self.__sizer.GetMinSize() + + + def destroy(self): + self._overlayList.removeListener('overlays', self._name) + self._displayCtx .removeListener('selectedOverlay', self ._name) + fslpanel.FSLViewPanel.destroy(self) + + + def __disable(self, message): + + self.__disabledText.SetLabel(message) + self.__sizer.Show(self.__disabledText, True) + self.__sizer.Show(self.__mainSizer, False) + self.Layout() + + + def __addZStatsClick(self, ev): + + overlay = self.__selectedOverlay + contrast = self.__statSelect.GetSelection() + zstats = overlay.getZStats(contrast) + + for ol in self._overlayList: + + # Already in overlay list + if ol.dataSource == zstats.dataSource: + return + + log.debug('Adding Z-statistic {} to overlay list'.format(zstats.name)) + self._overlayList.append(zstats) + + opts = self._displayCtx.getOpts(zstats) + zthres = float(overlay.thresholds()['z']) + + # Set some display parameters if + # we have a z value threshold + if zthres is not None: + + absmax = max(map(abs, (opts.dataMin, opts.dataMax))) + + opts.cmap = 'Render3' + opts.invertClipping = True + opts.displayRange.x = -absmax, absmax + opts.clippingRange.x = -zthres, zthres + + + def __addClustMaskClick(self, ev): + overlay = self.__selectedOverlay + contrast = self.__statSelect.GetSelection() + mask = overlay.getClusterMask(contrast) + + for ol in self._overlayList: + + # Already in overlay list + if ol.dataSource == mask.dataSource: + return + + log.debug('Adding cluster mask {} to overlay list'.format(mask.name)) + self._overlayList.append(mask) + self._displayCtx.getDisplay(mask).overlayType = 'label' + + + def __overlayListChanged(self, *a): + self.__selectedOverlayChanged() + self.__enableOverlayButtons() + + + def __enableOverlayButtons(self): + + if self.__selectedOverlay is None: + return + + overlay = self.__selectedOverlay + contrast = self.__statSelect.GetSelection() + + zstat = overlay.getZStats( contrast) + clustMask = overlay.getClusterMask(contrast) + + dss = [ovl.dataSource for ovl in self._overlayList] + + self.__addZStats .Enable(zstat .dataSource not in dss) + self.__addClustMask.Enable(clustMask.dataSource not in dss) + + + def __selectedOverlayChanged(self, *a): + + prevOverlay = self.__selectedOverlay + self.__selectedOverlay = None + + # No overlays are loaded + if len(self._overlayList) == 0: + self.__disable(strings.messages[self, 'noOverlays']) + return + + overlay = self._displayCtx.getSelectedOverlay() + + # Not a FEAT image, can't + # do anything with that + if not isinstance(overlay, featimage.FEATImage): + self.__disable(strings.messages[self, 'notFEAT']) + return + + # Selected overlay is either the + # same one (maybe the overlay list, + # rather than the selected overlay, + # changed) or the newly selected + # overlay is from the same FEAT + # analysis. No need to do anything. + if prevOverlay is not None and (prevOverlay is overlay or + prevOverlay.getFEATDir() == overlay.getFEATDir()): + self.__selectedOverlay = overlay + return + + self.__statSelect .Clear() + self.__clusterList.ClearGrid() + + self.__selectedOverlay = overlay + + self.__sizer.Show(self.__disabledText, False) + self.__sizer.Show(self.__mainSizer, True) + + numCons = overlay.numContrasts() + conNames = overlay.contrastNames() + + try: + # clusts is a list of (contrast, clusterList) tuples + clusts = [(c, overlay.clusterResults(c)) for c in range(numCons)] + clusts = filter(lambda (con, clust): clust is not None, clusts) + + # Error parsing the cluster data + except Exception as e: + log.warning('Error parsing cluster data for ' + '{}: {}'.format(overlay.name, str(e)), exc_info=True) + self.__disable(strings.messages[self, 'badData']) + return + + # No cluster results exist + # for any contrast + if len(clusts) == 0: + self.__disable(strings.messages[self, 'noClusters']) + return + + for contrast, clusterList in clusts: + name = conNames[contrast] + name = strings.labels[self, 'clustName'].format(contrast + 1, name) + + self.__statSelect.Append(name, clusterList) + + self.__overlayName.SetLabel(overlay.getAnalysisName()) + + self.__statSelect.SetSelection(0) + self.__displayClusterData(clusts[0][1]) + + self.Layout() + return + + + def __statSelected(self, ev): + idx = self.__statSelect.GetSelection() + data = self.__statSelect.GetClientData(idx) + self.__displayClusterData(data) + self.__enableOverlayButtons() + + + def __displayClusterData(self, clusters): + + cols = {'index' : 0, + 'nvoxels' : 1, + 'p' : 2, + 'logp' : 3, + 'zmax' : 4, + 'zmaxcoords' : 5, + 'zcogcoords' : 6, + 'copemax' : 7, + 'copemaxcoords' : 8, + 'copemean' : 9} + + grid = self.__clusterList + overlay = self.__selectedOverlay + opts = self._displayCtx.getOpts(overlay) + + grid.ClearGrid() + grid.SetGridSize(len(clusters), 10) + + for col, i in cols.items(): + grid.SetColLabel(i, strings.labels[self, col]) + + def makeCoordButton(coords): + + label = wx.StaticText(grid, label='[{} {} {}]'.format(*coords)) + btn = wx.Button(grid, label=u'\u2192', style=wx.BU_EXACTFIT) + + sizer = wx.BoxSizer(wx.HORIZONTAL) + sizer.Add(label, flag=wx.EXPAND, proportion=1) + sizer.Add(btn) + + def onClick(ev): + xfm = opts.getTransform('voxel', 'display') + dloc = transform.transform([coords], xfm)[0] + self._displayCtx.location = dloc + + btn.Bind(wx.EVT_BUTTON, onClick) + + return sizer + + for i, clust in enumerate(clusters): + + zmaxbtn = makeCoordButton((clust.zmaxx, + clust.zmaxy, + clust.zmaxz)) + zcogbtn = makeCoordButton((clust.zcogx, + clust.zcogy, + clust.zcogz)) + copemaxbtn = makeCoordButton((clust.copemaxx, + clust.copemaxy, + clust.copemaxz)) + + fmt = lambda v: '{}'.format(v) + grid.SetText( i, cols['index'], fmt(clust.index)) + grid.SetText( i, cols['nvoxels'], fmt(clust.nvoxels)) + grid.SetText( i, cols['p'], fmt(clust.p)) + grid.SetText( i, cols['logp'], fmt(clust.logp)) + grid.SetText( i, cols['zmax'], fmt(clust.zmax)) + grid.SetWidget(i, cols['zmaxcoords'], zmaxbtn) + grid.SetWidget(i, cols['zcogcoords'], zcogbtn) + grid.SetText( i, cols['copemax'], fmt(clust.copemax)) + grid.SetWidget(i, cols['copemaxcoords'], copemaxbtn) + grid.SetText( i, cols['copemean'], fmt(clust.copemean)) diff --git a/fsl/fslview/controls/histogramcontrolpanel.py b/fsl/fslview/controls/histogramcontrolpanel.py new file mode 100644 index 0000000000000000000000000000000000000000..9cddcca22f7f04af9905c550a1362a2d6203de7e --- /dev/null +++ b/fsl/fslview/controls/histogramcontrolpanel.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python +# +# histogramcontrolpanel.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import wx + +import props +import pwidgets.widgetlist as widgetlist + +import fsl.fslview.panel as fslpanel +import fsl.data.strings as strings + + +class HistogramControlPanel(fslpanel.FSLViewPanel): + + def __init__(self, parent, overlayList, displayCtx, hsPanel): + + fslpanel.FSLViewPanel.__init__(self, parent, overlayList, displayCtx) + + self.__hsPanel = hsPanel + self.__widgets = widgetlist.WidgetList(self) + self.__sizer = wx.BoxSizer(wx.VERTICAL) + + self.SetSizer(self.__sizer) + self.__sizer.Add(self.__widgets, flag=wx.EXPAND, proportion=1) + + histProps = ['histType', + 'autoBin', + 'showCurrent'] + plotProps = ['xLogScale', + 'yLogScale', + 'smooth', + 'legend', + 'ticks', + 'grid', + 'autoScale'] + + self.__widgets.AddGroup( + 'histSettings', strings.labels[self, 'histSettings']) + + for prop in histProps: + self.__widgets.AddWidget( + props.makeWidget(self.__widgets, hsPanel, prop), + displayName=strings.properties[hsPanel, prop], + groupName='histSettings') + + self.__widgets.AddGroup( + 'plotSettings', + strings.labels[hsPanel, 'plotSettings']) + + for prop in plotProps: + self.__widgets.AddWidget( + props.makeWidget(self.__widgets, hsPanel, prop), + displayName=strings.properties[hsPanel, prop], + groupName='plotSettings') + + xlabel = props.makeWidget(self.__widgets, hsPanel, 'xlabel') + ylabel = props.makeWidget(self.__widgets, hsPanel, 'ylabel') + + labels = wx.BoxSizer(wx.HORIZONTAL) + + labels.Add(wx.StaticText(self.__widgets, + label=strings.labels[hsPanel, 'xlabel'])) + labels.Add(xlabel, flag=wx.EXPAND, proportion=1) + labels.Add(wx.StaticText(self.__widgets, + label=strings.labels[hsPanel, 'ylabel'])) + labels.Add(ylabel, flag=wx.EXPAND, proportion=1) + + limits = props.makeListWidgets(self.__widgets, hsPanel, 'limits') + xlims = wx.BoxSizer(wx.HORIZONTAL) + ylims = wx.BoxSizer(wx.HORIZONTAL) + + xlims.Add(limits[0], flag=wx.EXPAND, proportion=1) + xlims.Add(limits[1], flag=wx.EXPAND, proportion=1) + ylims.Add(limits[2], flag=wx.EXPAND, proportion=1) + ylims.Add(limits[3], flag=wx.EXPAND, proportion=1) + + self.__widgets.AddWidget( + labels, + strings.labels[hsPanel, 'labels'], + groupName='plotSettings') + self.__widgets.AddWidget( + xlims, + strings.labels[hsPanel, 'xlim'], + groupName='plotSettings') + self.__widgets.AddWidget( + ylims, + strings.labels[hsPanel, 'ylim'], + groupName='plotSettings') + + self.__currentHs = None + hsPanel.addListener('selectedSeries', + self._name, + self.__selectedSeriesChanged) + + hsPanel.addListener('dataSeries', + self._name, + self.__selectedSeriesChanged) + hsPanel.addListener('autoBin', + self._name, + self.__autoBinChanged) + + self.__selectedSeriesChanged() + + + def destroy(self): + self.__hsPanel.removeListener('selectedSeries', self._name) + self.__hsPanel.removeListener('dataSeries', self._name) + if self.__currentHs is not None: + self.__currentHs.removeListener('label', self._name) + + fslpanel.FSLViewPanel.destroy(self) + + + def __selectedSeriesChanged(self, *a): + + panel = self.__hsPanel + + if len(panel.dataSeries) == 0: + self.__currentHs = None + + else: + self.__currentHs = panel.dataSeries[panel.selectedSeries] + + self.__updateCurrentProperties() + + + def __hsLabelChanged(self, *a): + if self.__currentHs is None: + return + + self.__widgets.RenameGroup( + 'currentSettings', + strings.labels[self, 'currentSettings'].format( + self.__currentHs.label)) + + + def __updateCurrentProperties(self): + + expanded = False + scrollPos = self.__widgets.GetViewStart() + + if self.__widgets.HasGroup('currentSettings'): + expanded = self.__widgets.IsExpanded('currentSettings') + self.__widgets.RemoveGroup('currentSettings') + + if self.__currentHs is None: + return + else: + self.__currentHs.removeListener('label', self._name) + + self.__widgets.AddGroup( + 'currentSettings', + strings.labels[self.__hsPanel, 'currentSettings'].format( + self.__currentHs.label)) + + wlist = self.__widgets + hs = self.__currentHs + + hs.addListener('label', self._name, self.__hsLabelChanged) + + self.__nbins = props.makeWidget(wlist, hs, 'nbins', showLimits=False) + + volume = props.makeWidget(wlist, hs, 'volume', showLimits=False) + dataRange = props.makeWidget(wlist, hs, 'dataRange', showLimits=False) + + ignoreZeros = props.makeWidget(wlist, hs, 'ignoreZeros') + showOverlay = props.makeWidget(wlist, hs, 'showOverlay') + includeOutliers = props.makeWidget(wlist, hs, 'includeOutliers') + + wlist.AddWidget(ignoreZeros, + groupName='currentSettings', + displayName=strings.properties[hs, 'ignoreZeros']) + wlist.AddWidget(showOverlay, + groupName='currentSettings', + displayName=strings.properties[hs, 'showOverlay']) + wlist.AddWidget(includeOutliers, + groupName='currentSettings', + displayName=strings.properties[hs, 'includeOutliers']) + wlist.AddWidget(self.__nbins, + groupName='currentSettings', + displayName=strings.properties[hs, 'nbins']) + wlist.AddWidget(volume, + groupName='currentSettings', + displayName=strings.properties[hs, 'volume']) + wlist.AddWidget(dataRange, + groupName='currentSettings', + displayName=strings.properties[hs, 'dataRange']) + + if expanded: + wlist.Expand('currentSettings') + + self.__widgets.Scroll(scrollPos) + + volume .Enable(hs.overlay.is4DImage()) + self.__nbins.Enable(not self.__hsPanel.autoBin) + + + def __autoBinChanged(self, *a): + self.__nbins.Enable(not self.__hsPanel.autoBin) diff --git a/fsl/fslview/controls/histogramlistpanel.py b/fsl/fslview/controls/histogramlistpanel.py new file mode 100644 index 0000000000000000000000000000000000000000..f0d01f66857a7dabe89f5fb46ef183079c536652 --- /dev/null +++ b/fsl/fslview/controls/histogramlistpanel.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +# +# histogramlistpanel.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + + +import wx + +import pwidgets.elistbox as elistbox +import fsl.fslview.panel as fslpanel +import fsl.fslview.colourmaps as fslcm + +import timeserieslistpanel + + +class HistogramListPanel(fslpanel.FSLViewPanel): + + def __init__(self, parent, overlayList, displayCtx, histPanel): + + fslpanel.FSLViewPanel.__init__(self, parent, overlayList, displayCtx) + + self.__hsPanel = histPanel + self.__hsList = elistbox.EditableListBox( + self, style=(elistbox.ELB_NO_MOVE | + elistbox.ELB_EDITABLE)) + + self.__sizer = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(self.__sizer) + + self.__sizer.Add(self.__hsList, flag=wx.EXPAND, proportion=1) + + self.__hsList.Bind(elistbox.EVT_ELB_ADD_EVENT, self.__onListAdd) + self.__hsList.Bind(elistbox.EVT_ELB_REMOVE_EVENT, self.__onListRemove) + self.__hsList.Bind(elistbox.EVT_ELB_EDIT_EVENT, self.__onListEdit) + self.__hsList.Bind(elistbox.EVT_ELB_SELECT_EVENT, self.__onListSelect) + + self.__hsPanel.addListener('dataSeries', + self._name, + self.__histSeriesChanged) + + self.__histSeriesChanged() + self.Layout() + + + def destroy(self): + self.__hsPanel.removeListener('dataSeries', self._name) + fslpanel.FSLViewPanel.destroy(self) + + + def getListBox(self): + return self.__hsList + + + def __histSeriesChanged(self, *a): + + self.__hsList.Clear() + + for hs in self.__hsPanel.dataSeries: + widg = timeserieslistpanel.TimeSeriesWidget(self, hs) + + self.__hsList.Append(hs.label, + clientData=hs, + extraWidget=widg) + + if len(self.__hsPanel.dataSeries) > 0: + self.__hsList.SetSelection(0) + + + def __onListAdd(self, ev): + hs = self.__hsPanel.getCurrent() + + if hs is None: + return + + hs.alpha = 1 + hs.lineWidth = 2 + hs.lineStyle = '-' + hs.colour = fslcm.randomColour() + hs.label = hs.overlay.name + + self.__hsPanel.dataSeries.append(hs) + self.__hsPanel.selectedSeries = self.__hsList.GetSelection() + + + def __onListEdit(self, ev): + ev.data.label = ev.label + + + def __onListSelect(self, ev): + overlay = ev.data.overlay + self._displayCtx.selectedOverlay = self._overlayList.index(overlay) + self.__hsPanel.selectedSeries = ev.idx + + + def __onListRemove(self, ev): + self.__hsPanel.dataSeries.remove(ev.data) + self.__hsPanel.selectedSeries = self.__hsList.GetSelection() + ev.data.destroy() diff --git a/fsl/fslview/controls/histogramtoolbar.py b/fsl/fslview/controls/histogramtoolbar.py deleted file mode 100644 index 6dee3804d0b1c536608c75ea06d0cb72402568a4..0000000000000000000000000000000000000000 --- a/fsl/fslview/controls/histogramtoolbar.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python -# -# histogramtoolbar.py - -# -# Author: Paul McCarthy <pauldmccarthy@gmail.com> -# -import logging - -import fsl.fslview.toolbar as fsltoolbar - - -log = logging.getLogger(__name__) - - -class HistogramToolBar(fsltoolbar.FSLViewToolBar): - - def __init__(self, parent, imageList, displayCtx, histPanel): - - import fsl.fslview.layouts as layouts - - fsltoolbar.FSLViewToolBar.__init__(self, parent, imageList, displayCtx) - - self.GenerateTools(layouts.layouts[self], histPanel) - - - def destroy(self): - fsltoolbar.FSLViewToolBar.destroy(self) diff --git a/fsl/fslview/controls/imagedisplaypanel.py b/fsl/fslview/controls/imagedisplaypanel.py deleted file mode 100644 index 1581294fde192b783b29b266a112edf223f8d8ba..0000000000000000000000000000000000000000 --- a/fsl/fslview/controls/imagedisplaypanel.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env python -# -# imagedisplaypanel.py - A panel which shows display control options for the -# currently selected image. -# -# Author: Paul McCarthy <pauldmccarthy@gmail.com> - -"""A :class:`wx.panel` which shows display control optionns for the currently -selected image - see :attr:`fsl.data.image.ImageList.selectedImage`. -""" - -import logging -log = logging.getLogger(__name__) - - -import wx -import props - -import fsl.fslview.panel as fslpanel -import imageselectpanel as imageselect - - -class ImageDisplayPanel(fslpanel.FSLViewPanel): - - def __init__(self, parent, imageList, displayCtx): - """ - """ - - # TODO Ability to link properties across images - - fslpanel.FSLViewPanel.__init__(self, parent, imageList, displayCtx) - - self.imageSelect = imageselect.ImageSelectPanel( - self, imageList, displayCtx) - - self.propPanel = wx.ScrolledWindow(self) - self.propPanel.SetScrollRate(0, 5) - self.dispPanel = wx.Panel(self.propPanel) - self.optsPanel = wx.Panel(self.propPanel) - - self.divider = wx.StaticLine( - self.propPanel, size=(-1, -1), style=wx.LI_HORIZONTAL) - - self.sizer = wx.BoxSizer(wx.VERTICAL) - self.propSizer = wx.BoxSizer(wx.VERTICAL) - self.dispSizer = wx.BoxSizer(wx.VERTICAL) - self.optsSizer = wx.BoxSizer(wx.VERTICAL) - - self .SetSizer(self.sizer) - self.propPanel.SetSizer(self.propSizer) - self.dispPanel.SetSizer(self.dispSizer) - self.optsPanel.SetSizer(self.optsSizer) - - self.sizer.Add(self.imageSelect, flag=wx.EXPAND) - self.sizer.Add(self.propPanel, flag=wx.EXPAND, proportion=1) - - flags = wx.EXPAND | wx.ALIGN_CENTRE | wx.ALL - - self.propSizer.Add(self.dispPanel, border=20, flag=flags) - self.propSizer.Add(self.divider, flag=flags) - self.propSizer.Add(self.optsPanel, border=20, flag=flags) - - displayCtx.addListener('selectedImage', - self._name, - self._selectedImageChanged) - imageList .addListener('images', - self._name, - self._selectedImageChanged) - - self._lastImage = None - self._selectedImageChanged() - - self.propSizer.Layout() - self.Layout() - - pSize = self.propSizer.GetMinSize().Get() - size = self.sizer .GetMinSize().Get() - self.SetMinSize((max(pSize[0], size[0]), max(pSize[1], size[1]) + 20)) - - - def destroy(self): - fslpanel.FSLViewPanel.destroy(self) - - self._displayCtx.removeListener('selectedImage', self._name) - self._imageList .removeListener('images', self._name) - self.imageSelect.destroy() - - for image in self._imageList: - image.removeListener('imageType', self._name) - - - def _selectedImageChanged(self, *a): - - image = self._displayCtx.getSelectedImage() - lastImage = self._lastImage - - if image is None: - self._lastImage = None - self.dispPanel.DestroyChildren() - self.optsPanel.DestroyChildren() - self.Layout() - return - - if image is lastImage: - return - - if lastImage is not None: - lastDisplay = self._displayCtx.getDisplayProperties(lastImage) - lastImage .removeListener('imageType', self._name) - lastDisplay.removeListener('transform', self._name) - - display = self._displayCtx.getDisplayProperties(image) - - image .addListener('imageType', - self._name, - lambda *a: self._updateProps(self.optsPanel, True)) - display.addListener('transform', self._name, self._transformChanged) - - self._lastImage = image - self._updateProps(self.dispPanel, False) - self._updateProps(self.optsPanel, True) - - - def _transformChanged(self, *a): - """Called when the transform setting of the currently selected image - changes. If affine transformation is selected, interpolation is - enabled, otherwise interpolation is disabled. - """ - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) - - choices = display.getProp('interpolation').getChoices(display) - - if display.transform in ('none', 'pixdim'): - display.interpolation = 'none' - - elif display.transform == 'affine': - if 'spline' in choices: display.interpolation = 'spline' - else: display.interpolation = 'linear' - - - def _updateProps(self, parent, opts): - - import fsl.fslview.layouts as layouts - - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) - - if opts: optObj = display.getDisplayOpts() - else: optObj = display - - parent.DestroyChildren() - - panel = props.buildGUI( - parent, optObj, view=layouts.layouts[self, optObj]) - - parent.GetSizer().Add(panel, flag=wx.EXPAND, proportion=1) - panel .Layout() - parent.Layout() diff --git a/fsl/fslview/controls/imagedisplaytoolbar.py b/fsl/fslview/controls/imagedisplaytoolbar.py deleted file mode 100644 index c43a562972243f0db2d1ad60df74ef8d5cd09f10..0000000000000000000000000000000000000000 --- a/fsl/fslview/controls/imagedisplaytoolbar.py +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env python -# -# imagedisplaytoolbar.py - A toolbar which shows display control options for -# the currently selected image. -# -# Author: Paul McCarthy <pauldmccarthy@gmail.com> - -"""A :class:`wx.panel` which shows display control optionns for the currently -selected image - see :attr:`fsl.data.image.ImageList.selectedImage`. -""" - -import logging -log = logging.getLogger(__name__) - - -import fsl.fslview.toolbar as fsltoolbar -import imagedisplaypanel as imagedisplay - - -class ImageDisplayToolBar(fsltoolbar.FSLViewToolBar): - - def __init__(self, parent, imageList, displayCtx, viewPanel): - - actionz = {'more' : self.showMoreSettings} - - fsltoolbar.FSLViewToolBar.__init__( - self, parent, imageList, displayCtx, actionz) - - self._viewPanel = viewPanel - self._imageTools = {} - self._currentImage = None - - self._displayCtx.addListener( - 'selectedImage', - self._name, - self._selectedImageChanged) - self._displayCtx.addListener( - 'imageOrder', - self._name, - self._selectedImageChanged) - self._imageList.addListener( - 'images', - self._name, - self._imageListChanged) - - self._selectedImageChanged() - - - def destroy(self): - """Deregisters property listeners. """ - fsltoolbar.FSLViewToolBar.destroy(self) - - self._imageList .removeListener('images', self._name) - self._displayCtx.removeListener('selectedImage', self._name) - self._displayCtx.removeListener('imageOrder', self._name) - - for image in self._imageList: - - display = self._displayCtx.getDisplayProperties(image) - image .removeListener('imageType', self._name) - display.removeListener('enabled', self._name) - - - def showMoreSettings(self, *a): - self._viewPanel.togglePanel(imagedisplay.ImageDisplayPanel, True) - - - def _imageListChanged(self, *a): - - for image in self._imageTools.keys(): - if image not in self._imageList: - - dispTools, optsTools = self._imageTools.pop(image) - - log.debug('Destroying all tools for {}'.format(image)) - - if image is self._currentImage: - self.ClearTools() - - for tool, _ in dispTools: tool.Destroy() - for tool, _ in optsTools: tool.Destroy() - - - def _imageTypeChanged(self, value, valid, image, name, refresh=True): - - dispTools, oldOptsTools = self._imageTools[image] - - newOptsTools = self._makeOptsWidgets(image, self) - - self._imageTools[image] = (dispTools, newOptsTools) - - if refresh and (image is self._displayCtx.getSelectedImage()): - self._refreshTools(image) - - log.debug('Destroying opts tools for {}'.format(image)) - - for tool, _ in oldOptsTools: - tool.Destroy() - - - def _toggleEnabled(self, value, valid, image, name): - - if image is not self._displayCtx.getSelectedImage(): - return - - display = self._displayCtx.getDisplayProperties(image) - - self.Enable(display.enabled) - - - def _selectedImageChanged(self, *a): - """Called when the :attr:`~fsl.data.image.ImageList.selectedImage` - index changes. Ensures that the correct display panel is visible. - """ - - image = self._displayCtx.getSelectedImage() - - if image is None: - self.ClearTools() - return - - display = self._displayCtx.getDisplayProperties(image) - - # Call _toggleEnabled when - # the image is enabled/disabled - self.Enable(display.enabled) - for i in self._imageList: - - d = self._displayCtx.getDisplayProperties(i) - - if i == image: - d.addListener('enabled', - self._name, - self._toggleEnabled, - overwrite=True) - else: - d.removeListener('enabled', self._name) - - # Build/refresh the toolbar widgets for this image - tools = self._imageTools.get(image, None) - - if tools is None: - displayTools = self._makeDisplayWidgets(image, self) - optsTools = self._makeOptsWidgets( image, self) - - self._imageTools[image] = (displayTools, optsTools) - - image.addListener( - 'imageType', - self._name, - self._imageTypeChanged, - overwrite=True) - - self._refreshTools(image) - - - def _refreshTools(self, image): - - self._currentImage = image - - log.debug('Showing tools for {}'.format(image)) - - tools = self.GetTools() - for widget in tools: - widget.Show(False) - - self.ClearTools(postevent=False) - - if image is None: - self.Layout() - - dispTools, optsTools = self._imageTools[image] - - dispTools, dispLabels = zip(*dispTools) - optsTools, optsLabels = zip(*optsTools) - - tools = list(dispTools) + list(optsTools) - labels = list(dispLabels) + list(optsLabels) - - for tool in tools: - tool.Show(True) - - self.SetTools(tools, labels) - - - def _makeDisplayWidgets(self, image, parent): - """Creates and returns panel containing widgets allowing - the user to edit the display properties of the given - :class:`~fsl.data.image.Image` instance. - """ - - import fsl.fslview.layouts as layouts - - display = self._displayCtx.getDisplayProperties(image) - toolSpecs = layouts.layouts[self, display] - - log.debug('Creating display tools for {}'.format(image)) - - return self.GenerateTools(toolSpecs, display, add=False) - - - def _makeOptsWidgets(self, image, parent): - - import fsl.fslview.layouts as layouts - - display = self._displayCtx.getDisplayProperties(image) - opts = display.getDisplayOpts() - toolSpecs = layouts.layouts[self, opts] - targets = { s.key : self if s.key == 'more' else opts - for s in toolSpecs} - - log.debug('Creating options tools for {}'.format(image)) - - return self.GenerateTools(toolSpecs, targets, add=False) diff --git a/fsl/fslview/controls/imagelistpanel.py b/fsl/fslview/controls/imagelistpanel.py deleted file mode 100644 index 358d447256b4733bdac8b747134c1e8d92fe909b..0000000000000000000000000000000000000000 --- a/fsl/fslview/controls/imagelistpanel.py +++ /dev/null @@ -1,266 +0,0 @@ -#!/usr/bin/env python -# -# imagelistpanel.py - A panel which displays a list of images in the image -# list. -# -# Author: Paul McCarthy <pauldmccarthy@gmail.com> -# -"""A panel which displays a list of image list in the image list (see -:class:`~fsl.data.image.ImageList`), and allows the user to add/remove -images, and to change their order. -""" - -import logging -log = logging.getLogger(__name__) - - -import wx - -import props - -import pwidgets.elistbox as elistbox - -import fsl.fslview.panel as fslpanel - -class ListItemWidget(wx.Panel): - - _enabledFG = '#000000' - _disabledFG = '#CCCCCC' - - def __init__(self, parent, image, display, listBox): - wx.Panel.__init__(self, parent) - - self.image = image - self.display = display - self.listBox = listBox - self.name = '{}_{}'.format(self.__class__.__name__, id(self)) - - self.saveButton = wx.Button(self, label='S', style=wx.BU_EXACTFIT) - self.visibility = props.makeWidget(self, display, 'enabled') - - self.sizer = wx.BoxSizer(wx.HORIZONTAL) - - self.SetSizer(self.sizer) - - self.sizer.Add(self.saveButton, flag=wx.EXPAND, proportion=1) - self.sizer.Add(self.visibility, flag=wx.EXPAND, proportion=1) - - self.display.addListener('enabled', self.name, self._vizChanged) - self.image .addListener('saved', self.name, self._saveStateChanged) - - - self.saveButton.Bind(wx.EVT_BUTTON, self._onSaveButton) - - - self.Bind(wx.EVT_WINDOW_DESTROY, self._onDestroy) - - self._vizChanged() - self._saveStateChanged() - - - def _onSaveButton(self, ev): - self.image.save() - - - def _onDestroy(self, ev): - ev.Skip() - if ev.GetEventObject() is not self: - return - - self.display.removeListener('enabled', self.name) - self.image .removeListener('saved', self.name) - - - def _saveStateChanged(self, *a): - idx = self.listBox.IndexOf(self.image) - - self.saveButton.Enable(not self.image.saved) - - if self.image.saved: - self.listBox.SetItemBackgroundColour(idx) - else: - self.listBox.SetItemBackgroundColour(idx, '#ffaaaa', '#aa4444') - - - def _vizChanged(self, *a): - - idx = self.listBox.IndexOf(self.image) - - if self.display.enabled: fgColour = ListItemWidget._enabledFG - else: fgColour = ListItemWidget._disabledFG - - self.listBox.SetItemForegroundColour(idx, fgColour) - - -class ImageListPanel(fslpanel.FSLViewPanel): - """A :class:`~fsl.fslview.panel.ControlPanel` which contains an - :class:`~pwidgets.EditableListBox` displaying the list of loaded images. - - The list box allows the image order to be changed, and allows images to be - added and removed from the list. - """ - - def __init__(self, parent, imageList, displayCtx): - """Create and lay out an :class:`ImageListPanel`. - - :param parent: The :mod:`wx` parent object. - :param imageList: A :class:`~fsl.data.image.ImageList` instance. - :param displayCtx: A - :class:`~fsl.fslview.displaycontext.DisplayContext` - instance. - """ - - fslpanel.FSLViewPanel.__init__(self, parent, imageList, displayCtx) - - # list box containing the list of images - it - # is populated in the _imageListChanged method - self._listBox = elistbox.EditableListBox( - self, - style=(elistbox.ELB_REVERSE | - elistbox.ELB_TOOLTIP | - elistbox.ELB_EDITABLE)) - - # listeners for when the user does - # something with the list box - self._listBox.Bind(elistbox.EVT_ELB_SELECT_EVENT, self._lbSelect) - self._listBox.Bind(elistbox.EVT_ELB_MOVE_EVENT, self._lbMove) - self._listBox.Bind(elistbox.EVT_ELB_REMOVE_EVENT, self._lbRemove) - self._listBox.Bind(elistbox.EVT_ELB_ADD_EVENT, self._lbAdd) - self._listBox.Bind(elistbox.EVT_ELB_EDIT_EVENT, self._lbEdit) - - self._sizer = wx.BoxSizer(wx.HORIZONTAL) - self.SetSizer(self._sizer) - - self._sizer.Add(self._listBox, flag=wx.EXPAND, proportion=1) - - self._imageList.addListener( - 'images', - self._name, - self._imageListChanged) - - self._displayCtx.addListener( - 'imageOrder', - self._name, - self._imageListChanged) - - self._displayCtx.addListener( - 'selectedImage', - self._name, - self._selectedImageChanged) - - self._imageListChanged() - self._selectedImageChanged() - - self.Layout() - - self.SetMinSize(self._sizer.GetMinSize()) - - - def destroy(self): - """Deregisters property listeners.""" - - fslpanel.FSLViewPanel.destroy(self) - - self._imageList .removeListener('images', self._name) - self._displayCtx.removeListener('selectedImage', self._name) - self._displayCtx.removeListener('imageOrder', self._name) - - # A listener on name was added - # in the_imageListChanged method - for image in self._imageList: - image.removeListener('name', self._name) - - - def _selectedImageChanged(self, *a): - """Called when the - :attr:`~fsl.fslview.displaycontext.DisplayContext.selectedImage` - property changes. Updates the selected item in the list box. - """ - - if len(self._imageList) > 0: - self._listBox.SetSelection( - self._displayCtx.getImageOrder( - self._displayCtx.selectedImage)) - - - def _imageListChanged(self, *a): - """Called when the :class:`~fsl.data.image.ImageList.images` - list changes. - - If the change was due to user action on the - :class:`~pwidgets.EditableListBox`, this method does nothing. - Otherwise, this method updates the :class:`~pwidgets.EditableListBox` - """ - - self._listBox.Clear() - - for i, image in enumerate(self._displayCtx.getOrderedImages()): - - display = self._displayCtx.getDisplayProperties(image) - name = image.name - if name is None: name = '' - - self._listBox.Append(name, image, image.imageFile) - - widget = ListItemWidget(self, image, display, self._listBox) - - self._listBox.SetItemWidget(i, widget) - - def nameChanged(value, valid, image, name): - idx = self._displayCtx.getImageOrder(image) - name = image.name - if name is None: name = '' - self._listBox.SetString(idx, name) - - image.addListener('name', self._name, nameChanged, overwrite=True) - - if len(self._imageList) > 0: - self._listBox.SetSelection( - self._displayCtx.getImageOrder( - self._displayCtx.selectedImage)) - - - def _lbMove(self, ev): - """Called when an image name is moved in the - :class:`~pwidgets.elistbox.EditableListBox`. Reorders the - :class:`~fsl.data.image.ImageList` to reflect the change. - """ - self._displayCtx.disableListener('imageOrder', self._name) - self._displayCtx.imageOrder.move(ev.oldIdx, ev.newIdx) - self._displayCtx.enableListener('imageOrder', self._name) - - - def _lbSelect(self, ev): - """Called when an image is selected in the - :class:`~pwidgets.elistbox.EditableListBox`. Sets the - :attr:`fsl.data.image.ImageList.selectedImage property. - """ - self._displayCtx.disableListener('selectedImage', self._name) - self._displayCtx.selectedImage = self._displayCtx.imageOrder[ev.idx] - self._displayCtx.enableListener('selectedImage', self._name) - - - def _lbAdd(self, ev): - """Called when the 'add' button on the list box is pressed. - Calls the :meth:`~fsl.data.image.ImageList.addImages` method. - """ - if self._imageList.addImages(): - self._displayCtx.selectedImage = len(self._imageList) - 1 - - - def _lbRemove(self, ev): - """Called when an item is removed from the image listbox. - - Removes the corresponding image from the - :class:`~fsl.data.image.ImageList`. - """ - self._imageList.pop(self._displayCtx.imageOrder[ev.idx]) - - - def _lbEdit(self, ev): - """Called when an item label is edited on the image list box. - Sets the corresponding image name to the new label. - """ - idx = self._displayCtx.imageOrder[ev.idx] - img = self._imageList[idx] - img.name = ev.label diff --git a/fsl/fslview/controls/imageselectpanel.py b/fsl/fslview/controls/imageselectpanel.py deleted file mode 100644 index 4ba65e62fc1dc545278a51189c24a721ba45718c..0000000000000000000000000000000000000000 --- a/fsl/fslview/controls/imageselectpanel.py +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env python -# -# imageselectpanel.py - A little panel which allows the currently selected -# image to be changed. -# -# Author: Paul McCarthy <pauldmccarthy@gmail.com> -# -"""Defines the :class:`ImageSelectPanel` which is a little panel that allows -the currently selected image to be changed. - -This panel is generally embedded within other control panels. -""" - -import logging -log = logging.getLogger(__name__) - -import wx - -import fsl.fslview.panel as fslpanel - - -class ImageSelectPanel(fslpanel.FSLViewPanel): - """A panel which displays the currently selected image, - and allows it to be changed. - """ - - def __init__(self, parent, imageList, displayCtx, showName=True): - - fslpanel.FSLViewPanel.__init__(self, parent, imageList, displayCtx) - - self.showName = showName - - # A button to select the previous image - self._prevButton = wx.Button(self, label=u'\u25C0', - style=wx.BU_EXACTFIT) - - # A button selecting the next image - self._nextButton = wx.Button(self, label=u'\u25B6', - style=wx.BU_EXACTFIT) - - self._sizer = wx.BoxSizer(wx.HORIZONTAL) - self.SetSizer(self._sizer) - - self._sizer.Add(self._prevButton, flag=wx.EXPAND) - self._sizer.Add(self._nextButton, flag=wx.EXPAND) - - # bind callbacks for next/prev buttons - self._nextButton.Bind(wx.EVT_BUTTON, self._onNextButton) - self._prevButton.Bind(wx.EVT_BUTTON, self._onPrevButton) - - # A label showing the name of the current image - if not showName: - self._imageLabel = None - else: - self._imageLabel = wx.StaticText(self, - style=wx.ALIGN_CENTRE | - wx.ST_ELLIPSIZE_MIDDLE) - - self._sizer.Insert(1, - self._imageLabel, - flag=wx.EXPAND, - proportion=1) - - # Make the image name label font a bit smaller - font = self._imageLabel.GetFont() - font.SetPointSize(font.GetPointSize() - 2) - font.SetWeight(wx.FONTWEIGHT_LIGHT) - self._imageLabel.SetFont(font) - - self._imageList.addListener( - 'images', - self._name, - self._imageListChanged) - - self._displayCtx.addListener( - 'imageOrder', - self._name, - self._imageListChanged) - - self._displayCtx.addListener( - 'selectedImage', - self._name, - self._selectedImageChanged) - - self._imageListChanged() - - self.Layout() - self.SetMinSize(self._sizer.GetMinSize()) - - - def destroy(self): - fslpanel.FSLViewPanel.destroy(self) - - self._imageList. removeListener('images', self._name) - self._displayCtx.removeListener('selectedImage', self._name) - self._displayCtx.removeListener('imageOrder', self._name) - - # the _imageListChanged method registers - # a listener on the name of each image - for image in self._imageList: - image.removeListener('name', self._name) - - - def _onPrevButton(self, ev): - """Called when the previous button is pushed. Selects the previous - image. - """ - allImages = self._displayCtx.getOrderedImages() - currImage = self._displayCtx.getSelectedImage() - currIdx = allImages.index(currImage) - - if currIdx == 0: - return - - self._displayCtx.selectImage(allImages[currIdx - 1]) - - - def _onNextButton(self, ev): - """Called when the previous button is pushed. Selects the next - image. - """ - allImages = self._displayCtx.getOrderedImages() - currImage = self._displayCtx.getSelectedImage() - currIdx = allImages.index(currImage) - - if currIdx == len(allImages) - 1: - return - - self._displayCtx.selectImage(allImages[currIdx + 1]) - - - def _imageListChanged(self, *a): - """Called when the :class:`~fsl.data.image.ImageList.images` - list changes. - - Ensures that the currently selected image is displayed on the panel, - and that listeners are registered on the name property of each image. - """ - - def nameChanged(value, valid, image, name): - - idx = self._imageList.index(image) - - # if the name of the currently selected image has changed, - # make sure that this panel updates to reflect the change - if idx == self._displayCtx.selectedImage: - self._selectedImageChanged() - - if self._imageLabel is not None: - for image in self._imageList: - image.addListener('name', - self._name, - nameChanged, - overwrite=True) - - self._selectedImageChanged() - - - def _selectedImageChanged(self, *a): - """Called when the selected image is changed. Updates the image name - label. - """ - - allImages = self._displayCtx.getOrderedImages() - image = self._displayCtx.getSelectedImage() - nimgs = len(allImages) - - if nimgs > 0: idx = allImages.index(image) - else: idx = -1 - - self._prevButton.Enable(nimgs > 0 and idx > 0) - self._nextButton.Enable(nimgs > 0 and idx < nimgs - 1) - - if self._imageLabel is None: - return - - if nimgs == 0: - self._imageLabel.SetLabel('') - return - - name = image.name - - if name is None: name = '' - self._imageLabel.SetLabel('{}'.format(name)) - - self.Layout() - self.Refresh() diff --git a/fsl/fslview/controls/lightboxsettingspanel.py b/fsl/fslview/controls/lightboxsettingspanel.py deleted file mode 100644 index 612f64f098e3c849cdba3e223a21810eef2879e9..0000000000000000000000000000000000000000 --- a/fsl/fslview/controls/lightboxsettingspanel.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python -# -# lightboxsettingspanel.py - -# -# Author: Paul McCarthy <pauldmccarthy@gmail.com> -# - -import logging - -import wx - -import props - -import fsl.fslview.panel as fslpanel - - -log = logging.getLogger(__name__) - - -class LightBoxSettingsPanel(fslpanel.FSLViewPanel): - - def __init__(self, parent, imageList, displayCtx, ortho): - fslpanel.FSLViewPanel.__init__(self, parent, imageList, displayCtx) - - import fsl.fslview.layouts as layouts - - self.panel = wx.ScrolledWindow(self) - self.panel.SetScrollRate(0, 5) - self.sizer = wx.BoxSizer(wx.VERTICAL) - self.SetSizer(self.sizer) - self.sizer.Add(self.panel, flag=wx.EXPAND, proportion=1) - - self.canvasSettings = props.buildGUI( - self.panel, ortho, layouts.layouts['CanvasPanel']) - - self.divider1 = wx.StaticLine( - self.panel, size=(-1, -1), style=wx.LI_HORIZONTAL) - - self.sceneSettings = props.buildGUI( - self.panel, - ortho.getSceneOptions(), - layouts.layouts['SceneOpts']) - - self.divider2 = wx.StaticLine( - self.panel, size=(-1, -1), style=wx.LI_HORIZONTAL) - - self.lightBoxSettings = props.buildGUI( - self.panel, - ortho.getSceneOptions(), - layouts.layouts['LightBoxPanel']) - - self.panelSizer = wx.BoxSizer(wx.VERTICAL) - self.panel.SetSizer(self.panelSizer) - - flags = wx.wx.EXPAND | wx.ALIGN_CENTRE | wx.ALL - - self.panelSizer.Add(self.canvasSettings, border=20, flag=flags) - self.panelSizer.Add(self.divider1, flag=flags) - self.panelSizer.Add(self.sceneSettings, border=20, flag=flags) - self.panelSizer.Add(self.divider2, flag=flags) - self.panelSizer.Add(self.lightBoxSettings, border=20, flag=flags) - - self.sizer .Layout() - self.panelSizer.Layout() - - size = self.panelSizer.GetMinSize() - - self.SetMinSize((size[0], size[1] / 3.0)) - - - def destroy(self): - fslpanel.FSLViewPanel.destroy(self) diff --git a/fsl/fslview/controls/lightboxtoolbar.py b/fsl/fslview/controls/lightboxtoolbar.py index 1356a89534e46f29994218ed6b753b41d2ae9169..71a5e36e3793d6ce4a6806cf4528b21bbc830b93 100644 --- a/fsl/fslview/controls/lightboxtoolbar.py +++ b/fsl/fslview/controls/lightboxtoolbar.py @@ -5,44 +5,31 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -import logging -import fsl.fslview.toolbar as fsltoolbar -import fsl.fslview.controls.lightboxsettingspanel as lightboxsettingspanel +import props - -log = logging.getLogger(__name__) +import fsl.fslview.toolbar as fsltoolbar +import fsl.fslview.actions as actions class LightBoxToolBar(fsltoolbar.FSLViewToolBar): - def __init__(self, parent, imageList, displayCtx, lb): - - import fsl.fslview.layouts as layouts + def __init__(self, parent, overlayList, displayCtx, lb): actionz = {'more' : self.showMoreSettings} fsltoolbar.FSLViewToolBar.__init__( - self, parent, imageList, displayCtx, actionz) + self, parent, overlayList, displayCtx, actionz) self.lightBoxPanel = lb sceneOpts = lb.getSceneOptions() - - toolSpecs = layouts.layouts[self] - - # TODO this is dodgy - there needs to be a - # way to have this automatically set up. - # - # 1. Add the ability to associate arbitrary - # data with a toolspec (modify props.ViewItem - # to allow a value to be set) - # - # 2. Update layouts.widget and actions.ActionButton - # to set that value to the target class - # - # 3. Here, loop through the toolspecs, check - # the target class, and set the instance - # appropriately + toolSpecs = [ + actions.ActionButton(lb, 'screenshot'), + props .Widget( 'zax'), + props .Widget( 'sliceSpacing', spin=False, showLimits=False), + props .Widget( 'zrange', spin=False, showLimits=False), + props .Widget( 'zoom', spin=False, showLimits=False), + actions.ActionButton(self, 'more')] targets = {'screenshot' : lb, 'zax' : sceneOpts, 'sliceSpacing' : sceneOpts, @@ -52,12 +39,10 @@ class LightBoxToolBar(fsltoolbar.FSLViewToolBar): self.GenerateTools(toolSpecs, targets) - - def destroy(self): - fsltoolbar.FSLViewToolBar.destroy(self) - + def showMoreSettings(self, *a): + import canvassettingspanel self.lightBoxPanel.togglePanel( - lightboxsettingspanel.LightBoxSettingsPanel, + canvassettingspanel.CanvasSettingsPanel, True, self.lightBoxPanel) diff --git a/fsl/fslview/controls/locationpanel.py b/fsl/fslview/controls/locationpanel.py index ddbffd4abf82c5915ad6c0f38e078af9354181c8..285306a68b0b83bb117cf2d7e02a900a42f0c200 100644 --- a/fsl/fslview/controls/locationpanel.py +++ b/fsl/fslview/controls/locationpanel.py @@ -6,24 +6,27 @@ # """This module provides the :class:`LocationPanel` class, a panel which displays controls allowing the user to change the currently displayed location -in both real world and voxel coordinates, both in the space of the currently -selected image. +in both world and local coordinates, both in the space of the currently +selected overlay. These changes are propagated to the current display coordinate system location, managed by the display context (and external changes to the display -context location are propagated back to the voxel/world location properties +context location are propagated back to the local/world location properties managed by a :class:`LocationPanel`). """ import logging import wx +import wx.html as wxhtml import numpy as np import props import fsl.utils.transform as transform +import fsl.data.image as fslimage +import fsl.data.constants as constants import fsl.data.strings as strings import fsl.fslview.panel as fslpanel @@ -33,358 +36,525 @@ log = logging.getLogger(__name__) class LocationPanel(fslpanel.FSLViewPanel): """ - A wx.Panel which contains widgets for changing the currently displayed - location in both world coordinates, and voxel coordinates (in terms of the - currently selected image). Also contains a label which contains the name - of the currently selected image and the value, in that image, at the - currently selected voxel. + A wx.Panel which displays information about the current location, + for each overlay in the overlay list. """ voxelLocation = props.Point(ndims=3, real=False, labels=('X', 'Y', 'Z')) - + """If the currently selected overlay is a :class:`.Image`, this property + tracks the current display location in voxel coordinates. + """ worldLocation = props.Point(ndims=3, real=True, labels=('X', 'Y', 'Z')) - - def _adjustFont(self, label, by, weight): - """ - Adjusts the font of the given wx.StaticText widget (or any other - widget which has a font) by the specified amount. Also sets the - font weight to the given weight. - """ - font = label.GetFont() - font.SetPointSize(font.GetPointSize() + by) - font.SetWeight(weight) - label.SetFont(font) - - def __init__(self, parent, imageList, displayCtx): + def __init__(self, parent, overlayList, displayCtx): """ Creates and lays out the LocationPanel, and sets up a few property event listeners. """ - fslpanel.FSLViewPanel.__init__(self, parent, imageList, displayCtx) + fslpanel.FSLViewPanel.__init__(self, parent, overlayList, displayCtx) + + # The world and voxel locations dispalyed by the LocationPanel + # are only really relevant to volumetric (i.e. NIFTI) overlay + # types. However, other overlay types (e.g. Model instances) + # may have an associated 'reference' image, from which details + # of the coordinate system may be obtained. + # + # When the current overlay is either an Image instance, or has + # an associated reference image, these attributes are used to + # store references to the image, and to the matrices that allow + # transformations between the different coordinate systems. + self._refImage = None + self._voxToDisplayMat = None + self._displayToVoxMat = None + self._worldToDisplayMat = None + self._displayToWorldMat = None + self._voxToWorldMat = None + self._worldToVoxMat = None + + # When the currently selected overlay is 4D, + # this attribute will refer to the + # corresponding DisplayOpts instance, which + # has a volume property that controls the + # volume - see e.g. the ImageOpts class. This + # attribute is set in _selectedOverlayChanged. + self.volumeTarget = None + + self.column1 = wx.Panel(self) + self.column2 = wx.Panel(self) + self.info = wxhtml.HtmlWindow(self) + + # HTMLWindow does not use + # the parent font by default, + # so we force it to at least + # have the parent font size + self.info.SetStandardFonts(self.GetFont().GetPointSize()) + + self.worldLabel = wx.StaticText( + self.column1, label=strings.labels[self, 'worldLocation']) + self.volumeLabel = wx.StaticText( + self.column1, label=strings.labels[self, 'volume']) + self.voxelLabel = wx.StaticText( + self.column2, label=strings.labels[self, 'voxelLocation']) - voxX, voxY, voxZ = props.makeListWidgets( - self, + worldX, worldY, worldZ = props.makeListWidgets( + self.column1, self, - 'voxelLocation', + 'worldLocation', slider=False, spin=True, showLimits=False) - worldX, worldY, worldZ = props.makeListWidgets( - self, + + voxelX, voxelY, voxelZ = props.makeListWidgets( + self.column2, self, - 'worldLocation', + 'voxelLocation', slider=False, spin=True, showLimits=False) - self.voxX = voxX - self.voxY = voxY - self.voxZ = voxZ - self.worldX = worldX - self.worldY = worldY - self.worldZ = worldZ - self.volume = props.makeWidget(self, - displayCtx, - 'volume', - slider=False, - spin=True, - showLimits=False) + self.worldX = worldX + self.worldY = worldY + self.worldZ = worldZ + self.voxelX = voxelX + self.voxelY = voxelY + self.voxelZ = voxelZ + self.volume = wx.SpinCtrl(self.column2) + self.volume.SetValue(0) + + self.column1Sizer = wx.BoxSizer(wx.VERTICAL) + self.column2Sizer = wx.BoxSizer(wx.VERTICAL) + self.sizer = wx.BoxSizer(wx.HORIZONTAL) + + self.column1Sizer.Add(self.worldLabel, flag=wx.EXPAND) + self.column1Sizer.Add(self.worldX, flag=wx.EXPAND) + self.column1Sizer.Add(self.worldY, flag=wx.EXPAND) + self.column1Sizer.Add(self.worldZ, flag=wx.EXPAND) + self.column1Sizer.Add(self.volumeLabel, flag=wx.ALIGN_RIGHT) + + self.column2Sizer.Add(self.voxelLabel, flag=wx.EXPAND) + self.column2Sizer.Add(self.voxelX, flag=wx.EXPAND) + self.column2Sizer.Add(self.voxelY, flag=wx.EXPAND) + self.column2Sizer.Add(self.voxelZ, flag=wx.EXPAND) + self.column2Sizer.Add(self.volume, flag=wx.EXPAND) - self.intensity = wx.TextCtrl(self, style=wx.TE_READONLY) - self.space = wx.TextCtrl(self, style=wx.TE_READONLY) - - self.voxLabel = wx.StaticText( - self, label=strings.labels[self, 'voxelLocation']) - self.worldLabel = wx.StaticText( - self, label=strings.labels[self, 'worldLocation']) - self.volumeLabel = wx.StaticText( - self, label=strings.labels[self, 'volume']) - self.spaceLabel = wx.StaticText( - self, label=strings.labels[self, 'space']) - self.intensityLabel = wx.StaticText( - self, label=strings.labels[self, 'intensity']) - - self.sizer = wx.FlexGridSizer(4, 4) - self.SetSizer(self.sizer) - - self.sizer.Add(self.voxLabel, flag=wx.EXPAND) - self.sizer.Add(self.worldLabel, flag=wx.EXPAND) - self.sizer.Add((0, 0)) - self.sizer.Add((0, 0)) - self.sizer.Add(self.voxX, flag=wx.EXPAND) - self.sizer.Add(self.worldX, flag=wx.EXPAND) - self.sizer.Add(self.volumeLabel, flag=wx.EXPAND) - self.sizer.Add(self.volume, flag=wx.EXPAND) + self.sizer.Add(self.column1, flag=wx.EXPAND) + self.sizer.Add((5, -1)) + self.sizer.Add(self.column2, flag=wx.EXPAND) + self.sizer.Add((5, -1)) + self.sizer.Add(self.info, flag=wx.EXPAND, proportion=1) + + self.column1.SetSizer(self.column1Sizer) + self.column2.SetSizer(self.column2Sizer) + self .SetSizer(self.sizer) - self.sizer.Add(self.voxY, flag=wx.EXPAND) - self.sizer.Add(self.worldY, flag=wx.EXPAND) - self.sizer.Add(self.intensityLabel, flag=wx.EXPAND) - self.sizer.Add(self.intensity, flag=wx.EXPAND) - self.sizer.Add(self.voxZ, flag=wx.EXPAND) - self.sizer.Add(self.worldZ, flag=wx.EXPAND) - self.sizer.Add(self.spaceLabel, flag=wx.EXPAND) - self.sizer.Add(self.space, flag=wx.EXPAND) - - self._adjustFont(self.voxLabel, -2, wx.FONTWEIGHT_LIGHT) - self._adjustFont(self.worldLabel, -2, wx.FONTWEIGHT_LIGHT) - self._adjustFont(self.volumeLabel, -2, wx.FONTWEIGHT_LIGHT) - self._adjustFont(self.spaceLabel, -2, wx.FONTWEIGHT_LIGHT) - self._adjustFont(self.intensityLabel, -2, wx.FONTWEIGHT_LIGHT) - self._adjustFont(self.intensity, -2, wx.FONTWEIGHT_LIGHT) - self._adjustFont(self.space, -2, wx.FONTWEIGHT_LIGHT) - + self._overlayList.addListener('overlays', + self._name, + self._selectedOverlayChanged) + self._displayCtx .addListener('overlayOrder', + self._name, + self._selectedOverlayChanged) + self._displayCtx .addListener('selectedOverlay', + self._name, + self._selectedOverlayChanged) + self._displayCtx .addListener('location', + self._name, + self._displayLocationChanged) + self.addListener( 'voxelLocation', + self._name, + self._voxelLocationChanged) + self.addListener( 'worldLocation', + self._name, + self._worldLocationChanged) + + self._selectedOverlayChanged() + + self.worldLabel.SetMinSize(self.__calcWorldLabelMinSize()) + self.info .SetMinSize((150, 100)) self.Layout() + self.SetMinSize(self.sizer.GetMinSize()) + + + def __calcWorldLabelMinSize(self): + """Calculates the minimum size that the world label (the label which + shows the coordinate space of the currently selected overlay) needs. - self._imageList.addListener( 'images', - self._name, - self._selectedImageChanged) - self._displayCtx.addListener('imageOrder', - self._name, - self._selectedImageChanged) - self._displayCtx.addListener('selectedImage', - self._name, - self._selectedImageChanged) - self._displayCtx.addListener('volume', - self._name, - self._volumeChanged) - self._displayCtx.addListener('location', - self._name, - self._displayLocationChanged) - self.addListener( 'voxelLocation', - self._name, - self._voxelLocationChanged) - self.addListener( 'worldLocation', - self._name, - self._worldLocationChanged) - - self._selectedImageChanged() - self._volumeChanged() + The world label displays different things depending on the currently + selected overlay. But we want it to be a fixed size. So this method + calculates the size of all possible values that the world label will + display, and returns the maximum size. This is then used as the + minimum size for the world label. + """ - self.SetMinSize(self.sizer.GetMinSize()) + dc = wx.ClientDC(self.worldLabel) + + width, height = 0, 0 + + labelPref = strings.labels[self, 'worldLocation'] + labelSufs = [ + strings.anatomy[fslimage.Image, + 'space', + constants.NIFTI_XFORM_UNKNOWN], + strings.anatomy[fslimage.Image, + 'space', + constants.NIFTI_XFORM_SCANNER_ANAT], + strings.anatomy[fslimage.Image, + 'space', + constants.NIFTI_XFORM_ALIGNED_ANAT], + strings.anatomy[fslimage.Image, + 'space', + constants.NIFTI_XFORM_TALAIRACH], + strings.anatomy[fslimage.Image, + 'space', + constants.NIFTI_XFORM_MNI_152], + strings.labels[self, 'worldLocation', 'unknown'] + ] + + for labelSuf in labelSufs: + + w, h = dc.GetTextExtent(labelPref + labelSuf) + + if w > width: width = w + if h > height: height = h + + return width + 5, height + 5 def destroy(self): """Deregisters property listeners.""" - fslpanel.FSLViewPanel.destroy(self) - self._imageList.removeListener( 'images', self._name) - self._displayCtx.removeListener('selectedImage', self._name) - self._displayCtx.removeListener('imageOrder', self._name) - self._displayCtx.removeListener('volume', self._name) - self._displayCtx.removeListener('location', self._name) + self._overlayList.removeListener('overlays', self._name) + self._displayCtx .removeListener('selectedOverlay', self._name) + self._displayCtx .removeListener('overlayOrder', self._name) + self._displayCtx .removeListener('location', self._name) + fslpanel.FSLViewPanel.destroy(self) - def _updateVoxelValue(self, voxVal=None): - """ - Retrieves the value of the voxel at the current location in the - currently selected image, and displays it on the value label. - If the voxVal argument is provided, it is displayed. Otherwise - the value at the current voxel location is displayed. + + def _selectedOverlayChanged(self, *a): + """Called when the selected overlay is changed. Updates the voxel label + (which contains the overlay name), and sets the voxel location limits. """ - if len(self._imageList) == 0: - voxVal = '' + self._updateReferenceImage() + self._updateWidgets() - if voxVal is not None: - self.intensity.SetValue('{}'.format(voxVal)) + if len(self._overlayList) == 0: + self._updateLocationInfo() return - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) + # Register a listener on the DisplayOpts + # instance of the currently selected overlay, + # so we can update the location if the + # overlay transforms/reference image change + overlay = self._displayCtx.getSelectedOverlay() + for ovl in self._overlayList: + display = self._displayCtx.getDisplay(ovl) + opts = display.getDisplayOpts() + if ovl is overlay: + opts .addGlobalListener(self._name, + self._overlayOptsChanged, + overwrite=True) + display.addGlobalListener(self._name, + self._overlayOptsChanged, + overwrite=True) + else: + opts .removeGlobalListener(self._name) + display.removeGlobalListener(self._name) - dloc = self._displayCtx.location.xyz - vloc = transform.transform( - [dloc], display.getTransform('display', 'voxel'))[0] - vloc = np.round(vloc) - volume = self._displayCtx.volume + # Refresh the world/voxel location properties + self._displayLocationChanged() - # Test to see if the voxel - # location/volume is out of bounds - inBounds = True - for i in range(3): - if vloc[i] < 0 or vloc[i] >= image.shape[i]: - inBounds = False - if image.is4DImage(): - if volume >= image.shape[3]: - inBounds = False + def _overlayOptsChanged(self, *a): - # If the value is out of the voxel bounds, - # display some appropriate text - if not inBounds: - voxVal = strings.labels[self, 'outOfBounds'] - + self._updateReferenceImage() + self._updateWidgets() + self._displayLocationChanged() + + + def _updateReferenceImage(self): + """Called by the :meth:`_selectedOverlayChanged` method. Looks at the + currently selected overlay, and figures out if there is a reference + image that can be used to transform between display, world, and voxel + coordinate systems. + """ + + refImage = None + + # Look at the currently selected overlay, and + # see if there is an associated NIFTI image + # that can be used as a reference image + if len(self._overlayList) > 0: + + overlay = self._displayCtx.getSelectedOverlay() + refImage = self._displayCtx.getReferenceImage(overlay) + + log.debug('Reference image for overlay {}: {}'.format( + overlay, refImage)) + + self._refImage = refImage + + if refImage is not None: + opts = self._displayCtx.getOpts(refImage) + self._voxToDisplayMat = opts.getTransform('voxel', 'display') + self._displayToVoxMat = opts.getTransform('display', 'voxel') + self._worldToDisplayMat = opts.getTransform('world', 'display') + self._displayToWorldMat = opts.getTransform('display', 'world') + self._voxToWorldMat = opts.getTransform('voxel', 'world') + self._worldToVoxMat = opts.getTransform('world', 'voxel') else: + self._voxToDisplayMat = None + self._displayToVoxMat = None + self._worldToDisplayMat = None + self._displayToWorldMat = None + self._voxToWorldMat = None + self._worldToVoxMat = None + + + def _updateWidgets(self): + + refImage = self._refImage + + haveRef = refImage is not None + + self.voxelX .Enable(haveRef) + self.voxelY .Enable(haveRef) + self.voxelZ .Enable(haveRef) + self.voxelLabel .Enable(haveRef) + + ###################### + # World location label + ###################### + + label = strings.labels[self, 'worldLocation'] + + if haveRef: label += strings.anatomy[refImage, + 'space', + refImage.getXFormCode()] + else: label += strings.labels[ self, + 'worldLocation', + 'unknown'] + + self.worldLabel.SetLabel(label) + + #################################### + # Voxel/world location widget limits + #################################### + + # Figure out the limits for the + # voxel/world location widgets + if self._refImage is not None: + shape = self._refImage.shape[:3] + vlo = [0, 0, 0] + vhi = np.array(shape) - 1 + wlo, whi = transform.axisBounds(shape, self._voxToWorldMat) + else: + vlo = [0, 0, 0] + vhi = [0, 0, 0] + wbounds = self._displayCtx.bounds[:] + wlo = wbounds[0::2] + whi = wbounds[1::2] + + # Update the voxel and world location limits, + # but don't trigger a listener callback, as + # this would change the display location. + self.disableNotification('worldLocation') + self.disableNotification('voxelLocation') + + log.debug('Setting voxelLocation limits: {} - {}'.format(vlo, vhi)) + log.debug('Setting worldLocation limits: {} - {}'.format(wlo, whi)) + + for i in range(3): + self.voxelLocation.setLimits(i, vlo[i], vhi[i]) + self.worldLocation.setLimits(i, wlo[i], whi[i]) - log.debug('Looking up voxel value in {} ({}, {} -> {})'.format( - image, vloc, volume, image.shape)) + self.enableNotification('worldLocation') + self.enableNotification('voxelLocation') + + ############### + # Volume widget + ############### + + # Unbind any listeners between the previous + # reference image and the volume widget + if self.volumeTarget is not None: + props.unbindWidget(self.volume, + self.volumeTarget, + 'volume', + (wx.EVT_SPIN, wx.EVT_SPINCTRL)) - # 3D image - if len(image.shape) == 3: - voxVal = image.data[vloc[0], vloc[1], vloc[2]] + self.volume.Unbind(wx.EVT_MOUSEWHEEL) + self.volumeTarget = None + self.volume.SetValue(0) - # No support for images of more - # than 4 dimensions at the moment - else: - voxVal = image.data[vloc[0], vloc[1], vloc[2], volume] + # Enable/disable the volume widget if the + # overlay is a 4D image, and bind/unbind + # the widget to the volume property of + # the associated ImageOpts instance + if haveRef and refImage.is4DImage(): + opts = self._displayCtx.getOpts(refImage) + self.volumeTarget = opts - if np.isnan(voxVal): voxVal = 'NaN' - elif np.isinf(voxVal): voxVal = 'Inf' + def onMouse(ev): + if not self.volume.IsEnabled(): + return - self.intensity.SetValue('{}'.format(voxVal)) + wheelDir = ev.GetWheelRotation() - - def _volumeChanged(self, *a): - """Called when the - :attr:`fsl.fslview.displaycontext.DisplayContext.volume` - property changes. Updates the voxel value label. - """ - self._updateVoxelValue() - + if wheelDir < 0: opts.volume -= 1 + elif wheelDir > 0: opts.volume += 1 - def _displayLocationChanged(self, *a): + props.bindWidget( + self.volume, opts, 'volume', (wx.EVT_SPIN, wx.EVT_SPINCTRL)) - if len(self._imageList) == 0: return + self.volume.Bind(wx.EVT_MOUSEWHEEL, onMouse) - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) + self.volume .Enable() + self.volumeLabel.Enable() + else: + self.volume .Disable() + self.volumeLabel.Disable() - dloc = self._displayCtx.location.xyz - vloc = transform.transform( - [dloc], display.getTransform('display', 'voxel'))[0] - wloc = transform.transform( - [dloc], display.getTransform('display', 'world'))[0] + + def _prePropagate(self): - log.debug('Updating location ({} -> vox {}, world {})'.format( - dloc, vloc, wloc)) - - self .disableListener('voxelLocation', self._name) - self .disableListener('worldLocation', self._name) - self._displayCtx.disableListener('location', self._name) + self .disableNotification('voxelLocation') + self .disableNotification('worldLocation') + self._displayCtx.disableListener( 'location', self._name) self.Freeze() + - self.voxelLocation.xyz = np.round(vloc) - self.worldLocation.xyz = wloc + def _propagate(self, source, target, xform): - self .enableListener('voxelLocation', self._name) - self .enableListener('worldLocation', self._name) - self._displayCtx.enableListener('location', self._name) + if source == 'display': coords = self._displayCtx.location.xyz + elif source == 'voxel': coords = self.voxelLocation.xyz + elif source == 'world': coords = self.worldLocation.xyz - self._updateVoxelValue() + if xform is not None: xformed = transform.transform([coords], xform)[0] + else: xformed = np.array(coords) + + log.debug('Updating location ({} {} -> {} {})'.format( + source, coords, target, xformed)) + + if target == 'display': + self._displayCtx.location.xyz = xformed + + # Voxel coordinates are transformed to [x - 0.5, x + 0.5], + # so we floor(x + 0.5) to get the corresponding integer + # coordinates + elif target == 'voxel': + self.voxelLocation.xyz = np.floor(xformed + 0.5) + + elif target == 'world': + self.worldLocation.xyz = xformed + + + def _postPropagate(self): + self .enableNotification('voxelLocation') + self .enableNotification('worldLocation') + self._displayCtx.enableListener( 'location', self._name) self.Thaw() self.Refresh() self.Update() - - def _voxelLocationChanged(self, *a): - """ - Called when the current voxel location is changed. Propagates the - change on to the display context world location. + + def _displayLocationChanged(self, *a): + """Called when the :attr:`.DisplayContext.location` changes. + Propagates the change on to the :attr:`voxelLocation` + and :attr:`worldLocation` properties. """ - if len(self._imageList) == 0: return + if len(self._overlayList) == 0: return - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) - - vloc = self.voxelLocation.xyz - dloc = transform.transform( - [vloc], display.getTransform('voxel', 'display'))[0] - wloc = transform.transform( - [vloc], display.getTransform('voxel', 'world'))[ 0] - - self .disableListener('voxelLocation', self._name) - self .disableListener('worldLocation', self._name) - self._displayCtx.disableListener('location', self._name) - - self._displayCtx.location.xyz = dloc - self.worldLocation .xyz = wloc + self._prePropagate() + self._propagate('display', 'voxel', self._displayToVoxMat) + self._propagate('display', 'world', self._displayToWorldMat) + self._postPropagate() + self._updateLocationInfo() - self .enableListener('voxelLocation', self._name) - self .enableListener('worldLocation', self._name) - self._displayCtx.enableListener('location', self._name) - - self._updateVoxelValue() - def _worldLocationChanged(self, *a): - """ - Called when the current location in the image list world changes. - Propagates the change on to the voxel location in the currently - selected image. - """ - - if len(self._imageList) == 0: return - - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) - wloc = self.worldLocation.xyz - dloc = transform.transform( - [wloc], display.getTransform('world', 'display'))[0] - vloc = transform.transform( - [wloc], display.getTransform('world', 'voxel'))[ 0] - - self .disableListener('voxelLocation', self._name) - self .disableListener('worldLocation', self._name) - self._displayCtx.disableListener('location', self._name) - - self._displayCtx.location.xyz = dloc - self.voxelLocation .xyz = np.round(vloc) + if len(self._overlayList) == 0: return - self .enableListener('voxelLocation', self._name) - self .enableListener('worldLocation', self._name) - self._displayCtx.enableListener('location', self._name) - - self._updateVoxelValue() + self._prePropagate() + self._propagate('world', 'voxel', self._worldToVoxMat) + self._propagate('world', 'display', self._worldToDisplayMat) + self._postPropagate() + self._updateLocationInfo() - def _selectedImageChanged(self, *a): - """ - Called when the selected image is changed. Updates the voxel label - (which contains the image name), and sets the voxel location limits. - """ + def _voxelLocationChanged(self, *a): + + if len(self._overlayList) == 0: return - if len(self._imageList) == 0: - self._updateVoxelValue( '') - self.space.SetValue('') - return + self._prePropagate() + self._propagate('voxel', 'world', self._voxToWorldMat) + self._propagate('voxel', 'display', self._voxToDisplayMat) + self._postPropagate() + self._updateLocationInfo() - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) - # Update the label which - # displays the image space - spaceLabel = strings.anatomy['Image', 'space', image.getXFormCode()] - self.space.SetValue(spaceLabel) + def _updateLocationInfo(self): - # Update the voxel and world location limits, - # but don't trigger a listener callback, as - # this would change the display location. - self.disableListener('worldLocation', self._name) - self.disableListener('voxelLocation', self._name) + if len(self._overlayList) == 0: + self.info.SetPage('') + return - self._displayCtx.disableListener('location', self._name) + overlays = self._displayCtx.getOrderedOverlays() + selOvl = self._displayCtx.getSelectedOverlay() - for i in range(3): - vlo, vhi = 0, image.shape[i] - 1 - wlo, whi = transform.axisBounds( - image.shape, display.getTransform('voxel', 'world'), i) + overlays.remove(selOvl) + overlays.insert(0, selOvl) - self.voxelLocation.setLimits(i, vlo, vhi) - self.worldLocation.setLimits(i, wlo, whi) + lines = [] + for overlay in overlays: - self._displayCtx.enableListener('location', self._name) + display = self._displayCtx.getDisplay(overlay) + + if not display.enabled: + continue - self.enableListener('worldLocation', self._name) - self.enableListener('voxelLocation', self._name) + title = '<b>{}</b>'.format(overlay.name) + info = None - # Refresh the world/voxel location properties - self._displayLocationChanged() + if not isinstance(overlay, fslimage.Image): + info = '{}'.format(strings.labels[self, 'noData']) + else: + opts = self._displayCtx.getOpts(overlay) + vloc = transform.transform( + [self._displayCtx.location.xyz], + opts.getTransform('display', 'voxel'))[0] + + # The above transformation gives us + # values between [x - 0.5, x + 0.5] for + # voxel x, so we need to floor(x + 0.5) + # to get the actual voxel coordinates + vloc = tuple(map(int, np.floor(vloc + 0.5))) + + if overlay.is4DImage(): + vloc = vloc + (opts.volume,) + + inBounds = True + for i in range(3): + if vloc[i] < 0 or vloc[i] >= overlay.shape[i]: + inBounds = False + + if inBounds: + vval = overlay.data[vloc] + info = '[{}]: {}'.format(' '.join(map(str, vloc)), vval) + else: + info = strings.labels[self, 'outOfBounds'] + + lines.append(title) + if info is not None: + lines.append(info) + + self.info.SetPage('<br>'.join(lines)) + self.info.Refresh() diff --git a/fsl/fslview/controls/lookuptablepanel.py b/fsl/fslview/controls/lookuptablepanel.py new file mode 100644 index 0000000000000000000000000000000000000000..ce4da7873acd8622cd2d83347742f0d211c79e69 --- /dev/null +++ b/fsl/fslview/controls/lookuptablepanel.py @@ -0,0 +1,604 @@ +#!/usr/bin/env python +# +# lookuptablepanel.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import os +import copy +import logging + +import wx + +import numpy as np + +import props + +import pwidgets.elistbox as elistbox + +import fsl.fslview.panel as fslpanel +import fsl.fslview.displaycontext as displayctx +import fsl.fslview.colourmaps as fslcmaps +import fsl.data.strings as strings + + +log = logging.getLogger(__name__) + + + + +class LabelWidget(wx.Panel): + + def __init__(self, lutPanel, overlayOpts, lut, value): + wx.Panel.__init__(self, lutPanel) + + self.lutPanel = lutPanel + self.opts = overlayOpts + self.lut = lut + self.value = value + + # TODO Change the enable box to a toggle + # button with an eye icon + + self.valueLabel = wx.StaticText(self, + style=wx.ALIGN_CENTRE_VERTICAL | + wx.ALIGN_RIGHT) + self.enableBox = wx.CheckBox(self) + self.colourButton = wx.ColourPickerCtrl(self) + + self.sizer = wx.BoxSizer(wx.HORIZONTAL) + self.SetSizer(self.sizer) + self.sizer.Add(self.valueLabel, flag=wx.ALIGN_CENTRE, proportion=1) + self.sizer.Add(self.enableBox, flag=wx.ALIGN_CENTRE, proportion=1) + self.sizer.Add(self.colourButton, flag=wx.ALIGN_CENTRE, proportion=1) + + label = lut.get(value) + colour = [np.floor(c * 255.0) for c in label.colour()] + + self.valueLabel .SetLabel(str(value)) + self.colourButton.SetColour(colour) + self.enableBox .SetValue(label.enabled()) + + self.enableBox .Bind(wx.EVT_CHECKBOX, self.__onEnable) + self.colourButton.Bind(wx.EVT_COLOURPICKER_CHANGED, self.__onColour) + + + def __onEnable(self, ev): + + # Disable the LutPanel listener, otherwise + # it will recreate the label list (see + # LookupTablePanel._initLabelList) + self.lut.disableListener('labels', self.lutPanel._name) + self.lut.set(self.value, enabled=self.enableBox.GetValue()) + self.lut.enableListener('labels', self.lutPanel._name) + + + def __onColour(self, ev): + + newColour = self.colourButton.GetColour() + newColour = [c / 255.0 for c in newColour] + + self.lut.disableListener('labels', self.lutPanel._name) + self.lut.set(self.value, colour=newColour) + self.lut.enableListener('labels', self.lutPanel._name) + + +class LookupTablePanel(fslpanel.FSLViewPanel): + + def __init__(self, parent, overlayList, displayCtx): + + fslpanel.FSLViewPanel.__init__(self, parent, overlayList, displayCtx) + + self.__controlRow = wx.Panel(self) + self.__disabledLabel = wx.StaticText(self, + style=wx.ALIGN_CENTER_VERTICAL | + wx.ALIGN_CENTER_HORIZONTAL) + self.__labelList = elistbox.EditableListBox( + self, + style=elistbox.ELB_NO_MOVE | elistbox.ELB_EDITABLE) + + self.__overlayNameLabel = wx.StaticText(self, + style=wx.ST_ELLIPSIZE_MIDDLE) + + self.__lutWidget = None + self.__newLutButton = wx.Button(self.__controlRow) + self.__copyLutButton = wx.Button(self.__controlRow) + self.__saveLutButton = wx.Button(self.__controlRow) + self.__loadLutButton = wx.Button(self.__controlRow) + + self.__controlRowSizer = wx.BoxSizer(wx.HORIZONTAL) + self.__sizer = wx.BoxSizer(wx.VERTICAL) + + self.__controlRow.SetSizer(self.__controlRowSizer) + self .SetSizer(self.__sizer) + + self.__controlRowSizer.Add(self.__newLutButton, + flag=wx.EXPAND, proportion=1) + self.__controlRowSizer.Add(self.__copyLutButton, + flag=wx.EXPAND, proportion=1) + self.__controlRowSizer.Add(self.__loadLutButton, + flag=wx.EXPAND, proportion=1) + self.__controlRowSizer.Add(self.__saveLutButton, + flag=wx.EXPAND, proportion=1) + + self.__sizer.Add(self.__overlayNameLabel, flag=wx.EXPAND) + self.__sizer.Add(self.__controlRow, flag=wx.EXPAND) + self.__sizer.Add(self.__disabledLabel, flag=wx.EXPAND, proportion=1) + self.__sizer.Add(self.__labelList, flag=wx.EXPAND, proportion=1) + + # Label the labels and buttons + self.__disabledLabel.SetLabel(strings.messages[self, 'notLutOverlay']) + self.__newLutButton .SetLabel(strings.labels[ self, 'newLut']) + self.__copyLutButton.SetLabel(strings.labels[ self, 'copyLut']) + self.__loadLutButton.SetLabel(strings.labels[ self, 'loadLut']) + self.__saveLutButton.SetLabel(strings.labels[ self, 'saveLut']) + + # Listen for listbox events + self.__labelList.Bind(elistbox.EVT_ELB_ADD_EVENT, + self.__onLabelAdd) + self.__labelList.Bind(elistbox.EVT_ELB_REMOVE_EVENT, + self.__onLabelRemove) + self.__labelList.Bind(elistbox.EVT_ELB_EDIT_EVENT, + self.__onLabelEdit) + + self.__newLutButton .Bind(wx.EVT_BUTTON, self.__onNewLut) + self.__copyLutButton.Bind(wx.EVT_BUTTON, self.__onCopyLut) + self.__loadLutButton.Bind(wx.EVT_BUTTON, self.__onLoadLut) + self.__saveLutButton.Bind(wx.EVT_BUTTON, self.__onSaveLut) + + self.__selectedOverlay = None + self.__selectedOpts = None + self.__selectedLut = None + + overlayList.addListener('overlays', + self._name, + self.__selectedOverlayChanged) + displayCtx .addListener('selectedOverlay', + self._name, + self.__selectedOverlayChanged) + + self.__disabledLabel.Show(False) + self.__controlRowSizer.SetMinSize(self.__calcControlRowMinSize()) + self.Layout() + self.SetMinSize(self.__sizer.GetMinSize()) + + self.__selectedOverlayChanged() + + + def __calcControlRowMinSize(self): + """This method calculates and returns a minimum width and height + for the control row. + + When the LookupTable is first created, there is no LUT widget - it is + created when an appropriate overlay is selected (see + :meth:`__overlayTypeChanged`). Here, we create a dummy LUT widget, and + use its best size, along with the control row button sizes, to + calculate the minimum size needed to lay out the control row. + """ + + class DummyLut(props.HasProperties): + lut = copy.copy(displayctx.LabelOpts.lut) + + dl = DummyLut() + dummyLutWidget = props.makeWidget(self, dl, 'lut') + width, height = dummyLutWidget.GetBestSize().Get() + + for btn in [self.__newLutButton, + self.__copyLutButton, + self.__saveLutButton, + self.__loadLutButton]: + + w, h = btn.GetBestSize().Get() + width += w + + if h > height: + height = h + + dummyLutWidget.Destroy() + + return width, height + + + def destroy(self): + + self._overlayList.removeListener('overlays', self._name) + self._displayCtx .removeListener('selectedOverlay', self._name) + + overlay = self.__selectedOverlay + opts = self.__selectedOpts + lut = self.__selectedLut + + if overlay is not None: + + display = self._displayCtx.getDisplay(overlay) + + display.removeListener('name', self._name) + display.removeListener('overlayType', self._name) + + if opts is not None: + opts.removeListener('lut', self._name) + + if lut is not None: + lut.removeListener('labels', self._name) + lut.removeListener('saved', self._name) + + fslpanel.FSLViewPanel.destroy(self) + + + def __selectedOverlayChanged(self, *a): + + newOverlay = self._displayCtx.getSelectedOverlay() + + if self.__selectedOverlay == newOverlay: + return + + if self.__selectedOverlay is not None and \ + self.__selectedOverlay in self._overlayList: + + display = self._displayCtx.getDisplay(self.__selectedOverlay) + + display.removeListener('name', self._name) + display.removeListener('overlayType', self._name) + + self.__selectedOverlay = newOverlay + + if newOverlay is not None: + display = self._displayCtx.getDisplay(newOverlay) + display.addListener('name', + self._name, + self.__overlayNameChanged) + display.addListener('overlayType', + self._name, + self.__overlayTypeChanged) + + self.__overlayNameChanged() + self.__overlayTypeChanged() + + + def __overlayNameChanged(self, *a): + + overlay = self.__selectedOverlay + + if overlay is None: + self.__overlayNameLabel.SetLabel('') + return + + display = self._displayCtx.getDisplay(overlay) + + self.__overlayNameLabel.SetLabel(display.name) + + + def __overlayTypeChanged(self, *a): + + if self.__lutWidget is not None: + self.__controlRowSizer.Detach(self.__lutWidget) + self.__lutWidget.Destroy() + self.__lutWidget = None + + if self.__selectedOpts is not None: + self.__selectedOpts.removeListener('lut', self._name) + self.__selectedOpts = None + + overlay = self.__selectedOverlay + enabled = False + + if overlay is not None: + opts = self._displayCtx.getOpts(overlay) + + if isinstance(opts, displayctx.LabelOpts): + enabled = True + + self.__overlayNameLabel.Show( enabled) + self.__controlRow .Show( enabled) + self.__labelList .Show( enabled) + self.__disabledLabel .Show(not enabled) + + if not enabled: + self.Layout() + return + + opts = self._displayCtx.getOpts(overlay) + + opts.addListener('lut', self._name, self.__lutChanged) + + self.__selectedOpts = opts + self.__lutWidget = props.makeWidget( + self.__controlRow, opts, 'lut') + + self.__controlRowSizer.Insert( + 0, self.__lutWidget, flag=wx.EXPAND, proportion=1) + + self.__lutChanged() + + self.Layout() + + + def __lutChanged(self, *a): + + if self.__selectedLut is not None: + self.__selectedLut.removeListener('labels', self._name) + self.__selectedLut.removeListener('saved', self._name) + self.__selecedLut = None + + opts = self.__selectedOpts + + if opts is not None: + self.__selectedLut = opts.lut + + self.__selectedLut.addListener( + 'labels', self._name, self.__initLabelList) + self.__selectedLut.addListener( + 'saved', self._name, self.__lutSaveStateChanged) + + self.__initLabelList() + self.__lutSaveStateChanged() + + + def __lutSaveStateChanged(self, *a): + self.__saveLutButton.Enable(not self.__selectedLut.saved) + + + def __initLabelList(self, *a): + + self.__labelList.Clear() + + if self.__selectedOpts is None: + return + + opts = self.__selectedOpts + lut = opts.lut + + for i, label in enumerate(lut.labels): + + self.__labelList.Append(label.name()) + + widget = LabelWidget(self, opts, lut, label.value()) + self.__labelList.SetItemWidget(i, widget) + + + def __onNewLut(self, ev): + + dlg = NewLutDialog(self.GetTopLevelParent()) + if dlg.ShowModal() != wx.ID_OK: + return + + log.debug('Creating and registering new ' + 'LookupTable: {}'.format(dlg.name)) + + lut = fslcmaps.LookupTable(dlg.name) + fslcmaps.registerLookupTable(lut, self._overlayList, self._displayCtx) + + if self.__selectedOpts is not None: + self.__selectedOpts.lut = lut + + + def __onCopyLut(self, ev): + + name = self.__selectedLut.name + + dlg = NewLutDialog(self.GetTopLevelParent(), name) + + if dlg.ShowModal() != wx.ID_OK: + return + + log.debug('Creating and registering new ' + 'LookupTable {} (copied from {})'.format(dlg.name, name)) + + lut = fslcmaps.LookupTable(dlg.name) + + for label in self.__selectedLut.labels: + lut.set(label.value(), + name=label.name(), + colour=label.colour(), + enabled=label.enabled()) + + fslcmaps.registerLookupTable(lut, self._overlayList, self._displayCtx) + + if self.__selectedOpts is not None: + self.__selectedOpts.lut = lut + + + def __onLoadLut(self, ev): + + nameDlg = NewLutDialog(self.GetTopLevelParent()) + + if nameDlg.ShowModal() != wx.ID_OK: + return + + fileDlg = wx.FileDialog(wx.GetApp().GetTopWindow(), + message=strings.titles[self, 'loadLut'], + defaultDir=os.getcwd(), + style=wx.FD_OPEN) + + if fileDlg.ShowModal() != wx.ID_OK: + return + + name = nameDlg.name + path = fileDlg.GetPath() + + lut = fslcmaps.registerLookupTable(path, + self._overlayList, + self._displayCtx, + name) + + if self.__selectedOpts is not None: + self.__selectedOpts.lut = lut + + + def __onSaveLut(self, ev): + fslcmaps.installLookupTable(self.__selectedLut.name) + + + def __onLabelAdd(self, ev): + + dlg = LutLabelDialog(self.GetTopLevelParent()) + if dlg.ShowModal() != wx.ID_OK: + return + + opts = self.__selectedOpts + value = dlg.value + name = dlg.name + colour = dlg.colour[:3] + colour = [c / 255.0 for c in colour] + + if opts.lut.get(value) is not None: + wx.MessageBox( + strings.messages[self, 'labelExists'].format( + opts.lut.name, value), + strings.titles[ self, 'labelExists'], + wx.ICON_INFORMATION | wx.OK) + return + + log.debug('New lut label for {}: {}, {}, {}'.format( + opts.lut.name, + value, + name, + colour)) + + opts.lut.set(value, name=name, colour=colour) + + + def __onLabelRemove(self, ev): + + opts = self.__selectedOpts + value = opts.lut.labels[ev.idx].value() + + self.__selectedLut.disableListener('labels', self._name) + opts.lut.delete(value) + self.__selectedLut.enableListener('labels', self._name) + + + def __onLabelEdit(self, ev): + + opts = self.__selectedOpts + value = opts.lut.labels[ev.idx].value() + + self.__selectedLut.disableListener('labels', self._name) + opts.lut.set(value, name=ev.label) + self.__selectedLut.enableListener('labels', self._name) + + +class NewLutDialog(wx.Dialog): + """A dialog which is displayed when the user chooses to create a new LUT. + + Prompts the user to enter a name. + """ + + def __init__(self, parent, name=None): + + if name is None: + name = strings.labels[self, 'newLut'] + + wx.Dialog.__init__(self, parent, title=strings.titles[self]) + + self._message = wx.StaticText(self) + self._name = wx.TextCtrl( self) + self._ok = wx.Button( self, id=wx.ID_OK) + self._cancel = wx.Button( self, id=wx.ID_CANCEL) + + self._message.SetLabel(strings.messages[self, 'newLut']) + self._ok .SetLabel(strings.labels[ self, 'ok']) + self._cancel .SetLabel(strings.labels[ self, 'cancel']) + self._name .SetValue(name) + + self._sizer = wx.BoxSizer(wx.VERTICAL) + self._btnSizer = wx.BoxSizer(wx.HORIZONTAL) + + self.SetSizer(self._sizer) + + self._sizer .Add(self._message, flag=wx.EXPAND | wx.ALL, border=10) + self._sizer .Add(self._name, flag=wx.EXPAND | wx.ALL, border=10) + self._sizer .Add(self._btnSizer, flag=wx.EXPAND) + self._btnSizer.Add(self._ok, flag=wx.EXPAND, proportion=1) + self._btnSizer.Add(self._cancel, flag=wx.EXPAND, proportion=1) + + self._ok .Bind(wx.EVT_BUTTON, self.onOk) + self._cancel.Bind(wx.EVT_BUTTON, self.onCancel) + + self._ok.SetDefault() + + self.Fit() + self.Layout() + + self.CentreOnParent() + + self.name = None + + + def onOk(self, ev): + self.name = self._name.GetValue() + self.EndModal(wx.ID_OK) + + + def onCancel(self, ev): + self.EndModal(wx.ID_CANCEL) + + +class LutLabelDialog(wx.Dialog): + """A dialog which is displayed when the user adds a new label to the + current :class:`.LookupTable`. + + Prompts the user to enter a label value, name, and colour. + """ + + def __init__(self, parent): + + wx.Dialog.__init__(self, parent, title=strings.titles[self]) + + self._value = wx.SpinCtrl( self) + self._name = wx.TextCtrl( self) + self._colour = wx.ColourPickerCtrl(self) + + self._valueLabel = wx.StaticText(self) + self._nameLabel = wx.StaticText(self) + self._colourLabel = wx.StaticText(self) + + self._ok = wx.Button(self, id=wx.ID_OK) + self._cancel = wx.Button(self, id=wx.ID_CANCEL) + + self._valueLabel .SetLabel(strings.labels[self, 'value']) + self._nameLabel .SetLabel(strings.labels[self, 'name']) + self._colourLabel.SetLabel(strings.labels[self, 'colour']) + self._ok .SetLabel(strings.labels[self, 'ok']) + self._cancel .SetLabel(strings.labels[self, 'cancel']) + self._name .SetValue(strings.labels[self, 'newLabel']) + self._value .SetValue(0) + + self._sizer = wx.GridSizer(4, 2) + self.SetSizer(self._sizer) + + self._sizer.Add(self._valueLabel, flag=wx.EXPAND) + self._sizer.Add(self._value, flag=wx.EXPAND) + self._sizer.Add(self._nameLabel, flag=wx.EXPAND) + self._sizer.Add(self._name, flag=wx.EXPAND) + self._sizer.Add(self._colourLabel, flag=wx.EXPAND) + self._sizer.Add(self._colour, flag=wx.EXPAND) + self._sizer.Add(self._ok, flag=wx.EXPAND) + self._sizer.Add(self._cancel, flag=wx.EXPAND) + + self._ok .Bind(wx.EVT_BUTTON, self.onOk) + self._cancel.Bind(wx.EVT_BUTTON, self.onCancel) + + self._ok.SetDefault() + + self.Layout() + self.Fit() + + self.CentreOnParent() + + self.value = None + self.name = None + self.colour = None + + + def onOk(self, ev): + self.value = self._value .GetValue() + self.name = self._name .GetValue() + self.colour = self._colour.GetColour() + + self.EndModal(wx.ID_OK) + + + def onCancel(self, ev): + self.EndModal(wx.ID_CANCEL) diff --git a/fsl/fslview/controls/orthoprofiletoolbar.py b/fsl/fslview/controls/orthoprofiletoolbar.py index fbe54844fa37b0101390cafeabb0657d2f067097..5b199d881229bc7ff77459aabcbb1d9e89bce2ea 100644 --- a/fsl/fslview/controls/orthoprofiletoolbar.py +++ b/fsl/fslview/controls/orthoprofiletoolbar.py @@ -19,8 +19,11 @@ log = logging.getLogger(__name__) class OrthoProfileToolBar(fsltoolbar.FSLViewToolBar): - def __init__(self, parent, imageList, displayCtx, ortho): - fsltoolbar.FSLViewToolBar.__init__(self, parent, imageList, displayCtx) + def __init__(self, parent, overlayList, displayCtx, ortho): + fsltoolbar.FSLViewToolBar.__init__(self, + parent, + overlayList, + displayCtx) self.orthoPanel = ortho @@ -33,8 +36,8 @@ class OrthoProfileToolBar(fsltoolbar.FSLViewToolBar): def destroy(self): - fsltoolbar.FSLViewToolBar.destroy(self) self.orthoPanel.removeListener('profile', self._name) + fsltoolbar.FSLViewToolBar.destroy(self) def _profileChanged(self, *a): diff --git a/fsl/fslview/controls/orthosettingspanel.py b/fsl/fslview/controls/orthosettingspanel.py deleted file mode 100644 index 78407858f3997b78d8dd1817c52bc6ef6f225556..0000000000000000000000000000000000000000 --- a/fsl/fslview/controls/orthosettingspanel.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env python -# -# orthosettingspanel.py - -# -# Author: Paul McCarthy <pauldmccarthy@gmail.com> -# - -import logging - -import wx - -import props - -import fsl.fslview.panel as fslpanel - - -log = logging.getLogger(__name__) - - -class OrthoSettingsPanel(fslpanel.FSLViewPanel): - - def __init__(self, parent, imageList, displayCtx, ortho): - fslpanel.FSLViewPanel.__init__(self, parent, imageList, displayCtx) - - import fsl.fslview.layouts as layouts - - self.panel = wx.ScrolledWindow(self) - self.panel.SetScrollRate(0, 5) - - self.sizer = wx.BoxSizer(wx.VERTICAL) - self.SetSizer(self.sizer) - self.sizer.Add(self.panel, flag=wx.EXPAND, proportion=1) - - self.canvasSettings = props.buildGUI( - self.panel, ortho, layouts.layouts['CanvasPanel']) - - self.divider1 = wx.StaticLine( - self.panel, size=(-1, -1), style=wx.LI_HORIZONTAL) - - self.sceneSettings = props.buildGUI( - self.panel, ortho.getSceneOptions(), layouts.layouts['SceneOpts']) - - self.divider2 = wx.StaticLine( - self.panel, size=(-1, -1), style=wx.LI_HORIZONTAL) - - self.orthoSettings = props.buildGUI( - self.panel, ortho.getSceneOptions(), layouts.layouts['OrthoPanel']) - - self.panelSizer = wx.BoxSizer(wx.VERTICAL) - self.panel.SetSizer(self.panelSizer) - - flags = wx.wx.EXPAND | wx.ALIGN_CENTRE | wx.ALL - - self.panelSizer.Add(self.canvasSettings, border=20, flag=flags) - self.panelSizer.Add(self.divider1, flag=flags) - self.panelSizer.Add(self.sceneSettings, border=20, flag=flags) - self.panelSizer.Add(self.divider2, flag=flags) - self.panelSizer.Add(self.orthoSettings, border=20, flag=flags) - - self.sizer .Layout() - self.panelSizer.Layout() - - size = self.panelSizer.GetMinSize() - - self.SetMinSize((size[0], size[1] / 3.0)) - - - def destroy(self): - fslpanel.FSLViewPanel.destroy(self) diff --git a/fsl/fslview/controls/orthotoolbar.py b/fsl/fslview/controls/orthotoolbar.py index db03495f18b0dc8cf2f2dacbe474dd33b79fdc1d..3a73e833ff501988dff4f71627f0aefb7151e7b7 100644 --- a/fsl/fslview/controls/orthotoolbar.py +++ b/fsl/fslview/controls/orthotoolbar.py @@ -5,31 +5,35 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -import logging -import fsl.fslview.toolbar as fsltoolbar -import fsl.fslview.controls.orthosettingspanel as orthosettingspanel +import props - -log = logging.getLogger(__name__) +import fsl.fslview.toolbar as fsltoolbar +import fsl.fslview.actions as actions class OrthoToolBar(fsltoolbar.FSLViewToolBar): - def __init__(self, parent, imageList, displayCtx, ortho): - - import fsl.fslview.layouts as layouts + def __init__(self, parent, overlayList, displayCtx, ortho): actionz = {'more' : self.showMoreSettings} fsltoolbar.FSLViewToolBar.__init__( - self, parent, imageList, displayCtx, actionz) + self, parent, overlayList, displayCtx, actionz) self.orthoPanel = ortho orthoOpts = ortho.getSceneOptions() - toolSpecs = layouts.layouts[self] + toolSpecs = [ + actions.ActionButton(ortho, 'screenshot'), + props .Widget( 'zoom', spin=False, showLimits=False), + props .Widget( 'layout'), + props .Widget( 'showXCanvas'), + props .Widget( 'showYCanvas'), + props .Widget( 'showZCanvas'), + actions.ActionButton(self, 'more')] + targets = {'screenshot' : ortho, 'zoom' : orthoOpts, 'layout' : orthoOpts, @@ -39,12 +43,9 @@ class OrthoToolBar(fsltoolbar.FSLViewToolBar): 'more' : self} self.GenerateTools(toolSpecs, targets) - - - def destroy(self): - fsltoolbar.FSLViewToolBar.destroy(self) - + def showMoreSettings(self, *a): + import canvassettingspanel self.orthoPanel.togglePanel( - orthosettingspanel.OrthoSettingsPanel, True, self.orthoPanel) + canvassettingspanel.CanvasSettingsPanel, True, self.orthoPanel) diff --git a/fsl/fslview/controls/overlaydisplaypanel.py b/fsl/fslview/controls/overlaydisplaypanel.py new file mode 100644 index 0000000000000000000000000000000000000000..7a59a759767f1ec32ed784ab352388c5df00f24d --- /dev/null +++ b/fsl/fslview/controls/overlaydisplaypanel.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python +# +# overlaydisplaypanel.py - A panel which shows display control options for the +# currently selected overlay. +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> + +"""A :class:`wx.panel` which shows display control optionns for the currently +selected overlay. +""" + +import logging + +import wx +import props + +import pwidgets.widgetlist as widgetlist + +import fsl.utils.typedict as td +import fsl.data.strings as strings +import fsl.fslview.panel as fslpanel +import fsl.fslview.actions.loadcolourmap as loadcmap +import fsl.fslview.displaycontext as displayctx + + + +log = logging.getLogger(__name__) + + +_DISPLAY_PROPS = td.TypeDict({ + 'Display' : [ + props.Widget('name'), + props.Widget('overlayType'), + props.Widget('enabled'), + props.Widget('alpha', showLimits=False), + props.Widget('brightness', showLimits=False), + props.Widget('contrast', showLimits=False)], + + 'VolumeOpts' : [ + props.Widget('resolution', showLimits=False), + props.Widget('transform'), + props.Widget('volume', + showLimits=False, + enabledWhen=lambda o: o.overlay.is4DImage()), + props.Widget('interpolation'), + props.Widget('cmap'), + props.Widget('invert'), + props.Widget('invertClipping'), + props.Widget('displayRange', showLimits=False, slider=True), + props.Widget('clippingRange', showLimits=False, slider=True)], + + 'MaskOpts' : [ + props.Widget('resolution', showLimits=False), + props.Widget('transform'), + props.Widget('volume', + showLimits=False, + enabledWhen=lambda o: o.overlay.is4DImage()), + props.Widget('colour'), + props.Widget('invert'), + props.Widget('threshold', showLimits=False)], + + 'RGBVectorOpts' : [ + props.Widget('resolution', showLimits=False), + props.Widget('transform'), + props.Widget('interpolation'), + props.Widget('xColour'), + props.Widget('yColour'), + props.Widget('zColour'), + props.Widget('suppressX'), + props.Widget('suppressY'), + props.Widget('suppressZ'), + props.Widget('modulate'), + props.Widget('modThreshold', showLimits=False, spin=False)], + + 'LineVectorOpts' : [ + props.Widget('resolution', showLimits=False), + props.Widget('transform'), + props.Widget('xColour'), + props.Widget('yColour'), + props.Widget('zColour'), + props.Widget('suppressX'), + props.Widget('suppressY'), + props.Widget('suppressZ'), + props.Widget('directed'), + props.Widget('lineWidth', showLimits=False), + props.Widget('modulate'), + props.Widget('modThreshold', showLimits=False, spin=False)], + + 'ModelOpts' : [ + props.Widget('colour'), + props.Widget('outline'), + props.Widget('outlineWidth', showLimits=False), + props.Widget('refImage'), + # props.Widget('showName'), + props.Widget('coordSpace', + enabledWhen=lambda o: o.refImage != 'none')], + + 'LabelOpts' : [ + props.Widget('lut'), + props.Widget('outline'), + props.Widget('outlineWidth', showLimits=False), + # props.Widget('showNames'), + props.Widget('resolution', showLimits=False), + props.Widget('transform'), + props.Widget('volume', + showLimits=False, + enabledWhen=lambda o: o.overlay.is4DImage())] +}) + + +class OverlayDisplayPanel(fslpanel.FSLViewPanel): + + def __init__(self, parent, overlayList, displayCtx): + """ + """ + + fslpanel.FSLViewPanel.__init__(self, parent, overlayList, displayCtx) + + self.__overlayName = wx.StaticText(self, style=wx.ALIGN_CENTRE) + self.__widgets = widgetlist.WidgetList(self) + self.__sizer = wx.BoxSizer(wx.VERTICAL) + + self.SetSizer(self.__sizer) + + self.__sizer.Add(self.__overlayName, flag=wx.EXPAND) + self.__sizer.Add(self.__widgets, flag=wx.EXPAND, proportion=1) + + displayCtx .addListener('selectedOverlay', + self._name, + self.__selectedOverlayChanged) + overlayList.addListener('overlays', + self._name, + self.__selectedOverlayChanged) + + self.__currentOverlay = None + self.__selectedOverlayChanged() + + self.Layout() + self.SetMinSize((100, 50)) + + + def destroy(self): + + self._displayCtx .removeListener('selectedOverlay', self._name) + self._overlayList.removeListener('overlays', self._name) + + if self.__currentOverlay is not None and \ + self.__currentOverlay in self._overlayList: + + display = self._displayCtx.getDisplay(self.__currentOverlay) + opts = display.getDisplayOpts() + + display.removeListener('overlayType', self._name) + display.removeListener('name', self._name) + + if isinstance(opts, displayctx.VolumeOpts): + opts.removeListener('transform', self._name) + + self.__currentOverlay = None + fslpanel.FSLViewPanel.destroy(self) + + + def __selectedOverlayChanged(self, *a): + + overlay = self._displayCtx.getSelectedOverlay() + lastOverlay = self.__currentOverlay + + if overlay is None: + self.__currentOverlay = None + self.__overlayName.SetLabel('') + self.__widgets.Clear() + self.Layout() + return + + if overlay is lastOverlay: + return + + self.__currentOverlay = overlay + + if lastOverlay is not None and \ + lastOverlay in self._overlayList: + + lastDisplay = self._displayCtx.getDisplay(lastOverlay) + lastOpts = lastDisplay.getDisplayOpts() + + lastDisplay.removeListener('overlayType', self._name) + lastDisplay.removeListener('name', self._name) + + if isinstance(lastOpts, displayctx.VolumeOpts): + lastOpts.removeListener('transform', self._name) + + if lastOverlay is not None: + displayExpanded = self.__widgets.IsExpanded('display') + optsExpanded = self.__widgets.IsExpanded('opts') + else: + displayExpanded = True + optsExpanded = True + + display = self._displayCtx.getDisplay(overlay) + opts = display.getDisplayOpts() + + display.addListener('overlayType', self._name, self.__ovlTypeChanged) + display.addListener('name', self._name, self.__ovlNameChanged) + + if isinstance(opts, displayctx.VolumeOpts): + opts.addListener('transform', self._name, self.__transformChanged) + + self.__widgets.Clear() + self.__widgets.AddGroup('display', strings.labels[self, display]) + self.__widgets.AddGroup('opts', strings.labels[self, opts]) + + self.__overlayName.SetLabel(display.name) + self.__updateWidgets(display, 'display') + self.__updateWidgets(opts, 'opts') + + + self.__widgets.Expand('display', displayExpanded) + self.__widgets.Expand('opts', optsExpanded) + + self.Layout() + + + def __ovlNameChanged(self, *a): + + display = self._displayCtx.getDisplay(self.__currentOverlay) + self.__overlayName.SetLabel(display.name) + self.Layout() + + + def __ovlTypeChanged(self, *a): + + opts = self._displayCtx.getOpts(self.__currentOverlay) + self.__updateWidgets(opts, 'opts') + self.Layout() + + + def __updateWidgets(self, target, groupName): + + self.__widgets.ClearGroup(groupName) + + dispProps = _DISPLAY_PROPS[target] + labels = [strings.properties[target, p.key] for p in dispProps] + + widgets = [] + + for p in dispProps: + + widget = props.buildGUI(self.__widgets, + target, + p, + showUnlink=False) + + # Add a 'load colour map' button next + # to the VolumeOpts.cmap control + if isinstance(target, displayctx.VolumeOpts) and \ + p.key == 'cmap': + widget = self.__buildColourMapWidget(widget) + + widgets.append(widget) + + for label, widget in zip(labels, widgets): + self.__widgets.AddWidget( + widget, + label, + groupName=groupName) + + self.Layout() + + + def __transformChanged(self, *a): + """Called when the transform setting of the currently selected overlay + changes. + + If the current overlay has an :attr:`.Display.overlayType` of + ``volume``, and the :attr:`.ImageOpts.transform` property has been set + to ``affine``, the :attr:`.VolumeOpts.interpolation` property is set to + ``spline``. Otherwise interpolation is disabled. + """ + overlay = self._displayCtx.getSelectedOverlay() + display = self._displayCtx.getDisplay(overlay) + opts = display.getDisplayOpts() + + if not isinstance(opts, displayctx.VolumeOpts): + return + + choices = opts.getProp('interpolation').getChoices(display) + + if opts.transform in ('none', 'pixdim'): + opts.interpolation = 'none' + + elif opts.transform == 'affine': + if 'spline' in choices: opts.interpolation = 'spline' + else: opts.interpolation = 'linear' + + + def __buildColourMapWidget(self, cmapWidget): + + action = loadcmap.LoadColourMapAction(self._overlayList, + self._displayCtx) + + button = wx.Button(self.__widgets) + button.SetLabel(strings.labels[self, 'loadCmap']) + + action.bindToWidget(self, wx.EVT_BUTTON, button) + + sizer = wx.BoxSizer(wx.HORIZONTAL) + + sizer.Add(cmapWidget, flag=wx.EXPAND, proportion=1) + sizer.Add(button, flag=wx.EXPAND) + + return sizer diff --git a/fsl/fslview/controls/overlaydisplaytoolbar.py b/fsl/fslview/controls/overlaydisplaytoolbar.py new file mode 100644 index 0000000000000000000000000000000000000000..b60ca7018433805814d7aa71907094be99c92f37 --- /dev/null +++ b/fsl/fslview/controls/overlaydisplaytoolbar.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python +# +# overlaydisplaytoolbar.py - A toolbar which shows display control options for +# the currently selected overlay. +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> + +"""A :class:`wx.Panel` which shows display control options for the currently +selected overlay. +""" + +import logging + +import wx + +import props + +import fsl.fslview.toolbar as fsltoolbar +import fsl.fslview.actions as actions +import fsl.utils.typedict as td +import overlaydisplaypanel as overlaydisplay + + +log = logging.getLogger(__name__) + + +_TOOLBAR_PROPS = td.TypeDict({ + 'Display' : [ + props.Widget('name'), + props.Widget('overlayType'), + props.Widget('alpha', spin=False, showLimits=False), + props.Widget('brightness', spin=False, showLimits=False), + props.Widget('contrast', spin=False, showLimits=False)], + + 'VolumeOpts' : [ + props.Widget('cmap'), + props.Widget('displayRange', showLimits=False)], + + + 'MaskOpts' : [ + props.Widget('colour')], + + 'VectorOpts' : [ + props.Widget('modulate'), + props.Widget('modThreshold', showLimits=False, spin=False)], + + 'LabelOpts' : [ + props.Widget('lut'), + props.Widget('outline'), + props.Widget('outlineWidth', showLimits=False, spin=False)], + + 'ModelOpts' : [ + props.Widget('colour'), + props.Widget('outline'), + props.Widget('outlineWidth', showLimits=False, spin=False)] +}) + + +class OverlayDisplayToolBar(fsltoolbar.FSLViewToolBar): + + def __init__(self, parent, overlayList, displayCtx, viewPanel): + + actionz = {'more' : self.showMoreSettings} + + fsltoolbar.FSLViewToolBar.__init__( + self, parent, overlayList, displayCtx, actionz) + + self.__viewPanel = viewPanel + self.__currentOverlay = None + + self._displayCtx.addListener( + 'selectedOverlay', + self._name, + self.__selectedOverlayChanged) + self._overlayList.addListener( + 'overlays', + self._name, + self.__selectedOverlayChanged) + + self.__selectedOverlayChanged() + + + def destroy(self): + """Deregisters property listeners. """ + + self._overlayList.removeListener('overlays', self._name) + self._displayCtx .removeListener('selectedOverlay', self._name) + + if self.__currentOverlay is not None and \ + self.__currentOverlay in self._overlayList: + + display = self._displayCtx.getDisplay(self.__currentOverlay) + display.removeListener('overlayType', self._name) + display.removeListener('enabled', self._name) + + self.__currentOverlay = None + self.__viewPanel = None + + fsltoolbar.FSLViewToolBar.destroy(self) + + + def showMoreSettings(self, *a): + self.__viewPanel.togglePanel(overlaydisplay.OverlayDisplayPanel, True) + + + def __overlayEnableChanged(self, *a): + display = self._displayCtx.getDisplay(self.__currentOverlay) + self.Enable(display.enabled) + + + def __selectedOverlayChanged(self, *a): + """Called when the :attr:`.DisplayContext.selectedOverlay` + index changes. Ensures that the correct display panel is visible. + """ + + if self.__currentOverlay is not None and \ + self.__currentOverlay in self._overlayList: + display = self._displayCtx.getDisplay(self.__currentOverlay) + display.removeListener('overlayType', self._name) + display.removeListener('enabled', self._name) + + overlay = self._displayCtx.getSelectedOverlay() + + self.__currentOverlay = overlay + + if overlay is None: + self.ClearTools(destroy=True) + return + + display = self._displayCtx.getDisplay(overlay) + + display.addListener('enabled', + self._name, + self.__overlayEnableChanged) + display.addListener('overlayType', + self._name, + self.__selectedOverlayChanged) + + self.__showTools(overlay) + self.Enable(display.enabled) + + + def __showTools(self, overlay): + + oldTools = self.GetTools() + + # See long comment at bottom + def destroyOldTools(): + for t in oldTools: + t.Destroy() + + for t in oldTools: + t.Show(False) + + self.ClearTools(destroy=False, postevent=False) + + log.debug('Showing tools for {}'.format(overlay)) + + display = self._displayCtx.getDisplay(overlay) + opts = display.getDisplayOpts() + + dispSpecs = _TOOLBAR_PROPS[display] + optsSpecs = _TOOLBAR_PROPS[opts] + + dispTools, dispLabels = zip(*self.GenerateTools( + dispSpecs, display, add=False)) + optsTools, optsLabels = zip(*self.GenerateTools( + optsSpecs, opts, add=False)) + + tools = list(dispTools) + list(optsTools) + labels = list(dispLabels) + list(optsLabels) + + # Button which opens the OverlayDisplayPanel + more = props.buildGUI( + self, + self, + view=actions.ActionButton(self, 'more')) + + tools .append(more) + labels.append(None) + + self.SetTools(tools, labels) + + # This method may have been called via an + # event handler an existing tool in the + # toolbar - in this situation, destroying + # that tool will result in nasty crashes, + # as the wx widget that generated the event + # will be destroyed while said event is + # being processed. So we destroy the old + # tools asynchronously, well after the event + # which triggered this method call will have + # returned. + wx.CallLater(1000, destroyOldTools) diff --git a/fsl/fslview/controls/overlaylistpanel.py b/fsl/fslview/controls/overlaylistpanel.py new file mode 100644 index 0000000000000000000000000000000000000000..530cc7d038222f983821a282e1f28d6bf78c2694 --- /dev/null +++ b/fsl/fslview/controls/overlaylistpanel.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python +# +# overlaylistpanel.py - A panel which displays a list of overlays in the +# overlay list. +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# +"""A panel which displays a list of overlays in the overlay list (see and +allows the user to add/remove overlays, and to change their order. +""" + +import logging + +import wx + +import props + +import pwidgets.elistbox as elistbox + +import fsl.fslview.panel as fslpanel +import fsl.data.image as fslimage + + +log = logging.getLogger(__name__) + + +class ListItemWidget(wx.Panel): + + _enabledFG = '#000000' + _disabledFG = '#CCCCCC' + + def __init__(self, parent, overlay, display, displayCtx, listBox): + wx.Panel.__init__(self, parent) + + self.overlay = overlay + self.display = display + self.displayCtx = displayCtx + self.listBox = listBox + self.name = '{}_{}'.format(self.__class__.__name__, id(self)) + + self.saveButton = wx.Button( self, + label='S', + style=wx.BU_EXACTFIT) + self.lockButton = wx.ToggleButton( self, + label='L', + style=wx.BU_EXACTFIT) + self.visibility = props.makeWidget(self, + display, + 'enabled') + + self.sizer = wx.BoxSizer(wx.HORIZONTAL) + + self.SetSizer(self.sizer) + + self.sizer.Add(self.saveButton, flag=wx.EXPAND, proportion=1) + self.sizer.Add(self.lockButton, flag=wx.EXPAND, proportion=1) + self.sizer.Add(self.visibility, flag=wx.EXPAND, proportion=1) + + # There is currently only one overlay + # group in the application. In the + # future there may be multiple groups. + group = displayCtx.overlayGroups[0] + + display.addListener('enabled', self.name, self.__vizChanged) + group .addListener('overlays', self.name, self.__overlayGroupChanged) + + if isinstance(overlay, fslimage.Image): + overlay.addListener('saved', self.name, self.__saveStateChanged) + else: + log.warn('No save button support for non-volumetric overlays') + self.saveButton.Enable(False) + + self.saveButton.Bind(wx.EVT_BUTTON, self.__onSaveButton) + self.lockButton.Bind(wx.EVT_TOGGLEBUTTON, self.__onLockButton) + self .Bind(wx.EVT_WINDOW_DESTROY, self.__onDestroy) + + self.__overlayGroupChanged() + self.__vizChanged() + self.__saveStateChanged() + + + def __overlayGroupChanged(self, *a): + + group = self.displayCtx.overlayGroups[0] + self.lockButton.SetValue(self.overlay in group.overlays) + + + def __onSaveButton(self, ev): + self.overlay.save() + + + def __onLockButton(self, ev): + group = self.displayCtx.overlayGroups[0] + + if self.lockButton.GetValue(): group.addOverlay( self.overlay) + else: group.removeOverlay(self.overlay) + + + def __onDestroy(self, ev): + ev.Skip() + if ev.GetEventObject() is not self: + return + + group = self.displayCtx.overlayGroups[0] + + self.display.removeListener('enabled', self.name) + group .removeListener('overlays', self.name) + + if isinstance(self.overlay, fslimage.Image): + self.overlay.removeListener('saved', self.name) + + + def __saveStateChanged(self, *a): + + if not isinstance(self.overlay, fslimage.Image): + return + + idx = self.listBox.IndexOf(self.overlay) + + self.saveButton.Enable(not self.overlay.saved) + + if self.overlay.saved: + self.listBox.SetItemBackgroundColour(idx) + else: + self.listBox.SetItemBackgroundColour(idx, '#ffaaaa', '#aa4444') + + + def __vizChanged(self, *a): + + idx = self.listBox.IndexOf(self.overlay) + + if self.display.enabled: fgColour = ListItemWidget._enabledFG + else: fgColour = ListItemWidget._disabledFG + + self.listBox.SetItemForegroundColour(idx, fgColour) + + +class OverlayListPanel(fslpanel.FSLViewPanel): + """A :class:`.ControlPanel` which contains an :class:`.EditableListBox` + displaying the list of loaded overlays. + + The list box allows the overlay order to be changed, and allows overlays + to be added and removed from the list. + """ + + def __init__(self, parent, overlayList, displayCtx): + """Create and lay out an :class:`OverlayListPanel`. + + :param parent: The :mod:`wx` parent object. + :param overlayList: An :class:`.OverlayList` instance. + :param displayCtx: A :class:`.DisplayContext` instance. + """ + + fslpanel.FSLViewPanel.__init__(self, parent, overlayList, displayCtx) + + # list box containing the list of overlays - it + # is populated in the _overlayListChanged method + self._listBox = elistbox.EditableListBox( + self, + style=(elistbox.ELB_REVERSE | + elistbox.ELB_TOOLTIP | + elistbox.ELB_EDITABLE)) + + # listeners for when the user does + # something with the list box + self._listBox.Bind(elistbox.EVT_ELB_SELECT_EVENT, self._lbSelect) + self._listBox.Bind(elistbox.EVT_ELB_MOVE_EVENT, self._lbMove) + self._listBox.Bind(elistbox.EVT_ELB_REMOVE_EVENT, self._lbRemove) + self._listBox.Bind(elistbox.EVT_ELB_ADD_EVENT, self._lbAdd) + self._listBox.Bind(elistbox.EVT_ELB_EDIT_EVENT, self._lbEdit) + + self._sizer = wx.BoxSizer(wx.HORIZONTAL) + self.SetSizer(self._sizer) + + self._sizer.Add(self._listBox, flag=wx.EXPAND, proportion=1) + + self._overlayList.addListener( + 'overlays', + self._name, + self._overlayListChanged) + + self._displayCtx.addListener( + 'overlayOrder', + self._name, + self._overlayListChanged) + + self._displayCtx.addListener( + 'selectedOverlay', + self._name, + self._selectedOverlayChanged) + + self._overlayListChanged() + self._selectedOverlayChanged() + + self.Layout() + + self.SetMinSize(self._sizer.GetMinSize()) + + + def destroy(self): + """Deregisters property listeners.""" + + self._overlayList.removeListener('overlays', self._name) + self._displayCtx .removeListener('selectedOverlay', self._name) + self._displayCtx .removeListener('overlayOrder', self._name) + + # A listener on name was added + # in the _overlayListChanged method + for overlay in self._overlayList: + display = self._displayCtx.getDisplay(overlay) + display.removeListener('name', self._name) + + fslpanel.FSLViewPanel.destroy(self) + + + def _selectedOverlayChanged(self, *a): + """Called when the :attr:`.DisplayContext.selectedOverlay` property + changes. Updates the selected item in the list box. + """ + + if len(self._overlayList) > 0: + self._listBox.SetSelection( + self._displayCtx.getOverlayOrder( + self._displayCtx.selectedOverlay)) + + + def _overlayNameChanged(self, value, valid, display, propName): + + overlay = display.getOverlay() + idx = self._displayCtx.getOverlayOrder(overlay) + name = display.name + + if name is None: + name = '' + + self._listBox.SetItemLabel(idx, name) + + + def _overlayListChanged(self, *a): + """Called when the :class:`.OverlayList.overlays` list changes. + + If the change was due to user action on the :class:`.EditableListBox`, + this method does nothing. Otherwise, this method updates the + :class:`.EditableListBox` + """ + + self._listBox.Clear() + + for i, overlay in enumerate(self._displayCtx.getOrderedOverlays()): + + display = self._displayCtx.getDisplay(overlay) + name = display.name + if name is None: name = '' + + tooltip = overlay.dataSource + + self._listBox.Append(name, overlay, tooltip) + + widget = ListItemWidget(self, + overlay, + display, + self._displayCtx, + self._listBox) + + self._listBox.SetItemWidget(i, widget) + + display.addListener('name', + self._name, + self._overlayNameChanged, + overwrite=True) + + if len(self._overlayList) > 0: + self._listBox.SetSelection( + self._displayCtx.getOverlayOrder( + self._displayCtx.selectedOverlay)) + + + def _lbMove(self, ev): + """Called when an overlay is moved in the :class:`.EditableListBox`. + Reorders the :class:`.OverlayList` to reflect the change. + """ + self._displayCtx.disableListener('overlayOrder', self._name) + self._displayCtx.overlayOrder.move(ev.oldIdx, ev.newIdx) + self._displayCtx.enableListener('overlayOrder', self._name) + + + def _lbSelect(self, ev): + """Called when an overlay is selected in the + :class:`.EditableListBox`. Sets the + :attr:`.DisplayContext.selectedOverlay` property. + """ + self._displayCtx.disableListener('selectedOverlay', self._name) + self._displayCtx.selectedOverlay = \ + self._displayCtx.overlayOrder[ev.idx] + self._displayCtx.enableListener('selectedOverlay', self._name) + + + def _lbAdd(self, ev): + """Called when the 'add' button on the list box is pressed. + + Calls the :meth:`.OverlayList.addOverlays` method. + """ + if self._overlayList.addOverlays(): + self._displayCtx.selectedOverlay = len(self._OverlayList) - 1 + + + def _lbRemove(self, ev): + """Called when an item is removed from the overlay listbox. + + Removes the corresponding overlay from the :class:`.OverlayList`. + """ + self._overlayList.pop(self._displayCtx.overlayOrder[ev.idx]) + + + def _lbEdit(self, ev): + """Called when an item label is edited on the overlay list box. + Sets the corresponding overlay name to the new label. + """ + idx = self._displayCtx.overlayOrder[ev.idx] + overlay = self._overlayList[idx] + display = self._displayCtx.getDisplay(overlay) + display.name = ev.label diff --git a/fsl/fslview/controls/timeseriescontrolpanel.py b/fsl/fslview/controls/timeseriescontrolpanel.py new file mode 100644 index 0000000000000000000000000000000000000000..1375c2d2b1a66d642109ca914157c87ac8f94e88 --- /dev/null +++ b/fsl/fslview/controls/timeseriescontrolpanel.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python +# +# timeseriescontrolpanel.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import wx + +import props +import pwidgets.widgetlist as widgetlist + +import fsl.fslview.panel as fslpanel +import fsl.data.strings as strings + + +class TimeSeriesControlPanel(fslpanel.FSLViewPanel): + + def __init__(self, parent, overlayList, displayCtx, tsPanel): + + fslpanel.FSLViewPanel.__init__(self, parent, overlayList, displayCtx) + + self.__tsPanel = tsPanel + self.__widgets = widgetlist.WidgetList(self) + self.__sizer = wx.BoxSizer(wx.VERTICAL) + + self.SetSizer(self.__sizer) + self.__sizer.Add(self.__widgets, flag=wx.EXPAND, proportion=1) + + tsProps = ['plotMode', + 'usePixdim', + 'showCurrent'] + plotProps = ['xLogScale', + 'yLogScale', + 'smooth', + 'legend', + 'ticks', + 'grid', + 'autoScale'] + + self.__widgets.AddGroup( + 'tsSettings', + strings.labels[self, 'tsSettings']) + + for prop in tsProps: + self.__widgets.AddWidget( + props.makeWidget(self.__widgets, tsPanel, prop), + displayName=strings.properties[tsPanel, prop], + groupName='tsSettings') + + self.__widgets.AddGroup( + 'plotSettings', + strings.labels[tsPanel, 'plotSettings']) + + for prop in plotProps: + self.__widgets.AddWidget( + props.makeWidget(self.__widgets, tsPanel, prop), + displayName=strings.properties[tsPanel, prop], + groupName='plotSettings') + + xlabel = props.makeWidget(self.__widgets, tsPanel, 'xlabel') + ylabel = props.makeWidget(self.__widgets, tsPanel, 'ylabel') + + labels = wx.BoxSizer(wx.HORIZONTAL) + + labels.Add(wx.StaticText(self.__widgets, + label=strings.labels[tsPanel, 'xlabel'])) + labels.Add(xlabel, flag=wx.EXPAND, proportion=1) + labels.Add(wx.StaticText(self.__widgets, + label=strings.labels[tsPanel, 'ylabel'])) + labels.Add(ylabel, flag=wx.EXPAND, proportion=1) + + limits = props.makeListWidgets(self.__widgets, tsPanel, 'limits') + xlims = wx.BoxSizer(wx.HORIZONTAL) + ylims = wx.BoxSizer(wx.HORIZONTAL) + + xlims.Add(limits[0], flag=wx.EXPAND, proportion=1) + xlims.Add(limits[1], flag=wx.EXPAND, proportion=1) + ylims.Add(limits[2], flag=wx.EXPAND, proportion=1) + ylims.Add(limits[3], flag=wx.EXPAND, proportion=1) + + self.__widgets.AddWidget( + labels, + strings.labels[tsPanel, 'labels'], + groupName='plotSettings') + self.__widgets.AddWidget( + xlims, + strings.labels[tsPanel, 'xlim'], + groupName='plotSettings') + self.__widgets.AddWidget( + ylims, + strings.labels[tsPanel, 'ylim'], + groupName='plotSettings') + + displayCtx .addListener('selectedOverlay', + self._name, + self.__selectedOverlayChanged) + overlayList.addListener('overlays', + self._name, + self.__selectedOverlayChanged) + + tsPanel.addListener('showCurrent', + self._name, + self.__showCurrentChanged) + + self.__showCurrentChanged() + + # This attribute keeps track of the currently + # selected overlay, but only if said overlay + # is a FEATImage. + self.__selectedOverlay = None + self.__selectedOverlayChanged() + + + def destroy(self): + self._displayCtx .removeListener('selectedOverlay', self._name) + self._overlayList.removeListener('overlays', self._name) + + if self.__selectedOverlay is not None: + display = self._displayCtx.getDisplay(self.__selectedOverlay) + display.removeListener('name', self._name) + + fslpanel.FSLViewPanel.destroy(self) + + + def __showCurrentChanged(self, *a): + widgets = self.__widgets + tsPanel = self.__tsPanel + showCurrent = tsPanel.showCurrent + areShown = widgets.HasGroup('currentSettings') + + if (not showCurrent) and areShown: + widgets.RemoveGroup('currentSettings') + + elif showCurrent and (not areShown): + + self.__widgets.AddGroup('currentSettings', + strings.labels[self, 'currentSettings']) + + colour = props.makeWidget(widgets, tsPanel, 'currentColour') + alpha = props.makeWidget(widgets, tsPanel, 'currentAlpha', + showLimits=False, spin=False) + lineWidth = props.makeWidget(widgets, tsPanel, 'currentLineWidth') + lineStyle = props.makeWidget(widgets, tsPanel, 'currentLineStyle') + + self.__widgets.AddWidget( + colour, + displayName=strings.properties[tsPanel, 'currentColour'], + groupName='currentSettings') + self.__widgets.AddWidget( + alpha, + displayName=strings.properties[tsPanel, 'currentAlpha'], + groupName='currentSettings') + self.__widgets.AddWidget( + lineWidth, + displayName=strings.properties[tsPanel, 'currentLineWidth'], + groupName='currentSettings') + self.__widgets.AddWidget( + lineStyle, + displayName=strings.properties[tsPanel, 'currentLineStyle'], + groupName='currentSettings') + + + def __selectedOverlayNameChanged(self, *a): + display = self._displayCtx.getDisplay(self.__selectedOverlay) + self.__widgets.RenameGroup( + 'currentFEATSettings', + strings.labels[self, 'currentFEATSettings'].format( + display.name)) + + + def __selectedOverlayChanged(self, *a): + + # We're assuminbg that the TimeSeriesPanel has + # already updated its current TimeSeries for + # the newly selected overlay. + + import fsl.fslview.views.timeseriespanel as tsp + + if self.__selectedOverlay is not None: + display = self._displayCtx.getDisplay(self.__selectedOverlay) + display.removeListener('name', self._name) + self.__selectedOverlay = None + + if self.__widgets.HasGroup('currentFEATSettings'): + self.__widgets.RemoveGroup('currentFEATSettings') + + ts = self.__tsPanel.getCurrent() + + if ts is None or not isinstance(ts, tsp.FEATTimeSeries): + return + + overlay = ts.overlay + display = self._displayCtx.getDisplay(overlay) + + self.__selectedOverlay = overlay + + display.addListener('name', + self._name, + self.__selectedOverlayNameChanged) + + self.__widgets.AddGroup( + 'currentFEATSettings', + displayName=strings.labels[self, 'currentFEATSettings'].format( + display.name)) + + full = props.makeWidget( self.__widgets, ts, 'plotFullModelFit') + res = props.makeWidget( self.__widgets, ts, 'plotResiduals') + evs = props.makeListWidgets(self.__widgets, ts, 'plotEVs') + pes = props.makeListWidgets(self.__widgets, ts, 'plotPEFits') + copes = props.makeListWidgets(self.__widgets, ts, 'plotCOPEFits') + reduced = props.makeWidget( self.__widgets, ts, 'plotReduced') + data = props.makeWidget( self.__widgets, ts, 'plotData') + + self.__widgets.AddWidget( + data, + displayName=strings.properties[ts, 'plotData'], + groupName='currentFEATSettings') + self.__widgets.AddWidget( + full, + displayName=strings.properties[ts, 'plotFullModelFit'], + groupName='currentFEATSettings') + + self.__widgets.AddWidget( + res, + displayName=strings.properties[ts, 'plotResiduals'], + groupName='currentFEATSettings') + + self.__widgets.AddWidget( + reduced, + displayName=strings.properties[ts, 'plotReduced'], + groupName='currentFEATSettings') + + self.__widgets.AddSpace(groupName='currentFEATSettings') + + for i, ev in enumerate(evs): + + evName = ts.overlay.evNames()[i] + self.__widgets.AddWidget( + ev, + displayName=strings.properties[ts, 'plotEVs'].format( + i + 1, evName), + groupName='currentFEATSettings') + + self.__widgets.AddSpace(groupName='currentFEATSettings') + + for i, pe in enumerate(pes): + evName = ts.overlay.evNames()[i] + self.__widgets.AddWidget( + pe, + displayName=strings.properties[ts, 'plotPEFits'].format( + i + 1, evName), + groupName='currentFEATSettings') + + self.__widgets.AddSpace(groupName='currentFEATSettings') + + copeNames = overlay.contrastNames() + for i, (cope, name) in enumerate(zip(copes, copeNames)): + self.__widgets.AddWidget( + cope, + displayName=strings.properties[ts, 'plotCOPEFits'].format( + i + 1, name), + groupName='currentFEATSettings') diff --git a/fsl/fslview/controls/timeserieslistpanel.py b/fsl/fslview/controls/timeserieslistpanel.py new file mode 100644 index 0000000000000000000000000000000000000000..89a2f4234415a72860622bcf424b539f00d7d1d3 --- /dev/null +++ b/fsl/fslview/controls/timeserieslistpanel.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python +# +# timeserieslistpanel.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import copy + +import wx +import numpy as np + +import props +import pwidgets.elistbox as elistbox +import fsl.fslview.panel as fslpanel +import fsl.utils.transform as transform +import fsl.data.strings as strings +import fsl.fslview.colourmaps as fslcm + + +class TimeSeriesWidget(wx.Panel): + + def __init__(self, parent, timeSeries): + + wx.Panel.__init__(self, parent) + + self.colour = props.makeWidget(self, + timeSeries, + 'colour') + self.alpha = props.makeWidget(self, + timeSeries, + 'alpha', + slider=True, + spin=False, + showLimits=False) + self.lineWidth = props.makeWidget(self, + timeSeries, + 'lineWidth') + self.lineStyle = props.makeWidget(self, + timeSeries, + 'lineStyle') + + self.sizer = wx.BoxSizer(wx.HORIZONTAL) + self.SetSizer(self.sizer) + + self.sizer.Add(self.colour) + self.sizer.Add(self.alpha) + self.sizer.Add(self.lineWidth) + self.sizer.Add(self.lineStyle) + + self.Layout() + + +class TimeSeriesListPanel(fslpanel.FSLViewPanel): + + def __init__(self, parent, overlayList, displayCtx, timeSeriesPanel): + + fslpanel.FSLViewPanel.__init__(self, parent, overlayList, displayCtx) + + self.__tsPanel = timeSeriesPanel + self.__currentLabel = wx.StaticText(self) + self.__tsList = elistbox.EditableListBox( + self, style=(elistbox.ELB_NO_MOVE | + elistbox.ELB_EDITABLE)) + + self.__sizer = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(self.__sizer) + + self.__sizer.Add(self.__currentLabel, flag=wx.EXPAND) + self.__sizer.Add(self.__tsList, flag=wx.EXPAND, proportion=1) + + self.__tsList.Bind(elistbox.EVT_ELB_ADD_EVENT, self.__onListAdd) + self.__tsList.Bind(elistbox.EVT_ELB_REMOVE_EVENT, self.__onListRemove) + self.__tsList.Bind(elistbox.EVT_ELB_EDIT_EVENT, self.__onListEdit) + self.__tsList.Bind(elistbox.EVT_ELB_SELECT_EVENT, self.__onListSelect) + + displayCtx .addListener('selectedOverlay', + self._name, + self.__locationChanged) + displayCtx .addListener('location', + self._name, + self.__locationChanged) + overlayList .addListener('overlays', + self._name, + self.__locationChanged) + self.__tsPanel.addListener('dataSeries', + self._name, + self.__timeSeriesChanged) + + self.__timeSeriesChanged() + self.__locationChanged() + self.Layout() + + + def destroy(self): + self._displayCtx .removeListener('selectedOverlay', self._name) + self._displayCtx .removeListener('location', self._name) + self._overlayList.removeListener('overlays', self._name) + self.__tsPanel .removeListener('dataSeries', self._name) + + fslpanel.FSLViewPanel.destroy(self) + + + def __makeLabel(self, ts): + + display = self._displayCtx.getDisplay(ts.overlay) + + return '{} [{} {} {}]'.format(display.name, + ts.coords[0], + ts.coords[1], + ts.coords[2]) + + + def __makeFEATModelTSLabel(self, parentTs, modelTs): + + import fsl.fslview.views.timeseriespanel as tsp + + overlay = modelTs.overlay + display = self._displayCtx.getDisplay(overlay) + + if isinstance(modelTs, tsp.FEATResidualTimeSeries): + return '{} ({})'.format( + parentTs.label, + strings.labels[modelTs]) + + elif isinstance(modelTs, tsp.FEATEVTimeSeries): + return '{} EV{} ({})'.format( + display.name, + modelTs.idx + 1, + overlay.evNames()[modelTs.idx]) + + label = '{} ({})'.format( + parentTs.label, + strings.labels[modelTs, modelTs.fitType]) + + if modelTs.fitType == 'full': + return label + + elif modelTs.fitType == 'cope': + return label.format( + modelTs.idx + 1, + overlay.contrastNames()[modelTs.idx]) + + elif modelTs.fitType == 'pe': + return label.format(modelTs.idx + 1) + + + def __timeSeriesChanged(self, *a): + + self.__tsList.Clear() + + for ts in self.__tsPanel.dataSeries: + widg = TimeSeriesWidget(self, ts) + self.__tsList.Append(ts.label, clientData=ts, extraWidget=widg) + + + def __locationChanged(self, *a): + + ts = self.__tsPanel.getCurrent() + + if ts is None: + self.__currentLabel.SetLabel('') + return + + self.__currentLabel.SetLabel(self.__makeLabel(ts)) + + + def __onListAdd(self, ev): + + import fsl.fslview.views.timeseriespanel as tsp + + ts = self.__tsPanel.getCurrent() + + if ts is None: + return + + ts = copy.copy(ts) + + ts.alpha = 1 + ts.lineWidth = 2 + ts.lineStyle = '-' + ts.colour = fslcm.randomColour() + ts.label = self.__makeLabel(ts) + + self.__tsPanel.dataSeries.append(ts) + + if isinstance(ts, tsp.FEATTimeSeries): + + modelTs = ts.getModelTimeSeries() + modelTs.remove(ts) + + for mts in modelTs: + + mts.alpha = 1 + mts.lineWidth = 2 + mts.lineStyle = '-' + mts.label = self.__makeFEATModelTSLabel(ts, mts) + + self.__tsPanel.dataSeries.extend(modelTs) + + + def __onListEdit(self, ev): + ev.data.label = ev.label + + + def __onListSelect(self, ev): + + overlay = ev.data.overlay + coords = ev.data.coords + opts = self._displayCtx.getOpts(overlay) + vox = np.array(coords) + xform = opts.getTransform('voxel', 'display') + disp = transform.transform([vox], xform)[0] + + self._displayCtx.selectedOverlay = self._overlayList.index(overlay) + self._displayCtx.location = disp + + + def __onListRemove(self, ev): + self.__tsPanel.dataSeries.remove(ev.data) diff --git a/fsl/fslview/displaycontext/__init__.py b/fsl/fslview/displaycontext/__init__.py index 14f3cd925502c42d8f2e95e479bb75b32cd96287..08ec6a172f11e643877c51b5f2842ac1d0654d1a 100644 --- a/fsl/fslview/displaycontext/__init__.py +++ b/fsl/fslview/displaycontext/__init__.py @@ -5,5 +5,32 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # + +import display + + from displaycontext import DisplayContext from display import Display +from group import OverlayGroup +from sceneopts import SceneOpts +from orthoopts import OrthoOpts +from lightboxopts import LightBoxOpts +from volumeopts import ImageOpts +from volumeopts import VolumeOpts +from maskopts import MaskOpts +from vectoropts import VectorOpts +from vectoropts import RGBVectorOpts +from vectoropts import LineVectorOpts +from modelopts import ModelOpts +from labelopts import LabelOpts + + +from displaycontext import InvalidOverlayError + + +ALL_OVERLAY_TYPES = list(set( + reduce(lambda a, b: a + b, + display.OVERLAY_TYPES.values()))) +"""This attribute contains a list of all possible overlay types - see the : +:attr:`.Display.overlayType` property. +""" diff --git a/fsl/fslview/displaycontext/display.py b/fsl/fslview/displaycontext/display.py index 80b63f148e93a93976d228b077a97c97ead4eb83..fae54eeae0a6a81105ffc4c3081c07c0b546feb7 100644 --- a/fsl/fslview/displaycontext/display.py +++ b/fsl/fslview/displaycontext/display.py @@ -7,9 +7,8 @@ """This module provides definitions of an important class - the :class:`Display` class. -A ``Display`` contains a specification for the way in which an -:class:`~fsl.data.image.Image` instance is to be displayed. - +A ``Display`` contains a specification for the way in which any overlays is to +be displayed. ..note:: Put a description of the three coordinate systems which exist in the system. @@ -17,13 +16,11 @@ A ``Display`` contains a specification for the way in which an import logging -import numpy as np - import props -import fsl.data.image as fslimage -import fsl.data.strings as strings -import fsl.utils.transform as transform +import fsl.data.image as fslimage +import fsl.data.strings as strings +import fsl.utils.typedict as td log = logging.getLogger(__name__) @@ -33,9 +30,9 @@ class DisplayOpts(props.SyncableHasProperties): def __init__( self, - image, + overlay, display, - imageList, + overlayList, displayCtx, parent=None, *args, @@ -43,77 +40,81 @@ class DisplayOpts(props.SyncableHasProperties): props.SyncableHasProperties.__init__(self, parent, *args, **kwargs) - self.image = image - self.display = display - self.imageList = imageList - self.displayCtx = displayCtx - self.imageType = image.imageType - self.name = '{}_{}'.format(type(self).__name__, id(self)) + self.overlay = overlay + self.display = display + self.overlayList = overlayList + self.displayCtx = displayCtx + self.overlayType = display.overlayType + self.name = '{}_{}'.format(type(self).__name__, id(self)) + + log.memory('{}.init ({})'.format(type(self).__name__, id(self))) + + + def __del__(self): + log.memory('{}.del ({})'.format(type(self).__name__, id(self))) def destroy(self): - pass + """If overridden, this method should be called by the subclass + implementation. + """ + self.overlay = None + self.display = None + self.overlayList = None + self.displayCtx = None -class Display(props.SyncableHasProperties): - """ - """ - - name = fslimage.Image.name - """The image name. This property is bound to the - :attr:`~fsl.data.image.Image.name` property. - """ + def getReferenceImage(self): + """Some non-volumetric overlay types (e.g. the :class:`.Model` - see + :class:`.ModelOpts`) may have a 'reference' :class:`.Image` instance + associated with them, allowing the overlay to be localised in the + coordinate space defined by the :class:`.Image`. The + :class:`.DisplayOpts` class which corresponds to such non-volumetric + overlays should override this method to return the reference image. + :class:`.DisplayOpts` subclasses which are associated with volumetric + overlays (i.e. :class:`.Image` instances) do not need to override + this method. + """ + + if isinstance(self.overlay, fslimage.Image): + return self.overlay + return None + - imageType = fslimage.Image.imageType - """The image data type. This property is bound to the - :attr:`~fsl.data.image.Image.imageType` property. + def getDisplayBounds(self): + """ + """ + raise NotImplementedError( + 'The getDisplayBounds method must be implemented by subclasses') + + + def transformDisplayLocation(self, propName, oldLoc): + """ + """ + return oldLoc + + +class Display(props.SyncableHasProperties): + """ """ - enabled = props.Boolean(default=True) - """Should this image be displayed at all?""" + name = props.String() + """The overlay name. """ - - resolution = props.Real(maxval=10, default=1, clamped=True) - """Data resolution in world space. The minimum value is set in __init__.""" - - - volume = props.Int(minval=0, maxval=0, default=0, clamped=True) - """If a 4D image, the current volume to display.""" - - - transform = props.Choice( - ('affine', 'pixdim', 'id'), - labels=[strings.choices['Display.transform.affine'], - strings.choices['Display.transform.pixdim'], - strings.choices['Display.transform.id']], - default='pixdim') - """This property defines how the image should be transformd into the display - coordinate system. - - - ``affine``: Use the affine transformation matrix stored in the image - (the ``qform``/``sform`` fields in NIFTI1 headers). - - - ``pixdim``: Scale voxel sizes by the ``pixdim`` fields in the image - header. - - - ``id``: Perform no scaling or transformation - voxels will be - interpreted as :math:`1mm^3` isotropic, with the origin at voxel - (0,0,0). - """ + overlayType = props.Choice() + """This property defines the overlay type - how the data is to be + displayed. - interpolation = props.Choice( - ('none', 'linear', 'spline'), - labels=[strings.choices['Display.interpolation.none'], - strings.choices['Display.interpolation.linear'], - strings.choices['Display.interpolation.spline']]) - """How the value shown at a real world location is derived from the - corresponding voxel value(s). 'No interpolation' is equivalent to nearest - neighbour interpolation. + The options for this property are populated in the :meth:`__init__` + method. See the :attr:`OVERLAY_TYPES` dictionary. """ + + enabled = props.Boolean(default=True) + """Should this overlay be displayed at all?""" alpha = props.Percentage(default=100.0) @@ -129,46 +130,61 @@ class Display(props.SyncableHasProperties): softwareMode = props.Boolean(default=False) """If possible, optimise for software-based rendering.""" - - def is4DImage(self): - """Returns ``True`` if this image is 4D, ``False`` otherwise. - """ - return self.image.is4DImage() - + + def getOverlay(self): + return self.__overlay - def __init__(self, image, imageList, displayCtx, parent=None): - """Create a :class:`Display` for the specified image. - :arg image: A :class:`~fsl.data.image.Image` object. + def __init__(self, + overlay, + overlayList, + displayCtx, + parent=None, + overlayType=None): + """Create a :class:`Display` for the specified overlay. - :arg parent: - """ - - self.image = image - self.imageList = imageList - self.displayCtx = displayCtx + :arg overlay: The overlay object. - # bind self.name to image.name, so changes - # in one are propagated to the other - self.bindProps('name', image) - self.bindProps('imageType', image) + :arg overlayList: The :class:`.OverlayList` instance which contains + all overlays. - # The display<->* transformation matrices - # are created in the _transformChanged method - self.__xforms = {} - self.__setupTransforms() + :arg displayCtx: A :class:`.DisplayContext` instance describing how + the overlays are to be displayed. - # is this a 4D volume? - if image.is4DImage(): - self.setConstraint('volume', 'maxval', image.shape[3] - 1) + :arg parent: A parent ``Display`` instance - see + :mod:`props.syncable`. - self.__oldTransform = None - self.__transform = self.transform - self.__transformChanged() + :arg overlayType: Initial overlay type. + """ + + self.__overlay = overlay + self.__overlayList = overlayList + self.__displayCtx = displayCtx + self.name = overlay.name + + # Populate the possible choices + # for the overlayType property + overlayTypeProp = self.getProp('overlayType') + possibleTypes = list(OVERLAY_TYPES[overlay]) + + # Special cases: + # + # If the overlay is an image which + # does not have a fourth dimension + # of length three, it can't be + # a vector + if isinstance(overlay, fslimage.Image) and \ + (len(overlay.shape) != 4 or overlay.shape[-1] != 3): + possibleTypes.remove('rgbvector') + possibleTypes.remove('linevector') + + for pt in possibleTypes: + log.debug('Enabling overlay type {} for {}'.format(pt, overlay)) + label = strings.choices[self, 'overlayType', pt] + overlayTypeProp.addChoice(pt, label, self) - # limit resolution to the image dimensions - self.resolution = min(image.pixdim[:3]) - self.setConstraint('resolution', 'minval', self.resolution) + if overlayType is not None: + self.overlayType = overlayType # Call the super constructor after our own # initialisation, in case the provided parent @@ -178,158 +194,58 @@ class Display(props.SyncableHasProperties): self, parent, - # The name property is implicitly bound - # through the image object so it doesn't - # need to be linked between ImageDisplays - nobind=['name'], - # These properties cannot be unbound, as # they affect the OpenGL representation - nounbind=['interpolation', - 'volume', - 'resolution', - 'transform', - 'softwareMode', - 'imageType']) - - # Set up listeners after caling Syncabole.__init__, - # so the callbacks don't get called during synchronisation - self.addListener( - 'transform', - 'Display_{}'.format(id(self)), - self.__transformChanged) + nounbind=['softwareMode', 'overlayType']) + + # Set up listeners after caling Syncable.__init__, + # so the callbacks don't get called during + # synchronisation self.addListener( - 'imageType', + 'overlayType', 'Display_{}'.format(id(self)), - self.__imageTypeChanged) - - # The imageTypeChanged method creates + self.__overlayTypeChanged) + + # The __overlayTypeChanged method creates # a new DisplayOpts instance - for this, # it needs to be able to access this - # Dispaly instance's parent (so it can + # Display instance's parent (so it can # subsequently access a parent for the # new DisplayOpts instance). Therefore, # we do this after calling # Syncable.__init__. self.__displayOpts = None - self.__imageTypeChanged() + self.__overlayTypeChanged() - - def __setupTransforms(self): - """Calculates transformation matrices between all of the possible - spaces in which the image may be displayed. - - These matrices are accessible via the :meth:`getTransform` method. - """ + log.memory('{}.init ({})'.format(type(self).__name__, id(self))) - image = self.image - - voxToIdMat = np.eye(4) - voxToPixdimMat = np.diag(list(image.pixdim[:3]) + [1.0]) - voxToAffineMat = image.voxToWorldMat.T - - idToVoxMat = transform.invert(voxToIdMat) - idToPixdimMat = transform.concat(idToVoxMat, voxToPixdimMat) - idToAffineMat = transform.concat(idToVoxMat, voxToAffineMat) - - pixdimToVoxMat = transform.invert(voxToPixdimMat) - pixdimToIdMat = transform.concat(pixdimToVoxMat, voxToIdMat) - pixdimToAffineMat = transform.concat(pixdimToVoxMat, voxToAffineMat) - - affineToVoxMat = image.worldToVoxMat.T - affineToIdMat = transform.concat(affineToVoxMat, voxToIdMat) - affineToPixdimMat = transform.concat(affineToVoxMat, voxToPixdimMat) - - self.__xforms['id', 'id'] = np.eye(4) - self.__xforms['id', 'pixdim'] = idToPixdimMat - self.__xforms['id', 'affine'] = idToAffineMat - - self.__xforms['pixdim', 'pixdim'] = np.eye(4) - self.__xforms['pixdim', 'id'] = pixdimToIdMat - self.__xforms['pixdim', 'affine'] = pixdimToAffineMat - - self.__xforms['affine', 'affine'] = np.eye(4) - self.__xforms['affine', 'id'] = affineToIdMat - self.__xforms['affine', 'pixdim'] = affineToPixdimMat - - - def getTransform(self, from_, to, xform=None): - """Return a matrix which may be used to transform coordinates - from ``from_`` to ``to``. Valid values for ``from_`` and ``to`` - are: - - ``id``: Voxel coordinates - - ``pixdim``: Voxel coordinates, scaled by voxel dimensions + def __del__(self): + log.memory('{}.del ({})'.format(type(self).__name__, id(self))) - - ``affine``: World coordinates, as defined by the NIFTI1 - ``qform``/``sform``. See - :attr:`~fsl.data.image.Image.voxToWorldMat`. - - - ``voxel``: Equivalent to ``id``. - - - ``display``: Equivalent to the current value of :attr:`transform`. - - - ``world``; Equivalent to ``affine``. - If the ``xform`` parameter is provided, and one of ``from_`` or ``to`` - is ``display``, the value of ``xform`` is used instead of the current - value of :attr:`transform`. + def destroy(self): + """This method should be called when this ``Display`` instance + is no longer needed. """ - - if xform is None: xform = self.transform - - if from_ == 'display': from_ = xform - elif from_ == 'world': from_ = 'affine' - elif from_ == 'voxel': from_ = 'id' - if to == 'display': to = xform - elif to == 'world': to = 'affine' - elif to == 'voxel': to = 'id' + if self.__displayOpts is not None: + self.__displayOpts.destroy() - return self.__xforms[from_, to] + self.removeListener('overlayType', 'Display_{}'.format(id(self))) + self.detachFromParent() + + self.__displayOpts = None + self.__overlay = None + - def getDisplayBounds(self): - """Calculates and returns the min/max values of a 3D bounding box, - in the display coordinate system, which is big enough to contain - the image associated with this :class:`ImageDisplay` instance. - - The coordinate system in which the bounding box is defined is - determined by the current value of the :attr:`transform` property. - - A tuple containing two values is returned, with the first value - a sequence of three low bounds, and the second value a sequence - of three high bounds. - """ - return transform.axisBounds( - self.image.shape[:3], self.getTransform('voxel', 'display')) - - - def getLastTransform(self): - """Returns the most recent value of the :attr:`transform` property, - before its current value. - """ - return self.__oldTransform - - - def __transformChanged(self, *a): - """Called when the :attr:`transform` property is changed.""" - - # Store references to the previous display related transformation - # matrices, just in case anything (hint the DisplayContext object) - # needs them for any particular reason (hint: so the DisplayContext - # can preserve the current display location, in terms of image world - # space, when the transform of the selected image changes) - self.__oldTransform = self.__transform - self.__transform = self.transform - - def getDisplayOpts(self): """ """ - if (self.__displayOpts is None) or \ - (self.__displayOpts.imageType != self.imageType): + if (self.__displayOpts is None) or \ + (self.__displayOpts.overlayType != self.overlayType): if self.__displayOpts is not None: self.__displayOpts.destroy() @@ -343,38 +259,61 @@ class Display(props.SyncableHasProperties): """ """ - import volumeopts - import vectoropts - import maskopts - if self.getParent() is None: oParent = None else: oParent = self.getParent().getDisplayOpts() - optsMap = { - 'volume' : volumeopts.VolumeOpts, - 'rgbvector' : vectoropts.VectorOpts, - 'linevector' : vectoropts.LineVectorOpts, - 'mask' : maskopts. MaskOpts - } - - optType = optsMap[self.imageType] - log.debug('Creating DisplayOpts for image {}: {}'.format( - self.name, - optType.__name__)) + optType = DISPLAY_OPTS_MAP[self.overlayType] - return optType(self.image, + log.debug('Creating {} instance for overlay {} ({})'.format( + optType.__name__, self.__overlay, self.overlayType)) + + return optType(self.__overlay, self, - self.imageList, - self.displayCtx, + self.__overlayList, + self.__displayCtx, oParent) - def __imageTypeChanged(self, *a): + def __overlayTypeChanged(self, *a): """ """ # make sure that the display # options instance is up to date self.getDisplayOpts() + + +import volumeopts +import vectoropts +import maskopts +import labelopts +import modelopts + + +OVERLAY_TYPES = td.TypeDict({ + + 'Image' : ['volume', 'mask', 'rgbvector', 'linevector', 'label'], + 'Model' : ['model'] +}) +"""This dictionary provides a mapping between the overlay classes, and +the way in which they may be represented. + +For each overlay class, the first entry in the corresponding overlay type +list is used as the default overlay type. +""" + + +DISPLAY_OPTS_MAP = { + 'volume' : volumeopts.VolumeOpts, + 'rgbvector' : vectoropts.RGBVectorOpts, + 'linevector' : vectoropts.LineVectorOpts, + 'mask' : maskopts. MaskOpts, + 'model' : modelopts. ModelOpts, + 'label' : labelopts. LabelOpts, +} +"""This dictionary provides a mapping between each overlay type, and +the :class:`DisplayOpts` subclass which contains overlay type-specific +display options. +""" diff --git a/fsl/fslview/displaycontext/displaycontext.py b/fsl/fslview/displaycontext/displaycontext.py index 86b152f3f8425195a68d237e2661444524bc2f4c..931b25acfeaeb76dd634ce01febee5a598c39d7d 100644 --- a/fsl/fslview/displaycontext/displaycontext.py +++ b/fsl/fslview/displaycontext/displaycontext.py @@ -10,32 +10,37 @@ import logging import props -import fsl.data.image as fslimage -import fsl.utils.transform as transform - import display as fsldisplay log = logging.getLogger(__name__) +class InvalidOverlayError(Exception): + """An error raised by the :meth:`DisplayContext.getDisplay` + and :meth:`DisplayContext.getOpts` methods to indicate that + the specified overlay is not in the :class:`.OverlayList`. + """ + pass + + class DisplayContext(props.SyncableHasProperties): - """Contains a number of properties defining how an - :class:`~fsl.dat.aimage.ImageList` is to be displayed. + """Contains a number of properties defining how an :class:`.OverlayList` + is to be displayed. """ - selectedImage = props.Int(minval=0, default=0, clamped=True) - """Index of the currently 'selected' image. + selectedOverlay = props.Int(minval=0, default=0, clamped=True) + """Index of the currently 'selected' overlay. Note that this index is in relation to the - :class:`~fsl.data.image.ImageList`, rather than to the :attr:`imageOrder` + :class:`.OverlayList`, rather than to the :attr:`overlayOrder` list. - If you're interested in the currently selected image, you must also listen - for changes to the :attr:`fsl.data.image.ImageList.images` list as, if the - list changes, the :attr:`selectedImage` index may not change, but the - image to which it points may be different. + If you're interested in the currently selected overlay, you must also + listen for changes to the :attr:`.OverlayList.images` list as, if the list + changes, the :attr:`selectedOverlay` index may not change, but the overlay + to which it points may be different. """ @@ -47,258 +52,375 @@ class DisplayContext(props.SyncableHasProperties): bounds = props.Bounds(ndims=3) """This property contains the min/max values of a bounding box (in display - coordinates) which is big enough to contain all of the images in the - :attr:`images` list. This property shouid be read-only, but I don't have a - way to enforce it (yet). + coordinates) which is big enough to contain all of the overlays in the + :attr:`overlays` list. This property shouid be read-only, but I don't have + a way to enforce it (yet). """ - volume = props.Int(minval=0, maxval=0, default=0, clamped=True) - """The volume property contains the currently selected volume - across the 4D images in the :class:`~fsl.data.image/ImageList`. - This property may not be relevant to all images in the image list - (i.e. it is meaningless for 3D images). + overlayOrder = props.List(props.Int()) + """A list of indices into the :attr:`.OverlayList.overlays` + list, defining the order in which the overlays are to be displayed. + + See the :meth:`getOrderedOverlays` method. """ - imageOrder = props.List(props.Int()) - """A list of indices into the :attr:`~fsl.data.image.ImageList.images` - list, defining the order in which the images are to be displayed. + overlayGroups = props.List() + """A list of :class:`.OverlayGroup` instances, each of which defines + a group of overlays which share display properties. + """ - See the :meth:`getOrderedImages` method. + + syncOverlayDisplay = props.Boolean(default=True) + """If this ``DisplayContext`` instance has a parent (see + :mod:`props.syncable`), and this is ``True``, the properties of the + :class:`.Display` and :class:`.DisplayOpts` for every overlay managed + by this ``DisplayContext`` instance will be synchronised to those of + the parent instance. Otherwise, the display properties for every overlay + will be unsynchronised from the parent. """ - def __init__(self, imageList, parent=None): + def __init__(self, overlayList, parent=None): """Create a :class:`DisplayContext` object. - :arg imageList: A :class:`~fsl.data.image.ImageList` instance. + :arg overlayList: A :class:`.OverlayList` instance. :arg parent: Another :class`DisplayContext` instance to be used as the parent of this instance. """ - props.SyncableHasProperties.__init__(self, parent, nounbind=('volume')) + props.SyncableHasProperties.__init__( + self, + parent, + nounbind=['overlayGroups'], + nobind=[ 'syncOverlayDisplay']) + + self.__overlayList = overlayList + self.__name = '{}_{}'.format(self.__class__.__name__, id(self)) + + # Keep track of the overlay list + # length so we can do some things in the + # _overlayListChanged method + self.__prevOverlayListLen = 0 + + # Ensure that a Display object exists + # for every overlay, and that the display + # bounds property is initialised + self.__displays = {} + self.__overlayListChanged() + + overlayList.addListener('overlays', + self.__name, + self.__overlayListChanged) + + self.addListener('syncOverlayDisplay', + self.__name, + self.__syncOverlayDisplayChanged) + + log.memory('{}.init ({})'.format(type(self).__name__, id(self))) + - self._imageList = imageList - self._name = '{}_{}'.format(self.__class__.__name__, id(self)) - - # Keep track of the image list length - # so we can do some things in the - # _imageListChanged method - self._prevImageListLen = 0 - - # Ensure that an ImageDisplay object exists for - # every image, and that the display bounds - # property is initialised - - self._imageDisplays = {} - self._imageListChanged() - - imageList.addListener('images', - self._name, - self._imageListChanged) - self.addListener( 'bounds', - self._name, - self._boundsChanged) - self.addListener( 'volume', - self._name, - self._volumeChanged) + def __del__(self): + log.memory('{}.del ({})'.format(type(self).__name__, id(self))) - def getDisplayProperties(self, image): - """Returns the display property object (e.g. an :class:`ImageDisplay` - object) for the specified image (or image index). + def destroy(self): + + self.__overlayList.removeListener('overlays', self.__name) + + for overlay, display in self.__displays.items(): + display.destroy() + + self.__displays = None - If an :class:`ImageDisplay` object does not exist for the given image, + + def getDisplay(self, overlay, overlayType=None): + """Returns the display property object (e.g. a :class:`.Display` + object) for the specified overlay (or overlay index). + + If a :class:`Display` object does not exist for the given overlay, one is created. + + :arg overlay: The overlay to retrieve a ``Display`` + instance for. + + :arg overlayType: If a ``Display`` instance for the specified + ``overlay`` does not exist, one is created - the + specified ``overlayType`` is passed to the + :meth:`.Display.__init__` method. """ - if not isinstance(image, (int, fslimage.Image)): - raise ValueError('image must be an integer or an Image object') + if overlay is None: + raise ValueError('No overlay specified') + + if overlay not in self.__overlayList: + raise InvalidOverlayError('Overlay {} is not in ' + 'list'.format(overlay.name)) - if isinstance(image, int): - image = self._imageList[image] + if isinstance(overlay, int): + overlay = self.__overlayList[overlay] try: - display = self._imageDisplays[image] + display = self.__displays[overlay] except KeyError: if self.getParent() is None: dParent = None else: - dParent = self.getParent().getDisplayProperties(image) - - display = fsldisplay.Display(image, self._imageList, self, dParent) - self._imageDisplays[image] = display + dParent = self.getParent().getDisplay(overlay, overlayType) + if overlayType is None: + overlayType = dParent.overlayType + + display = fsldisplay.Display(overlay, + self.__overlayList, + self, + parent=dParent, + overlayType=overlayType) + self.__displays[overlay] = display + + if (self.getParent() is not None) and \ + (not self.syncOverlayDisplay): + display .unsyncAllFromParent() + display.getDisplayOpts().unsyncAllFromParent() return display - - def selectImage(self, image): - self.selectedImage = self._imageList.index(image) + + def getOpts(self, overlay, overlayType=None): + """Returns the :class:`.DisplayOpts` instance associated with the + specified overlay. + + See :meth:`.Display.getDisplayOpts` and :meth:`getDisplay`. + """ + + if overlay is None: + raise ValueError('No overlay specified') + + if overlay not in self.__overlayList: + raise InvalidOverlayError('Overlay {} is not in ' + 'list'.format(overlay.name)) + + return self.getDisplay(overlay, overlayType).getDisplayOpts() + + + def getReferenceImage(self, overlay): + """Convenience method which returns the reference image associated + with the given overlay, or ``None`` if there is no reference image. + + See the :class:`.DisplayOpts.getReferenceImage` method. + """ + if overlay is None: + return None + + return self.getOpts(overlay).getReferenceImage() + + + def selectOverlay(self, overlay): + """Selects the specified ``overlay``. Raises an ``IndexError`` if + the overlay is not in the list. + """ + self.selectedOverlay = self.__overlayList.index(overlay) - def getSelectedImage(self): - """Returns the currently selected :class:`~fsl.data.image.Image` - object, or ``None`` if there are no images. + def getSelectedOverlay(self): + """Returns the currently selected overlay object, + or ``None`` if there are no overlays. """ - if len(self._imageList) == 0: return None - return self._imageList[self.selectedImage] + if len(self.__overlayList) == 0: return None + return self.__overlayList[self.selectedOverlay] - def getImageOrder(self, image): - """Returns the order in which the given image (or an index into - the :class:`~fsl.data.image.ImageList` list) should be displayed - (see the :attr:`imageOrder property). + def getOverlayOrder(self, overlay): + """Returns the order in which the given overlay (or an index into + the :class:`.OverlayList` list) should be displayed + (see the :attr:`overlayOrder property). + + Raises an ``IndexError`` if the overlay is not in the list. """ - if isinstance(image, fslimage.Image): - image = self._imageList.index(image) - return self.imageOrder.index(image) + if not isinstance(overlay, int): + overlay = self.__overlayList.index(overlay) + + return self.overlayOrder.index(overlay) - def getOrderedImages(self): - """Returns a list of :class:`~fsl.data.image.Image` objects from - the :class:`~fsl.data.image.ImageList` list, sorted into the order + def getOrderedOverlays(self): + """Returns a list of overlay objects from + the :class:`.OverlayList` list, sorted into the order that they are to be displayed. """ - return [self._imageList[idx] for idx in self.imageOrder] + return [self.__overlayList[idx] for idx in self.overlayOrder] - def _imageListChanged(self, *a): - """Called when the :attr:`fsl.data.image.ImageList.images` property + def __overlayListChanged(self, *a): + """Called when the :attr:`.OverlayList.overlays` property changes. - Ensures that an :class:`ImageDisplay` object exists for every image, - and updates the constraints on the :attr:`selectedImage` and - :attr:`volume` properties. + Ensures that a :class:`.Display` and :class:`.DisplayOpts` object + exists for every image, updates the :attr:`bounds` property, makes + sure that the :attr:`overlayOrder` property is consistent, and updates + constraints on the :attr:`selectedOverlay` property. """ - nimages = len(self._imageList) + # Discard all Display instances + # which refer to overlays that + # are no longer in the list + for overlay in list(self.__displays.keys()): + if overlay not in self.__overlayList: + + display = self.__displays.pop(overlay) + opts = display.getDisplayOpts() - # Ensure that an ImageDisplay - # object exists for every image - for image in self._imageList: + display.removeListener('overlayType', self.__name) + opts.removeGlobalListener(self.__name) + + # The display instance will destroy the + # opts instance, so we don't do it here + display.destroy() + + # Ensure that a Display object + # exists for every overlay in + # the list + for overlay in self.__overlayList: - # The getDisplayProperties method - # will create an ImageDisplay object + # The getDisplay method + # will create a Display object # if one does not already exist - display = self.getDisplayProperties(image) - - # Register a listener with the transform property - # of every image display so that when they change, - # we can update the display bounds, and preserve - # the current display location so that it is in - # terms of the world location of the currently - # selected image - # - # This may be called multiple times on each image, - # but it doesn't matter, as any listener which has - # previously been registered with an image will - # just be replaced by the new one here. - display.addListener( - 'transform', - self.__class__.__name__, - self._transformChanged, - overwrite=True) - - - # Ensure that the imageOrder + display = self.getDisplay(overlay) + opts = display.getDisplayOpts() + + # Register a listener on the overlay type, + # because when it changes, the DisplayOpts + # instance will change, and we will need to + # re-register the next listener + display.addListener('overlayType', + self.__name, + self.__overlayListChanged, + overwrite=True) + + # Register a listener on the DisplayOpts + # object for every overlay - if any + # DisplayOpts properties change, the + # overlay display bounds may have changed, + # so we need to know when this happens. + opts.addGlobalListener(self.__name, + self.__displayOptsChanged, + overwrite=True) + + # Ensure that the overlayOrder # property is valid ... + # + # NOTE: The following logic assumes that operations + # which modify the overlay list will only do + # one of the following: + # + # - Adding one or more overlays to the list + # - Removing one or more overlays from the list # - # If images have been added to - # the image list, add indices - # for them to the imageOrder list - if len(self.imageOrder) < len(self._imageList): - self.imageOrder.extend(range(len(self.imageOrder), - len(self._imageList))) - - # Otherwise, if images have been removed - # from the image list, remove the corresponding - # indices from the imageOrder list - elif len(self.imageOrder) > len(self._imageList): - for idx in range(len(self._imageList), - len(self.imageOrder)): - self.imageOrder.remove(idx) + # More complex overlay list modifications + # will cause this code to break. + + oldList = self.__overlayList.getLastValue('overlays')[:] + oldOrder = self.overlayOrder[:] + + # If overlays have been added to + # the overlay list, add indices + # for them to the overlayOrder list + if len(self.overlayOrder) < len(self.__overlayList): + + newOrder = [] + newOverlayIdx = len(oldList) + + # The order of existing overlays is preserved, + # and all new overlays added to the end of the + # overlay order. + for overlay in self.__overlayList: + + if overlay in oldList: + newOrder.append(oldOrder[oldList.index(overlay)]) + else: + newOrder.append(newOverlayIdx) + newOverlayIdx += 1 + + self.overlayOrder[:] = newOrder + + # Otherwise, if overlays have been + # removed from the overlay list ... + elif len(self.overlayOrder) > len(self.__overlayList): + + # Remove the corresponding indices + # from the overlayOrder list + for overlay, orderIdx in zip(oldList, self.overlayOrder): + if overlay not in self.__overlayList: + oldOrder.remove(orderIdx) + + # Re-generate new indices, + # preserving the order of + # the remaining overlays + newOrder = [sorted(oldOrder).index(idx) for idx in oldOrder] + self.overlayOrder[:] = newOrder # Ensure that the bounds property is accurate - self._updateBounds() + self.__updateBounds() - # If the image list was empty, + # If the overlay list was empty, # and is now non-empty, centre # the currently selected location - if (self._prevImageListLen == 0) and (len(self._imageList) > 0): + if (self.__prevOverlayListLen == 0) and (len(self.__overlayList) > 0): # initialise the location to be - # the centre of the image world + # the centre of the world b = self.bounds self.location.xyz = [ b.xlo + b.xlen / 2.0, b.ylo + b.ylen / 2.0, b.zlo + b.zlen / 2.0] - self._prevImageListLen = len(self._imageList) + self.__prevOverlayListLen = len(self.__overlayList) - # Limit the selectedImage property + # Limit the selectedOverlay property # so it cannot take a value greater - # than len(imageList)-1 - if nimages > 0: - self.setConstraint('selectedImage', 'maxval', nimages - 1) - else: - self.setConstraint('selectedImage', 'maxval', 0) - - # Limit the volume property so it - # cannot take a value greater than - # the longest 4D volume in the - # image list - maxvols = 0 - - for image in self._imageList: - - if not image.is4DImage(): continue - - if image.shape[3] > maxvols: - maxvols = image.shape[3] - - if maxvols > 0: - self.setConstraint('volume', 'maxval', maxvols - 1) + # than len(overlayList)-1 + nOverlays = len(self.__overlayList) + if nOverlays > 0: + self.setConstraint('selectedOverlay', 'maxval', nOverlays - 1) else: - self.setConstraint('volume', 'maxval', 0) + self.setConstraint('selectedOverlay', 'maxval', 0) - - def _transformChanged(self, xform, valid, display, propName): - """Called when the - :attr:`~fsl.fslview.displaycontext.ImageDisplay.transform property - changes on any image in the :attr:`images` list. Sets the - :attr:`location` property, so that the selected image world location - is preserved, in the new display coordinate system. + + def __displayOptsChanged(self, value, valid, opts, name): + """Called when the :class:`.DisplayOpts` properties of any overlay + change. If the bounds o the Updates the :attr:`bounds` property and, + if the currently selected """ # This check is ugly, and is required due to # an ugly circular relationship which exists - # between parent/child DCs and the transform/ + # between parent/child DCs and the *Opts/ # location properties: # - # 1. When the transform property of a child DC - # Display object changes (this should always - # be due to user input), that change is - # propagated to the parent DC Display object. + # 1. When a property of a child DC DisplayOpts + # object changes (e.g. ImageOpts.transform) + # this should always be due to user input), + # that change is propagated to the parent DC + # DisplayOpts object. # - # 2. This results in the DC._transformChanged + # 2. This results in the DC._displayOptsChanged # method (this method) being called on the # parent DC. # # 3. Said method correctly updates the DC.location # property, so that the world location of the - # selected image is preserved. + # selected overlay is preserved. # # 4. This location update is propagated back to # the child DC.location property, which is # updated to have the new correct location # value. # - # 5. Then, the child DC._transformChanged method + # 5. Then, the child DC._displayOpteChanged method # is called, which goes and updates the child # DC.location property to contain a bogus # value. @@ -312,34 +434,76 @@ class DisplayContext(props.SyncableHasProperties): if self.getParent().location == self.location: return - if display.image != self.getSelectedImage(): - self._updateBounds() + overlay = opts.display.getOverlay() + + # Save a copy of the location before + # updating the bounds, as the change + # to the bounds may result in the + # location being modified + oldDispLoc = self.location.xyz + + # Update the display context bounds + # to take into account any changes + # to individual overlay bounds + self.__updateBounds() + + # The main purpose of this method is to preserve + # the current display location in terms of the + # currently selected overlay, when the overlay + # bounds have changed. We don't care about changes + # to the options for other overlays. + if (overlay != self.getSelectedOverlay()): return - # Calculate the image world location using - # the old display<-> world transform, then - # transform it back to the new world->display - # transform - - imgWorldLoc = transform.transform( - [self.location.xyz], - display.getTransform(display.getLastTransform(), 'world'))[0] - newDispLoc = transform.transform( - [imgWorldLoc], - display.getTransform('world', 'display'))[0] + # Now we want to update the display location + # so that it is preserved with respect to the + # currently selected overlay. + newDispLoc = opts.transformDisplayLocation(name, oldDispLoc) + + # Ignore the new display location + # if it is not in the display bounds + if self.bounds.inBounds(newDispLoc): + log.debug('Preserving display location in ' + 'terms of overlay {} ({}.{}): {} -> {}'.format( + overlay, + type(opts).__name__, + name, + oldDispLoc, + newDispLoc)) + + self.location.xyz = newDispLoc + + + def __syncOverlayDisplayChanged(self, *a): + """Called when the :attr:`syncOverlayDisplay` property + changes. + + Synchronises or unsychronises the :class:`.Display` and + :class:`.DisplayOpts` instances for every overlay to/from their + parent instances. + """ - # Update the display coordinate - # system bounds, and the location - self._updateBounds() - self.location.xyz = newDispLoc + if self.getParent() is None: + return + for display in self.__displays.values(): + + opts = display.getDisplayOpts() - def _updateBounds(self, *a): - """Called when the image list changes, or when any image display + if self.syncOverlayDisplay: + display.syncAllToParent() + opts .syncAllToParent() + else: + display.unsyncAllFromParent() + opts .unsyncAllFromParent() + + + def __updateBounds(self, *a): + """Called when the overlay list changes, or when any overlay display transform is changed. Updates the :attr:`bounds` property. """ - if len(self._imageList) == 0: + if len(self.__overlayList) == 0: minBounds = [0.0, 0.0, 0.0] maxBounds = [0.0, 0.0, 0.0] @@ -347,10 +511,11 @@ class DisplayContext(props.SyncableHasProperties): minBounds = 3 * [ sys.float_info.max] maxBounds = 3 * [-sys.float_info.max] - for img in self._imageList.images: + for ovl in self.__overlayList: - display = self._imageDisplays[img] - lo, hi = display.getDisplayBounds() + display = self.__displays[ovl] + opts = display.getDisplayOpts() + lo, hi = opts .getDisplayBounds() for ax in range(3): @@ -360,32 +525,9 @@ class DisplayContext(props.SyncableHasProperties): self.bounds[:] = [minBounds[0], maxBounds[0], minBounds[1], maxBounds[1], minBounds[2], maxBounds[2]] - - - def _volumeChanged(self, *a): - """Called when the :attr:`volume` property changes. - - Propagates the change on to the :attr:`ImageDisplay.volume` property - for each image in the :class:`~fsl.data.image.ImageList`. - """ - - for image in self._imageList: - - display = self._imageDisplays[image] - - # The volume property for each image should - # be clamped to the possible value for that - # image, so we don't need to check if the - # current volume value is valid for each image - display.volume = self.volume - - - def _boundsChanged(self, *a): - """Called when the :attr:`bounds` property changes. - - Updates the constraints on the :attr:`location` property. - """ - + + # Update the constraints on the :attr:`location` + # property to be aligned with the new bounds self.location.setLimits(0, self.bounds.xlo, self.bounds.xhi) self.location.setLimits(1, self.bounds.ylo, self.bounds.yhi) self.location.setLimits(2, self.bounds.zlo, self.bounds.zhi) diff --git a/fsl/fslview/displaycontext/group.py b/fsl/fslview/displaycontext/group.py new file mode 100644 index 0000000000000000000000000000000000000000..cea1852f3fc88e92bdb5f68d2e6c948ac593e81c --- /dev/null +++ b/fsl/fslview/displaycontext/group.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python +# +# group.py - Overlay groups +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import logging +import copy + +import props + +import fsl.utils.typedict as td + + +log = logging.getLogger(__name__) + + +class OverlayGroup(props.HasProperties): + + + overlays = props.List() + """Do not add/remove overlays directly to this list - use the + :meth:`addOverlay` and :meth:`removeOverlay` methods. + """ + + + _groupBindings = td.TypeDict({ + 'Display' : ['enabled', + 'alpha'], + 'ImageOpts' : ['volume', + 'transform'], + 'VolumeOpts' : ['interpolation'], + 'LabelOpts' : ['outline', + 'outlineWidth'], + 'ModelOpts' : ['outline', + 'outlineWidth', + 'refImage', + 'coordSpace', + 'transform'], + 'VectorOpts' : ['suppressX', + 'suppressY', + 'suppressZ', + 'modulate', + 'modThreshold'], + 'LineVectorOpts' : ['lineWidth', + 'directed'], + 'RGBVectorOpts' : ['interpolation'], + }) + """This dictionary defines the properties which are bound across Display + instances, and instances of the DisplayOpts sub-classes, for overlays in + the same group. + """ + + + def __init__(self, displayCtx, overlayList): + + self.__displayCtx = displayCtx + self.__overlayList = overlayList + self.__hasBeenSet = {} + self.__name = '{}_{}'.format(type(self).__name__, id(self)) + + # Copy all of the properties listed + # in the _groupBindings dict + from . import \ + Display, \ + ImageOpts, \ + VolumeOpts, \ + MaskOpts, \ + VectorOpts, \ + RGBVectorOpts, \ + LineVectorOpts, \ + ModelOpts, \ + LabelOpts + + for clsName, propNames in OverlayGroup._groupBindings.items(): + cls = locals()[clsName] + + for propName in propNames: + prop = copy.copy(getattr(cls, propName)) + self.addProperty('{}_{}'.format(clsName, propName), prop) + + self.__hasBeenSet[clsName, propName] = False + + + def __copy__(self): + return OverlayGroup(self, self.__displayCtx, self.__overlayList) + + + def addOverlay(self, overlay): + + self.overlays.append(overlay) + + display = self.__displayCtx.getDisplay(overlay) + opts = display.getDisplayOpts() + + log.debug('Adding overlay {} to group {}'.format( + overlay.name, self.__name)) + + self.__bindDisplayOpts(display) + self.__bindDisplayOpts(opts) + + display.addListener('overlayType', + self.__name, + self.__overlayTypeChanged) + + + def removeOverlay(self, overlay): + + self.overlays.remove(overlay) + + display = self.__displayCtx.getDisplay(overlay) + opts = display.getDisplayOpts() + + log.debug('Removing overlay {} from group {}'.format( + overlay.name, self.__name)) + + self.__bindDisplayOpts(display, unbind=True) + self.__bindDisplayOpts(opts, unbind=True) + + display.removeListener('overlayType', self.__name) + + if len(self.overlays) == 0: + for key in self.__hasBeenSet.keys(): + self.__hasBeenSet[key] = False + + + def __bindDisplayOpts(self, target, unbind=False): + + # This is the first overlay to be added - the + # group should inherit its property values + if len(self.overlays) == 1: + master, slave = target, self + + # Other overlays are already in the group - the + # new overlay should inherit the group properties + else: + master, slave = self, target + + bindProps = OverlayGroup._groupBindings.get(target, + allhits=True, + bykey=True) + + for clsName, propNames in bindProps.items(): + for propName in propNames: + + groupName = '{}_{}'.format(clsName, propName) + + # If the group property has not yet + # taken on a value, initialise it + # to the property value being bound. + # + # We do this to avoid clobbering + # property values with un-initialised + # group property values. + # + if not self.__hasBeenSet[clsName, propName]: + setattr(self, groupName, getattr(target, propName)) + + if slave is self: + otherName = propName + propName = groupName + else: + otherName = groupName + + slave.bindProps(propName, + master, + otherName, + bindatt=False, + unbind=unbind) + + + def __overlayTypeChanged(self, value, valid, display, name): + opts = display.getDisplayOpts() + self.__bindDisplayOpts(opts) diff --git a/fsl/fslview/displaycontext/labelopts.py b/fsl/fslview/displaycontext/labelopts.py new file mode 100644 index 0000000000000000000000000000000000000000..21ee9c87abb3a91c655cf35c4d249b8218575523 --- /dev/null +++ b/fsl/fslview/displaycontext/labelopts.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# +# labelopts.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import props + +import volumeopts + +import fsl.fslview.colourmaps as fslcm + + +luts = fslcm.getLookupTables() +names = [l.name for l in luts] + +class LabelOpts(volumeopts.ImageOpts): + + lut = props.Choice(luts, names) + outline = props.Boolean(default=False) + outlineWidth = props.Real(minval=0, maxval=1, default=0.25, clamped=True) + showNames = props.Boolean(default=False) + + + def __init__(self, overlay, *args, **kwargs): + volumeopts.ImageOpts.__init__(self, overlay, *args, **kwargs) diff --git a/fsl/fslview/displaycontext/lightboxopts.py b/fsl/fslview/displaycontext/lightboxopts.py index 522f5be8bb0c3f9ba7171f6311dc34c88a98a51a..7a3fd72da9cf2e4dd70913769b3ce11084ef27d6 100644 --- a/fsl/fslview/displaycontext/lightboxopts.py +++ b/fsl/fslview/displaycontext/lightboxopts.py @@ -11,6 +11,7 @@ import sceneopts import fsl.fslview.gl.lightboxcanvas as lightboxcanvas + class LightBoxOpts(sceneopts.SceneOpts): nrows = copy.copy(lightboxcanvas.LightBoxCanvas.nrows) ncols = copy.copy(lightboxcanvas.LightBoxCanvas.ncols) diff --git a/fsl/fslview/displaycontext/maskopts.py b/fsl/fslview/displaycontext/maskopts.py index 4acb2a123a6e30c02313cda3fb0f0f89b603771e..6616f4752439f833bfb3bcde03d878fba0bf7981 100644 --- a/fsl/fslview/displaycontext/maskopts.py +++ b/fsl/fslview/displaycontext/maskopts.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# maskdisplay.py - +# maskopts.py - # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # @@ -8,42 +8,51 @@ import numpy as np - import props import fsl.data.strings as strings -import display as fsldisplay +import volumeopts + -class MaskOpts(fsldisplay.DisplayOpts): +class MaskOpts(volumeopts.ImageOpts): colour = props.Colour() invert = props.Boolean(default=False) threshold = props.Bounds( ndims=1, labels=[strings.choices['VolumeOpts.displayRange.min'], - strings.choices['VolumeOpts.displayRange.max']]) + strings.choices['VolumeOpts.displayRange.max']]) - def __init__(self, image, display, imageList, displayCtx, parent=None): + def __init__(self, overlay, *args, **kwargs): - if np.prod(image.shape) > 2 ** 30: - sample = image.data[..., image.shape[-1] / 2] + if np.prod(overlay.shape) > 2 ** 30: + sample = overlay.data[..., overlay.shape[-1] / 2] self.dataMin = float(sample.min()) self.dataMax = float(sample.max()) else: - self.dataMin = float(image.data.min()) - self.dataMax = float(image.data.max()) + self.dataMin = float(overlay.data.min()) + self.dataMax = float(overlay.data.max()) dRangeLen = abs(self.dataMax - self.dataMin) dMinDistance = dRangeLen / 10000.0 - # This is a hack. Mask images are rendered - # using GLMask, which inherits from GLVolume. - # The latter assumes that a 'clippingRange' - # attribute is present on Opts instances - # (see GLVolume.clippingRange). So we're - # adding a dummy attribute to make the + ################# + # This is a hack. + ################# + + # Mask images are rendered using GLMask, which + # inherits from GLVolume. The latter assumes + # that 'clippingRange', 'interpolation', and + # 'invertClipping' attributes are present on + # Opts instances (see the VolumeOpts class). + # So we're adding dummy attributes to make the # GLVolume rendering code happy. - self.clippingRange = (self.dataMin - 1, self.dataMax + 1) + # + # TODO Write independent GLMask rendering routines + # instead of using the GLVolume implementations + self.clippingRange = (self.dataMin - 1, self.dataMax + 1) + self.interpolation = 'none' + self.invertClipping = False self.threshold.xmin = self.dataMin - dMinDistance self.threshold.xmax = self.dataMax + dMinDistance @@ -51,9 +60,4 @@ class MaskOpts(fsldisplay.DisplayOpts): self.threshold.xhi = self.dataMax + dMinDistance self.setConstraint('threshold', 'minDistance', dMinDistance) - fsldisplay.DisplayOpts.__init__(self, - image, - display, - imageList, - displayCtx, - parent) + volumeopts.ImageOpts.__init__(self, overlay, *args, **kwargs) diff --git a/fsl/fslview/displaycontext/modelopts.py b/fsl/fslview/displaycontext/modelopts.py new file mode 100644 index 0000000000000000000000000000000000000000..0c5ef4c009d2892309b8d98ef6e908156ae472f8 --- /dev/null +++ b/fsl/fslview/displaycontext/modelopts.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python +# +# modelopts.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import copy + +import numpy as np + +import props + +import display as fsldisplay + +import fsl.fslview.colourmaps as colourmaps +import fsl.data.image as fslimage +import fsl.data.strings as strings +import fsl.utils.transform as transform + +import volumeopts + + +class ModelOpts(fsldisplay.DisplayOpts): + + colour = props.Colour() + outline = props.Boolean(default=False) + outlineWidth = props.Real(minval=0, maxval=1, default=0.25, clamped=True) + showName = props.Boolean(default=False) + refImage = props.Choice() + coordSpace = copy.copy(volumeopts.ImageOpts.transform) + transform = copy.copy(volumeopts.ImageOpts.transform) + + + def __init__(self, *args, **kwargs): + + # Create a random, highly + # saturated colour + colour = colourmaps.randomBrightColour() + self.colour = np.concatenate((colour, [1.0])) + + nounbind = kwargs.get('nounbind', []) + nounbind.extend(['refImage', 'coordSpace', 'transform']) + kwargs['nounbind'] = nounbind + + # But create that colour before + # base class initialisation, as + # there may be a parent colour + # value which will override the + # one we generated above. + fsldisplay.DisplayOpts.__init__(self, *args, **kwargs) + + self.__oldRefImage = 'none' + + self.overlayList.addListener('overlays', + self.name, + self.__overlayListChanged) + + self.addListener('refImage', self.name, self.__refImageChanged) + + self.__overlayListChanged() + + + def destroy(self): + self.overlayList.removeListener('overlays', self.name) + + for overlay in self.overlayList: + display = self.displayCtx.getDisplay(overlay) + display.removeListener('name', self.name) + + fsldisplay.DisplayOpts.destroy(self) + + + + def getReferenceImage(self): + """Overrides :meth:`.DisplayOpts.getReferenceIamge`. + + If a :attr:`refImage` is selected, it is returned. Otherwise,``None`` + is returned. + """ + if self.refImage == 'none': + return None + return self.refImage + + + def getDisplayBounds(self): + + lo, hi = self.overlay.getBounds() + xform = self.getCoordSpaceTransform() + + if xform is None: + return lo, hi + + lohi = transform.transform([lo, hi], xform) + + return lohi[0, :], lohi[1, :] + + + def getCoordSpaceTransform(self): + + if self.refImage == 'none': + return None + + if self.coordSpace == self.transform: + return None + + opts = self.displayCtx.getOpts(self.refImage) + + return opts.getTransform(self.coordSpace, self.transform) + + + def transformDisplayLocation(self, propName, oldLoc): + + newLoc = oldLoc + + if propName == 'refImage': + + refImage = self.refImage + oldRefImage = self.getLastValue('refImage') + + if oldRefImage == 'none': + refOpts = self.displayCtx.getOpts(refImage) + newLoc = transform.transform( + [oldLoc], + refOpts.getTransform(self.coordSpace, 'display'))[0] + + elif refImage == 'none': + if oldRefImage is not None: + oldRefOpts = self.displayCtx.getOpts(oldRefImage) + newLoc = transform.transform( + [oldLoc], + oldRefOpts.getTransform('display', self.coordSpace))[0] + + elif propName == 'coordSpace': + if self.refImage != 'none': + refOpts = self.displayCtx.getOpts(self.refImage) + worldLoc = transform.transform( + [oldLoc], + refOpts.getTransform( + self.getLastValue('coordSpace'), + 'world'))[0] + newLoc = transform.transform( + [worldLoc], + refOpts.getTransform( + 'world', + self.coordSpace))[0] + + elif propName == 'transform': + + if self.refImage != 'none': + refOpts = self.displayCtx.getOpts(self.refImage) + newLoc = refOpts.transformDisplayLocation(propName, oldLoc) + + return newLoc + + + def __refImageChanged(self, *a): + + if self.__oldRefImage != 'none': + opts = self.displayCtx.getOpts(self.__oldRefImage) + self.unbindProps('transform', opts) + + self.__oldRefImage = self.refImage + + if self.refImage != 'none': + opts = self.displayCtx.getOpts(self.refImage) + self.bindProps('transform', opts) + + + def __overlayListChanged(self, *a): + """Called when the overlay list changes. Updates the ``refImage`` + property so that it contains a list of overlays which can be + associated with the model. + """ + + imgProp = self.getProp('refImage') + imgVal = self.refImage + overlays = self.displayCtx.getOrderedOverlays() + + # the overlay for this ModelOpts + # instance has been removed + if self.overlay not in overlays: + self.overlayList.removeListener('overlays', self.name) + return + + imgOptions = ['none'] + imgLabels = [strings.choices['ModelOpts.refImage.none']] + + for overlay in overlays: + + # The image must be an Image instance. + if not isinstance(overlay, fslimage.Image): + continue + + imgOptions.append(overlay) + imgLabels .append(overlay.name) + + overlay.addListener('name', + self.name, + self.__overlayListChanged, + overwrite=True) + + imgProp.setChoices(imgOptions, imgLabels, self) + + if imgVal in overlays: self.refImage = imgVal + else: self.refImage = 'none' diff --git a/fsl/fslview/displaycontext/sceneopts.py b/fsl/fslview/displaycontext/sceneopts.py index 7e725c8cb258de603009caf2294a30bea2a2d953..fdbac6a8cff72b1b58a267cc6a6de97422112ebf 100644 --- a/fsl/fslview/displaycontext/sceneopts.py +++ b/fsl/fslview/displaycontext/sceneopts.py @@ -6,6 +6,7 @@ # import copy +import logging import props @@ -14,6 +15,10 @@ import fsl.fslview.gl.colourbarcanvas as colourbarcanvas import fsl.data.strings as strings + +log = logging.getLogger(__name__) + + class SceneOpts(props.HasProperties): """The ``SceneOpts`` class defines settings which are applied to :class:`.CanvasPanel` views. @@ -50,31 +55,29 @@ class SceneOpts(props.HasProperties): strings.choices['SceneOpts.performance.5']]) """User controllable performacne setting. - This property is linked to the :attr:`twoStageRender`, + This property is linked to the :attr:`renderMode`, :attr:`resolutionLimit`, and :attr:`softwareMode` properties. Setting the performance to a low value will result in faster rendering time, at the cost of reduced features, and poorer rendering quality. - See the :meth:`_onPerformanceChange` method. + See the :meth:`__onPerformanceChange` method. """ resolutionLimit = copy.copy(slicecanvas.SliceCanvas.resolutionLimit) """The highest resolution at which any image should be displayed. - See :attr:`~fsl.fslview.gl.slicecanvas.SliceCanvas.resolutionLimit` and - :attr:`~fsl.fslview.displaycontext.display.Display.resolution`. + See :attr:`.SliceCanvas.resolutionLimit` and :attr:`.Display.resolution`. """ renderMode = copy.copy(slicecanvas.SliceCanvas.renderMode) - """Enable two-stage rendering, useful for low-performance graphics cards/ + """Controls the rendering mode, useful for low-performance graphics cards/ software rendering. - See :attr:`~fsl.fslview.gl.slicecanvas.SliceCanvas.twoStageRender`. + See :attr:`.SliceCanvas.renderMode`. """ - softwareMode = copy.copy(slicecanvas.SliceCanvas.softwareMode) """If ``True``, all images should be displayed in a mode optimised for @@ -91,12 +94,12 @@ class SceneOpts(props.HasProperties): def __init__(self): name = '{}_{}'.format(type(self).__name__, id(self)) - self.addListener('performance', name, self._onPerformanceChange) + self.addListener('performance', name, self.__onPerformanceChange) - self._onPerformanceChange() + self.__onPerformanceChange() - def _onPerformanceChange(self, *a): + def __onPerformanceChange(self, *a): """Called when the :attr:`performance` property changes. Changes the values of the :attr:`renderMode`, :attr:`softwareMode` @@ -128,3 +131,11 @@ class SceneOpts(props.HasProperties): self.renderMode = 'prerender' self.softwareMode = True self.resolutionLimit = 1 + + log.debug('Performance settings changed: ' + 'renderMode={}, ' + 'softwareMode={}, ' + 'resolutionLimit={}'.format( + self.renderMode, + self.softwareMode, + self.resolutionLimit)) diff --git a/fsl/fslview/displaycontext/vectoropts.py b/fsl/fslview/displaycontext/vectoropts.py index 4fb40e7a588831dc2b3e17aa3542c0827fd58ea3..1df0d25b99c4e772b3094295dce67c2d5f23f7da 100644 --- a/fsl/fslview/displaycontext/vectoropts.py +++ b/fsl/fslview/displaycontext/vectoropts.py @@ -4,17 +4,20 @@ # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -"""This module defines the :class:`VectorOpts` class, which contains -display options for rendering :class:`~fsl.fslview.gl.glvector.GLVector` -instances. +"""This module defines the :class:`VectorOpts` class, which contains display +options for rendering :class:`.GLVector` instances. """ +import copy + import props +import fsl.data.image as fslimage import fsl.data.strings as strings -import display as fsldisplay +import volumeopts + -class VectorOpts(fsldisplay.DisplayOpts): +class VectorOpts(volumeopts.ImageOpts): xColour = props.Colour(default=(1.0, 0.0, 0.0)) @@ -52,80 +55,80 @@ class VectorOpts(fsldisplay.DisplayOpts): """Hide voxels for which the modulation value is below this threshold.""" - def __init__(self, - image, - display, - imageList, - displayCtx, - parent=None, - *args, - **kwargs): + def __init__(self, *args, **kwargs): """Create a ``VectorOpts`` instance for the given image. - See the :class:`~fsl.fslview.displaycontext.display.DisplayOpts` - documentation for more details. + See the :class:`.ImageOpts` documentation for more details. """ - fsldisplay.DisplayOpts.__init__(self, - image, - display, - imageList, - displayCtx, - parent, - *args, - **kwargs) - - imageList.addListener('images', self.name, self.imageListChanged) - self.imageListChanged() - - - def imageListChanged(self, *a): - """Called when the image list changes. Updates the ``modulate`` - property so that it contains a list of images which could be used + + volumeopts.ImageOpts.__init__(self, *args, **kwargs) + + self.overlayList.addListener('overlays', + self.name, + self.__overlayListChanged) + + self.__overlayListChanged() + + + def destroy(self): + self.overlayList.removeListener('overlays', self.name) + + for overlay in self.overlayList: + overlay.removeListener('name', self.name) + + volumeopts.ImageOpts.destroy(self) + + + def __overlayListChanged(self, *a): + """Called when the overlay list changes. Updates the ``modulate`` + property so that it contains a list of overlays which could be used to modulate the vector image. """ - modProp = self.getProp('modulate') - modVal = self.modulate - images = self.displayCtx.getOrderedImages() + modProp = self.getProp('modulate') + modVal = self.modulate + overlays = self.displayCtx.getOrderedOverlays() # the image for this VectorOpts # instance has been removed - if self.image not in images: - self.imageList.removeListener('images', self.name) + if self.overlay not in overlays: + self.overlayList.removeListener('overlays', self.name) return modOptions = ['none'] modLabels = [strings.choices['VectorOpts.modulate.none']] - for image in images: + for overlay in overlays: # It doesn't make sense to # modulate the image by itself - if image is self.image: + if overlay is self.overlay: + continue + + # The modulate image must + # be an image. Duh. + if not isinstance(overlay, fslimage.Image): continue # an image can only be used to modulate # the vector image if it shares the same # dimensions as said vector image - if image.shape != self.image.shape[:3]: + if overlay.shape != self.overlay.shape[:3]: continue - modOptions.append(image) - modLabels .append(image.name) + modOptions.append(overlay) + modLabels .append(overlay.name) - image.addListener('name', - self.name, - self.imageListChanged, - overwrite=True) + overlay.addListener('name', + self.name, + self.__overlayListChanged, + overwrite=True) modProp.setChoices(modOptions, modLabels, self) - if modVal in images: self.modulate = modVal - else: self.modulate = 'none' - + if modVal in overlays: self.modulate = modVal + else: self.modulate = 'none' -# TODO RGBVector/LineVector subclasses for any type -# specific options (e.g. line width for linevector) class LineVectorOpts(VectorOpts): @@ -143,3 +146,9 @@ class LineVectorOpts(VectorOpts): kwargs['nounbind'] = ['directed'] VectorOpts.__init__(self, *args, **kwargs) + + + +class RGBVectorOpts(VectorOpts): + + interpolation = copy.copy(volumeopts.VolumeOpts.interpolation) diff --git a/fsl/fslview/displaycontext/volumeopts.py b/fsl/fslview/displaycontext/volumeopts.py index 91b24539f38425fd88418b87eee2c54be6994b32..928a92b96d7bfc4f857493fc14cff011a0cdfb38 100644 --- a/fsl/fslview/displaycontext/volumeopts.py +++ b/fsl/fslview/displaycontext/volumeopts.py @@ -5,8 +5,7 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # """This module defines the :class:`VolumeOpts` class, which contains -display options for rendering :class:`~fsl.fslview.gl.glvolume.GLVolume` -instances. +display options for rendering :class:`.GLVolume` instances. """ import logging @@ -16,6 +15,7 @@ import numpy as np import props import fsl.data.strings as strings +import fsl.utils.transform as transform import fsl.fslview.colourmaps as fslcm import display as fsldisplay @@ -24,23 +24,194 @@ import display as fsldisplay log = logging.getLogger(__name__) -# TODO Define a super/mixin class which -# has a displayRange and colour map. This -# will allow other bits of code which -# need display range/cmap options to -# test for their presence without having -# to explicitly test against the VolumeOpts -# class (and other future *Opts classes -# which have a display range/cmap). +class ImageOpts(fsldisplay.DisplayOpts): + """A class which describes how an :class:`.Image` should be displayed. + """ + + + volume = props.Int(minval=0, maxval=0, default=0, clamped=True) + """If the data is 4D , the current volume to display.""" + + + resolution = props.Real(maxval=10, default=1, clamped=True) + """Data resolution in world space. The minimum value is set in __init__.""" + + + transform = props.Choice( + ('affine', 'pixdim', 'id'), + labels=[strings.choices['ImageOpts.transform.affine'], + strings.choices['ImageOpts.transform.pixdim'], + strings.choices['ImageOpts.transform.id']], + default='pixdim') + """This property defines how the overlay should be transformd into the display + coordinate system. + + - ``affine``: Use the affine transformation matrix stored in the image + (the ``qform``/``sform`` fields in NIFTI1 headers). + + - ``pixdim``: Scale voxel sizes by the ``pixdim`` fields in the image + header. + + - ``id``: Perform no scaling or transformation - voxels will be + interpreted as :math:`1mm^3` isotropic, with the origin at voxel + (0,0,0). + """ + + + def __init__(self, *args, **kwargs): + # The transform property cannot be unsynced + # across different displays, as it affects + # the display context bounds, wich also + # cannot be unsynced + nounbind = kwargs.get('nounbind', []) + nounbind.append('transform') + + kwargs['nounbind'] = nounbind + + fsldisplay.DisplayOpts.__init__(self, *args, **kwargs) + + overlay = self.overlay + + # The display<->* transformation matrices + # are created in the _setupTransforms method + self.__xforms = {} + self.__setupTransforms() + + # is this a 4D volume? + if self.overlay.is4DImage(): + self.setConstraint('volume', 'maxval', overlay.shape[3] - 1) + + # limit resolution to the image dimensions + self.resolution = min(overlay.pixdim[:3]) + self.setConstraint('resolution', 'minval', self.resolution) + + + def destroy(self): + fsldisplay.DisplayOpts.destroy(self) -class VolumeOpts(fsldisplay.DisplayOpts): - """A class which describes how an :class:`~fsl.data.image.Image` should - be displayed. + + def getDisplayBounds(self): + """Calculates and returns the min/max values of a 3D bounding box, + in the display coordinate system, which is big enough to contain + the image. + + The coordinate system in which the bounding box is defined is + determined by the current value of the :attr:`transform` property. + + A tuple containing two values is returned, with the first value + a sequence of three low bounds, and the second value a sequence + of three high bounds. + """ + + return transform.axisBounds( + self.overlay.shape[:3], self.getTransform('voxel', 'display')) + + + def __setupTransforms(self): + """Calculates transformation matrices between all of the possible + spaces in which the overlay may be displayed. + + These matrices are accessible via the :meth:`getTransform` method. + """ + + image = self.overlay + + voxToIdMat = np.eye(4) + voxToPixdimMat = np.diag(list(image.pixdim[:3]) + [1.0]) + voxToAffineMat = image.voxToWorldMat.T + + idToVoxMat = transform.invert(voxToIdMat) + idToPixdimMat = transform.concat(idToVoxMat, voxToPixdimMat) + idToAffineMat = transform.concat(idToVoxMat, voxToAffineMat) + + pixdimToVoxMat = transform.invert(voxToPixdimMat) + pixdimToIdMat = transform.concat(pixdimToVoxMat, voxToIdMat) + pixdimToAffineMat = transform.concat(pixdimToVoxMat, voxToAffineMat) + + affineToVoxMat = image.worldToVoxMat.T + affineToIdMat = transform.concat(affineToVoxMat, voxToIdMat) + affineToPixdimMat = transform.concat(affineToVoxMat, voxToPixdimMat) + + self.__xforms['id', 'id'] = np.eye(4) + self.__xforms['id', 'pixdim'] = idToPixdimMat + self.__xforms['id', 'affine'] = idToAffineMat + + self.__xforms['pixdim', 'pixdim'] = np.eye(4) + self.__xforms['pixdim', 'id'] = pixdimToIdMat + self.__xforms['pixdim', 'affine'] = pixdimToAffineMat + + self.__xforms['affine', 'affine'] = np.eye(4) + self.__xforms['affine', 'id'] = affineToIdMat + self.__xforms['affine', 'pixdim'] = affineToPixdimMat + + + def getTransform(self, from_, to, xform=None): + """Return a matrix which may be used to transform coordinates + from ``from_`` to ``to``. Valid values for ``from_`` and ``to`` + are: + - ``id``: Voxel coordinates + + - ``pixdim``: Voxel coordinates, scaled by voxel dimensions + + - ``affine``: World coordinates, as defined by the NIFTI1 + ``qform``/``sform``. See + :attr:`~fsl.data.image.Image.voxToWorldMat`. + + - ``voxel``: Equivalent to ``id``. + + - ``display``: Equivalent to the current value of :attr:`transform`. + + - ``world``; Equivalent to ``affine``. + + If the ``xform`` parameter is provided, and one of ``from_`` or ``to`` + is ``display``, the value of ``xform`` is used instead of the current + value of :attr:`transform`. + """ + + if xform is None: + xform = self.transform + + if from_ == 'display': from_ = xform + elif from_ == 'world': from_ = 'affine' + elif from_ == 'voxel': from_ = 'id' + + if to == 'display': to = xform + elif to == 'world': to = 'affine' + elif to == 'voxel': to = 'id' + + return self.__xforms[from_, to] + + + def transformDisplayLocation(self, propName, oldLoc): + + if propName != 'transform': + return oldLoc + + lastVal = self.getLastValue('transform') + if lastVal is None: + lastVal = self.transform + + # Calculate the image world location using the + # old display<-> world transform, then transform + # it back to the new world->display transform. + worldLoc = transform.transform( + [oldLoc], + self.getTransform(lastVal, 'world'))[0] + + newLoc = transform.transform( + [worldLoc], + self.getTransform('world', 'display'))[0] + + return newLoc + + +class VolumeOpts(ImageOpts): + """A class which describes how an :class:`.Image` should be displayed. This class doesn't have much functionality - it is up to things which - actually display an :class:`~fsl.data.image.Image` to adhere to the - properties stored in the associated :class:`ImageDisplay` object. + actually display an :class:`.Image` to adhere to the properties stored in + the associated :class:`.Display` and :class:`VolumeOpts` object. """ @@ -54,49 +225,45 @@ class VolumeOpts(fsldisplay.DisplayOpts): clippingRange = props.Bounds( ndims=1, labels=[strings.choices['VolumeOpts.displayRange.min'], - strings.choices['VolumeOpts.displayRange.max']]) + strings.choices['VolumeOpts.displayRange.max']]) + """Values outside of this range are not shown.""" - cmap = props.ColourMap(default=fslcm.getDefault(), - cmapNames=fslcm.getColourMaps()) - """The colour map, a :class:`matplotlib.colors.Colourmap` instance.""" - - - invert = props.Boolean(default=False) - """Invert the colour map.""" + invertClipping = props.Boolean(default=False) + """If ``True``, the behaviour of ``clippingRange`` is inverted, i.e. + values inside the clipping range are clipped, instead of those outside + the clipping range. + """ - _tooltips = { - 'name' : 'The name of this image', - 'enabled' : 'Enable/disable this image', - 'alpha' : 'Opacity, between 0.0 (transparent) ' - 'and 100.0 (opaque)', - 'displayRange' : 'Minimum/maximum display values', - 'clipLow' : 'Do not show image values which are ' - 'lower than the display range', - 'clipHigh' : 'Do not show image values which are ' - 'higher than the display range', - 'interpolation' : 'Interpolate between voxel values at ' - 'each displayed real world location', - 'resolution' : 'Data resolution in voxels', - 'volume' : 'Volume number (for 4D images)', - 'transform' : 'The transformation matrix which specifies the ' - 'conversion from voxel coordinates to a real ' - 'world location', - 'imageType' : 'the type of data contained in the image', - 'cmap' : 'Colour map'} + cmap = props.ColourMap(default=fslcm.getColourMaps()[0], + cmapNames=fslcm.getColourMaps()) + """The colour map, a :class:`matplotlib.colors.Colourmap` instance.""" - _propHelp = _tooltips + interpolation = props.Choice( + ('none', 'linear', 'spline'), + labels=[strings.choices['VolumeOpts.interpolation.none'], + strings.choices['VolumeOpts.interpolation.linear'], + strings.choices['VolumeOpts.interpolation.spline']]) + """How the value shown at a real world location is derived from the + corresponding data value(s). 'No interpolation' is equivalent to nearest + neighbour interpolation. + """ + invert = props.Boolean(default=False) + """Invert the colour map.""" - def __init__(self, image, display, imageList, displayCtx, parent=None): - """Create an :class:`ImageDisplay` for the specified image. - See the :class:`~fsl.fslview.displaycontext.display.DisplayOpts` - documentation for more details. - """ + def __init__(self, + overlay, + display, + overlayList, + displayCtx, + parent=None, + **kwargs): + """Create a :class:`VolumeOpts` instance for the specified image.""" # Attributes controlling image display. Only # determine the real min/max for small images - @@ -104,13 +271,13 @@ class VolumeOpts(fsldisplay.DisplayOpts): # it may be! So we calculate the min/max of a # sample (either a slice or an image, depending # on whether the image is 3D or 4D) - if np.prod(image.shape) > 2 ** 30: - sample = image.data[..., image.shape[-1] / 2] + if np.prod(overlay.shape) > 2 ** 30: + sample = overlay.data[..., overlay.shape[-1] / 2] self.dataMin = float(sample.min()) self.dataMax = float(sample.max()) else: - self.dataMin = float(image.data.min()) - self.dataMax = float(image.data.max()) + self.dataMin = float(overlay.data.min()) + self.dataMax = float(overlay.data.max()) dRangeLen = abs(self.dataMax - self.dataMin) dMinDistance = dRangeLen / 10000.0 @@ -134,23 +301,24 @@ class VolumeOpts(fsldisplay.DisplayOpts): self.setConstraint('displayRange', 'minDistance', dMinDistance) - fsldisplay.DisplayOpts.__init__(self, - image, - display, - imageList, - displayCtx, - parent) + ImageOpts.__init__(self, + overlay, + display, + overlayList, + displayCtx, + parent, + **kwargs) # The displayRange property of every child VolumeOpts # instance is linked to the corresponding # Display.brightness/contrast properties, so changes # in one are reflected in the other. if parent is not None: - display.addListener('brightness', self.name, self.briconChanged) - display.addListener('contrast', self.name, self.briconChanged) + display.addListener('brightness', self.name, self.__briconChanged) + display.addListener('contrast', self.name, self.__briconChanged) self .addListener('displayRange', self.name, - self.displayRangeChanged) + self.__displayRangeChanged) # Because displayRange and bri/con are intrinsically # linked, it makes no sense to let the user sync/unsync @@ -163,7 +331,8 @@ class VolumeOpts(fsldisplay.DisplayOpts): display.getSyncPropertyName('brightness')) self.bindProps(self .getSyncPropertyName('displayRange'), display, - display.getSyncPropertyName('contrast')) + display.getSyncPropertyName('contrast')) + def destroy(self): @@ -177,7 +346,9 @@ class VolumeOpts(fsldisplay.DisplayOpts): display.getSyncPropertyName('brightness')) self.unbindProps(self .getSyncPropertyName('displayRange'), display, - display.getSyncPropertyName('contrast')) + display.getSyncPropertyName('contrast')) + + ImageOpts.destroy(self) def __toggleListeners(self, enable=True): @@ -185,9 +356,9 @@ class VolumeOpts(fsldisplay.DisplayOpts): are registered on the :attr:`displayRange` and :attr:`.Display.brightness`/:attr:`.Display.contrast`/ properties. - Because these properties are linked via the :meth:`displayRangeChanged` - and :meth:`briconChanged` methods, we need to be careful about avoiding - recursive callbacks. + Because these properties are linked via the + :meth:`__displayRangeChanged` and :meth:`__briconChanged` methods, + we need to be careful about avoiding recursive callbacks. Furthermore, because the properties of both :class:`VolumeOpts` and :class:`.Display` instances are possibly synchronised to a parent @@ -224,9 +395,9 @@ class VolumeOpts(fsldisplay.DisplayOpts): peer .disableListener('displayRange', peer.name) - def briconChanged(self, *a): + def __briconChanged(self, *a): """Called when the ``brightness``/``contrast`` properties of the - :class:`~fsl.fslview.displaycontext.display.Display` instance change. + :class:`.Display` instance change. Updates the :attr:`displayRange` property accordingly. @@ -243,7 +414,7 @@ class VolumeOpts(fsldisplay.DisplayOpts): self.__toggleListeners(True) - def displayRangeChanged(self, *a): + def __displayRangeChanged(self, *a): """Called when the `attr`:displayRange: property changes. Updates the :attr:`.Display.brightness` and :attr:`.Display.contrast` @@ -259,7 +430,7 @@ class VolumeOpts(fsldisplay.DisplayOpts): self.__toggleListeners(False) # update bricon - self.display.brightness = 100 - brightness * 100 - self.display.contrast = 100 - contrast * 100 + self.display.brightness = brightness * 100 + self.display.contrast = contrast * 100 self.__toggleListeners(True) diff --git a/fsl/fslview/editor/editor.py b/fsl/fslview/editor/editor.py index 2f0b70b319e6f92f38872dbf94b9c2ea425ac90b..f45894e5c505f6775fc92d681bed9006e66195ac 100644 --- a/fsl/fslview/editor/editor.py +++ b/fsl/fslview/editor/editor.py @@ -19,8 +19,8 @@ import fsl.data.image as fslimage class ValueChange(object): - def __init__(self, image, volume, offset, oldVals, newVals): - self.image = image + def __init__(self, overlay, volume, offset, oldVals, newVals): + self.overlay = overlay self.volume = volume self.offset = offset self.oldVals = oldVals @@ -28,8 +28,8 @@ class ValueChange(object): class SelectionChange(object): - def __init__(self, image, offset, oldSelection, newSelection): - self.image = image + def __init__(self, overlay, offset, oldSelection, newSelection): + self.overlay = overlay self.offset = offset self.oldSelection = oldSelection self.newSelection = newSelection @@ -40,13 +40,14 @@ class Editor(props.HasProperties): canUndo = props.Boolean(default=False) canRedo = props.Boolean(default=False) - def __init__(self, imageList, displayCtx): + def __init__(self, overlayList, displayCtx): - self._name = '{}_{}'.format(self.__class__.__name__, id(self)) - self._imageList = imageList - self._displayCtx = displayCtx - self._selection = None - self._currentImage = None + self._name = '{}_{}'.format(self.__class__.__name__, + id(self)) + self._overlayList = overlayList + self._displayCtx = displayCtx + self._selection = None + self._currentOverlay = None # A list of state objects, providing # records of what has been done. The @@ -60,35 +61,42 @@ class Editor(props.HasProperties): self._doneIndex = -1 self._inGroup = False - self._displayCtx.addListener('selectedImage', - self._name, - self._selectedImageChanged) - self._imageList .addListener('images', - self._name, - self._selectedImageChanged) + self._displayCtx .addListener('selectedOverlay', + self._name, + self._selectedOverlayChanged) + self._overlayList.addListener('overlays', + self._name, + self._selectedOverlayChanged) - self._selectedImageChanged() + self._selectedOverlayChanged() def __del__(self): - self._displayCtx.removeListener('selectedImage', self._name) - self._imageList .removeListener('images', self._name) + self._displayCtx .removeListener('selectedOverlay', self._name) + self._overlayList.removeListener('overlays', self._name) - def _selectedImageChanged(self, *a): - image = self._displayCtx.getSelectedImage() + def _selectedOverlayChanged(self, *a): + overlay = self._displayCtx.getSelectedOverlay() - if image is None: - self._currentImage = None - self._selection = None + if self._currentOverlay == overlay: return - if self._currentImage == image: + if overlay is None: + self._currentOverlay = None + self._selection = None return - display = self._displayCtx.getDisplayProperties(image) - self._currentImage = image - self._selection = selection.Selection(image.data, display) + display = self._displayCtx.getDisplay(overlay) + + if not isinstance(overlay, fslimage.Image) or \ + display.overlayType != 'volume': + self._currentOverlay = None + self._selection = None + return + + self._currentOverlay = overlay + self._selection = selection.Selection(overlay.data, display) self._selection.addListener('selection', self._name, @@ -97,10 +105,9 @@ class Editor(props.HasProperties): def _selectionChanged(self, *a): - image = self._displayCtx.getSelectedImage() old, new, offset = self._selection.getLastChange() - change = SelectionChange(image, offset, old, new) + change = SelectionChange(self._currentOverlay, offset, old, new) self._changeMade(change) @@ -110,8 +117,12 @@ class Editor(props.HasProperties): def fillSelection(self, newVals): - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) + overlay = self._currentOverlay + + if overlay is None: + return + + opts = self._displayCtx.getOpts(overlay) selectBlock, offset = self._selection.getBoundedSelection() @@ -127,17 +138,17 @@ class Editor(props.HasProperties): yhi = ylo + selectBlock.shape[1] zhi = zlo + selectBlock.shape[2] - if len(image.shape) == 3: - oldVals = image.data[xlo:xhi, ylo:yhi, zlo:zhi] - elif len(image.shape) == 4: - oldVals = image.data[xlo:xhi, ylo:yhi, zlo:zhi, display.volume] + if len(overlay.shape) == 3: + oldVals = overlay.data[xlo:xhi, ylo:yhi, zlo:zhi] + elif len(overlay.shape) == 4: + oldVals = overlay.data[xlo:xhi, ylo:yhi, zlo:zhi, opts.volume] else: raise RuntimeError('Only 3D and 4D images are currently supported') selectBlock = selectBlock == 0 newVals[selectBlock] = oldVals[selectBlock] - change = ValueChange(image, display.volume, offset, oldVals, newVals) + change = ValueChange(overlay, opts.volume, offset, oldVals, newVals) self._applyChange(change) self._changeMade( change) @@ -224,20 +235,19 @@ class Editor(props.HasProperties): def _applyChange(self, change): - image = change.image - display = self._displayCtx.getDisplayProperties(image) + overlay = change.overlay + opts = self._displayCtx.getOpts(overlay) - if image.is4DImage(): volume = display.volume - else: volume = None + if overlay.is4DImage(): volume = opts.volume + else: volume = None - if self._displayCtx.getSelectedImage() != image: - self._displayCtx.selectImage(image) + self._displayCtx.selectOverlay(overlay) if isinstance(change, ValueChange): log.debug('Changing image data - offset ' '{}, volume {}, size {}'.format( change.offset, change.volume, change.oldVals.shape)) - change.image.applyChange(change.offset, change.newVals, volume) + change.overlay.applyChange(change.offset, change.newVals, volume) elif isinstance(change, SelectionChange): self._selection.disableListener('selection', self._name) @@ -247,17 +257,16 @@ class Editor(props.HasProperties): def _revertChange(self, change): - image = change.image - display = self._displayCtx.getDisplayProperties(image) + overlay = change.ovelay + opts = self._displayCtx.getOpts(overlay) - if self._displayCtx.getSelectedImage() != image: - self._displayCtx.selectImage(image) + self._displayCtx.selectOverlay(overlay) - if image.is4DImage(): volume = display.volume - else: volume = None + if overlay.is4DImage(): volume = opts.volume + else: volume = None if isinstance(change, ValueChange): - change.image.applyChange(change.offset, change.oldVals, volume) + change.overlay.applyChange(change.offset, change.oldVals, volume) elif isinstance(change, SelectionChange): self._selection.disableListener('selection', self._name) @@ -267,35 +276,40 @@ class Editor(props.HasProperties): def createMaskFromSelection(self): - imageIdx = self._displayCtx.selectedImage - image = self._imageList[imageIdx] - mask = np.array(self._selection.selection, dtype=np.uint8) + overlay = self._currentOverlay + if overlay is None: + return - header = image.nibImage.get_header() - name = '{}_mask'.format(image.name) + overlayIdx = self._overlayList.index(overlay) + mask = np.array(self._selection.selection, dtype=np.uint8) + header = overlay.nibImage.get_header() + name = '{}_mask'.format(overlay.name) roiImage = fslimage.Image(mask, name=name, header=header) - self._imageList.insert(imageIdx + 1, roiImage) + self._overlayList.insert(overlayIdx + 1, roiImage) def createROIFromSelection(self): - imageIdx = self._displayCtx.selectedImage - image = self._imageList[imageIdx] - display = self._displayCtx.getDisplayProperties(image) + overlay = self._currentOverlay + if overlay is None: + return + + overlayIdx = self._overlayList.index(overlay) + opts = self._displayCtx.getDisplay(overlay) - roi = np.zeros(image.shape[:3], dtype=image.data.dtype) + roi = np.zeros(overlay.shape[:3], dtype=overlay.data.dtype) selection = self._selection.selection > 0 - if len(image.shape) == 3: - roi[selection] = image.data[selection] - elif len(image.shape) == 4: - roi[selection] = image.data[:, :, :, display.volume][selection] + if len(overlay.shape) == 3: + roi[selection] = overlay.data[selection] + elif len(overlay.shape) == 4: + roi[selection] = overlay.data[:, :, :, opts.volume][selection] else: raise RuntimeError('Only 3D and 4D images are currently supported') - header = image.nibImage.get_header() - name = '{}_roi'.format(image.name) + header = overlay.nibImage.get_header() + name = '{}_roi'.format(overlay.name) roiImage = fslimage.Image(roi, name=name, header=header) - self._imageList.insert(imageIdx + 1, roiImage) + self._overlayList.insert(overlayIdx + 1, roiImage) diff --git a/fsl/fslview/editor/selection.py b/fsl/fslview/editor/selection.py index 6a42ee7ddc289a57f3edeb088e5a9b58faaf83b9..2c3477dba8e9d0e0cd42f3954911a6691a2ace22 100644 --- a/fsl/fslview/editor/selection.py +++ b/fsl/fslview/editor/selection.py @@ -29,6 +29,7 @@ class Selection(props.HasProperties): def __init__(self, image, display): self._image = image self._display = display + self._opts = display.getDisplayOpts() self._lastChangeOffset = None self._lastChangeOldBlock = None self._lastChangeNewBlock = None @@ -206,7 +207,7 @@ class Selection(props.HasProperties): if len(self._image.shape) == 3: data = self._image elif len(self._image.shape) == 4: - data = self._image[:, :, :, self._display.volume] + data = self._image[:, :, :, self._opts.volume] else: raise RuntimeError('Only 3D and 4D images are currently supported') diff --git a/fsl/fslview/frame.py b/fsl/fslview/frame.py index 4b2429eb261824b7cb127440c806fad13ce4b380..29c55ae34d8d23271290e387c37177de9eabf83f 100644 --- a/fsl/fslview/frame.py +++ b/fsl/fslview/frame.py @@ -7,67 +7,75 @@ """A 3D image viewer. This module provides the :class:`FSLViewFrame` which is the top level frame -for the FSLView application, providing functionality to view 3D/4D MR images. +for the FSLView application, providing functionality to view 3D/4D images, +and other types of data. The application logic is spread across several sub-packages: - - :mod:`actions` - Global actions (e.g. load file), and abstract base - classes for other actions, and entities which provide - actions. + - :mod:`actions` - Global actions (e.g. load file), and abstract base + classes for other actions, and entities which + provide actions. - - :mod:`controls` - GUI panels which provide an interface to control the - display of a single view. + - :mod:`controls` - GUI panels which provide an interface to control + the display of a single view. - - :mod:`views` - GUI panels which display image data. + - :mod:`displaycontext` - Classes which define options controlling the + display. - - :mod:`gl` - OpenGL visualisation logic. + - :mod:`editor` - Image editing functionality. - - :mod:`profiles` - Mouse/keyboard interaction profiles. + - :mod:`gl` - OpenGL visualisation logic. - - :mod:`editor` - Image editing functionality. + - :mod:`profiles` - Mouse/keyboard interaction profiles. - - :mod:`widgets` - General purpose custom :mod:`wx` widgets. + - :mod:`views` - GUI panels which display image data. + + - :mod:`widgets` - General purpose custom :mod:`wx` widgets. A :class:`FSLViewFrame` is a container for one or more 'views' - all of the -possible views are contained within the :mod:`views` sub-package, and the +possible views are contained within the :mod:`.views` sub-package, and the views which may be opened by the user are defined by the -:func:`views.listViewPanels` function. View panels may contain one or more -'control' panels (all defined in the :mod:controls` sub-package), which +:func:`.views.listViewPanels` function. View panels may contain one or more +'control' panels (all defined in the :mod:`.controls` sub-package), which provide an interface allowing the user to control the view. -All view (and control) panels are derived from the :class:`panel.FSLViewPanel` -which, in turn, is derived from the :class:`actions.ActionProvider` class. +All view (and control) panels are derived from the :class:`.FSLViewPanel` +which, in turn, is derived from the :class:`.ActionProvider` class. As such, view panels may expose both actions, and properties, which can be performed or modified by the user. """ + import logging -log = logging.getLogger(__name__) import wx import wx.aui as aui -import fsl.data.strings as strings +import fsl.data.strings as strings +import fsl.fslview.settings as fslsettings import views import actions import displaycontext +log = logging.getLogger(__name__) + + class FSLViewFrame(wx.Frame): """A frame which implements a 3D image viewer.""" def __init__(self, parent, - imageList, + overlayList, displayCtx, restore=True): """ :arg parent: - :arg imageList: + :arg overlayList: :arg displayCtx: @@ -77,11 +85,18 @@ class FSLViewFrame(wx.Frame): """ wx.Frame.__init__(self, parent, title='FSLView') + + # Default application font - this is + # inherited by all child controls. + font = self.GetFont() + font.SetPointSize(10) + font.SetWeight(wx.FONTWEIGHT_LIGHT) + self.SetFont(font) - self._imageList = imageList - self._displayCtx = displayCtx + self.__overlayList = overlayList + self.__displayCtx = displayCtx - self._centrePane = aui.AuiNotebook( + self.__centrePane = aui.AuiNotebook( self, style=aui.AUI_NB_TOP | aui.AUI_NB_TAB_SPLIT | @@ -90,26 +105,27 @@ class FSLViewFrame(wx.Frame): # Keeping track of all # open view panels - self._viewPanels = [] - self._viewPanelTitles = {} - self._viewPanelMenus = {} - self._viewPanelCount = 0 + self.__viewPanels = [] + self.__viewPanelDCs = {} + self.__viewPanelTitles = {} + self.__viewPanelMenus = {} + self.__viewPanelCount = 0 - self._makeMenuBar() - self._restoreState(restore) + self.__makeMenuBar() + self.__restoreState(restore) - self._centrePane.Bind(aui.EVT_AUINOTEBOOK_PAGE_CLOSE, - self._onViewPanelClose) + self.__centrePane.Bind(aui.EVT_AUINOTEBOOK_PAGE_CLOSE, + self.__onViewPanelClose) - self.Bind(wx.EVT_CLOSE, self._onClose) + self.Bind(wx.EVT_CLOSE, self.__onClose) def getViewPanels(self): """Returns a list of all view panels that currently exist, and a list of their titles. """ - return (self._viewPanels, - [self._viewPanelTitles[vp] for vp in self._viewPanels]) + return (self.__viewPanels, + [self.__viewPanelTitles[vp] for vp in self.__viewPanels]) def addViewPanel(self, panelCls): @@ -119,31 +135,37 @@ class FSLViewFrame(wx.Frame): title = '{} {}'.format( strings.titles[panelCls], - self._viewPanelCount + 1) + self.__viewPanelCount + 1) childDC = displaycontext.DisplayContext( - self._imageList, - self._displayCtx) + self.__overlayList, + self.__displayCtx) panel = panelCls( - self._centrePane, - self._imageList, - childDC) + self.__centrePane, + self.__overlayList, + childDC) + + log.debug('Created new {} ({}) with DisplayContext {}'.format( + panelCls.__name__, + id(panel), + id(childDC))) - self._viewPanelCount = self._viewPanelCount + 1 + self.__viewPanelCount = self.__viewPanelCount + 1 - self._viewPanels.append(panel) - self._viewPanelTitles[panel] = title + self.__viewPanels.append(panel) + self.__viewPanelTitles[panel] = title + self.__viewPanelDCs[ panel] = childDC - self._centrePane.AddPage(panel, title, True) - self._centrePane.Split( - self._centrePane.GetPageIndex(panel), + self.__centrePane.AddPage(panel, title, True) + self.__centrePane.Split( + self.__centrePane.GetPageIndex(panel), wx.RIGHT) - self._addViewPanelMenu(panel, title) + self.__addViewPanelMenu(panel, title) - def _addViewPanelMenu(self, panel, title): + def __addViewPanelMenu(self, panel, title): actionz = panel.getActions() @@ -154,7 +176,7 @@ class FSLViewFrame(wx.Frame): menu = wx.Menu() menuBar.Append(menu, title) - self._viewPanelMenus[panel] = menu + self.__viewPanelMenus[panel] = menu for actionName, actionObj in actionz.items(): @@ -164,34 +186,47 @@ class FSLViewFrame(wx.Frame): actionObj.bindToWidget(self, wx.EVT_MENU, menuItem) - def _onViewPanelClose(self, ev): + def __onViewPanelClose(self, ev): ev.Skip() pageIdx = ev.GetSelection() - panel = self._centrePane.GetPage(pageIdx) + panel = self.__centrePane.GetPage(pageIdx) - if panel not in self._viewPanels: + if panel not in self.__viewPanels: return - self._viewPanels .remove(panel) - title = self._viewPanelTitles.pop( panel) + self.__viewPanels .remove(panel) + self.__viewPanelMenus .pop( panel, None) + title = self.__viewPanelTitles.pop( panel) + dctx = self.__viewPanelDCs .pop( panel) - log.debug('Destroying view panel {} ({})'.format( - title, type(panel).__name__)) + log.debug('Destroying {} (title {}, id {}) and ' + 'associated DisplayContext ({})'.format( + type(panel).__name__, + title, + id(panel), + id(dctx))) - # Calling fslpanel.FSLViewPanel.destroy() - # - I think that the AUINotebook does the - # wx.Window.Destroy side of things ... - panel.destroy() + # Unbind view panel menu + # items, and remove the menu + for actionName, actionObj in panel.getActions().items(): + actionObj.unbindAllWidgets() menuBar = self.GetMenuBar() menuIdx = menuBar.FindMenu(title) if menuIdx != wx.NOT_FOUND: menuBar.Remove(menuIdx) + # Calling fslpanel.FSLViewPanel.destroy() + # and DisplayContext.destroy() - the + # AUINotebook should do the + # wx.Window.Destroy side of things ... + panel.destroy() + dctx .destroy() + - def _onClose(self, ev): + def __onClose(self, ev): """Called on requests to close this :class:`FSLViewFrame`. Saves the frame position, size, and layout, so it may be preserved the @@ -200,25 +235,20 @@ class FSLViewFrame(wx.Frame): ev.Skip() - config = wx.Config('FSLView') - size = self.GetSize().Get() position = self.GetScreenPosition().Get() - log.debug('Saving size: {}' .format(str(size))) - log.debug('Saving position: {}'.format(str(position))) - - config.Write('size', str(size)) - config.Write('position', str(position)) + fslsettings.write('framesize', str(size)) + fslsettings.write('frameposition', str(position)) # It's nice to explicitly clean # up our FSLViewPanels, otherwise # they'll probably complain - for panel in self._viewPanels: + for panel in self.__viewPanels: panel.destroy() - def _parseSavedSize(self, size): + def __parseSavedSize(self, size): """Parses the given string, which is assumed to contain a size tuple. """ @@ -226,12 +256,12 @@ class FSLViewFrame(wx.Frame): except: return None - _parseSavedPoint = _parseSavedSize - """A proxy for the :meth:`_parseSavedSize` method. + __parseSavedPoint = __parseSavedSize + """A proxy for the :meth:`__parseSavedSize` method. """ - def _parseSavedLayout(self, layout): + def __parseSavedLayout(self, layout): """Parses the given string, which is assumed to contain an encoded :class:`wx.aui.AuiManager` perspective (see :meth:`~wx.aui.AuiManager.SavePerspective`). @@ -261,7 +291,7 @@ class FSLViewFrame(wx.Frame): return [] - def _restoreState(self, restore=True): + def __restoreState(self, restore=True): """Called on :meth:`__init__`. If any frame size/layout properties have previously been saved, they are applied to this frame. @@ -270,11 +300,11 @@ class FSLViewFrame(wx.Frame): from operator import itemgetter as iget - config = wx.Config('FSLView') - # Restore the saved frame size/position - size = self._parseSavedSize( config.Read('size')) - position = self._parseSavedPoint(config.Read('position')) + size = self.__parseSavedSize( + fslsettings.read('framesize')) + position = self.__parseSavedPoint( + fslsettings.read('frameposition')) if (size is not None) and (position is not None): @@ -363,22 +393,20 @@ class FSLViewFrame(wx.Frame): # Set up a default for ortho views # layout (this will hopefully eventually - # be done by the FSLViewFrame instance) - import fsl.fslview.controls.imagelistpanel as ilp - import fsl.fslview.controls.locationpanel as lop - import fsl.fslview.controls.imagedisplaytoolbar as idt - import fsl.fslview.controls.orthotoolbar as ot + # be restored from a saved state) + import fsl.fslview.controls.overlaylistpanel as olp + import fsl.fslview.controls.locationpanel as lop + import fsl.fslview.controls.overlaydisplaytoolbar as odt + import fsl.fslview.controls.orthotoolbar as ot - viewPanel.togglePanel(ilp.ImageListPanel) + viewPanel.togglePanel(olp.OverlayListPanel) viewPanel.togglePanel(lop.LocationPanel) - viewPanel.togglePanel(idt.ImageDisplayToolBar, False, viewPanel) - viewPanel.togglePanel(ot .OrthoToolBar, False, viewPanel) + viewPanel.togglePanel(odt.OverlayDisplayToolBar, False, viewPanel) + viewPanel.togglePanel(ot .OrthoToolBar, False, viewPanel) - def _makeMenuBar(self): - """Constructs a bunch of menu items for working with the given - :class:`~fsl.fslview.fslviewframe.FslViewFrame`. - """ + def __makeMenuBar(self): + """Constructs a bunch of menu items for this :class:`FSLViewFrame`.""" menuBar = wx.MenuBar() self.SetMenuBar(menuBar) @@ -389,8 +417,8 @@ class FSLViewFrame(wx.Frame): viewMenu = wx.Menu() menuBar.Append(viewMenu, 'View') - self._fileMenu = fileMenu - self._viewMenu = viewMenu + self.__fileMenu = fileMenu + self.__viewMenu = viewMenu viewPanels = views .listViewPanels() actionz = actions .listGlobalActions() @@ -398,7 +426,7 @@ class FSLViewFrame(wx.Frame): for action in actionz: menuItem = fileMenu.Append(wx.ID_ANY, strings.actions[action]) - actionObj = action(self._imageList, self._displayCtx) + actionObj = action(self.__overlayList, self.__displayCtx) actionObj.bindToWidget(self, wx.EVT_MENU, menuItem) diff --git a/fsl/fslview/gl/__init__.py b/fsl/fslview/gl/__init__.py index c39f170321c82b039e30dc441a01ef8ad61d20cd..e63a3ca6c42c51107cbe413528cdecf4bec785da 100644 --- a/fsl/fslview/gl/__init__.py +++ b/fsl/fslview/gl/__init__.py @@ -9,10 +9,9 @@ This package contains the OpenGL rendering code used by FSLView. It contains a number of modules which contain logic that is independent of the available -OpenGL version (e.g. the :class:`~fsl.fslview.gl.slicecanvas.SliceCanvas` -class), and also contains a number of sub-packages (currently two) which -contain OpenGL-version-dependent modules that are used by the version -independent ones. +OpenGL version (e.g. the :class:`.SliceCanvas` class), and also contains a +number of sub-packages (currently two) which contain OpenGL-version-dependent +modules that are used by the version independent ones. The available OpenGL API version can only be determined once an OpenGL context has been created, and a display is available for rendering. Therefore, the @@ -20,9 +19,8 @@ package-level :func:`bootstrap` function is called by the version-independent module classes as needed, to dynamically determine which version-dependent modules should be loaded. Users of this package should not need to worry about any of this - just instantiate the GUI classes provided by the -version-independent modules (e.g. the -:class:`~fsl.fslview.gl.lightboxcanvas.LightBoxCanvas` class) as you would any -other :mod:`wx` widget. +version-independent modules (e.g. the :class:`.LightBoxCanvas` class) as you +would any other :mod:`wx` widget. Two methods of OpenGL usage are supported: @@ -41,12 +39,20 @@ Two super classes are provided for each of these cases: After the :func:`boostrap` function has been called, the following package-level attributes will be available: - - ``GL_VERSION``: A string containing the target OpenGL version, in the - format ``major.minor``, e.g. ``2.1``. + - ``GL_VERSION``: A string containing the target OpenGL version, in + the format ``major.minor``, e.g. ``2.1``. - - ``glvolume_funcs``: The version-specific module containing functions for - rendering :class:`~fsl.fslview.gl.glvolume.GLVolume` - instances. + - ``glvolume_funcs``: The version-specific module containing functions for + rendering :class:`.GLVolume` instances. + + - ``glrgbvector_funcs``: The version-specific module containing functions for + rendering :class:`.GLRGBVector` instances. + + - ``gllinevector_funcs``: The version-specific module containing functions for + rendering :class:`.GLLineVector` instances. + + - ``glmodel_funcs``: The version-specific module containing functions for + rendering :class:`.GLModel` instances. """ import logging @@ -88,7 +94,7 @@ OpenGL.ERROR_LOGGING = True if os.environ.get('PYOPENGL_PLATFORM', None) == 'osmesa': OpenGL.STORE_POINTERS = False - + def bootstrap(glVersion=None): """Imports modules appropriate to the specified OpenGL version. @@ -170,8 +176,9 @@ def bootstrap(glVersion=None): # Spline interpolation is not currently # available in the GL14 implementation - import fsl.fslview.displaycontext.display as di - di.Display.interpolation.removeChoice('spline') + import fsl.fslview.displaycontext as dc + dc.VolumeOpts .interpolation.removeChoice('spline') + dc.RGBVectorOpts.interpolation.removeChoice('spline') renderer = gl.glGetString(gl.GL_RENDERER) @@ -190,21 +197,27 @@ def bootstrap(glVersion=None): log.debug('Software-based rendering detected - ' 'lowering default performance settings.') - import fsl.fslview.displaycontext.display as di - import fsl.fslview.displaycontext.sceneopts as so + import fsl.fslview.displaycontext as dc - so.SceneOpts.performance.setConstraint(None, 'default', 2) + dc.SceneOpts.performance.setConstraint(None, 'default', 2) # And disable some fancy options - spline # may have been disabled above, so absorb # the ValueError if it occurs - try: di.Display.interpolation.removeChoice('spline') + + # TODO Remove this code duplication + try: + dc.VolumeOpts .interpolation.removeChoice('spline') + dc.RGBVectorOpts.interpolation.removeChoice('spline') + except ValueError: pass thismod.GL_VERSION = verstr thismod.glvolume_funcs = glpkg.glvolume_funcs thismod.glrgbvector_funcs = glpkg.glrgbvector_funcs thismod.gllinevector_funcs = glpkg.gllinevector_funcs + thismod.glmodel_funcs = glpkg.glmodel_funcs + thismod.gllabel_funcs = glpkg.gllabel_funcs thismod._bootstrapped = True @@ -251,7 +264,24 @@ def getWXGLContext(parent=None): # context has been created. Destroying # the canvas is the responsibility of the # calling code. - canvas = wxgl.GLCanvas(parent) + + # There's something wrong with wxPython's + # GLCanvas (on OSX at least) - the pixel + # format attributes have to be set on the + # *first* GLCanvas that is created - + # setting them on subsequent canvases will + # have no effect. But if you set them on + # the first canvas, all canvases that are + # subsequently created will inherit the + # same properties. + attribs = [wxgl.WX_GL_RGBA, + wxgl.WX_GL_DOUBLEBUFFER, + wxgl.WX_GL_STENCIL_SIZE, 4, + wxgl.WX_GL_DEPTH_SIZE, 8, + 0, + 0] + + canvas = wxgl.GLCanvas(parent, attribList=attribs) canvas.SetSize((0, 0)) # The canvas must be visible before we are diff --git a/fsl/fslview/gl/annotations.py b/fsl/fslview/gl/annotations.py index b88850cd053dd0cca8eba5f0b192fe0f16e199fd..685d9c3c2d87f73456b5525489b912e9ca04e222 100644 --- a/fsl/fslview/gl/annotations.py +++ b/fsl/fslview/gl/annotations.py @@ -7,9 +7,8 @@ """This module provides the :class:`Annotations` class, which implements functionality to draw 2D OpenGL annotations on a canvas -The :class:`Annotations` class is used by the -:class:`~fsl.fslview.gl.slicecanvas.SliceCanvas` class, and users of that -class, to annotate the canvas. +The :class:`Annotations` class is used by the :class:`.SliceCanvas` class, and +users of that class, to annotate the canvas. """ import logging @@ -98,6 +97,7 @@ class Annotations(object): hold = kwargs.pop('hold', False) return self.obj(VoxelGrid(*args, **kwargs), hold) + def selection(self, *args, **kwargs): """Queues a mask for drawing - see the :class:`VoxelMask` class. @@ -119,9 +119,9 @@ class Annotations(object): def dequeue(self, obj, hold=False): - """Removes the given :class:`AnnotationObject` from the queue, but - does not call its :meth:`~fsl.fslview.gl.globject.GLObject.destroy` - method - this is the responsibility of the caller. + """Removes the given :class:`AnnotationObject` from the queue, but does + not call its :meth:`.GLObject.destroy` method - this is the + responsibility of the caller. """ if hold: @@ -134,8 +134,8 @@ class Annotations(object): def clear(self): """Clears both the normal queue and the persistent (a.k.a. ``hold``) - queue, and calls the :meth:`~fsl.fslview.gl.globject.GLObject.destroy` - method of all objects in the queue. + queue, and calls the :meth:`.GLObject.destroy` method of all objects + in the queue. """ for obj in self._q: obj.destroy() diff --git a/fsl/fslview/gl/colourbarcanvas.py b/fsl/fslview/gl/colourbarcanvas.py index 84995b1aed4e282ab4f8263f7627007d165eca44..e0e3e9f0b40f225b052dfca581f23431d0c3d0ce 100644 --- a/fsl/fslview/gl/colourbarcanvas.py +++ b/fsl/fslview/gl/colourbarcanvas.py @@ -67,12 +67,13 @@ class ColourBarCanvas(props.HasProperties): self._tex = None self._name = '{}_{}'.format(self.__class__.__name__, id(self)) - def _update(*a): - self._genColourBarTexture() - self._refresh() - for prop in ('cmap', 'vrange', 'label', 'orientation', 'labelSide'): - self.addListener(prop, self._name, _update) + self.addListener(prop, self._name, self.__updateTexture) + + + def __updateTexture(self, *a): + self._genColourBarTexture() + self._refresh() def _initGL(self): @@ -85,6 +86,14 @@ class ColourBarCanvas(props.HasProperties): self._genColourBarTexture() + def destroy(self): + """Should be called when this ``ColourBarCanvas`` is no longer needed. + Destroys the :class:`.Texture2D` instance used to render the colour + bar. + """ + self._tex.destroy() + + def _genColourBarTexture(self): """Generates a texture containing an image of the colour bar, according to the current property values. diff --git a/fsl/fslview/gl/gl14/__init__.py b/fsl/fslview/gl/gl14/__init__.py index 360c9cafe687bebc15183151878599ac7ae73b12..2c0843a8bb6d8812a09c5e55dbb1081c5b31e506 100644 --- a/fsl/fslview/gl/gl14/__init__.py +++ b/fsl/fslview/gl/gl14/__init__.py @@ -8,3 +8,5 @@ import glvolume_funcs import glrgbvector_funcs import gllinevector_funcs +import glmodel_funcs +import gllabel_funcs diff --git a/fsl/fslview/gl/gl14/edge2D.prog b/fsl/fslview/gl/gl14/edge2D.prog new file mode 100644 index 0000000000000000000000000000000000000000..170a5f87d1490a1d6aee78e6e10484740d88bd4e --- /dev/null +++ b/fsl/fslview/gl/gl14/edge2D.prog @@ -0,0 +1,84 @@ +# Inputs: +# coord +# val +# tol +# offsets +# +# Outputs: +# isEdge + + +TEMP off; +TEMP back; +TEMP front; +TEMP tempCoord; +TEMP isEdgeBack0; +TEMP isEdgeBack1; +TEMP isEdgeFront0; +TEMP isEdgeFront1; +TEMP isEdge; + +# Test along the x axis +MOV off, offsets; +MUL off, off, {1, 0, 0, 0}; + +# Sample a value behind the coordinate +MOV tempCoord, coord; +ADD tempCoord, tempCoord, off; +TEX back, tempCoord, texture[0], 2D; + +# Sample a value in front of the coordinatea +MOV tempCoord, coord; +SUB tempCoord, tempCoord, off; +TEX front, tempCoord, texture[0], 2D; + +SUB back, back, val; +SUB front, front, val; +ABS back, back; +ABS front, front; + +SLT isEdgeBack0, tol, back; +SLT isEdgeFront0, tol, front; + + +# Test along the y axis +MOV off, offsets; +MUL off, off, {0, 1, 0, 0}; + +# Sample a value behind the coordinate +MOV tempCoord, coord; +ADD tempCoord, tempCoord, off; +TEX back, tempCoord, texture[0], 2D; + +# Sample a value in front of the coordinatea +MOV tempCoord, coord; +SUB tempCoord, tempCoord, off; +TEX front, tempCoord, texture[0], 2D; + +SUB back, back, val; +SUB front, front, val; +ABS back, back; +ABS front, front; + +SLT isEdgeBack1, tol, back; +SLT isEdgeFront1, tol, front; + + +# For each of the isEdgeBack/isEdgeFront +# vectors, set all components to 1 if an +# edge was found on any component. +DP4 isEdgeBack0, isEdgeBack0, isEdgeBack0; +DP4 isEdgeFront0, isEdgeFront0, isEdgeFront0; +DP4 isEdgeBack1, isEdgeBack1, isEdgeBack1; +DP4 isEdgeFront1, isEdgeFront1, isEdgeFront1; + +# Set isEdge.i if there was an edge +# on any component of the i axis. +MAX isEdge.x, isEdgeBack0, isEdgeFront0; +MAX isEdge.y, isEdgeBack1, isEdgeFront1; + +# Clamp the isEdge values to 1 +SGE isEdge, isEdge, 1; + +# Clear if offsets[i] < 0 +CMP isEdge, offsets, 0, isEdge; diff --git a/fsl/fslview/gl/gl14/edge3D.prog b/fsl/fslview/gl/gl14/edge3D.prog new file mode 100644 index 0000000000000000000000000000000000000000..d0305ca64467c7b87fc5215287330e757b78d2f4 --- /dev/null +++ b/fsl/fslview/gl/gl14/edge3D.prog @@ -0,0 +1,103 @@ +# Inputs: +# coord +# val +# tol +# offsets +# +# Outputs: +# isEdge + +# Something like this would be nice: +# #pragma INPUTS (coord, val, tol, offsets) +# #pragma OUTPUTS (isEdge) + +# This only works for single-channel textures + +TEMP off; +TEMP back; +TEMP front; +TEMP tempCoord; +TEMP isEdgeBack; +TEMP isEdgeFront; +TEMP isEdge; + + +# Test along the x axis +MOV off, offsets; +MUL off, off, {1, 0, 0, 0}; + +# Sample a value behind the coordinate +MOV tempCoord, coord; +ADD tempCoord, tempCoord, off; +TEX back, tempCoord, texture[0], 3D; + +# Sample a value in front of the coordinatea +MOV tempCoord, coord; +SUB tempCoord, tempCoord, off; +TEX front, tempCoord, texture[0], 3D; + +SUB back, back, val; +SUB front, front, val; +ABS back, back; +ABS front, front; + +# Unsafe operation - will only work +# for single channel textures +SLT isEdgeBack.x, tol, back; +SLT isEdgeFront.x, tol, front; + + + +# Test along the y axis +MOV off, offsets; +MUL off, off, {0, 1, 0, 0}; + +# Sample a value behind the coordinate +MOV tempCoord, coord; +ADD tempCoord, tempCoord, off; +TEX back, tempCoord, texture[0], 3D; + +# Sample a value in front of the coordinatea +MOV tempCoord, coord; +SUB tempCoord, tempCoord, off; +TEX front, tempCoord, texture[0], 3D; + +SUB back, back, val; +SUB front, front, val; +ABS back, back; +ABS front, front; + +SLT isEdgeBack.y, tol, back; +SLT isEdgeFront.y, tol, front; + + + +# Test along the z axis +MOV off, offsets; +MUL off, off, {0, 0, 1, 0}; + +# Sample a value behind the coordinate +MOV tempCoord, coord; +ADD tempCoord, tempCoord, off; +TEX back, tempCoord, texture[0], 3D; + +# Sample a value in front of the coordinatea +MOV tempCoord, coord; +SUB tempCoord, tempCoord, off; +TEX front, tempCoord, texture[0], 3D; + +SUB back, back, val; +SUB front, front, val; +ABS back, back; +ABS front, front; + +SLT isEdgeBack.z, tol, back; +SLT isEdgeFront.z, tol, front; + + +# Set isEdge to 1 whereever either +# isEdgeBack or isEdgeFront are 1 +MAX isEdge, isEdgeBack, isEdgeFront; + +# Clear if offsets[i] < 0 +CMP isEdge, offsets, 0, isEdge; diff --git a/fsl/fslview/gl/gl14/gllabel_frag.prog b/fsl/fslview/gl/gl14/gllabel_frag.prog new file mode 100644 index 0000000000000000000000000000000000000000..655652b1df2830accb848c6104d429c3a6b8846b --- /dev/null +++ b/fsl/fslview/gl/gl14/gllabel_frag.prog @@ -0,0 +1,96 @@ +!!ARBfp1.0 +# +# Fragment program used for rendering GLLabel instances. + +TEMP voxCoord; +TEMP lutCoord; +TEMP invNumLabels; +TEMP voxValue; + +PARAM imageShape = program.local[4]; +MOV invNumLabels, program.local[5]; +PARAM outline = program.local[6]; + +# This matrix scales the voxel value to +# lie in a range which is appropriate to +# the current display range +PARAM voxValXform[4] = { program.local[0], + program.local[1], + program.local[2], + program.local[3] }; + +# retrieve the voxel coordinates, +# bail if they are are out of bounds +MOV voxCoord, fragment.texcoord[1]; + +#pragma include test_in_bounds.prog + +# look up image voxel value +# from 3D image texture +TEX voxValue, fragment.texcoord[0], texture[0], 3D; + +# Scale the texture value +# to its original voxel value +MOV lutCoord, voxValue; +MAD lutCoord, lutCoord, voxValXform[0].x, voxValXform[3].x; + +# Scale the voxel value to +# a lut texture coordinate +ADD lutCoord, lutCoord, { 0.5, 0, 0, 0 }; +MUL lutCoord, lutCoord, invNumLabels; + +# look up the appropriate colour +# in the 1D colour map texture +TEX result.color, lutCoord.x, texture[1], 1D; + + +# Test whether this fragment lies +# on an edge between label regions. + +TEMP coord; +TEMP val; +TEMP tol; +TEMP offsets; + +MOV coord, fragment.texcoord[0]; +MOV val, voxValue; +MOV tol, invNumLabels.x; +MUL tol, tol, 0.001; + +MOV offsets, outline.yzww; + +#pragma include edge3D.prog + + +# Figure out if we want to fill the +# fragment, or kill the fragment. +TEMP fill; + +# Set fill to 1 if there is an edge +# along any dimension, 0 otherwise +MAX fill, isEdge.x, isEdge.y; +MAX fill, fill, isEdge.z; + +# +# If outlines are enabled, and +# this fragment is not on an edge, +# kill the fragment. +# +# Simiarly, if outlines are disabled +# and this fragment is on an edge, +# kill the fragment. +# +# This boils down to testing whether +# isEdge == outline.x +# +SUB fill, fill, outline.x; +ABS fill, fill; + +# Fill is now 0 if isEdge == outline.x, +# 1 otherwise + +MAD fill, fill, -1, 0.5; +KIL fill; + + +END diff --git a/fsl/fslview/gl/gl14/gllabel_funcs.py b/fsl/fslview/gl/gl14/gllabel_funcs.py new file mode 100644 index 0000000000000000000000000000000000000000..85027e61f1bdb7b5a48cded9a0bb78f9ad8f1c72 --- /dev/null +++ b/fsl/fslview/gl/gl14/gllabel_funcs.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +# +# gllabel_funcs.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import numpy as np + +import OpenGL.GL as gl +import OpenGL.raw.GL._types as gltypes +import OpenGL.GL.ARB.fragment_program as arbfp +import OpenGL.GL.ARB.vertex_program as arbvp + +import fsl.fslview.gl.shaders as shaders + +import glvolume_funcs + +def compileShaders(self): + if self.vertexProgram is not None: + arbvp.glDeleteProgramsARB(1, gltypes.GLuint(self.vertexProgram)) + + if self.fragmentProgram is not None: + arbfp.glDeleteProgramsARB(1, gltypes.GLuint(self.fragmentProgram)) + + vertShaderSrc = shaders.getVertexShader( self, + sw=self.display.softwareMode) + fragShaderSrc = shaders.getFragmentShader(self, + sw=self.display.softwareMode) + + vertexProgram, fragmentProgram = shaders.compilePrograms( + vertShaderSrc, fragShaderSrc) + + self.vertexProgram = vertexProgram + self.fragmentProgram = fragmentProgram + + +def init(self): + self.vertexProgram = None + self.fragmentProgram = None + + compileShaders( self) + updateShaderState(self) + + +def destroy(self): + arbvp.glDeleteProgramsARB(1, gltypes.GLuint(self.vertexProgram)) + arbfp.glDeleteProgramsARB(1, gltypes.GLuint(self.fragmentProgram)) + + +def updateShaderState(self): + opts = self.displayOpts + + # enable the vertex and fragment programs + gl.glEnable(arbvp.GL_VERTEX_PROGRAM_ARB) + gl.glEnable(arbfp.GL_FRAGMENT_PROGRAM_ARB) + + arbvp.glBindProgramARB(arbvp.GL_VERTEX_PROGRAM_ARB, + self.vertexProgram) + arbfp.glBindProgramARB(arbfp.GL_FRAGMENT_PROGRAM_ARB, + self.fragmentProgram) + + voxValXform = self.imageTexture.voxValXform + shape = list(self.image.shape[:3]) + offsets = opts.outlineWidth / \ + np.array(self.image.shape[:3], dtype=np.float32) + invNumLabels = 1.0 / (opts.lut.max() + 1) + + if opts.transform == 'affine': + minOffset = offsets.min() + offsets = np.array([minOffset] * 3) + else: + offsets[self.zax] = -1 + + if opts.outline: offsets = [1] + list(offsets) + else: offsets = [0] + list(offsets) + + shaders.setVertexProgramVector( 0, shape + [0]) + shaders.setFragmentProgramMatrix(0, voxValXform) + shaders.setFragmentProgramVector(4, shape + [0]) + shaders.setFragmentProgramVector(5, [invNumLabels, 0, 0, 0]) + shaders.setFragmentProgramVector(6, offsets) + + gl.glDisable(arbvp.GL_VERTEX_PROGRAM_ARB) + gl.glDisable(arbfp.GL_FRAGMENT_PROGRAM_ARB) + + +preDraw = glvolume_funcs.preDraw +draw = glvolume_funcs.draw +drawAll = glvolume_funcs.drawAll +postDraw = glvolume_funcs.postDraw diff --git a/fsl/fslview/gl/gl14/gllabel_sw_frag.prog b/fsl/fslview/gl/gl14/gllabel_sw_frag.prog new file mode 100644 index 0000000000000000000000000000000000000000..580fbca034626757427a2c5bb153bb1bdda0bb43 --- /dev/null +++ b/fsl/fslview/gl/gl14/gllabel_sw_frag.prog @@ -0,0 +1,37 @@ +!!ARBfp1.0 +# +# Fragment program used for rendering GLLabel instances. + +TEMP lutCoord; +TEMP invNumLabels; +TEMP voxValue; + +MOV invNumLabels, program.local[5]; + +# This matrix scales the voxel value to +# lie in a range which is appropriate to +# the current display range +PARAM voxValXform[4] = { program.local[0], + program.local[1], + program.local[2], + program.local[3] }; + +# look up image voxel value +# from 3D image texture +TEX voxValue, fragment.texcoord[0], texture[0], 3D; + +# Scale the texture value +# to its original voxel value +MOV lutCoord, voxValue; +MAD lutCoord, lutCoord, voxValXform[0].x, voxValXform[3].x; + +# Scale the voxel value to +# a lut texture coordinate +ADD lutCoord, lutCoord, { 0.5, 0, 0, 0 }; +MUL lutCoord, lutCoord, invNumLabels; + +# look up the appropriate colour +# in the 1D colour map texture +TEX result.color, lutCoord.x, texture[1], 1D; + +END diff --git a/fsl/fslview/gl/gl14/gllinevector_funcs.py b/fsl/fslview/gl/gl14/gllinevector_funcs.py index 7204194878c9f619544fca69763d09bf0086c7de..38c0cf9ff065ca5cb25fd34cbb79da7fbae375b7 100644 --- a/fsl/fslview/gl/gl14/gllinevector_funcs.py +++ b/fsl/fslview/gl/gl14/gllinevector_funcs.py @@ -30,29 +30,34 @@ def init(self): self.lineVertices = None self._vertexResourceName = '{}_{}_vertices'.format( - type(self).__name__, id(self.image)) + type(self).__name__, id(self.image)) compileShaders( self) updateShaderState(self) updateVertices( self) - display = self.display - opts = self.opts + opts = self.displayOpts def vertexUpdate(*a): updateVertices(self) self.onUpdate() - display.addListener('resolution', self.name, vertexUpdate) - opts .addListener('directed', self.name, vertexUpdate) + name = '{}_vertices'.format(self.name) + + opts.addListener('transform', name, vertexUpdate, weak=False) + opts.addListener('resolution', name, vertexUpdate, weak=False) + opts.addListener('directed', name, vertexUpdate, weak=False) def destroy(self): arbvp.glDeleteProgramsARB(1, gltypes.GLuint(self.vertexProgram)) arbfp.glDeleteProgramsARB(1, gltypes.GLuint(self.fragmentProgram)) - self.display.removeListener('resolution', self.name) - self.opts .removeListener('directed', self.name) + name = '{}_vertices'.format(self.name) + + self.displayOpts.removeListener('transform', name) + self.displayOpts.removeListener('resolution', name) + self.displayOpts.removeListener('directed', name) glresources.delete(self._vertexResourceName) @@ -80,17 +85,16 @@ def compileShaders(self): def updateVertices(self): - image = self.image - display = self.display - opts = self.opts + image = self.image + opts = self.displayOpts if self.lineVertices is None: self.lineVertices = glresources.get( self._vertexResourceName, gllinevector.GLLineVertices, self) - newHash = (hash(display.transform) ^ - hash(display.resolution) ^ - hash(opts .directed)) + newHash = (hash(opts.transform) ^ + hash(opts.resolution) ^ + hash(opts.directed)) if hash(self.lineVertices) != newHash: @@ -161,7 +165,7 @@ def draw(self, zpos, xform=None): gl.glTexCoordPointer(3, gl.GL_FLOAT, 0, texCoords) vertices = vertices.ravel('C') - v2d = self.display.getTransform('voxel', 'display') + v2d = opts.getTransform('voxel', 'display') if xform is None: xform = v2d else: xform = transform.concat(v2d, xform) diff --git a/fsl/fslview/gl/gl14/glmodel_frag.prog b/fsl/fslview/gl/gl14/glmodel_frag.prog new file mode 100644 index 0000000000000000000000000000000000000000..ced6c8ffc6237e51a817a159586ff93b85887daa --- /dev/null +++ b/fsl/fslview/gl/gl14/glmodel_frag.prog @@ -0,0 +1,36 @@ +!!ARBfp1.0 +# +# Fragment program used for rendering GLModel instances. +# +# Inputs: +# fragment.texcoord[0] +# +# program.local[0] - Offsets +# + +TEMP val; +TEMP coord; +TEMP output; + +# edge detection tolerance is +# 1 / 255 - a colour change of 1 bit +PARAM tol = 0.00392156862745098; +PARAM offsets = program.local[0]; + +MOV coord, fragment.texcoord[0]; + +TEX val, coord, texture[0], 2D; + +#pragma include edge2D.prog + +# Kill the fragment if +# it is not on an edge +TEMP fill; +MAX fill, isEdge.x, isEdge.y; +SUB fill, fill, 0.1; + +KIL fill; + +MOV result.color, val; + +END \ No newline at end of file diff --git a/fsl/fslview/gl/gl14/glmodel_funcs.py b/fsl/fslview/gl/gl14/glmodel_funcs.py new file mode 100644 index 0000000000000000000000000000000000000000..f65179f2f43e44ca5cec9f2c18695626265aa436 --- /dev/null +++ b/fsl/fslview/gl/gl14/glmodel_funcs.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +# +# glmodel_funcs.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + + +import OpenGL.GL as gl +import OpenGL.raw.GL._types as gltypes +import OpenGL.GL.ARB.fragment_program as arbfp +import OpenGL.GL.ARB.vertex_program as arbvp + +import fsl.fslview.gl.shaders as shaders + + +def compileShaders(self): + + vertShaderSrc = shaders.getVertexShader( self) + fragShaderSrc = shaders.getFragmentShader(self) + + vertexProgram, fragmentProgram = shaders.compilePrograms( + vertShaderSrc, fragShaderSrc) + + self.vertexProgram = vertexProgram + self.fragmentProgram = fragmentProgram + + +def destroy(self): + arbvp.glDeleteProgramsARB(1, gltypes.GLuint(self.vertexProgram)) + arbfp.glDeleteProgramsARB(1, gltypes.GLuint(self.fragmentProgram)) + + +def updateShaders(self): + + offsets = self.getOutlineOffsets() + + loadShaders(self) + shaders.setFragmentProgramVector(0, list(offsets) + [0, 0]) + unloadShaders(self) + + +def loadShaders(self): + gl.glEnable(arbvp.GL_VERTEX_PROGRAM_ARB) + gl.glEnable(arbfp.GL_FRAGMENT_PROGRAM_ARB) + + arbvp.glBindProgramARB(arbvp.GL_VERTEX_PROGRAM_ARB, + self.vertexProgram) + arbfp.glBindProgramARB(arbfp.GL_FRAGMENT_PROGRAM_ARB, + self.fragmentProgram) + + +def unloadShaders(self): + gl.glDisable(arbvp.GL_VERTEX_PROGRAM_ARB) + gl.glDisable(arbfp.GL_FRAGMENT_PROGRAM_ARB) diff --git a/fsl/fslview/gl/gl14/glmodel_vert.prog b/fsl/fslview/gl/gl14/glmodel_vert.prog new file mode 100644 index 0000000000000000000000000000000000000000..c1ff267a1f59c5c28f29f6e4e6a36a9d66ce5a20 --- /dev/null +++ b/fsl/fslview/gl/gl14/glmodel_vert.prog @@ -0,0 +1,24 @@ +!!ARBvp1.0 +# +# Vertex program for rendering GLModel instances. +# +# Inputs: +# state.matrix.mvp +# vertex.position +# vertex.texcoord[0] +# +# Outputs: +# result.position +# result.texcoord[0] +# + +# Transform the vertex position into display coordinates +DP4 result.position.x, state.matrix.mvp.row[0], vertex.position; +DP4 result.position.y, state.matrix.mvp.row[1], vertex.position; +DP4 result.position.z, state.matrix.mvp.row[2], vertex.position; +DP4 result.position.w, state.matrix.mvp.row[3], vertex.position; + +# Copy the vertex texture coordinate +MOV result.texcoord[0], vertex.texcoord[0]; + +END diff --git a/fsl/fslview/gl/gl14/glvolume_frag.prog b/fsl/fslview/gl/gl14/glvolume_frag.prog index 4de3ebdece1915d5c16066d6c1aad06fff870beb..99d0e41ad6cca5a289173141308dc9771ba86885 100644 --- a/fsl/fslview/gl/gl14/glvolume_frag.prog +++ b/fsl/fslview/gl/gl14/glvolume_frag.prog @@ -31,8 +31,12 @@ # # program.local[5] - Vector containing clipping values - voxels with a # value below the low threshold (x), or above the -# high threshold (y) will not be shown. Assumed to -# be normalised to the image texture value range. +# high threshold (y) will not be shown. The (z) +# component determines the clipping direction - pass +# in -1 for the above behaviour, or +1 to invert +# this behaviour (i.e. to clip values that are within +# the range). Clipping values are assumed to be +# normalised to the image texture value range. # # # Outputs: @@ -43,7 +47,8 @@ # TEMP voxCoord; -TEMP voxClip; +TEMP voxClipLo; +TEMP voxClipHi; TEMP voxValue; PARAM imageShape = program.local[4]; @@ -67,16 +72,29 @@ MOV voxCoord, fragment.texcoord[1]; # from 3D image texture TEX voxValue, fragment.texcoord[0], texture[0], 3D; -# If the voxel value is outside the -# clipping range, don't draw it - # Test the low clipping range -SUB voxClip, voxValue.x, clipping.x; -KIL voxClip; +SUB voxClipLo, voxValue.x, clipping.x; # And the high clipping range -SUB voxClip, clipping.y, voxValue.x; -KIL voxClip; +SUB voxClipHi, voxValue.x, clipping.y; + +# Multiply the low/high results - after +# this, voxClipLo will be positive if +# the value is outside of the clipping +# range, or negative if the value is +# within the clipping range +MUL voxClipLo, voxClipLo, voxClipHi; + +# Multiply by the clipping.z setting - +# this will invert the sign if normal +# clipping is active +MUL voxClipLo, voxClipLo, clipping.z; + +# If the voxel value is outside +# the clipping range (or inside, +# if clipping is inverted), don't +# draw it +KIL voxClipLo; # Scale voxel value according # to the current display range diff --git a/fsl/fslview/gl/gl14/glvolume_funcs.py b/fsl/fslview/gl/gl14/glvolume_funcs.py index 0d42cd78be04ae5b7bebf65d2333e95ad46f75dc..1b998ac57cdfb0422c8896975934b3cebae30c7d 100644 --- a/fsl/fslview/gl/gl14/glvolume_funcs.py +++ b/fsl/fslview/gl/gl14/glvolume_funcs.py @@ -99,18 +99,19 @@ def updateShaderState(self): # And the clipping range, normalised # to the image texture value range - clipLo = opts.clippingRange[0] * \ + invClip = 1 if opts.invertClipping else -1 + clipLo = opts.clippingRange[0] * \ self.imageTexture.invVoxValXform[0, 0] + \ self.imageTexture.invVoxValXform[3, 0] - clipHi = opts.clippingRange[1] * \ + clipHi = opts.clippingRange[1] * \ self.imageTexture.invVoxValXform[0, 0] + \ self.imageTexture.invVoxValXform[3, 0] - + shaders.setVertexProgramVector( 0, shape + [0]) shaders.setFragmentProgramMatrix(0, voxValXform) shaders.setFragmentProgramVector(4, shape + [0]) - shaders.setFragmentProgramVector(5, [clipLo, clipHi, 0, 0]) + shaders.setFragmentProgramVector(5, [clipLo, clipHi, invClip, 0]) gl.glDisable(arbvp.GL_VERTEX_PROGRAM_ARB) gl.glDisable(arbfp.GL_FRAGMENT_PROGRAM_ARB) diff --git a/fsl/fslview/gl/gl14/sobel.prog b/fsl/fslview/gl/gl14/sobel.prog new file mode 100644 index 0000000000000000000000000000000000000000..7e1141662b5eb110471c11bd68f3c49c3fd61241 --- /dev/null +++ b/fsl/fslview/gl/gl14/sobel.prog @@ -0,0 +1,57 @@ +# Inputs: +# - coord +# - invShape +# - output + +TEMP s1; +TEMP s2; +TEMP s3; +TEMP s4; +TEMP sx; +TEMP sy; +TEMP temp1; +TEMP temp2; + +MUL invShape, invShape, { -1, -1, 0, 0 }; +ADD temp1, coord, invShape; +TEX s1, temp1, texture[0], 2D; + +MUL invShape, invShape, { -1, 1, 0, 0 }; +ADD temp1, coord, invShape; +TEX s2, temp1, texture[0], 2D; + +MUL invShape, invShape, { -1, -1, 0, 0 }; +ADD temp1, coord, invShape; +TEX s3, temp1, texture[0], 2D; + +MUL invShape, invShape, { -1, 1, 0, 0 }; +ADD temp1, coord, invShape; +TEX s4, temp1, texture[0], 2D; + +ADD sx, s4, s3; +SUB sx, sx, s2; +SUB sx, sx, s1; +MUL sx, sx, { 4, 4, 4, 4 }; + +ADD sy, s2, s4; +SUB sy, sy, s1; +SUB sy, sy, s3; +MUL sy, sy, { 4, 4, 4, 4 }; + +MUL sx, sx, sx; +MUL sy, sy, sy; + +MOV temp1, sx; +ADD temp1, temp1, sy; + +MOV temp2, temp1; + +RSQ temp1.x, temp1.x; +RSQ temp1.y, temp1.y; +RSQ temp1.z, temp1.z; +RSQ temp1.w, temp1.w; + +MUL temp1, temp1, temp2; + + +MOV output, temp1; diff --git a/fsl/fslview/gl/gl21/__init__.py b/fsl/fslview/gl/gl21/__init__.py index 360c9cafe687bebc15183151878599ac7ae73b12..2c0843a8bb6d8812a09c5e55dbb1081c5b31e506 100644 --- a/fsl/fslview/gl/gl21/__init__.py +++ b/fsl/fslview/gl/gl21/__init__.py @@ -8,3 +8,5 @@ import glvolume_funcs import glrgbvector_funcs import gllinevector_funcs +import glmodel_funcs +import gllabel_funcs diff --git a/fsl/fslview/gl/gl21/edge.glsl b/fsl/fslview/gl/gl21/edge.glsl new file mode 100644 index 0000000000000000000000000000000000000000..2babaa05766cd65513c1bdeb808580b813f9a941 --- /dev/null +++ b/fsl/fslview/gl/gl21/edge.glsl @@ -0,0 +1,81 @@ +/* + * Simple edge-detection functions. + * + * + */ + +/* for single-channel 3D textures */ +bool edge3D(sampler3D tex, vec3 coord, float val, float tol, vec3 offsets) { + + vec3 off; + + for (int i = 0; i < 3; i++) { + + if (offsets[i] <= 0) + continue; + + off = vec3(0, 0, 0); + off[i] = offsets[i]; + + float back = texture3D(tex, coord + off).r; + float front = texture3D(tex, coord - off).r; + + if (abs(val - back) > tol || + abs(val - front) > tol) { + return true; + } + } + + return false; +} + +/* for single-channel 2D textures */ +bool edge2D(sampler2D tex, vec2 coord, float val, float tol, vec2 offsets) { + + vec2 off; + + for (int i = 0; i < 2; i++) { + + if (offsets[i] <= 0) + continue; + + off = vec2(0, 0); + off[i] = offsets[i]; + + float back = texture2D(tex, coord + off).r; + float front = texture2D(tex, coord - off).r; + + if (abs(val - back) > tol || + abs(val - front) > tol) { + return true; + } + } + + return false; +} + + +/* for multi-channel 2D textures */ +bool edge2D(sampler2D tex, vec2 coord, vec4 val, vec4 tol, vec2 offsets) { + + vec2 off; + + for (int i = 0; i < 2; i++) { + + if (offsets[i] <= 0) + continue; + + off = vec2(0, 0); + off[i] = offsets[i]; + + vec4 back = texture2D(tex, coord + off); + vec4 front = texture2D(tex, coord - off); + + if (any(greaterThan(abs(val - back), tol)) || + any(greaterThan(abs(val - front), tol))) { + return true; + } + } + + return false; +} diff --git a/fsl/fslview/gl/gl21/gllabel_frag.glsl b/fsl/fslview/gl/gl21/gllabel_frag.glsl new file mode 100644 index 0000000000000000000000000000000000000000..173c9d12d3ece495fc9d7b9aa9befaf26a1ff879 --- /dev/null +++ b/fsl/fslview/gl/gl21/gllabel_frag.glsl @@ -0,0 +1,67 @@ +#version 120 + +#pragma include edge.glsl +#pragma include spline_interp.glsl +#pragma include test_in_bounds.glsl + +uniform sampler3D imageTexture; + +uniform sampler1D lutTexture; + +uniform mat4 voxValXform; + +uniform vec3 imageShape; + +uniform float numLabels; + +uniform bool useSpline; + +uniform bool outline; + +uniform vec3 outlineOffsets; + +varying vec3 fragVoxCoord; + +varying vec3 fragTexCoord; + + +void main(void) { + + vec3 voxCoord = fragVoxCoord; + + if (!test_in_bounds(voxCoord, imageShape)) { + + gl_FragColor = vec4(0, 0, 0, 0); + return; + } + + float voxValue; + voxValue = texture3D(imageTexture, fragTexCoord).r; + + float lutCoord = ((voxValXform * vec4(voxValue, 0, 0, 1)).x + 0.5) / numLabels; + + if (lutCoord < 0 || lutCoord > 1) { + gl_FragColor.a = 0.0; + return; + } + + vec4 colour = texture1D(lutTexture, lutCoord); + + float tol = 0.001 / numLabels; + bool isEdge = edge3D(imageTexture, fragTexCoord, voxValue, tol, outlineOffsets); + + /* + * I want the fragment to be filled if outlines + * are enabled, and the fragment lies on an edge, + * or if outlines are disabled and the fragment + * does not lie on an edge. The corresponding + * boolean logic: + * + * (isEdge && outline) || !(isEdge || outline); + * + * is a negated exclusive or, which reduces + * down to a simple equality test ... + */ + if (isEdge == outline) gl_FragColor = colour; + else gl_FragColor.a = 0.0; +} diff --git a/fsl/fslview/gl/gl21/gllabel_funcs.py b/fsl/fslview/gl/gl21/gllabel_funcs.py new file mode 100644 index 0000000000000000000000000000000000000000..f9121cdfb7ebe12d3ddb938a5ca00554379b7ff8 --- /dev/null +++ b/fsl/fslview/gl/gl21/gllabel_funcs.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python +# +# gllabel_funcs.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import numpy as np +import OpenGL.GL as gl +import OpenGL.raw.GL._types as gltypes + +import fsl.fslview.gl.shaders as shaders +import glvolume_funcs + +def compileShaders(self): + + if self.shaders is not None: + gl.glDeleteProgram(self.shaders) + + vertShaderSrc = shaders.getVertexShader( self, + sw=self.display.softwareMode) + fragShaderSrc = shaders.getFragmentShader(self, + sw=self.display.softwareMode) + self.shaders = shaders.compileShaders(vertShaderSrc, fragShaderSrc) + + self.vertexPos = gl.glGetAttribLocation( self.shaders, + 'vertex') + self.voxCoordPos = gl.glGetAttribLocation( self.shaders, + 'voxCoord') + self.texCoordPos = gl.glGetAttribLocation( self.shaders, + 'texCoord') + self.imageTexturePos = gl.glGetUniformLocation(self.shaders, + 'imageTexture') + self.lutTexturePos = gl.glGetUniformLocation(self.shaders, + 'lutTexture') + self.voxValXformPos = gl.glGetUniformLocation(self.shaders, + 'voxValXform') + self.imageShapePos = gl.glGetUniformLocation(self.shaders, + 'imageShape') + self.useSplinePos = gl.glGetUniformLocation(self.shaders, + 'useSpline') + self.numLabelsPos = gl.glGetUniformLocation(self.shaders, + 'numLabels') + self.outlinePos = gl.glGetUniformLocation(self.shaders, + 'outline') + self.outlineOffsetsPos = gl.glGetUniformLocation(self.shaders, + 'outlineOffsets') + + +def init(self): + self.shaders = None + + compileShaders( self) + updateShaderState(self) + + self.vertexAttrBuffer = gl.glGenBuffers(1) + + +def destroy(self): + gl.glDeleteBuffers(1, gltypes.GLuint(self.vertexAttrBuffer)) + gl.glDeleteProgram(self.shaders) + + +def updateShaderState(self): + + opts = self.displayOpts + + gl.glUseProgram(self.shaders) + + gl.glUniform1f( self.outlinePos, opts.outline) + gl.glUniform1f( self.numLabelsPos, opts.lut.max() + 1) + gl.glUniform3fv(self.imageShapePos, 1, np.array(self.image.shape[:3], + dtype=np.float32)) + + vvx = self.imageTexture.voxValXform.ravel('C') + gl.glUniformMatrix4fv(self.voxValXformPos, 1, False, vvx) + + outlineOffsets = opts.outlineWidth / \ + np.array(self.image.shape[:3], dtype=np.float32) + + if opts.transform == 'affine': + minOffset = outlineOffsets.min() + outlineOffsets = np.array([minOffset] * 3) + else: + outlineOffsets[self.zax] = -1 + + gl.glUniform3fv(self.outlineOffsetsPos, 1, outlineOffsets) + + gl.glUniform1i(self.imageTexturePos, 0) + gl.glUniform1i(self.lutTexturePos, 1) + + gl.glUseProgram(0) + + +preDraw = glvolume_funcs.preDraw +draw = glvolume_funcs.draw +drawAll = glvolume_funcs.drawAll +postDraw = glvolume_funcs.postDraw diff --git a/fsl/fslview/gl/gl21/gllabel_sw_frag.glsl b/fsl/fslview/gl/gl21/gllabel_sw_frag.glsl new file mode 100644 index 0000000000000000000000000000000000000000..00bf090784e2ddef010f82c72dff6089126e30cf --- /dev/null +++ b/fsl/fslview/gl/gl21/gllabel_sw_frag.glsl @@ -0,0 +1,17 @@ +#version 120 + +uniform sampler3D imageTexture; +uniform sampler1D lutTexture; +uniform mat4 voxValXform; +uniform vec3 imageShape; +uniform float numLabels; +varying vec3 fragTexCoord; + + +void main(void) { + + float voxValue = texture3D(imageTexture, fragTexCoord).r; + float lutCoord = ((voxValXform * vec4(voxValue, 0, 0, 1)).x + 0.5) / numLabels; + + gl_FragColor = texture1D(lutTexture, lutCoord); +} diff --git a/fsl/fslview/gl/gl21/gllinevector_funcs.py b/fsl/fslview/gl/gl21/gllinevector_funcs.py index b0967f35cf79212e171a8b00b4de0b740d39c92a..678fe5f21b3b6309f70c0fd36a161764567c94b0 100644 --- a/fsl/fslview/gl/gl21/gllinevector_funcs.py +++ b/fsl/fslview/gl/gl21/gllinevector_funcs.py @@ -23,17 +23,20 @@ log = logging.getLogger(__name__) def init(self): - self.shaders = None - self.vertexBuffer = gl.glGenBuffers(1) - self.texCoordBuffer = gl.glGenBuffers(1) - self.vertexIDBuffer = gl.glGenBuffers(1) - self.lineVertices = None + self.shaders = None + self.vertexBuffer = gl.glGenBuffers(1) + self.texCoordBuffer = gl.glGenBuffers(1) + self.vertexIDBuffer = gl.glGenBuffers(1) + self.lineVertices = None + + # False -> hardware shaders are in use + # True -> software shaders are in use + self.swShadersInUse = False self._vertexResourceName = '{}_{}_vertices'.format( type(self).__name__, id(self.image)) - - display = self.display - opts = self.opts + + opts = self.displayOpts def vertexUpdate(*a): @@ -41,9 +44,11 @@ def init(self): self.updateShaderState() self.onUpdate() - display.addListener('transform', self.name, vertexUpdate) - display.addListener('resolution', self.name, vertexUpdate) - opts .addListener('directed', self.name, vertexUpdate) + name = '{}_vertices'.format(self.name) + + opts.addListener('transform', name, vertexUpdate, weak=False) + opts.addListener('resolution', name, vertexUpdate, weak=False) + opts.addListener('directed', name, vertexUpdate, weak=False) compileShaders( self) updateShaderState(self) @@ -55,9 +60,10 @@ def destroy(self): gl.glDeleteBuffers(1, gltypes.GLuint(self.texCoordBuffer)) gl.glDeleteProgram(self.shaders) - self.display.removeListener('transform', self.name) - self.display.removeListener('resolution', self.name) - self.opts .removeListener('directed', self.name) + name = '{}_vertices'.format(self.name) + self.displayOpts.removeListener('transform', name) + self.displayOpts.removeListener('resolution', name) + self.displayOpts.removeListener('directed', name) if self.display.softwareMode: glresources.delete(self._vertexResourceName) @@ -75,6 +81,8 @@ def compileShaders(self): self.shaders = shaders.compileShaders(vertShaderSrc, fragShaderSrc) + self.swShadersInUse = self.display.softwareMode + self.vertexPos = gl.glGetAttribLocation( self.shaders, 'vertex') self.vertexIDPos = gl.glGetAttribLocation( self.shaders, @@ -123,7 +131,7 @@ def updateShaderState(self): # so we'll just use the xColourTexture matrix cmapXform = self.xColourTexture.getCoordinateTransform() voxValXform = self.imageTexture.voxValXform - useSpline = display.interpolation == 'spline' + useSpline = False imageShape = np.array(self.image.shape[:3], dtype=np.float32) voxValXform = np.array(voxValXform, dtype=np.float32).ravel('C') @@ -149,8 +157,8 @@ def updateShaderState(self): directed = opts.directed imageDims = self.image.pixdim[:3] - d2vMat = display.getTransform('display', 'voxel') - v2dMat = display.getTransform('voxel', 'display') + d2vMat = opts.getTransform('display', 'voxel') + v2dMat = opts.getTransform('voxel', 'display') imageDims = np.array(imageDims, dtype=np.float32) d2vMat = np.array(d2vMat, dtype=np.float32).ravel('C') @@ -169,7 +177,7 @@ def updateVertices(self): image = self.image display = self.display - opts = self.opts + opts = self.displayOpts if not display.softwareMode: @@ -184,9 +192,9 @@ def updateVertices(self): self.lineVertices = glresources.get( self._vertexResourceName, gllinevector.GLLineVertices, self) - newHash = (hash(display.transform) ^ - hash(display.resolution) ^ - hash(opts .directed)) + newHash = (hash(opts.transform) ^ + hash(opts.resolution) ^ + hash(opts.directed)) if hash(self.lineVertices) != newHash: @@ -208,6 +216,11 @@ def draw(self, zpos, xform=None): def softwareDraw(self, zpos, xform=None): + # Software shaders have not yet been compiled - + # we can't draw until they're updated + if not self.swShadersInUse: + return + opts = self.displayOpts vertices, texCoords = self.lineVertices.getVertices(self, zpos) @@ -217,7 +230,7 @@ def softwareDraw(self, zpos, xform=None): vertices = vertices .ravel('C') texCoords = texCoords.ravel('C') - v2d = self.display.getTransform('voxel', 'display') + v2d = opts.getTransform('voxel', 'display') if xform is None: xform = v2d else: xform = transform.concat(v2d, xform) @@ -251,15 +264,17 @@ def softwareDraw(self, zpos, xform=None): def hardwareDraw(self, zpos, xform=None): + if self.swShadersInUse: + return + image = self.image - display = self.display opts = self.displayOpts - v2dMat = self.display.getTransform('voxel', 'display') - resolution = np.array([display.resolution] * 3) + v2dMat = opts.getTransform('voxel', 'display') + resolution = np.array([opts.resolution] * 3) - if display.transform == 'id': + if opts.transform == 'id': resolution = resolution / min(image.pixdim[:3]) - elif display.transform == 'pixdim': + elif opts.transform == 'pixdim': resolution = map(lambda r, p: max(r, p), resolution, image.pixdim[:3]) vertices = glroutines.calculateSamplePoints( diff --git a/fsl/fslview/gl/gl21/glmodel_frag.glsl b/fsl/fslview/gl/gl21/glmodel_frag.glsl new file mode 100644 index 0000000000000000000000000000000000000000..7c13880b0724426cee752c1182bc48ea948a9aac --- /dev/null +++ b/fsl/fslview/gl/gl21/glmodel_frag.glsl @@ -0,0 +1,16 @@ +#version 120 + +#pragma include edge.glsl + +uniform sampler2D tex; +uniform vec2 offsets; +varying vec2 texCoord; + +void main(void) { + + vec4 colour = texture2D(tex, texCoord); + vec4 tol = 1.0 / vec4(255, 255, 255, 255); + + if (edge2D(tex, texCoord, colour, tol, offsets)) gl_FragColor = colour; + else gl_FragColor.a = 0.0; +} diff --git a/fsl/fslview/gl/gl21/glmodel_funcs.py b/fsl/fslview/gl/gl21/glmodel_funcs.py new file mode 100644 index 0000000000000000000000000000000000000000..f8eddb959bfdcd65a7f7ebfe69edefeb892abd01 --- /dev/null +++ b/fsl/fslview/gl/gl21/glmodel_funcs.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python +# +# glmodel_funcs.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import numpy as np +import OpenGL.GL as gl + +import fsl.fslview.gl.shaders as shaders + + +def compileShaders(self): + vertShaderSrc = shaders.getVertexShader( self) + fragShaderSrc = shaders.getFragmentShader(self) + self.shaders = shaders.compileShaders(vertShaderSrc, fragShaderSrc) + + self.texPos = gl.glGetUniformLocation(self.shaders, 'tex') + self.offsetPos = gl.glGetUniformLocation(self.shaders, 'offsets') + + +def destroy(self): + gl.glDeleteProgram(self.shaders) + + +def updateShaders(self): + + width, height = self._renderTexture.getSize() + outlineWidth = self.opts.outlineWidth + + # outlineWidth is a value between 0.0 and 1.0 - + # we use this value so that it effectly sets the + # outline to between 0% and 10% of the model + # width/height (whichever is smaller) + outlineWidth *= 10 + offsets = 2 * [min(outlineWidth / width, outlineWidth / height)] + offsets = np.array(offsets, dtype=np.float32) + + gl.glUseProgram(self.shaders) + gl.glUniform1i( self.texPos, 0) + gl.glUniform2fv(self.offsetPos, 1, offsets) + gl.glUseProgram(0) + + +def loadShaders(self): + gl.glUseProgram(self.shaders) + + +def unloadShaders(self): + gl.glUseProgram(0) diff --git a/fsl/fslview/gl/gl21/glmodel_vert.glsl b/fsl/fslview/gl/gl21/glmodel_vert.glsl new file mode 100644 index 0000000000000000000000000000000000000000..2efb49a2754f52f7578d5ae24ddae10df5bbd6e4 --- /dev/null +++ b/fsl/fslview/gl/gl21/glmodel_vert.glsl @@ -0,0 +1,15 @@ +/* + * Vertex shader for GLModel instances. + * + * Author: Paul McCarthy <pauldmccarthy@gmail.com> + */ +#version 120 + +varying vec2 texCoord; + +void main(void) { + + texCoord = gl_MultiTexCoord0.xy; + + gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex; +} diff --git a/fsl/fslview/gl/gl21/glrgbvector_funcs.py b/fsl/fslview/gl/gl21/glrgbvector_funcs.py index 65378fd73906d1a6ffade780efc00a7a476b554d..c9896ca10e40f45ad03608009454989c1cc538b2 100644 --- a/fsl/fslview/gl/gl21/glrgbvector_funcs.py +++ b/fsl/fslview/gl/gl21/glrgbvector_funcs.py @@ -69,14 +69,13 @@ def compileShaders(self): def updateShaderState(self): - display = self.display - opts = self.displayOpts + opts = self.displayOpts # The coordinate transformation matrices for # each of the three colour textures are identical voxValXform = self.imageTexture.voxValXform cmapXform = self.xColourTexture.getCoordinateTransform() - useSpline = display.interpolation == 'spline' + useSpline = opts.interpolation == 'spline' imageShape = np.array(self.image.shape, dtype=np.float32) gl.glUseProgram(self.shaders) diff --git a/fsl/fslview/gl/gl21/glvolume_frag.glsl b/fsl/fslview/gl/gl21/glvolume_frag.glsl index d24efa14d0d59847f23003312fcd5ad728a249bb..98e952ca940d7374fb5cf819e6d656e344d07791 100644 --- a/fsl/fslview/gl/gl21/glvolume_frag.glsl +++ b/fsl/fslview/gl/gl21/glvolume_frag.glsl @@ -46,6 +46,12 @@ uniform float clipLow; */ uniform float clipHigh; +/* + * Invert clipping behaviour - clip voxels + * that are inside the clipLow/High bounds. + */ +uniform bool invertClip; + /* * Image voxel coordinates. */ @@ -85,8 +91,9 @@ void main(void) { /* * Clip out of range voxel values */ - if (voxValue < clipLow || voxValue > clipHigh) { + if ((!invertClip && (voxValue < clipLow || voxValue > clipHigh)) || + (invertClip && (voxValue >= clipLow && voxValue <= clipHigh))) { gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0); return; } diff --git a/fsl/fslview/gl/gl21/glvolume_funcs.py b/fsl/fslview/gl/gl21/glvolume_funcs.py index 36066ab9f4a0b75dba363e7d4b90e95ea78a9d61..e99987dff8f5d83fec5fa60b895925cf83d4f483 100644 --- a/fsl/fslview/gl/gl21/glvolume_funcs.py +++ b/fsl/fslview/gl/gl21/glvolume_funcs.py @@ -81,7 +81,9 @@ def compileShaders(self): self.clipLowPos = gl.glGetUniformLocation(self.shaders, 'clipLow') self.clipHighPos = gl.glGetUniformLocation(self.shaders, - 'clipHigh') + 'clipHigh') + self.invertClipPos = gl.glGetUniformLocation(self.shaders, + 'invertClip') def init(self): @@ -108,15 +110,14 @@ def updateShaderState(self): current display properties. """ - display = self.display - opts = self.displayOpts + opts = self.displayOpts gl.glUseProgram(self.shaders) # bind the current interpolation setting, # image shape, and image->screen axis # mappings - gl.glUniform1f( self.useSplinePos, display.interpolation == 'spline') + gl.glUniform1f( self.useSplinePos, opts.interpolation == 'spline') gl.glUniform3fv(self.imageShapePos, 1, np.array(self.image.shape, dtype=np.float32)) @@ -124,20 +125,18 @@ def updateShaderState(self): # range, but the shader needs them to be in image # texture value range (0.0 - 1.0). So let's scale # them. - clipLow = opts.clippingRange[0] * \ - self.imageTexture.invVoxValXform[0, 0] + \ - self.imageTexture.invVoxValXform[3, 0] - clipHigh = opts.clippingRange[1] * \ - self.imageTexture.invVoxValXform[0, 0] + \ - self.imageTexture.invVoxValXform[3, 0] - - gl.glUniform1f(self.clipLowPos, clipLow) - gl.glUniform1f(self.clipHighPos, clipHigh) - - # Bind transformation matrices to transform - # display coordinates to voxel coordinates, - # and to scale voxel values to colour map - # texture coordinates + xform = self.imageTexture.invVoxValXform + clipLow = opts.clippingRange[0] * xform[0, 0] + xform[3, 0] + clipHigh = opts.clippingRange[1] * xform[0, 0] + xform[3, 0] + + gl.glUniform1f(self.clipLowPos, clipLow) + gl.glUniform1f(self.clipHighPos, clipHigh) + gl.glUniform1f(self.invertClipPos, opts.invertClipping) + + # Bind transformation matrix to transform + # from image texture values to voxel values, + # and and to scale said voxel values to + # colour map texture coordinates vvx = transform.concat(self.imageTexture.voxValXform, self.colourTexture.getCoordinateTransform()) vvx = np.array(vvx, dtype=np.float32).ravel('C') @@ -187,10 +186,12 @@ def _prepareVertexAttributes(self, vertices, voxCoords, texCoords): gl.glVertexAttribPointer( texPos, 3, gl.GL_FLOAT, gl.GL_FALSE, 36, ctypes.c_void_p(24)) - # The fast shader does not use voxel coordinates - # so, on some GL drivers, attempting to bind it - # will cause an error - if not self.display.softwareMode: + # The sw shader does not use voxel coordinates + # so attempting to binding would raise an error. + # So we only attempt to bind if softwareMode is + # false, and there is a shader uniform position + # for the voxel coordinates available. + if not self.display.softwareMode and voxPos != -1: gl.glVertexAttribPointer( voxPos, 3, gl.GL_FLOAT, gl.GL_FALSE, 36, ctypes.c_void_p(12)) gl.glEnableVertexAttribArray(self.voxCoordPos) @@ -246,7 +247,9 @@ def postDraw(self): gl.glDisableVertexAttribArray(self.vertexPos) gl.glDisableVertexAttribArray(self.texCoordPos) - if not self.display.softwareMode: + # See comments in _prepareVertexAttributes + # about softwareMode/voxel coordinates + if not self.display.softwareMode and self.voxCoordPos != -1: gl.glDisableVertexAttribArray(self.voxCoordPos) gl.glBindBuffer(gl.GL_ARRAY_BUFFER, 0) diff --git a/fsl/fslview/gl/gllabel.py b/fsl/fslview/gl/gllabel.py new file mode 100644 index 0000000000000000000000000000000000000000..a1fc9edebf7cb66ba050ba5bed765c3ee96309b0 --- /dev/null +++ b/fsl/fslview/gl/gllabel.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python +# +# gllabel.py - OpenGL representation for label/atlas images. +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import OpenGL.GL as gl + +import fsl.fslview.gl as fslgl +import resources as glresources +import globject +import textures + + +class GLLabel(globject.GLImageObject): + + + def __init__(self, image, display): + + globject.GLImageObject.__init__(self, image, display) + + lutTexName = '{}_lut'.format(self.name) + + self.lutTexture = textures.LookupTableTexture(lutTexName) + self.imageTexture = None + + self.refreshImageTexture() + self.refreshLutTexture() + + fslgl.gllabel_funcs.init(self) + self.addListeners() + + + def destroy(self): + + glresources.delete(self.imageTexture.getTextureName()) + self.lutTexture.destroy() + + self.removeListeners() + fslgl.gllabel_funcs.destroy(self) + globject.GLImageObject.destroy(self) + + + def addListeners(self): + + display = self.display + opts = self.displayOpts + name = self.name + + def shaderUpdate(*a): + fslgl.gllabel_funcs.updateShaderState(self) + self.onUpdate() + + def shaderCompile(*a): + fslgl.gllabel_funcs.compileShaders(self) + fslgl.gllabel_funcs.updateShaderState(self) + self.onUpdate() + + def lutUpdate(*a): + self.refreshLutTexture() + fslgl.gllabel_funcs.updateShaderState(self) + self.onUpdate() + + def lutChanged(*a): + if self.__lut is not None: + self.__lut.removeListener('labels', self.name) + + self.__lut = opts.lut + + if self.__lut is not None: + self.__lut.addListener('labels', self.name, lutUpdate) + + lutUpdate() + + def imageRefresh(*a): + self.refreshImageTexture() + fslgl.gllabel_funcs.updateShaderState(self) + self.onUpdate() + + def imageUpdate(*a): + self.imageTexture.set(volume=opts.volume, + resolution=opts.resolution) + + fslgl.gllabel_funcs.updateShaderState(self) + self.onUpdate() + + self.__lut = opts.lut + + # TODO If you add a software shader, you will + # need to call gllabel_funcs.compileShaders + # when display.softwareMode changes + + display .addListener('alpha', name, lutUpdate, weak=False) + display .addListener('brightness', name, lutUpdate, weak=False) + display .addListener('contrast', name, lutUpdate, weak=False) + display .addListener('softwareMode', name, shaderCompile, weak=False) + opts .addListener('outline', name, shaderUpdate, weak=False) + opts .addListener('outlineWidth', name, shaderUpdate, weak=False) + opts .addListener('lut', name, lutChanged, weak=False) + opts .addListener('volume', name, imageUpdate, weak=False) + opts .addListener('resolution', name, imageUpdate, weak=False) + opts.lut.addListener('labels', name, lutUpdate, weak=False) + + if opts.getParent() is not None: + opts.addSyncChangeListener( + 'volume', name, imageRefresh, weak=False) + opts.addSyncChangeListener( + 'resolution', name, imageRefresh, weak=False) + + + def removeListeners(self): + display = self.display + opts = self.displayOpts + name = self.name + + display .removeListener( 'alpha', name) + display .removeListener( 'brightness', name) + display .removeListener( 'contrast', name) + display .removeListener( 'softwareMode', name) + opts .removeListener( 'outline', name) + opts .removeListener( 'outlineWidth', name) + opts .removeListener( 'lut', name) + opts .removeListener( 'volume', name) + opts .removeListener( 'resolution', name) + opts.lut.removeListener( 'labels', name) + + if opts.getParent() is not None: + opts.removeSyncChangeListener('volume', name) + opts.removeSyncChangeListener('resolution', name) + + + + def setAxes(self, xax, yax): + """Overrides :meth:`.GLImageObject.setAxes`. + """ + globject.GLImageObject.setAxes(self, xax, yax) + fslgl.gllabel_funcs.updateShaderState(self) + + + def refreshImageTexture(self): + + opts = self.displayOpts + texName = '{}_{}' .format(type(self).__name__, id(self.image)) + + unsynced = (opts.getParent() is None or + not opts.isSyncedToParent('volume') or + not opts.isSyncedToParent('resolution')) + + if unsynced: + texName = '{}_unsync_{}'.format(texName, id(opts)) + + if self.imageTexture is not None: + glresources.delete(self.imageTexture.getTextureName()) + + self.imageTexture = glresources.get( + texName, + textures.ImageTexture, + texName, + self.image) + + + def refreshLutTexture(self, *a): + + display = self.display + opts = self.displayOpts + + self.lutTexture.set(alpha=display.alpha / 100.0, + brightness=display.brightness / 100.0, + contrast=display.contrast / 100.0, + lut=opts.lut) + + def preDraw(self): + + self.imageTexture.bindTexture(gl.GL_TEXTURE0) + self.lutTexture .bindTexture(gl.GL_TEXTURE1) + fslgl.gllabel_funcs.preDraw(self) + + + def draw(self, zpos, xform=None): + fslgl.gllabel_funcs.draw(self, zpos, xform) + + + def postDraw(self): + self.imageTexture.unbindTexture() + self.lutTexture .unbindTexture() + fslgl.gllabel_funcs.postDraw(self) diff --git a/fsl/fslview/gl/gllinevector.py b/fsl/fslview/gl/gllinevector.py index 949a62b4869df4c10e0b9f295434b6317a036381..bd8e99c1667eec3f5572b0fc8965ecf20b6b4840 100644 --- a/fsl/fslview/gl/gllinevector.py +++ b/fsl/fslview/gl/gllinevector.py @@ -38,14 +38,13 @@ class GLLineVertices(object): def refresh(self, glvec): - display = glvec.display - opts = glvec.opts - image = glvec.image + opts = glvec.displayOpts + image = glvec.image # Extract a sub-sample of the vector image # at the current display resolution data, starts, steps = glroutines.subsample(image.data, - display.resolution, + opts.resolution, image.pixdim) # Pull out the xyz components of the @@ -105,18 +104,18 @@ class GLLineVertices(object): self.texCoords = texCoords self.starts = starts self.steps = steps - self.__hash = (hash(display.transform) ^ - hash(display.resolution) ^ - hash(opts .directed)) + self.__hash = (hash(opts.transform) ^ + hash(opts.resolution) ^ + hash(opts.directed)) def getVertices(self, glvec, zpos): - display = glvec.display - image = glvec.image - xax = glvec.xax - yax = glvec.yax - zax = glvec.zax + opts = glvec.displayOpts + image = glvec.image + xax = glvec.xax + yax = glvec.yax + zax = glvec.zax vertices = self.vertices texCoords = self.texCoords @@ -126,10 +125,10 @@ class GLLineVertices(object): # If in id/pixdim space, the display # coordinate system axes are parallel # to the voxeld coordinate system axes - if display.transform in ('id', 'pixdim'): + if opts.transform in ('id', 'pixdim'): # Turn the z position into a voxel index - if display.transform == 'pixdim': + if opts.transform == 'pixdim': zpos = zpos / image.pixdim[zax] zpos = round(zpos) @@ -155,8 +154,8 @@ class GLLineVertices(object): # in the display coordinate system coords = glroutines.calculateSamplePoints( image.shape[ :3], - [display.resolution] * 3, - display.getTransform('voxel', 'display'), + [opts.resolution] * 3, + opts.getTransform('voxel', 'display'), xax, yax)[0] @@ -165,7 +164,7 @@ class GLLineVertices(object): # transform that plane of display # coordinates into voxel coordinates coords = transform.transform( - coords, display.getTransform('display', 'voxel')) + coords, opts.getTransform('display', 'voxel')) # The voxel vertex matrix may have # been sub-sampled (see the @@ -196,21 +195,20 @@ class GLLineVector(glvector.GLVector): glvector.GLVector.__init__(self, image, display) - self.opts = display.getDisplayOpts() - fslgl.gllinevector_funcs.init(self) def update(*a): self.onUpdate() - self.opts.addListener('lineWidth', self.name, update) + self.displayOpts.addListener( + 'lineWidth', self.name, update, weak=False) def destroy(self): - glvector.GLVector.destroy(self) - fslgl.gllinevector_funcs.destroy(self) - self.opts.removeListener('lineWidth', self.name) + self.displayOpts.removeListener('lineWidth', self.name) + fslgl.gllinevector_funcs.destroy(self) + glvector.GLVector.destroy(self) def getDataResolution(self, xax, yax): diff --git a/fsl/fslview/gl/glmask.py b/fsl/fslview/gl/glmask.py index 26ee689e6c2a893c9f97a39a110ca38286ccd766..bf120fe3ccde33b428fac938c3c7b805fe85fe03 100644 --- a/fsl/fslview/gl/glmask.py +++ b/fsl/fslview/gl/glmask.py @@ -8,21 +8,21 @@ for OpenGL rendering of a 3D volume as a binary mask. When created, a :class:`GLMask` instance assumes that the provided -:class:`~fsl.data.image.Image` instance has an ``imageType`` of ``mask``, and -that its associated :class:`~fsl.fslview.displaycontext.Display` instance -contains a :class:`~fsl.fslview.displatcontext.maskopts.MaskOpts` instance, +:class:`.Image` instance has an ``overlayType`` of ``mask``, and that its +associated :class:`.Display` instance contains a :class:`.MaskOpts` instance, containing mask-specific display properties. -The :class:`GLMask` class uses the functionality of the -:class:`~fsl.fslview.gl.glvolume.GLVolume` class through inheritance. +The :class:`GLMask` class uses the functionality of the :class:`.GLVolume` +class through inheritance. """ import logging +import numpy as np -import numpy as np - -import fsl.fslview.gl.glvolume as glvolume +import fsl.fslview.gl as fslgl +import fsl.fslview.colourmaps as colourmaps +import glvolume log = logging.getLogger(__name__) @@ -30,71 +30,113 @@ log = logging.getLogger(__name__) class GLMask(glvolume.GLVolume): """The :class:`GLMask` class encapsulates logic to render 2D slices of a - :class:`~fsl.data.image.Image` instance as a binary mask in OpenGL. + :class:`.Image` instance as a binary mask in OpenGL. - ``GLMask`` is a subclass of the :class:`~fsl.fslview.gl.glvolume.GLVolume - class. It overrides a few key methods of ``GLVolume``, but most of the - logic is provided by ``GLVolume``. + ``GLMask`` is a subclass of the :class:`.GLVolume class. It overrides a + few key methods of ``GLVolume``, but most of the logic is provided by + ``GLVolume``. """ def addDisplayListeners(self): - """Overrides - :meth:`~fsl.fslview.gl.glvolume.GLVolume.addDisplayListeners`. + """Overrides :meth:`.GLVolume.addDisplayListeners`. - Adds a bunch of listeners to the - :class:`~fsl.fslview.displaycontext.Display` object, and the - associated :class:`~fsl.fslview.displaycontext.maskopts.MaskOpts` - instance, which define how the mask image should be displayed. + Adds a bunch of listeners to the :class:`.Display` object, and the + associated :class:`.MaskOpts` instance, which define how the mask + image should be displayed. """ - def vertexUpdate(*a): - self.setAxes(self.xax, self.yax) - self.onUpdate() + display = self.display + opts = self.displayOpts + name = self.name + + def shaderCompile(*a): + fslgl.glvolume_funcs.compileShaders( self) + fslgl.glvolume_funcs.updateShaderState(self) + self.onUpdate() + + def shaderUpdate(*a): + fslgl.glvolume_funcs.updateShaderState(self) + self.onUpdate() + def colourUpdate(*a): self.refreshColourTexture() + fslgl.glvolume_funcs.updateShaderState(self) + self.onUpdate() + + def imageRefresh(*a): + self.refreshImageTexture() + fslgl.glvolume_funcs.updateShaderState(self) self.onUpdate() - lnrName = '{}_{}'.format(type(self).__name__, id(self)) + def imageUpdate(*a): + volume = opts.volume + resolution = opts.resolution - self.display .addListener('transform', lnrName, vertexUpdate) - self.display .addListener('alpha', lnrName, colourUpdate) - self.displayOpts.addListener('colour', lnrName, colourUpdate) - self.displayOpts.addListener('threshold', lnrName, colourUpdate) - self.displayOpts.addListener('invert', lnrName, colourUpdate) + self.imageTexture.set(volume=volume, resolution=resolution) + + fslgl.glvolume_funcs.updateShaderState(self) + self.onUpdate() + + display.addListener('softwareMode', name, shaderCompile, weak=False) + display.addListener('alpha', name, colourUpdate, weak=False) + display.addListener('brightness', name, colourUpdate, weak=False) + display.addListener('contrast', name, colourUpdate, weak=False) + opts .addListener('colour', name, colourUpdate, weak=False) + opts .addListener('threshold', name, colourUpdate, weak=False) + opts .addListener('invert', name, colourUpdate, weak=False) + opts .addListener('volume', name, imageUpdate, weak=False) + opts .addListener('resolution', name, imageUpdate, weak=False) + + if opts.getParent() is not None: + opts.addSyncChangeListener( + 'volume', name, imageRefresh, weak=False) + opts.addSyncChangeListener( + 'resolution', name, imageRefresh, weak=False) def removeDisplayListeners(self): - """Overrides - :meth:`~fsl.fslview.gl.glvolume.GLVolume.removeDisplayListeners`. + """Overrides :meth:`.GLVolume.removeDisplayListeners`. Removes all the listeners added by :meth:`addDisplayListeners`. """ + + display = self.display + opts = self.displayOpts + name = self.name - lnrName = '{}_{}'.format(type(self).__name__, id(self)) - - self.display .removeListener('transform', lnrName) - self.display .removeListener('interpolation', lnrName) - self.display .removeListener('alpha', lnrName) - self.display .removeListener('resolution', lnrName) - self.display .removeListener('volume', lnrName) - self.image .removeListener('data', lnrName) - self.displayOpts.removeListener('colour', lnrName) - self.displayOpts.removeListener('threshold', lnrName) - self.displayOpts.removeListener('invert', lnrName) + display.removeListener( 'softwareMode', name) + display.removeListener( 'alpha', name) + display.removeListener( 'brightness', name) + display.removeListener( 'contrast', name) + opts .removeListener( 'colour', name) + opts .removeListener( 'threshold', name) + opts .removeListener( 'invert', name) + opts .removeListener( 'volume', name) + opts .removeListener( 'resolution', name) + + if opts.getParent() is not None: + opts.removeSyncChangeListener('volume', name) + opts.removeSyncChangeListener('resolution', name) + + + def testUnsynced(self): + """Overrides :meth:`.GLVolume.testUnsynced`. + """ + return (self.displayOpts.getParent() is None or + not self.displayOpts.isSyncedToParent('volume') or + not self.displayOpts.isSyncedToParent('resolution')) def refreshColourTexture(self, *a): - """Overrides - :meth:`~fsl.fslview.gl.glvolume.GLVolume.refreshColourTexture`. - - Creates a colour texture which contains the current mask colour, - and a transformation matrix which maps from the current - :attr:`~fsl.fslview.displaycontext.maskopts.MaskOpts.threshold` range - to the texture range, so that voxels within this range are coloured, - and voxels outside the range are transparent (or vice versa, if the - :attr:`~fsl.fslview.displaycontext.maskopts.MaskOpts.invert` flag - is set). + """Overrides :meth:`.GLVolume.refreshColourTexture`. + + Creates a colour texture which contains the current mask colour, and a + transformation matrix which maps from the current + :attr:`.MaskOpts.threshold` range to the texture range, so that voxels + within this range are coloured, and voxels outside the range are + transparent (or vice versa, if the :attr:`.MaskOpts.invert` flag is + set). """ display = self.display @@ -105,6 +147,9 @@ class GLMask(glvolume.GLVolume): dmax = opts.threshold[1] colour[3] = 1.0 + colour = colourmaps.applyBricon(colour, + display.brightness / 100.0, + display.contrast / 100.0) if opts.invert: cmap = np.tile([0.0, 0.0, 0.0, 0.0], (4, 1)) @@ -112,7 +157,7 @@ class GLMask(glvolume.GLVolume): else: cmap = np.tile([opts.colour], (4, 1)) border = np.array([0.0, 0.0, 0.0, 0.0], dtype=np.float32) - + self.colourTexture.set(cmap=cmap, border=border, displayRange=(dmin, dmax), diff --git a/fsl/fslview/gl/glmodel.py b/fsl/fslview/gl/glmodel.py new file mode 100644 index 0000000000000000000000000000000000000000..a1d339292e7306e4c130a4a06c7a1b80153cdfbc --- /dev/null +++ b/fsl/fslview/gl/glmodel.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python +# +# glmodel.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + + +import numpy as np +import OpenGL.GL as gl + +import globject +import fsl.utils.transform as transform +import fsl.fslview.gl as fslgl +import fsl.fslview.gl.routines as glroutines +import fsl.fslview.gl.textures as textures +import fsl.fslview.colourmaps as fslcmaps + + +class GLModel(globject.GLObject): + + def __init__(self, overlay, display): + + globject.GLObject.__init__(self) + + self.overlay = overlay + self.display = display + self.opts = display.getDisplayOpts() + + self.addListeners() + self._updateVertices() + + self._renderTexture = textures.GLObjectRenderTexture( + self.name, self, 0, 1) + self._renderTexture.setInterpolation(gl.GL_LINEAR) + + fslgl.glmodel_funcs.compileShaders(self) + fslgl.glmodel_funcs.updateShaders( self) + + + def destroy(self): + self._renderTexture.destroy() + fslgl.glmodel_funcs.destroy(self) + self.removeListeners() + + self.overlay = None + self.display = None + self.opts = None + + + def addListeners(self): + + name = self.name + display = self.display + opts = self.opts + + def refresh(*a): + self.onUpdate() + + def shaderUpdate(*a): + fslgl.glmodel_funcs.updateShaders(self) + self.onUpdate() + + opts .addListener('refImage', name, self._updateVertices) + opts .addListener('coordSpace', name, self._updateVertices) + opts .addListener('transform', name, self._updateVertices) + opts .addListener('colour', name, refresh, weak=False) + opts .addListener('outline', name, refresh, weak=False) + opts .addListener('showName', name, refresh, weak=False) + display.addListener('brightness', name, refresh, weak=False) + display.addListener('contrast', name, refresh, weak=False) + display.addListener('alpha', name, refresh, weak=False) + opts .addListener('outlineWidth', name, shaderUpdate, weak=False) + + + def removeListeners(self): + self.opts .removeListener('refImage', self.name) + self.opts .removeListener('coordSpace', self.name) + self.opts .removeListener('transform', self.name) + self.opts .removeListener('colour', self.name) + self.opts .removeListener('outline', self.name) + self.opts .removeListener('outlineWidth', self.name) + self.display.removeListener('brightness', self.name) + self.display.removeListener('contrast', self.name) + self.display.removeListener('alpha', self.name) + + + def _updateVertices(self, *a): + + vertices = self.overlay.vertices + indices = self.overlay.indices + + xform = self.opts.getCoordSpaceTransform() + + if xform is not None: + vertices = transform.transform(vertices, xform) + + self.vertices = np.array(vertices, dtype=np.float32) + self.indices = np.array(indices, dtype=np.uint32) + self.onUpdate() + + + def getDisplayBounds(self): + return self.opts.getDisplayBounds() + + + def getDataResolution(self, xax, yax): + + # TODO How can I have this resolution tied + # to the rendering target resolution (i.e. + # the screen), instead of being fixed? + # + # Perhaps the DisplayContext class should + # have a method which returns the current + # rendering resolution for a given display + # space axis/length .. + # + # Also, isn't it a bit dodgy to be accessing + # the DisplayContext instance through the + # DisplayOpts instance? Why not just pass + # the displayCtx instance to the GLObject + # constructor, and have it directly accessible + # by all GLobjects? + + zax = 3 - xax - yax + + xDisplayLen = self.opts.displayCtx.bounds.getLen(xax) + yDisplayLen = self.opts.displayCtx.bounds.getLen(yax) + zDisplayLen = self.opts.displayCtx.bounds.getLen(zax) + + lo, hi = self.getDisplayBounds() + + xModelLen = abs(hi[xax] - lo[xax]) + yModelLen = abs(hi[yax] - lo[yax]) + zModelLen = abs(hi[zax] - lo[zax]) + + resolution = [1, 1, 1] + resolution[xax] = int(round(2048.0 * xModelLen / xDisplayLen)) + resolution[yax] = int(round(2048.0 * yModelLen / yDisplayLen)) + resolution[zax] = int(round(2048.0 * zModelLen / zDisplayLen)) + + return resolution + + + def setAxes(self, xax, yax): + globject.GLObject.setAxes(self, xax, yax) + self._renderTexture.setAxes(xax, yax) + + + def getOutlineOffsets(self): + """Used by the :mod:`glmodel_funcs` modules. + """ + width, height = self._renderTexture.getSize() + outlineWidth = self.opts.outlineWidth + + # outlineWidth is a value between 0.0 and 1.0 - + # we use this value so that it effectly sets the + # outline to between 0% and 10% of the model + # width/height (whichever is smaller) + outlineWidth *= 10 + offsets = 2 * [min(outlineWidth / width, outlineWidth / height)] + offsets = np.array(offsets, dtype=np.float32) + return offsets + + + def preDraw(self): + pass + + + def draw(self, zpos, xform=None): + + display = self.display + opts = self.opts + + xax = self.xax + yax = self.yax + zax = self.zax + + vertices = self.vertices + indices = self.indices + + lo, hi = self.getDisplayBounds() + + xmin = lo[xax] + ymin = lo[yax] + xmax = hi[xax] + ymax = hi[yax] + + clipPlaneVerts = np.zeros((4, 3), dtype=np.float32) + clipPlaneVerts[0, [xax, yax]] = [xmin, ymin] + clipPlaneVerts[1, [xax, yax]] = [xmin, ymax] + clipPlaneVerts[2, [xax, yax]] = [xmax, ymax] + clipPlaneVerts[3, [xax, yax]] = [xmax, ymin] + clipPlaneVerts[:, zax] = zpos + + vertices = vertices.ravel('C') + planeEq = glroutines.planeEquation(clipPlaneVerts[0, :], + clipPlaneVerts[1, :], + clipPlaneVerts[2, :]) + + self._renderTexture.bindAsRenderTarget() + self._renderTexture.setRenderViewport(xax, yax, lo, hi) + + gl.glClear(gl.GL_COLOR_BUFFER_BIT) + gl.glClear(gl.GL_DEPTH_BUFFER_BIT) + gl.glClear(gl.GL_STENCIL_BUFFER_BIT) + + gl.glEnableClientState(gl.GL_VERTEX_ARRAY) + gl.glEnable(gl.GL_CLIP_PLANE0) + gl.glEnable(gl.GL_CULL_FACE) + gl.glEnable(gl.GL_STENCIL_TEST) + + gl.glClipPlane(gl.GL_CLIP_PLANE0, planeEq) + gl.glColorMask(gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE) + + # First and second passes - render front and + # back faces separately. In the stencil buffer, + # subtract the mask created by the second + # render from the mask created by the first - + # this gives us a mask which shows the + # intersection of the model with the clipping + # plane. + gl.glStencilFunc(gl.GL_ALWAYS, 0, 0) + + # I don't understand why, but if any of the + # display system axes are inverted, we need + # to render the back faces first, otherwise + # the cross-section mask will not be created + # correctly. + direction = [gl.GL_INCR, gl.GL_DECR] + if np.any(hi < lo): faceOrder = [gl.GL_FRONT, gl.GL_BACK] + else: faceOrder = [gl.GL_BACK, gl.GL_FRONT] + + for face, direction in zip(faceOrder, direction): + + gl.glStencilOp(gl.GL_KEEP, gl.GL_KEEP, direction) + gl.glCullFace(face) + + gl.glVertexPointer(3, gl.GL_FLOAT, 0, vertices) + gl.glDrawElements(gl.GL_TRIANGLES, + len(indices), + gl.GL_UNSIGNED_INT, + indices) + + # third pass - render the intersection of the + # front and back faces from the stencil buffer + gl.glColorMask(gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE) + + gl.glDisable(gl.GL_CLIP_PLANE0) + gl.glDisable(gl.GL_CULL_FACE) + gl.glDisableClientState(gl.GL_VERTEX_ARRAY) + + gl.glStencilFunc(gl.GL_NOTEQUAL, 0, 255) + + colour = list(fslcmaps.applyBricon( + opts.colour[:3], + display.brightness / 100.0, + display.contrast / 100.0)) + + colour.append(display.alpha / 100.0) + + gl.glColor(*colour) + gl.glBegin(gl.GL_QUADS) + + gl.glVertex3f(*clipPlaneVerts[0, :]) + gl.glVertex3f(*clipPlaneVerts[1, :]) + gl.glVertex3f(*clipPlaneVerts[2, :]) + gl.glVertex3f(*clipPlaneVerts[3, :]) + gl.glEnd() + + gl.glDisable(gl.GL_STENCIL_TEST) + + self._renderTexture.unbindAsRenderTarget() + self._renderTexture.restoreViewport() + + if opts.outline: + fslgl.glmodel_funcs.loadShaders(self) + + self._renderTexture.drawOnBounds( + zpos, xmin, xmax, ymin, ymax, xax, yax, xform) + + if opts.outline: + fslgl.glmodel_funcs.unloadShaders(self) + + + def postDraw(self): + pass diff --git a/fsl/fslview/gl/globject.py b/fsl/fslview/gl/globject.py index 88e10133759c808c69bdf4634cfd79c9a5a6ae29..6a143bd43ed33bb0813b54cf5e8b8991f5ff2b8a 100644 --- a/fsl/fslview/gl/globject.py +++ b/fsl/fslview/gl/globject.py @@ -1,7 +1,6 @@ #!/usr/bin/env python # -# globject.py - Mapping between fsl.data.image types and OpenGL -# representations. +# globject.py - Mapping between overlay types and OpenGL representations. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # @@ -9,56 +8,74 @@ all 2D representations of objects in OpenGL. This module also provides the :func:`createGLObject` function, which provides -mappings between :class:`~fsl.data.image.Image` types, and their corresponding -OpenGL representation. +mappings between overlay objects and their corresponding OpenGL +representation. """ +import logging import numpy as np +import routines as glroutines +import fsl.utils.transform as transform -def createGLObject(image, display): - """Create :class:`GLObject` instance for the given - :class:`~fsl.data.image.Image` instance. - :arg image: A :class:`~fsl.data.image.Image` instance. - :arg display: A :class:`~fsl.fslview.displaycontext.Display` instance. - """ +log = logging.getLogger(__name__) - import fsl.fslview.gl.glvolume as glvolume - import fsl.fslview.gl.glmask as glmask - import fsl.fslview.gl.glrgbvector as glrgbvector - import fsl.fslview.gl.gllinevector as gllinevector - _objectmap = { - 'volume' : glvolume .GLVolume, - 'mask' : glmask .GLMask, - 'rgbvector' : glrgbvector .GLRGBVector, - 'linevector' : gllinevector.GLLineVector - } +def createGLObject(overlay, display): + """Create :class:`GLObject` instance for the given overlay, as specified + by the :attr:`.Display.overlayType` property. - ctr = _objectmap.get(display.imageType, None) + :arg overlay: An overlay object (e.g. a :class:`.Image` instance). + + :arg display: A :class:`.Display` instance describing how the overlay + should be displayed. + """ + ctr = GLOBJECT_OVERLAY_TYPE_MAP.get(display.overlayType, None) - if ctr is not None: return ctr(image, display) + if ctr is not None: return ctr(overlay, display) else: return None class GLObject(object): """The :class:`GLObject` class is a superclass for all 2D OpenGL objects. + + The following attributes will always be available on ``GLObject`` + instances: + + - ``name``: A unique name for this ``GLObject`` instance. + + - ``xax``: Index of the display coordinate system axis that + corresponds to the horizontal screen axis. + + - ``yax``: Index of the display coordinate system axis that + corresponds to the vertical screen axis. + + - ``zax``: Index of the display coordinate system axis that + corresponds to the depth screen axis. """ + def __init__(self): - """Create a :class:`GLObject`. The constructor adds one attribute to - this instance, ``name``, which is simply a unique name for this - instance. + """Create a :class:`GLObject`. The constructor adds one attribute + to this instance, ``name``, which is simply a unique name for this + instance, and gives default values to the ``xax``, ``yax``, and + ``zax`` attributes. Subclass implementations must call this method, and should also perform any necessary OpenGL initialisation, such as creating textures. """ + # Give this instance a name, and set + # initial values for the display axes self.name = '{}_{}'.format(type(self).__name__, id(self)) + self.xax = 0 + self.yax = 1 + self.zax = 2 + self.__updateListeners = {} @@ -99,10 +116,15 @@ class GLObject(object): def setAxes(self, xax, yax): """This method is called when the display orientation for this - :class:`GLObject` changes. It should perform any necessary updates to - the GL data (e.g. regenerating/moving vertices). + :class:`GLObject` changes. It sets :attr:`xax`, :attr:`yax`, + and :attr:`zax` attributes on this ``GLObject`` instance. + + Subclass implementations should call this method, or should set + the ``xax``, ``yax``, and ``zax`` attributes themselves. """ - raise NotImplementedError() + self.xax = xax + self.yax = yax + self.zax = 3 - xax - yax def destroy(self): @@ -142,14 +164,13 @@ class GLObject(object): transformation matrices contained in the ``zposes`` and ``xforms`` arrays. - In some circumstances (hint: the - :class:`~fsl.fslview.gl.lightboxcanvas.LightBoxCanvas`), - better performance may be achievbed in combining multiple - renders, rather than doing it with separate calls to :meth:`draw`. + In some circumstances (hint: the :class:`.LightBoxCanvas`), better + performance may be achieved in combining multiple renders, rather + than doing it with separate calls to :meth:`draw`. - The default implementation does exactly this, so this method - need only be overridden for subclasses which are able to get - better performance by combining the draws. + The default implementation does exactly this, so this method need only + be overridden for subclasses which are able to get better performance + by combining the draws. """ for (zpos, xform) in zip(zposes, xforms): self.draw(zpos, xform) @@ -173,33 +194,19 @@ class GLSimpleObject(GLObject): Subclasses should not assume that any of the other methods will ever be called. - - On calls to :meth:`draw`, the following attributes will be available on - ``GLSimpleObject`` instances: - - - ``xax``: Index of the display coordinate system axis that corresponds - to the horizontal screen axis. - - ``yax``: Index of the display coordinate system axis that corresponds - to the vertical screen axis. """ def __init__(self): GLObject.__init__(self) - def setAxes(self, xax, yax): - self.xax = xax - self.yax = yax - self.zax = 3 - xax - yax - - - def destroy(self): pass + def destroy( self): pass def preDraw( self): pass def postDraw(self): pass class GLImageObject(GLObject): """The ``GLImageObject` class is the superclass for all GL representations - of :class:`~fsl.data.image.Image` instances. + of :class:`.Image` instances. """ def __init__(self, image, display): @@ -212,9 +219,9 @@ class GLImageObject(GLObject): - ``displayOpts``: A reference to the image type-specific display options. - :arg image: The :class:`~fsl.data.image.Image` instance - :arg display: An associated - :class:`~fsl.fslview.displaycontext.Display` instance. + :arg image: The :class:`.Image` instance + + :arg display: An associated :class:`.Display` instance. """ GLObject.__init__(self) @@ -222,18 +229,34 @@ class GLImageObject(GLObject): self.display = display self.displayOpts = display.getDisplayOpts() + log.memory('{}.init ({})'.format(type(self).__name__, id(self))) + + + def __del__(self): + log.memory('{}.del ({})'.format(type(self).__name__, id(self))) + + + def destroy(self): + """If this method is overridden, it should be called by the subclass + implementation. It clears references to the :class:`.Image`, + :class:`.Display`, and :class:`.DisplayOpts` instances. + """ + self.image = None + self.display = None + self.displayOpts = None + def getDisplayBounds(self): - return self.display.getDisplayBounds() + return self.displayOpts.getDisplayBounds() def getDataResolution(self, xax, yax): image = self.image - display = self.display - res = display.resolution + opts = self.displayOpts + res = opts.resolution - if display.transform in ('id', 'pixdim'): + if opts.transform in ('id', 'pixdim'): pixdim = np.array(image.pixdim[:3]) steps = [res, res, res] / pixdim @@ -242,6 +265,59 @@ class GLImageObject(GLObject): return np.array(res.round(), dtype=np.uint32) else: - lo, hi = display.getDisplayBounds() + lo, hi = opts.getDisplayBounds() minres = int(round(((hi - lo) / res).min())) return [minres] * 3 + + + def generateVertices(self, zpos, xform): + """Generates vertex coordinates for a 2D slice through the given + ``zpos``, with the optional ``xform`` applied to the coordinates. + + This method is called by the :mod:`.gl14.glvolume_funcs` and + :mod:`.gl21.glvolume_funcs` modules. + + A tuple of three values is returned, containing: + + - A ``6*3 numpy.float32`` array containing the vertex coordinates + + - A ``6*3 numpy.float32`` array containing the voxel coordinates + corresponding to each vertex + + - A ``6*3 numpy.float32`` array containing the texture coordinates + corresponding to each vertex + """ + vertices, voxCoords, texCoords = glroutines.slice2D( + self.image.shape[:3], + self.xax, + self.yax, + zpos, + self.displayOpts.getTransform('voxel', 'display'), + self.displayOpts.getTransform('display', 'voxel')) + + if xform is not None: + vertices = transform.transform(vertices, xform) + + return vertices, voxCoords, texCoords + + +import glvolume +import glmask +import glrgbvector +import gllinevector +import glmodel +import gllabel + + +GLOBJECT_OVERLAY_TYPE_MAP = { + 'volume' : glvolume .GLVolume, + 'mask' : glmask .GLMask, + 'rgbvector' : glrgbvector .GLRGBVector, + 'linevector' : gllinevector.GLLineVector, + 'model' : glmodel .GLModel, + 'label' : gllabel .GLLabel +} +"""This dictionary provides a mapping between all available overlay types (see +the :attr:`.Display.overlayType` property), and the :class:`GLObject` subclass +used to represent them. +""" diff --git a/fsl/fslview/gl/glrgbvector.py b/fsl/fslview/gl/glrgbvector.py index 6d78c29f045d1c86c501f9358432e680c1307427..95a1e87ee3e2e184fe179341c726623d39524334 100644 --- a/fsl/fslview/gl/glrgbvector.py +++ b/fsl/fslview/gl/glrgbvector.py @@ -9,10 +9,11 @@ vector images in RGB mode. """ import numpy as np +import OpenGL.GL as gl + + import fsl.fslview.gl as fslgl -import fsl.fslview.gl.routines as glroutines import fsl.fslview.gl.glvector as glvector -import fsl.utils.transform as transform class GLRGBVector(glvector.GLVector): @@ -27,20 +28,34 @@ class GLRGBVector(glvector.GLVector): glvector.GLVector.__init__(self, image, display, self.__prefilter) fslgl.glrgbvector_funcs.init(self) + self.displayOpts.addListener('interpolation', + self.name, + self.__interpChanged) + - def generateVertices(self, zpos, xform): - vertices, voxCoords, texCoords = glroutines.slice2D( - self.image.shape[:3], - self.xax, - self.yax, - zpos, - self.display.getTransform('voxel', 'display'), - self.display.getTransform('display', 'voxel')) + def destroy(self): + self.displayOpts.removeListener('interpolation', self.name) + glvector.GLVector.destroy(self) - if xform is not None: - vertices = transform.transform(vertices, xform) - return vertices, voxCoords, texCoords + def refreshImageTexture(self): + glvector.GLVector.refreshImageTexture(self) + self.__setInterp() + + + def __setInterp(self): + opts = self.displayOpts + + if opts.interpolation == 'none': interp = gl.GL_NEAREST + else: interp = gl.GL_LINEAR + + self.imageTexture.set(interp=interp) + + + def __interpChanged(self, *a): + self.__setInterp() + self.updateShaderState() + self.onUpdate() def compileShaders(self): diff --git a/fsl/fslview/gl/glvector.py b/fsl/fslview/gl/glvector.py index dfd6ffc029fbf3bdc7150970963d8ae75cd2cd3c..efd05bdd4bd7500f992cb1b43e379b88d01edcb8 100644 --- a/fsl/fslview/gl/glvector.py +++ b/fsl/fslview/gl/glvector.py @@ -6,9 +6,9 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # """Defines the :class:`GLVector` class, which encapsulates the logic for -rendering 2D slices of a ``X*Y*Z*3`` image as a vector. The ``GLVector`` class -provides the interface defined by the -:class:`.GLObject` class. +rendering 2D slices of a ``X*Y*Z*3`` :class:`.Image` as a vector. The +``GLVector`` class provides the interface defined by the :class:`.GLObject` +class. The ``GLVector`` class is a base class whcih is not intended to be @@ -27,10 +27,10 @@ Three 1D textures are used to store a colour table for each of the ``x``, ``xyz`` vector values, looks up colours for each of them, and combines the three colours to form the final fragment colour. + The colour of each vector may be modulated by another image, specified by the :attr:`.VectorOpts.modulate` property. This modulation image is stored as a 3D single-channel texture. - """ import numpy as np @@ -38,9 +38,9 @@ import OpenGL.GL as gl import fsl.data.image as fslimage import fsl.fslview.colourmaps as fslcm -import fsl.fslview.gl.resources as glresources -import fsl.fslview.gl.textures as textures -import fsl.fslview.gl.globject as globject +import resources as glresources +import textures +import globject @@ -81,15 +81,48 @@ class GLVector(globject.GLImageObject): globject.GLImageObject.__init__(self, image, display) - display = self.display - opts = self.displayOpts - name = self.name + name = self.name self.xColourTexture = textures.ColourMapTexture('{}_x'.format(name)) self.yColourTexture = textures.ColourMapTexture('{}_y'.format(name)) self.zColourTexture = textures.ColourMapTexture('{}_z'.format(name)) self.modTexture = None self.imageTexture = None + self.prefilter = prefilter + + self.addListeners() + self.refreshImageTexture() + self.refreshModulateTexture() + self.refreshColourTextures() + + + def destroy(self): + """Deletes the GL textures, and deregisters the listeners configured in + :meth:`__init__`. + + This method must be called by subclass implementations. + """ + + self.xColourTexture.destroy() + self.yColourTexture.destroy() + self.zColourTexture.destroy() + + glresources.delete(self.imageTexture.getTextureName()) + glresources.delete(self.modTexture .getTextureName()) + + self.imageTexture = None + self.modTexture = None + + self.removeListeners() + + globject.GLImageObject.destroy(self) + + + def addListeners(self): + + display = self.display + opts = self.displayOpts + name = self.name def modUpdate( *a): self.refreshModulateTexture() @@ -110,20 +143,70 @@ class GLVector(globject.GLImageObject): self.updateShaderState() self.onUpdate() - display.addListener('interpolation', name, shaderUpdate) - display.addListener('softwareMode', name, shaderCompile) - display.addListener('alpha', name, cmapUpdate) - display.addListener('brightness', name, cmapUpdate) - display.addListener('contrast', name, cmapUpdate) - opts .addListener('xColour', name, cmapUpdate) - opts .addListener('yColour', name, cmapUpdate) - opts .addListener('zColour', name, cmapUpdate) - opts .addListener('suppressX', name, cmapUpdate) - opts .addListener('suppressY', name, cmapUpdate) - opts .addListener('suppressZ', name, cmapUpdate) - opts .addListener('modulate', name, modUpdate) - opts .addListener('modThreshold', name, shaderUpdate) + def imageRefresh(*a): + self.refreshImageTexture() + self.updateShaderState() + self.onUpdate() + + def imageUpdate(*a): + + self.imageTexture.set(resolution=opts.resolution) + self.updateShaderState() + self.onUpdate() + + display.addListener('softwareMode', name, shaderCompile, weak=False) + display.addListener('alpha', name, cmapUpdate, weak=False) + display.addListener('brightness', name, cmapUpdate, weak=False) + display.addListener('contrast', name, cmapUpdate, weak=False) + opts .addListener('xColour', name, cmapUpdate, weak=False) + opts .addListener('yColour', name, cmapUpdate, weak=False) + opts .addListener('zColour', name, cmapUpdate, weak=False) + opts .addListener('suppressX', name, cmapUpdate, weak=False) + opts .addListener('suppressY', name, cmapUpdate, weak=False) + opts .addListener('suppressZ', name, cmapUpdate, weak=False) + opts .addListener('modulate', name, modUpdate, weak=False) + opts .addListener('modThreshold', name, shaderUpdate, weak=False) + opts .addListener('resolution', name, imageUpdate, weak=False) + + if opts.getParent() is not None: + opts.addSyncChangeListener( + 'resolution', name, imageRefresh, weak=False) + + + def removeListeners(self): + display = self.display + opts = self.displayOpts + name = self.name + + display.removeListener( 'softwareMode', name) + display.removeListener( 'alpha', name) + display.removeListener( 'brightness', name) + display.removeListener( 'contrast', name) + opts .removeListener( 'xColour', name) + opts .removeListener( 'yColour', name) + opts .removeListener( 'zColour', name) + opts .removeListener( 'suppressX', name) + opts .removeListener( 'suppressY', name) + opts .removeListener( 'suppressZ', name) + opts .removeListener( 'modulate', name) + opts .removeListener( 'modThreshold', name) + opts .removeListener( 'volume', name) + opts .removeListener( 'resolution', name) + + if opts.getParent() is not None: + opts.removeSyncChangeListener('resolution', name) + + + def refreshImageTexture(self): + + opts = self.displayOpts + prefilter = self.prefilter + texName = '{}_{}'.format(type(self).__name__, id(self.image)) + + if self.imageTexture is not None: + glresources.delete(self.imageTexture.getTextureName()) + # the fourth dimension (the vector directions) # must be the fastest changing in the texture data if prefilter is None: @@ -131,52 +214,22 @@ class GLVector(globject.GLImageObject): else: realPrefilter = lambda d: prefilter(d.transpose((3, 0, 1, 2))) - texName = '{}_{}'.format(type(self).__name__, id(self.image)) + unsynced = (opts.getParent() is None or + not opts.isSyncedToParent('resolution') or + not opts.isSyncedToParent('volume')) + + if unsynced: + texName = '{}_unsync_{}'.format(texName, id(opts)) + self.imageTexture = glresources.get( texName, textures.ImageTexture, texName, self.image, - display=self.display, nvals=3, normalise=True, prefilter=realPrefilter) - self.refreshModulateTexture() - self.refreshColourTextures() - - - def destroy(self): - """Deletes the GL textures, and deregisters the listeners configured in - :meth:`__init__`. - - This method must be called by subclass implementations. - """ - - self.xColourTexture.destroy() - self.yColourTexture.destroy() - self.zColourTexture.destroy() - - glresources.delete(self.imageTexture.getTextureName()) - glresources.delete(self.modTexture .getTextureName()) - - self.imageTexture = None - self.modTexture = None - - self.display .removeListener('interpolation', self.name) - self.display .removeListener('softwareMode', self.name) - self.display .removeListener('alpha', self.name) - self.display .removeListener('brightness', self.name) - self.display .removeListener('contrast', self.name) - self.displayOpts.removeListener('xColour', self.name) - self.displayOpts.removeListener('yColour', self.name) - self.displayOpts.removeListener('zColour', self.name) - self.displayOpts.removeListener('suppressX', self.name) - self.displayOpts.removeListener('suppressY', self.name) - self.displayOpts.removeListener('suppressZ', self.name) - self.displayOpts.removeListener('modulate', self.name) - self.displayOpts.removeListener('modThreshold', self.name) - def updateShaderState(self): """This method must be provided by subclasses.""" @@ -200,29 +253,40 @@ class GLVector(globject.GLImageObject): if self.modTexture is not None: glresources.delete(self.modTexture.getTextureName()) - self.modTexture = None modImage = self.displayOpts.modulate - if modImage == 'none': + if modImage is None or modImage == 'none': textureData = np.zeros((5, 5, 5), dtype=np.uint8) textureData[:] = 255 modImage = fslimage.Image(textureData) - modDisplay = None + modOpts = None norm = False else: - modDisplay = self.display - norm = True + modOpts = self.displayOpts.displayCtx.getOpts(modImage) + norm = True texName = '{}_{}_{}_modulate'.format( type(self).__name__, id(self.image), id(modImage)) + + if modOpts is not None: + unsynced = (modOpts.getParent() is None or + not modOpts.isSyncedToParent('resolution') or + not modOpts.isSyncedToParent('volume')) + + # TODO If unsynced, this GLVector needs to + # update the modulate texture whenever its + # volume/resolution properties change. + # Right? + if unsynced: + texName = '{}_unsync_{}'.format(texName, id(modOpts)) + self.modTexture = glresources.get( texName, textures.ImageTexture, texName, modImage, - display=modDisplay, normalise=norm) @@ -278,14 +342,6 @@ class GLVector(globject.GLImageObject): texture.set(cmap=cmap, displayRange=drange) - - def setAxes(self, xax, yax): - """Stores the new x/y/z axes.""" - - self.xax = xax - self.yax = yax - self.zax = 3 - xax - yax - def preDraw(self): """Must be called by subclass implementations. diff --git a/fsl/fslview/gl/glvolume.py b/fsl/fslview/gl/glvolume.py index 55800ec6818f96ed3f1a56ed1ad90368ffe654fc..40e4a5a227367b9847bc3818d4581e579514646b 100644 --- a/fsl/fslview/gl/glvolume.py +++ b/fsl/fslview/gl/glvolume.py @@ -8,20 +8,18 @@ """Defines the :class:`GLVolume` class, which creates and encapsulates the data and logic required to render 2D slice of a 3D image. The :class:`GLVolume` class provides the interface defined in the -:class:`~fsl.fslview.gl.globject.GLObject` class. +:class:`.GLObject` class. -A :class:`GLVolume` instance may be used to render an -:class:`~fsl.data.image.Image` instance which has an ``imageType`` of -``volume``. It is assumed that this ``Image`` instance is associated with a -:class:`~fsl.fslview.displaycontext.Display` instance which contains a -:class:`~fsl.fslview.displaycontext.volumeopts.VolumeOpts` instance, -containing display options specific to volume rendering. +A :class:`GLVolume` instance may be used to render an :class:`.Image` instance +which has an ``overlayType`` of ``volume``. It is assumed that this ``Image`` +instance is associated with a :class:`.Display` instance which, in turn, +contains a :class:`.VolumeOpts` instance, containing display options specific +to volume rendering. The :class:`GLVolume` class makes use of the functions defined in the -:mod:`fsl.fslview.gl.gl14.glvolume_funcs` or the -:mod:`fsl.fslview.gl.gl21.glvolume_funcs` modules, which provide OpenGL -version specific details for creation/storage of vertex data, and for -rendering. +:mod:`.gl14.glvolume_funcs` or the :mod:`.gl21.glvolume_funcs` modules, which +provide OpenGL version specific details for creation/storage of vertex data, +and for rendering. These version dependent modules must provide the following functions: @@ -34,7 +32,7 @@ These version dependent modules must provide the following functions: - ``updateShaderState(GLVolume)``: Updates the shader program states when display parameters are changed. - - ``preDraw()``: Initialise the GL state, ready for drawing. + - ``preDraw(GLVolume)``: Initialise the GL state, ready for drawing. - ``draw(GLVolume, zpos, xform=None)``: Draw a slice of the image at the given Z position. If xform is not None, it must be applied as a @@ -43,7 +41,7 @@ These version dependent modules must provide the following functions: - ``drawAll(Glvolume, zposes, xforms)`` - Draws slices at each of the specified ``zposes``, applying the corresponding ``xforms`` to each. - - ``postDraw()``: Clear the GL state after drawing. + - ``postDraw(GLVolume)``: Clear the GL state after drawing. Images are rendered in essentially the same way, regardless of which OpenGL version-specific module is used. The image data itself is stored on the GPU @@ -55,14 +53,12 @@ of the image bounds. import logging log = logging.getLogger(__name__) -import OpenGL.GL as gl +import OpenGL.GL as gl -import fsl.utils.transform as transform -import fsl.fslview.gl as fslgl -import fsl.fslview.gl.textures as textures -import fsl.fslview.gl.resources as glresources -import fsl.fslview.gl.globject as globject -import fsl.fslview.gl.routines as glroutines +import fsl.fslview.gl as fslgl +import textures +import globject +import resources as glresources class GLVolume(globject.GLImageObject): @@ -76,11 +72,10 @@ class GLVolume(globject.GLImageObject): Initialises the OpenGL data required to render the given image. - :arg image: A :class:`~fsl.data.image.Image` object. + :arg image: An :class:`.Image` object. - :arg display: A :class:`~fsl.fslview.displaycontext.Display` - object which describes how the image is to be - displayed. + :arg display: A :class:`.Display` object which describes how the image + is to be displayed. """ globject.GLImageObject.__init__(self, image, display) @@ -90,109 +85,17 @@ class GLVolume(globject.GLImageObject): self.addDisplayListeners() # Create an image texture and a colour map texture - texName = '{}_{}'.format(type(self).__name__, id(self.image)) + self.texName = '{}_{}'.format(type(self).__name__, id(self.image)) - # The image texture may be used elsewhere, - # so we'll use the resource management - # module rather than creating one directly - self.imageTexture = glresources.get( - texName, - textures.ImageTexture, - texName, - self.image, - self.display) - - self.colourTexture = textures.ColourMapTexture(texName) - + self.imageTexture = None + self.colourTexture = textures.ColourMapTexture(self.texName) + + self.refreshImageTexture() self.refreshColourTexture() fslgl.glvolume_funcs.init(self) - def setAxes(self, xax, yax): - """This method should be called when the image display axes change.""" - - self.xax = xax - self.yax = yax - self.zax = 3 - xax - yax - - - def preDraw(self): - """Sets up the GL state to draw a slice from this :class:`GLVolume` - instance. - """ - - # Set up the image and colour textures - self.imageTexture .bindTexture(gl.GL_TEXTURE0) - self.colourTexture.bindTexture(gl.GL_TEXTURE1) - - fslgl.glvolume_funcs.preDraw(self) - - - def generateVertices(self, zpos, xform=None): - """Generates vertex coordinates for a 2D slice through the given - ``zpos``, with the optional ``xform`` applied to the coordinates. - - This method is called by the :mod:`.gl14.glvolume_funcs` and - :mod:`.gl21.glvolume_funcs` modules. - - A tuple of three values is returned, containing: - - - A ``6*3 numpy.float32`` array containing the vertex coordinates - - - A ``6*3 numpy.float32`` array containing the voxel coordinates - corresponding to each vertex - - - A ``6*3 numpy.float32`` array containing the texture coordinates - corresponding to each vertex - """ - vertices, voxCoords, texCoords = glroutines.slice2D( - self.image.shape[:3], - self.xax, - self.yax, - zpos, - self.display.getTransform('voxel', 'display'), - self.display.getTransform('display', 'voxel')) - - if xform is not None: - vertices = transform.transform(vertices, xform) - - return vertices, voxCoords, texCoords - - - def draw(self, zpos, xform=None): - """Draws a 2D slice of the image at the given real world Z location. - This is performed via a call to the OpenGL version-dependent `draw` - function, contained in one of the :mod:`~fsl.fslview.gl.gl14` or - :mod:`~fsl.fslview.gl.gl21` packages. - - If `xform` is not None, it is applied as an affine transformation to - the vertex coordinates of the rendered image data. - - Note: Calls to this method must be preceded by a call to - :meth:`preDraw`, and followed by a call to :meth:`postDraw`. - """ - - fslgl.glvolume_funcs.draw(self, zpos, xform) - - - def drawAll(self, zposes, xforms): - """Calls the module-specific ``drawAll`` function. """ - - fslgl.glvolume_funcs.drawAll(self, zposes, xforms) - - - def postDraw(self): - """Clears the GL state after drawing from this :class:`GLVolume` - instance. - """ - - self.imageTexture .unbindTexture() - self.colourTexture.unbindTexture() - - fslgl.glvolume_funcs.postDraw(self) - - def destroy(self): """This should be called when this :class:`GLVolume` object is no longer needed. It performs any needed clean up of OpenGL data (e.g. @@ -208,6 +111,44 @@ class GLVolume(globject.GLImageObject): self.removeDisplayListeners() fslgl.glvolume_funcs.destroy(self) + + globject.GLImageObject.destroy(self) + + + def testUnsynced(self): + """Used by the :meth:`refreshImageTexture` method. + + Returns ``True`` if certain critical :class:`VolumeOpts` properties + have been unsynced from the parent instance, meaning that this + :class:`GLVolume` instance needs to create its own image texture; + returns ``False`` otherwise. + """ + return (self.displayOpts.getParent() is None or + not self.displayOpts.isSyncedToParent('volume') or + not self.displayOpts.isSyncedToParent('resolution') or + not self.displayOpts.isSyncedToParent('interpolation')) + + + def refreshImageTexture(self): + + opts = self.displayOpts + texName = self.texName + unsynced = self.testUnsynced() + + if unsynced: + texName = '{}_unsync_{}'.format(texName, id(opts)) + + if self.imageTexture is not None: + glresources.delete(self.imageTexture.getTextureName()) + + # The image texture may be used elsewhere, + # so we'll use the resource management + # module rather than creating one directly + self.imageTexture = glresources.get( + texName, + textures.ImageTexture, + texName, + self.image) def refreshColourTexture(self): @@ -258,17 +199,43 @@ class GLVolume(globject.GLImageObject): fslgl.glvolume_funcs.updateShaderState(self) self.onUpdate() - def update(*a): + def imageRefresh(*a): + self.refreshImageTexture() + fslgl.glvolume_funcs.updateShaderState(self) self.onUpdate() - display.addListener('resolution', lName, update) - display.addListener('interpolation', lName, shaderUpdate) - display.addListener('softwareMode', lName, shaderCompile) - display.addListener('alpha', lName, colourUpdate) - opts .addListener('displayRange', lName, colourUpdate) - opts .addListener('clippingRange', lName, shaderUpdate) - opts .addListener('cmap', lName, colourUpdate) - opts .addListener('invert', lName, colourUpdate) + def imageUpdate(*a): + volume = opts.volume + resolution = opts.resolution + + if opts.interpolation == 'none': interp = gl.GL_NEAREST + else: interp = gl.GL_LINEAR + + self.imageTexture.set(volume=volume, + interp=interp, + resolution=resolution) + + fslgl.glvolume_funcs.updateShaderState(self) + self.onUpdate() + + display.addListener('softwareMode', lName, shaderCompile, weak=False) + display.addListener('alpha', lName, colourUpdate, weak=False) + opts .addListener('displayRange', lName, colourUpdate, weak=False) + opts .addListener('clippingRange', lName, shaderUpdate, weak=False) + opts .addListener('invertClipping', lName, shaderUpdate, weak=False) + opts .addListener('cmap', lName, colourUpdate, weak=False) + opts .addListener('invert', lName, colourUpdate, weak=False) + opts .addListener('volume', lName, imageUpdate, weak=False) + opts .addListener('resolution', lName, imageUpdate, weak=False) + opts .addListener('interpolation', lName, imageUpdate, weak=False) + + if opts.getParent() is not None: + opts.addSyncChangeListener( + 'volume', lName, imageRefresh, weak=False) + opts.addSyncChangeListener( + 'resolution', lName, imageRefresh, weak=False) + opts.addSyncChangeListener( + 'interpolation', lName, imageRefresh, weak=False) def removeDisplayListeners(self): @@ -280,12 +247,63 @@ class GLVolume(globject.GLImageObject): opts = self.displayOpts lName = self.name + + display.removeListener( 'softwareMode', lName) + display.removeListener( 'alpha', lName) + opts .removeListener( 'displayRange', lName) + opts .removeListener( 'clippingRange', lName) + opts .removeListener( 'invertClipping', lName) + opts .removeListener( 'cmap', lName) + opts .removeListener( 'invert', lName) + opts .removeListener( 'volume', lName) + opts .removeListener( 'resolution', lName) + opts .removeListener( 'interpolation', lName) + if opts.getParent() is not None: + opts.removeSyncChangeListener('volume', lName) + opts.removeSyncChangeListener('resolution', lName) + opts.removeSyncChangeListener('interpolation', lName) + + + def preDraw(self): + """Sets up the GL state to draw a slice from this :class:`GLVolume` + instance. + """ + + # Set up the image and colour textures + self.imageTexture .bindTexture(gl.GL_TEXTURE0) + self.colourTexture.bindTexture(gl.GL_TEXTURE1) - display.removeListener('resolution', lName) - display.removeListener('interpolation', lName) - display.removeListener('softwareMode', lName) - display.removeListener('alpha', lName) - opts .removeListener('displayRange', lName) - opts .removeListener('clippingRange', lName) - opts .removeListener('cmap', lName) - opts .removeListener('invert', lName) + fslgl.glvolume_funcs.preDraw(self) + + + def draw(self, zpos, xform=None): + """Draws a 2D slice of the image at the given real world Z location. + This is performed via a call to the OpenGL version-dependent `draw` + function, contained in one of the :mod:`~fsl.fslview.gl.gl14` or + :mod:`~fsl.fslview.gl.gl21` packages. + + If `xform` is not None, it is applied as an affine transformation to + the vertex coordinates of the rendered image data. + + Note: Calls to this method must be preceded by a call to + :meth:`preDraw`, and followed by a call to :meth:`postDraw`. + """ + + fslgl.glvolume_funcs.draw(self, zpos, xform) + + + def drawAll(self, zposes, xforms): + """Calls the module-specific ``drawAll`` function. """ + + fslgl.glvolume_funcs.drawAll(self, zposes, xforms) + + + def postDraw(self): + """Clears the GL state after drawing from this :class:`GLVolume` + instance. + """ + + self.imageTexture .unbindTexture() + self.colourTexture.unbindTexture() + + fslgl.glvolume_funcs.postDraw(self) diff --git a/fsl/fslview/gl/lightboxcanvas.py b/fsl/fslview/gl/lightboxcanvas.py index 7a15fe3fe458de0f638b2ebef9e985626e348dc7..219058c1c89c573dc5b26a8ceeb1d03af5994b75 100644 --- a/fsl/fslview/gl/lightboxcanvas.py +++ b/fsl/fslview/gl/lightboxcanvas.py @@ -1,12 +1,12 @@ #!/usr/bin/env python # -# lightboxcanvas.py - A SliceCanvas which displays multiple slices along a -# single axis from a collection of 3D images. +# lightboxcanvas.py - A SliceCanvas which displays multiple 2D slices along +# a single axis from a collection of 3D overlays. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -"""A :class:`~fsl.fslview.gl.slicecanvas.SliceCanvas` which displays multiple -slices along a single axis from a collection of 3D images. +"""A :class:`.SliceCanvas` which displays multiple 2D slices along a single +axis from a collection of 3D overlays. """ import sys @@ -27,10 +27,10 @@ log = logging.getLogger(__name__) class LightBoxCanvas(slicecanvas.SliceCanvas): """Represents an OpenGL canvas which displays multiple slices from a - collection of 3D images (see :class:`fsl.data.image.ImageList`). The - slices are laid out on the same canvas along rows and columns, with the - slice at the minimum Z position translated to the top left of the canvas, - and the slice with the maximum Z value translated to the bottom right. + collection of 3D overlays. The slices are laid out on the same canvas + along rows and columns, with the slice at the minimum Z position + translated to the top left of the canvas, and the slice with the maximum Z + value translated to the bottom right. """ @@ -39,7 +39,7 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): maxval=30.0, default=1.0) """This property controls the spacing - between slices (in real world coordinates). + between slices (in display coordinates). """ @@ -64,7 +64,7 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): zrange = props.Bounds(ndims=1) - """This property controls the range, in world + """This property controls the range, in display coordinates, of the slices to be displayed. """ @@ -80,8 +80,8 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): def worldToCanvas(self, xpos, ypos, zpos): - """Given an x/y/z location in the image list world (with xpos - corresponding to the horizontal screen axis, ypos to the vertical + """Given an x/y/z location in the overlay list display space (with + xpos corresponding to the horizontal screen axis, ypos to the vertical axis, and zpos to the depth axis), converts it into an x/y position, in world coordinates, on the canvas. """ @@ -100,13 +100,13 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): def canvasToWorld(self, xpos, ypos): - """Overrides - :meth:`fsl.fslview.gl.slicecanvas.SliceCanvas.canvasToWorld`. + """Overrides :meth:.SliceCanvas.canvasToWorld`. Given pixel x/y coordinates on this canvas, translates them into the real world x/y/z coordinates of the displayed slice. Returns a 3-tuple containing the (x, y, z) display system coordinates. If the - given canvas position is out of the image range, ``None`` is returned. + given canvas position is out of the overlay range, ``None`` is + returned. """ nrows = self._totalRows @@ -154,24 +154,22 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): def getTotalRows(self): - """Returns the total number of rows that may be displayed. - """ + """Returns the total number of rows that may be displayed. """ return self._totalRows - def __init__(self, imageList, displayCtx, zax=0): + def __init__(self, overlayList, displayCtx, zax=0): """Create a :class:`LightBoxCanvas` object. - :arg imageList: a :class:`~fsl.data.image.ImageList` object which - contains, or will contain, a list of images to be - displayed. + :arg overlayList: An :class:`.OverlayList` object which contains a + list of overlays to be displayed. - :arg displayCtx: A :class:`~fsl.fslview.displaycontext.DisplayContext` - object which defines how that image list is to be - displayed. + :arg displayCtx: A :class:`.DisplayContext` object which defines how + that overlay list is to be displayed. - :arg zax: Image axis to be used as the 'depth' axis. Can be - changed via the :attr:`LightBoxCanvas.zax` property. + :arg zax: Display coordinate system axis to be used as the + 'depth' axis. Can be changed via the + :attr:`.SliceCanvas.zax` property. """ # These attributes are used to keep track of @@ -182,9 +180,9 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): # This will point to a RenderTexture if # the offscreen render mode is enabled - self.__offscreenRenderTexture = None + self._offscreenRenderTexture = None - slicecanvas.SliceCanvas.__init__(self, imageList, displayCtx, zax) + slicecanvas.SliceCanvas.__init__(self, overlayList, displayCtx, zax) # default to showing the entire slice range zmin, zmax = displayCtx.bounds.getRange(self.zax) @@ -200,14 +198,7 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): self.addListener('zrange', self.name, self._slicePropsChanged) self.addListener('showGridLines', self.name, self._refresh) self.addListener('highlightSlice', self.name, self._refresh) - - # Called when the top row changes - - # adjusts display range and refreshes - def rowChange(*a): - self._updateDisplayBounds() - self._refresh() - - self.addListener('topRow', self.name, rowChange) + self.addListener('topRow', self.name, self._topRowChanged) # Add a listener to the position so when it # changes we can adjust the display range (via @@ -221,6 +212,30 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): '{}_zPosChanged'.format(self.name), self._zPosChanged) + def destroy(self): + + self.removeListener('pos', '{}_zPosChanged'.format(self.name)) + self.removeListener('sliceSpacing', self.name) + self.removeListener('ncols', self.name) + self.removeListener('nrows', self.name) + self.removeListener('zrange', self.name) + self.removeListener('showGridLines', self.name) + self.removeListener('highlightSlice', self.name) + self.removeListener('topRow', self.name) + + if self._offscreenRenderTexture is not None: + self._offscreenRenderTexture.destroy() + + slicecanvas.SliceCanvas.destroy(self) + + + def _topRowChanged(self, *a): + """Called when the :attr:`topRow` property changes. Adjusts display + range and refreshes the canvas. + """ + self._updateDisplayBounds() + self._refresh() + def _slicePropsChanged(self, *a): """Called when any of the slice properties change. Regenerates slice @@ -235,37 +250,35 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): def _renderModeChange(self, *a): - """Overrides :meth:`.SliceCanvas._renderModeChange`. - """ + """Overrides :meth:`.SliceCanvas._renderModeChange`.""" - if self.__offscreenRenderTexture is not None: - self.__offscreenRenderTexture.destroy() - self.__offscreenRenderTexture = None + if self._offscreenRenderTexture is not None: + self._offscreenRenderTexture.destroy() + self._offscreenRenderTexture = None slicecanvas.SliceCanvas._renderModeChange(self, *a) def _updateRenderTextures(self): - """Overrides :meth:`.SliceCanvas._updateRenderTextures`. - """ + """Overrides :meth:`.SliceCanvas._updateRenderTextures`.""" if self.renderMode == 'onscreen': return # The LightBoxCanvas does offscreen rendering # a bit different to the SliceCanvas. The latter - # uses a separate render texture for each image, + # uses a separate render texture for each overlay # whereas here we're going to use a single - # render texture for all images. + # render texture for all overlays. elif self.renderMode == 'offscreen': - if self.__offscreenRenderTexture is not None: - self.__offscreenRenderTexture.destroy() + if self._offscreenRenderTexture is not None: + self._offscreenRenderTexture.destroy() - self.__offscreenRenderTexture = textures.RenderTexture( + self._offscreenRenderTexture = textures.RenderTexture( '{}_{}'.format(type(self).__name__, id(self)), gl.GL_LINEAR) - self.__offscreenRenderTexture.setSize(768, 768) + self._offscreenRenderTexture.setSize(768, 768) # The LightBoxCanvas handles re-render mode # the same way as the SliceCanvas - a separate @@ -273,25 +286,25 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): elif self.renderMode == 'prerender': # Delete any RenderTextureStack instances for - # images which have been removed from the list - for image, (tex, name) in self._prerenderTextures.items(): - if image not in self.imageList: - self._prerenderTextures.pop(image) + # overlays which have been removed from the list + for overlay, (tex, name) in self._prerenderTextures.items(): + if overlay not in self.overlayList: + self._prerenderTextures.pop(overlay) glresources.delete(name) - # Create a RendeTextureStack for images + # Create a RendeTextureStack for overlays # which have been added to the list - for image in self.imageList: - if image in self._prerenderTextures: + for overlay in self.overlayList: + if overlay in self._prerenderTextures: continue - globj = self._glObjects.get(image, None) + globj = self._glObjects.get(overlay, None) if globj is None: continue name = '{}_{}_zax{}'.format( - id(image), + id(overlay), textures.RenderTextureStack.__name__, self.zax) @@ -303,7 +316,7 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): rt.setAxes(self.xax, self.yax) glresources.set(name, rt) - self._prerenderTextures[image] = rt, name + self._prerenderTextures[overlay] = rt, name self._refresh() @@ -346,8 +359,7 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): def _zPosChanged(self, *a): - """Called when the :attr:`~fsl.fslview.gl.slicecanvas.SliceCanvas.pos` - ``z`` value changes. + """Called when the :attr:`.SliceCanvas.pos` ``z`` value changes. Makes sure that the corresponding slice is visible. """ @@ -374,29 +386,27 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): self.topRow = row - def _imageListChanged(self, *a): - """Overrides - :meth:`~fsl.fslview.gl.slicecanvas.SliceCanvas._imageListChanged`. + def _overlayListChanged(self, *a): + """Overrides :meth:`.SliceCanvas._overlayListChanged`. - Regenerates slice locations for all images, and calls the super + Regenerates slice locations for all overlays, and calls the super implementation. """ self._updateZAxisProperties() self._genSliceLocations() - slicecanvas.SliceCanvas._imageListChanged(self, *a) + slicecanvas.SliceCanvas._overlayListChanged(self, *a) def _updateZAxisProperties(self): - """Called by the :meth:`_imageBoundsChanged` method. + """Called by the :meth:`_overlayBoundsChanged` method. The purpose of this method is to set the slice spacing and displayed Z - range to something sensible when the Z axis, or Z image bounds are - changed (e.g. due to images being added/removed, or to image + range to something sensible when the Z axis, or Z bounds are + changed (e.g. due to overlays being added/removed, or to overlay transformation matrices being changed). - """ - if len(self.imageList) == 0: + if len(self.overlayList) == 0: self.setConstraint('zrange', 'minDistance', 0) self.zrange.x = (0, 0) self.sliceSpacing = 0 @@ -404,47 +414,58 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): # Pick a sensible default for the # slice spacing - the smallest pixdim - # across all images in the list + # across all overlays in the list newZGap = sys.float_info.max - for image in self.imageList: - display = self.displayCtx.getDisplayProperties(image) + for overlay in self.overlayList: - # TODO this is specific to the Image type, - # and shouldn't be. We're going to need to - # support other overlay types soon... - if display.transform == 'id': + overlay = self.displayCtx.getReferenceImage(overlay) + + if overlay is None: + continue + + opts = self.displayCtx.getOpts(overlay) + + if opts.transform == 'id': zgap = 1 - elif display.transform == 'pixdim': - zgap = image.pixdim[self.zax] + elif opts.transform == 'pixdim': + zgap = overlay.pixdim[self.zax] else: - zgap = min(image.pixdim[:3]) + zgap = min(overlay.pixdim[:3]) if zgap < newZGap: newZGap = zgap newZRange = self.displayCtx.bounds.getRange(self.zax) + # If there were no volumetric overlays + # in the overlay list, choose an arbitrary + # default slice spacing + if newZGap == sys.float_info.max: + newZGap = (newZRange[1] - newZRange[0]) / 10.0 + # Changing the zrange/sliceSpacing properties will, in most cases, - # trigger a call to _slicePropsChanged. But for images which have + # trigger a call to _slicePropsChanged. But for overlays which have # the same range across more than one dimension, the call might not # happen. So we do a check and, if the dimension ranges are the - # same, manually call _slicePropsChanged. Bringing out the ugly + # same, manually call _slicePropsChanged. Bringing out the ugly # side of event driven programming. self.zrange.setLimits(0, *newZRange) self.setConstraint('zrange', 'minDistance', newZGap) self.setConstraint('sliceSpacing', 'minval', newZGap) + self.zrange.x = newZRange + self.sliceSpacing = newZGap + - def _imageBoundsChanged(self, *a): - """Overrides - :meth:`fsl.fslview.gl.slicecanvas.SliceCanvas._imageBoundsChanged`. + def _overlayBoundsChanged(self, *a): + """Overrides :meth:`.SliceCanvas._overlayBoundsChanged`. - Called when the image bounds change. Updates the :attr:`zrange` - min/max values. + Called when the :attr:`.DisplayContext.bounds` change. Updates the + :attr:`zrange` min/max values. """ - slicecanvas.SliceCanvas._imageBoundsChanged(self) + slicecanvas.SliceCanvas._overlayBoundsChanged(self) self._updateZAxisProperties() self._calcNumSlices() @@ -452,12 +473,11 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): def _updateDisplayBounds(self): - """Overrides - :meth:`fsl.fslview.gl.slicecanvas.SliceCanvas._updateDisplayBounds`. + """Overrides :meth:`.SliceCanvas._updateDisplayBounds`. - Called on canvas resizes, image bound changes and lightbox slice + Called on canvas resizes, display bound changes and lightbox slice property changes. Calculates the required bounding box that is to - be displayed, in real world coordinates. + be displayed, in display coordinates. """ xmin = self.displayCtx.bounds.getLo( self.xax) @@ -465,7 +485,6 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): xlen = self.displayCtx.bounds.getLen(self.xax) ylen = self.displayCtx.bounds.getLen(self.yax) - # Calculate the vertical offset required to # ensure that the current 'topRow' is the first # row, and the correct number of rows ('nrows') @@ -505,13 +524,14 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): def _genSliceLocations(self): """Called when any of the slice display properties change. - For every image in the image list, generates a list of transformation - matrices, and a list of slice indices. The latter specifies the slice - indices from the image to be displayed, and the former specifies the - transformation matrix to be used to position the slice on the canvas. + For every overlay in the overlay list, generates a list of + transformation matrices, and a list of slice indices. The latter + specifies the slice indices from the overlay to be displayed, and the + former specifies the transformation matrix to be used to position the + slice on the canvas. """ - # calculate the locations, in real world coordinates, + # calculate the locations, in display coordinates, # of all slices to be displayed on the canvas sliceLocs = np.arange( self.zrange.xlo + self.sliceSpacing * 0.5, @@ -522,27 +542,27 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): self._transforms = {} # calculate the transformation for each - # slice in each image, and the index of + # slice in each overlay, and the index of # each slice to be displayed - for i, image in enumerate(self.imageList): + for i, overlay in enumerate(self.overlayList): iSliceLocs = [] iTransforms = [] for zi, zpos in enumerate(sliceLocs): - xform = self._calculateSliceTransform(image, zi) + xform = self._calculateSliceTransform(overlay, zi) iTransforms.append(xform) iSliceLocs .append(zpos) - self._transforms[image] = iTransforms - self._sliceLocs[ image] = iSliceLocs + self._transforms[overlay] = iTransforms + self._sliceLocs[ overlay] = iSliceLocs - def _calculateSliceTransform(self, image, sliceno): + def _calculateSliceTransform(self, overlay, sliceno): """Calculates a transformation matrix for the given slice number - (voxel index) in the given image. + (voxel index) in the given overlay. Each slice is displayed on the same canvas, but is translated to a specific row/column. So translation matrix is created, to position @@ -640,7 +660,7 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): def _drawCursor(self): """Draws a cursor at the current canvas position (the - :attr:`~fsl.fslview.gl.SliceCanvas.pos` property). + :attr:`.SliceCanvas.pos` property). """ sliceno = int(np.floor((self.pos.z - self.zrange.xlo) / @@ -691,7 +711,7 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): log.debug('Rendering to off-screen texture') - rt = self.__offscreenRenderTexture + rt = self._offscreenRenderTexture lo = [None] * 3 hi = [None] * 3 @@ -713,35 +733,37 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): if endSlice > self._nslices: endSlice = self._nslices - # Draw all the slices for all the images. - for image in self.displayCtx.getOrderedImages(): - - display = self.displayCtx.getDisplayProperties(image) + # Draw all the slices for all the overlays. + for overlay in self.displayCtx.getOrderedOverlays(): - globj = self._glObjects.get(image, None) + display = self.displayCtx.getDisplay(overlay) + globj = self._glObjects.get(overlay, None) if (globj is None) or (not display.enabled): continue log.debug('Drawing {} slices ({} - {}) for ' - 'image {} directly to canvas'.format( - endSlice - startSlice, startSlice, endSlice, image)) + 'overlay {} directly to canvas'.format( + endSlice - startSlice, + startSlice, + endSlice, + overlay)) - zposes = self._sliceLocs[ image][startSlice:endSlice] - xforms = self._transforms[image][startSlice:endSlice] + zposes = self._sliceLocs[ overlay][startSlice:endSlice] + xforms = self._transforms[overlay][startSlice:endSlice] if self.renderMode == 'prerender': - rt, name = self._prerenderTextures.get(image, (None, None)) + rt, name = self._prerenderTextures.get(overlay, (None, None)) if rt is None: continue - log.debug('Drawing {} slices ({} - {}) for image {} ' + log.debug('Drawing {} slices ({} - {}) for overlay {} ' 'from pre-rendered texture'.format( endSlice - startSlice, startSlice, endSlice, - image)) + overlay)) for zpos, xform in zip(zposes, xforms): rt.draw(zpos, xform) @@ -753,6 +775,7 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): if self.renderMode == 'offscreen': rt.unbindAsRenderTarget() + rt.restoreViewport() self._setViewport() rt.drawOnBounds( 0, @@ -765,7 +788,7 @@ class LightBoxCanvas(slicecanvas.SliceCanvas): self.getAnnotations().draw(self.pos.z) - if len(self.imageList) > 0: + if len(self.overlayList) > 0: if self.showCursor: self._drawCursor() if self.showGridLines: self._drawGridLines() if self.highlightSlice: self._drawSliceHighlight() diff --git a/fsl/fslview/gl/osmesalightboxcanvas.py b/fsl/fslview/gl/osmesalightboxcanvas.py index 767938fad4bf85bc1808a41ebeb34cd2d8062fe0..358dc4b81f1400c4b20fc0a1bbcb9137ce333c3b 100644 --- a/fsl/fslview/gl/osmesalightboxcanvas.py +++ b/fsl/fslview/gl/osmesalightboxcanvas.py @@ -1,11 +1,11 @@ #!/usr/bin/env python # -# osmesaslicecanvas.py - A SliceCanvas which uses OSMesa for off-screen OpenGL -# rendering. +# osmesalightboxcanvas.py - A LightBoxCanvas which uses OSMesa for off-screen +# OpenGL rendering. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -"""Provides the :class:`OSMesaSliceCanvas` which supports off-screen +"""Provides the :class:`OSMesaLightBoxCanvas` which supports off-screen rendering. """ @@ -19,29 +19,30 @@ import lightboxcanvas class OSMesaLightBoxCanvas(lightboxcanvas.LightBoxCanvas, fslgl.OSMesaCanvasTarget): - """A :class:`~fsl.fslview.gl.slicecanvas.SliceCanvas` - which uses OSMesa for static off-screen OpenGL rendering. + """A :class:`.LightBoxCanvas` which uses OSMesa for static off-screen Open + GL rendering. """ def __init__(self, - imageList, + overlayList, displayCtx, zax=0, width=0, height=0, bgColour=(0, 0, 0, 255)): - """See the :class:`~fsl.fslview.gl.slicecanvas.SliceCanvas` constructor - for details on the other parameters. + """See the :class:`.LightBoxCanvas` constructor for details on the other + parameters. :arg width: Canvas width in pixels :arg height: Canvas height in pixels - :arg bgColour: Canvas background colour + :arg bgColour: Canvas background colour + """ fslgl.OSMesaCanvasTarget .__init__(self, width, height, bgColour) lightboxcanvas.LightBoxCanvas.__init__(self, - imageList, + overlayList, displayCtx, zax) diff --git a/fsl/fslview/gl/osmesaslicecanvas.py b/fsl/fslview/gl/osmesaslicecanvas.py index 6c6d4e62be63c864fcebf584ec638a340828190d..dde7b52baaa128f0b4cd748bd1e89c73b6ef3384 100644 --- a/fsl/fslview/gl/osmesaslicecanvas.py +++ b/fsl/fslview/gl/osmesaslicecanvas.py @@ -24,7 +24,7 @@ class OSMesaSliceCanvas(sc.SliceCanvas, """ def __init__(self, - imageList, + overlayList, displayCtx, zax=0, width=0, @@ -41,4 +41,4 @@ class OSMesaSliceCanvas(sc.SliceCanvas, """ fslgl.OSMesaCanvasTarget.__init__(self, width, height, bgColour) - sc.SliceCanvas .__init__(self, imageList, displayCtx, zax) + sc.SliceCanvas .__init__(self, overlayList, displayCtx, zax) diff --git a/fsl/fslview/gl/routines.py b/fsl/fslview/gl/routines.py index 6b1ff056212090eec1300706456304b1b87d4e27..f47810ead95f3233944984885e6c62ae77db2fb8 100644 --- a/fsl/fslview/gl/routines.py +++ b/fsl/fslview/gl/routines.py @@ -5,6 +5,8 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # +import logging + import itertools as it import OpenGL.GL as gl @@ -13,6 +15,9 @@ import numpy as np import fsl.utils.transform as transform +log = logging.getLogger(__name__) + + def show2D(xax, yax, width, height, lo, hi): zax = 3 - xax - yax @@ -27,6 +32,10 @@ def show2D(xax, yax, width, height, lo, hi): zdist = max(abs(zmin), abs(zmax)) + log.debug('Configuring orthographic viewport: ' + 'X: [{} - {}] Y: [{} - {}] Z: [{} - {}]'.format( + xmin, xmax, ymin, ymax, -zdist, zdist)) + gl.glOrtho(xmin, xmax, ymin, ymax, -zdist, zdist) gl.glMatrixMode(gl.GL_MODELVIEW) gl.glLoadIdentity() @@ -475,6 +484,10 @@ def subsample(data, resolution, pixdim=None, volume=None): xstart = np.floor(xstep / 2) ystart = np.floor(ystep / 2) zstart = np.floor(zstep / 2) + + if xstart >= data.shape[0]: xstart = data.shape[0] - 1 + if ystart >= data.shape[1]: ystart = data.shape[1] - 1 + if zstart >= data.shape[2]: zstart = data.shape[2] - 1 if len(data.shape) > 3: sample = data[xstart::xstep, ystart::ystep, @@ -540,3 +553,32 @@ def broadcast(vertices, indices, zposes, xforms, zax): allVertCoords[vStart:vEnd, :] = transform.transform(vertices, xform) return allVertCoords, allTexCoords, allIndices + + +def planeEquation(xyz1, xyz2, xyz3): + """Calculates the equation of a plane which contains each + of the given points. + + Returns a tuple containing four values, the coefficients of the + equation: + + a * x + b * y + c * z = d + + for any point (x, y, z) that lies on the plane. + + See http://paulbourke.net/geometry/pointlineplane/ + """ + x1, y1, z1 = xyz1 + x2, y2, z2 = xyz2 + x3, y3, z3 = xyz3 + + eq = np.zeros(4, dtype=np.float64) + + eq[0] = (y1 * (z2 - z3)) + (y2 * (z3 - z1)) + (y3 * (z1 - z2)) + eq[1] = (z1 * (x2 - x3)) + (z2 * (x3 - x1)) + (z3 * (x1 - x2)) + eq[2] = (x1 * (y2 - y3)) + (x2 * (y3 - y1)) + (x3 * (y1 - y2)) + eq[3] = -((x1 * ((y2 * z3) - (y3 * z2))) + + (x2 * ((y3 * z1) - (y1 * z3))) + + (x3 * ((y1 * z2) - (y2 * z1)))) + + return eq diff --git a/fsl/fslview/gl/shaders.py b/fsl/fslview/gl/shaders.py index 548723fdb0bcca086f3a50dd787c46cef21eaf5d..7b966a37c9b62d90efaea460df8b170b456f8c5a 100644 --- a/fsl/fslview/gl/shaders.py +++ b/fsl/fslview/gl/shaders.py @@ -37,6 +37,11 @@ _shaderTypePrefixMap = td.TypeDict({ ('GLVolume', 'vert', True) : 'glvolume_sw', ('GLVolume', 'frag', False) : 'glvolume', ('GLVolume', 'frag', True) : 'glvolume_sw', + + ('GLLabel', 'vert', False) : 'glvolume', + ('GLLabel', 'vert', True) : 'glvolume_sw', + ('GLLabel', 'frag', False) : 'gllabel', + ('GLLabel', 'frag', True) : 'gllabel_sw', ('GLRGBVector', 'vert', False) : 'glvolume', ('GLRGBVector', 'vert', True) : 'glvolume_sw', @@ -48,7 +53,12 @@ _shaderTypePrefixMap = td.TypeDict({ ('GLLineVector', 'vert', True) : 'gllinevector_sw', ('GLLineVector', 'frag', False) : 'glvector', - ('GLLineVector', 'frag', True) : 'glvector_sw', + ('GLLineVector', 'frag', True) : 'glvector_sw', + + ('GLModel', 'vert', False) : 'glmodel', + ('GLModel', 'vert', True) : 'glmodel', + ('GLModel', 'frag', False) : 'glmodel', + ('GLModel', 'frag', True) : 'glmodel', }) """This dictionary provides a mapping between :class:`.GLObject` types, and file name prefixes, identifying the shader programs to use. diff --git a/fsl/fslview/gl/slicecanvas.py b/fsl/fslview/gl/slicecanvas.py index f645a4e0d1eb6c3a2ea8d39ddd53583f33c96f96..6e43d1205b310c09aca09bf4b4dacfef807c55c0 100644 --- a/fsl/fslview/gl/slicecanvas.py +++ b/fsl/fslview/gl/slicecanvas.py @@ -1,23 +1,22 @@ #!/usr/bin/env python # # slicecanvas.py - Provides the SliceCanvas class, which contains the -# functionality to display a single slice from a collection of 3D images. +# functionality to display a single slice from a collection of 3D overlays. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # """Provides the :class:`SliceCanvas` class, which contains the functionality -to display a single slice from a collection of 3D images. +to display a single slice from a collection of 3D overlays. The :class:`SliceCanvas` class is not intended to be instantiated - use one of the subclasses: - - :class:`~fsl.fslview.gl.osmesaslicecanvas.OSMesaSliceCanvas` for static - off-screen rendering of a scene. + - :class:`.OSMesaSliceCanvas` for static off-screen rendering of a scene. - - :class:`~fsl.fslview.gl.wxglslicecanvas.WXGLSliceCanvas` for interactive - rendering on a :class:`wx.glcanvas.GLCanvas` canvas. + - :class:`.WXGLSliceCanvas` for interactive rendering on a + :class:`wx.glcanvas.GLCanvas` canvas. -See also the :class:`~fsl.fslview.gl.lightboxcanvas.LightBoxCanvas`. +See also the :class:`.LightBoxCanvas` class. """ import logging @@ -38,7 +37,7 @@ import fsl.fslview.gl.annotations as annotations class SliceCanvas(props.HasProperties): """Represens a canvas which may be used to display a single 2D slice from a - collection of 3D images (see :class:`fsl.data.image.ImageList`). + collection of 3D overlays. """ @@ -58,16 +57,16 @@ class SliceCanvas(props.HasProperties): maxval=1000.0, default=100.0, clamped=True) - """The image bounds are divided by this zoom - factor to produce the display bounds. + """The :attr:`.DisplayContext.bounds` are divided by this zoom + factor to produce the canvas display bounds. """ displayBounds = props.Bounds(ndims=2) """The display bound x/y values specify the horizontal/vertical display range of the canvas, in display coordinates. This may be a larger area - than the size of the displayed images, as it is adjusted to preserve the - aspect ratio. + than the size of the displayed overlays, as it is adjusted to preserve + the aspect ratio. """ @@ -78,7 +77,9 @@ class SliceCanvas(props.HasProperties): zax = props.Choice((0, 1, 2), ('X axis', 'Y axis', 'Z axis')) - """The image axis to be used as the screen 'depth' axis.""" + """The display coordinate system axis to be used as the screen 'depth' + axis. + """ invertX = props.Boolean(default=False) @@ -105,7 +106,7 @@ class SliceCanvas(props.HasProperties): resolutionLimit = props.Real(default=0, minval=0, maxval=5, clamped=True) - """The minimum resolution at which image types should be drawn.""" + """The minimum resolution at which overlays should be drawn.""" def calcPixelDims(self): @@ -157,10 +158,10 @@ class SliceCanvas(props.HasProperties): display coordinates). """ - if len(self.imageList) == 0: return + if len(self.overlayList) == 0: return dispBounds = self.displayBounds - imgBounds = self.displayCtx.bounds + ovlBounds = self.displayCtx.bounds xmin, xmax, ymin, ymax = self.displayBounds[:] @@ -169,28 +170,28 @@ class SliceCanvas(props.HasProperties): ymin = ymin + yoff ymax = ymax + yoff - if dispBounds.xlen > imgBounds.getLen(self.xax): + if dispBounds.xlen > ovlBounds.getLen(self.xax): xmin = dispBounds.xlo xmax = dispBounds.xhi - elif xmin < imgBounds.getLo(self.xax): - xmin = imgBounds.getLo(self.xax) + elif xmin < ovlBounds.getLo(self.xax): + xmin = ovlBounds.getLo(self.xax) xmax = xmin + self.displayBounds.getLen(0) - elif xmax > imgBounds.getHi(self.xax): - xmax = imgBounds.getHi(self.xax) + elif xmax > ovlBounds.getHi(self.xax): + xmax = ovlBounds.getHi(self.xax) xmin = xmax - self.displayBounds.getLen(0) - if dispBounds.ylen > imgBounds.getLen(self.yax): + if dispBounds.ylen > ovlBounds.getLen(self.yax): ymin = dispBounds.ylo ymax = dispBounds.yhi - elif ymin < imgBounds.getLo(self.yax): - ymin = imgBounds.getLo(self.yax) + elif ymin < ovlBounds.getLo(self.yax): + ymin = ovlBounds.getLo(self.yax) ymax = ymin + self.displayBounds.getLen(1) - elif ymax > imgBounds.getHi(self.yax): - ymax = imgBounds.getHi(self.yax) + elif ymax > ovlBounds.getHi(self.yax): + ymax = ovlBounds.getHi(self.yax) ymin = ymax - self.displayBounds.getLen(1) self.displayBounds[:] = [xmin, xmax, ymin, ymax] @@ -239,43 +240,34 @@ class SliceCanvas(props.HasProperties): return self._annotations - def __init__(self, imageList, displayCtx, zax=0): + def __init__(self, overlayList, displayCtx, zax=0): """Creates a canvas object. - .. note:: It is assumed that each :class:`~fsl.data.image.Image` - contained in the ``imageList`` has an attribute called ``display``, - which refers to an :class:`~fsl.fslview.displaycontext.ImageDisplay` - instance defining how that image is to be displayed. - - :arg imageList: An :class:`~fsl.data.image.ImageList` object. + :arg overlayList: An :class:`.OverlayList` object containing a + collection of overlays to be displayed. - :arg displayCtx: A :class:`~fsl.fslview.displaycontext.DisplayContext` - object. + :arg displayCtx: A :class:`.DisplayContext` object which describes + how the overlays should be displayed. - :arg zax: Image axis perpendicular to the plane to be displayed - (the 'depth' axis), default 0. + :arg zax: Display coordinate system axis perpendicular to the + plane to be displayed (the 'depth' axis), default 0. """ - if not isinstance(imageList, fslimage.ImageList): - raise TypeError( - 'imageList must be a fsl.data.image.ImageList instance') - props.HasProperties.__init__(self) - self.imageList = imageList - self.displayCtx = displayCtx - self.name = '{}_{}'.format(self.__class__.__name__, id(self)) - + self.overlayList = overlayList + self.displayCtx = displayCtx + self.name = '{}_{}'.format(self.__class__.__name__, id(self)) # A GLObject instance is created for - # every image in the image list, and - # stored in this dictionary + # every overlay in the overlay list, + # and stored in this dictionary self._glObjects = {} # If render mode is offscren or prerender, these # dictionaries will contain a RenderTexture or - # RenderTextureStack instance for each image in - # the image list + # RenderTextureStack instance for each overlay in + # the overlay list self._offscreenTextures = {} self._prerenderTextures = {} @@ -297,80 +289,117 @@ class SliceCanvas(props.HasProperties): self.addListener('showCursor', self.name, self._refresh) self.addListener('invertX', self.name, self._refresh) self.addListener('invertY', self.name, self._refresh) - self.addListener('zoom', - self.name, - lambda *a: self._updateDisplayBounds()) - - self.addListener('renderMode', - self.name, - self._renderModeChange) - + self.addListener('zoom', self.name, self._zoomChanged) + self.addListener('renderMode', self.name, self._renderModeChange) self.addListener('resolutionLimit', self.name, self._resolutionLimitChange) - # When the image list changes, refresh the + # When the overlay list changes, refresh the # display, and update the display bounds - self.imageList.addListener( 'images', - self.name, - self._imageListChanged) - self.displayCtx.addListener('imageOrder', - self.name, - self._imageListChanged) - self.displayCtx.addListener('bounds', - self.name, - self._imageBoundsChanged) + self.overlayList.addListener('overlays', + self.name, + self._overlayListChanged) + self.displayCtx .addListener('overlayOrder', + self.name, + self._refresh) + self.displayCtx .addListener('bounds', + self.name, + self._overlayBoundsChanged) + + + def destroy(self): + """This method must be called when this ``SliceCanvas`` is no longer + being used. + + It removes listeners from all :class:`.OverlayList`, + :class:`.DisplayContext`, and :class:`.Display` instances, and + destroys OpenGL representations of all overlays. + """ + self.removeListener('zax', self.name) + self.removeListener('pos', self.name) + self.removeListener('displayBounds', self.name) + self.removeListener('showCursor', self.name) + self.removeListener('invertX', self.name) + self.removeListener('invertY', self.name) + self.removeListener('zoom', self.name) + self.removeListener('renderMode', self.name) + self.overlayList.removeListener('overlays', self.name) + self.displayCtx .removeListener('bounds', self.name) + self.displayCtx .removeListener('overlayOrder', self.name) + + for overlay in self.overlayList: + disp = self.displayCtx.getDisplay(overlay) + globj = self._glObjects[overlay] + + disp.removeListener('overlayType', self.name) + disp.removeListener('enabled', self.name) + disp.unbindProps( 'softwareMode', self) + + globj.destroy() + + rt, rtName = self._prerenderTextures.get(overlay, (None, None)) + ot = self._offscreenTextures.get(overlay, None) + + if rt is not None: glresources.delete(rtName) + if ot is not None: ot .destroy() + + self.overlayList = None + self.displayCxt = None + def _initGL(self): - """Call the _imageListChanged method - it will generate - any necessary GL data for each of the images + """Call the _overlayListChanged method - it will generate + any necessary GL data for each of the overlays """ - self._imageListChanged() + self._overlayListChanged() def _updateRenderTextures(self): - """Called when the :attr:`renderMode` changes, when the image - list changes, or when the GLObject representation of an image + """Called when the :attr:`renderMode` changes, when the overlay + list changes, or when the GLObject representation of an overlay changes. If the :attr:`renderMode` property is ``onscreen``, this method does nothing. Otherwise, creates/destroys :class:`.RenderTexture` or - :class:`.RenderTextureStack` instances for newly added/removed images. + :class:`.RenderTextureStack` instances for newly added/removed + overlays. """ if self.renderMode == 'onscreen': return - # If any images have been removed from the image + # If any overlays have been removed from the overlay # list, destroy the associated render texture stack if self.renderMode == 'offscreen': - for image, texture in self._offscreenTextures.items(): - if image not in self.imageList: - self._offscreenTextures.pop(image) + for ovl, texture in self._offscreenTextures.items(): + if ovl not in self.overlayList: + self._offscreenTextures.pop(ovl) texture.destroy() elif self.renderMode == 'prerender': - for image, (texture, name) in self._prerenderTextures.items(): - if image not in self.imageList: - self._prerenderTextures.pop(image) + for ovl, (texture, name) in self._prerenderTextures.items(): + if ovl not in self.overlayList: + self._prerenderTextures.pop(ovl) glresources.delete(name) - # If any images have been added to the list, + # If any overlays have been added to the list, # create a new render textures for them - for image in self.imageList: + for overlay in self.overlayList: if self.renderMode == 'offscreen': - if image in self._offscreenTextures: + if overlay in self._offscreenTextures: continue elif self.renderMode == 'prerender': - if image in self._prerenderTextures: + if overlay in self._prerenderTextures: continue - globj = self._glObjects.get(image, None) + globj = self._glObjects.get(overlay, None) + display = self.displayCtx.getDisplay(overlay) if globj is None: continue @@ -382,14 +411,14 @@ class SliceCanvas(props.HasProperties): # by a RenderTexture object. if self.renderMode == 'offscreen': - name = '{}_{}_{}'.format(image.name, self.xax, self.yax) + name = '{}_{}_{}'.format(display.name, self.xax, self.yax) rt = textures.GLObjectRenderTexture( name, globj, self.xax, self.yax) - self._offscreenTextures[image] = rt + self._offscreenTextures[overlay] = rt # For prerender mode, slices of the # GLObjects are pre-rendered on a @@ -398,7 +427,7 @@ class SliceCanvas(props.HasProperties): # object. elif self.renderMode == 'prerender': name = '{}_{}_zax{}'.format( - id(image), + id(overlay), textures.RenderTextureStack.__name__, self.zax) @@ -410,7 +439,7 @@ class SliceCanvas(props.HasProperties): rt.setAxes(self.xax, self.yax) glresources.set(name, rt) - self._prerenderTextures[image] = rt, name + self._prerenderTextures[overlay] = rt, name self._refresh() @@ -421,12 +450,12 @@ class SliceCanvas(props.HasProperties): log.debug('Render mode changed: {}'.format(self.renderMode)) # destroy any existing render textures - for image, texture in self._offscreenTextures.items(): - self._offscreenTextures.pop(image) + for ovl, texture in self._offscreenTextures.items(): + self._offscreenTextures.pop(ovl) texture.destroy() - for image, (texture, name) in self._prerenderTextures.items(): - self._prerenderTextures.pop(image) + for ovl, (texture, name) in self._prerenderTextures.items(): + self._prerenderTextures.pop(ovl) glresources.delete(name) # Onscreen rendering - each GLObject @@ -445,33 +474,38 @@ class SliceCanvas(props.HasProperties): def _resolutionLimitChange(self, *a): """Called when the :attr:`resolutionLimit` property changes. - Updates the minimum resolution of all images in the image list. + Updates the minimum resolution of all overlays in the overlay list. """ - for image in self.imageList: - - display = self.displayCtx.getDisplayProperties(image) - minres = min(image.pixdim[:3]) + for ovl in self.overlayList: + + # No support for non-volumetric overlay + # types yet (or maybe ever?) + if not isinstance(ovl, fslimage.Image): + continue + + opts = self.displayCtx.getOpts(ovl) + minres = min(ovl.pixdim[:3]) if self.resolutionLimit > minres: minres = self.resolutionLimit - if display.resolution < minres: - display.resolution = minres + if opts.resolution < minres: + opts.resolution = minres def _zAxisChanged(self, *a): """Called when the :attr:`zax` property is changed. Calculates the corresponding X and Y axes, and saves them as attributes of - this object. Also regenerates the GL index buffers for every - image in the image list, as they are dependent upon how the - image is being displayed. + this object. Also notifies the GLObjects for every overlay in + the overlay list. """ log.debug('{}'.format(self.zax)) # Store the canvas position, in the - # axis order of the image space + # axis order of the display coordinate + # system pos = [None] * 3 pos[self.xax] = self.pos.x pos[self.yax] = self.pos.y @@ -486,12 +520,12 @@ class SliceCanvas(props.HasProperties): self._annotations.setAxes(self.xax, self.yax) - for image, globj in self._glObjects.items(): + for ovl, globj in self._glObjects.items(): if globj is not None: globj.setAxes(self.xax, self.yax) - self._imageBoundsChanged() + self._overlayBoundsChanged() # Reset the canvas position as, because the # z axis has been changed, the old coordinates @@ -506,108 +540,123 @@ class SliceCanvas(props.HasProperties): # display axes. Easiest way to do this # is to destroy and re-create them self._renderModeChange() - - - def _imageListChanged(self, *a): - """This method is called every time an image is added or removed - to/from the image list. For newly added images, it creates the - appropriate :mod:`~fsl.fslview.gl.globject` type, which - initialises the OpenGL data necessary to render the image, and then - triggers a refresh. - """ - # Destroy any GL objects for images - # which are no longer in the list - for image, globj in self._glObjects.items(): - if image not in self.imageList: - self._glObjects.pop(image) - if globj is not None: - globj.destroy() - # Create a GL 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: + def __overlayTypeChanged(self, value, valid, display, name): + """Called when the :attr:`.Display.overlayType` setting for any + overlay changes. Makes sure that an appropriate :class:`.GLObject` + has been created for the overlay (see the :meth:`__genGLObject` + method). + """ - # A GLObject already exists for this image - if image in self._glObjects: - continue + log.debug('GLObject representation for {} ' + 'changed to {}'.format(display.name, + display.overlayType)) - display = self.displayCtx.getDisplayProperties(image) + self.__genGLObject(display.getOverlay()) + self._refresh() - # Called when the GL object representation - # of the image needs to be re-created - def genGLObject(value=None, - valid=None, - ctx=None, - name=None, - disp=display, - img=image, - updateRenderTextures=True): - log.debug('GLObject representation for {} ' - 'changed to {}'.format(disp.name, - disp.imageType)) + def __genGLObject(self, overlay, updateRenderTextures=True): + """Creates a :class:`.GLObject` instance for the given ``overlay``, + destroying any existing instance. - if not self._setGLContext(): - return + If ``updateRenderTextures`` is ``True`` (the default), and the + :attr:`.renderMode` is ``offscreen`` or ``prerender``, any + render texture associated with the overlay is destroyed. + """ - # Tell the previous GLObject (if - # any) to clean up after itself - globj = self._glObjects.get(img, None) - if globj is not None: - globj.destroy() - - if updateRenderTextures: - if self.renderMode == 'offscreen': - tex = self._offscreenTextures.pop(img, None) - if tex is not None: - tex.destroy() - - elif self.renderMode == 'prerender': - tex, name = self._prerenderTextures.pop( - img, (None, None)) - if tex is not None: - glresources.delete(name) - - globj = globject.createGLObject(img, disp) + display = self.displayCtx.getDisplay(overlay) + + # Tell the previous GLObject (if + # any) to clean up after itself + globj = self._glObjects.pop(overlay, None) + if globj is not None: + globj.destroy() + + if updateRenderTextures: + if self.renderMode == 'offscreen': + tex = self._offscreenTextures.pop(overlay, None) + if tex is not None: + tex.destroy() + + elif self.renderMode == 'prerender': + tex, name = self._prerenderTextures.pop( + overlay, (None, None)) + if tex is not None: + glresources.delete(name) + + # We need a GL context to create a new GL + # object. If we can't get it now, the + # _glObjects value for this overlay will + # stay as None, and the _draw method will + # manually call this method again later. + if not self._setGLContext(): + return None - if globj is not None: - globj.setAxes(self.xax, self.yax) + globj = globject.createGLObject(overlay, display) - self._glObjects[img] = globj + if globj is not None: + globj.setAxes(self.xax, self.yax) + globj.addUpdateListener(self.name, self._refresh) - if updateRenderTextures: - self._updateRenderTextures() + self._glObjects[overlay] = globj - opts = disp.getDisplayOpts() - opts.addGlobalListener(self.name, self._refresh) - self._refresh() - - genGLObject(updateRenderTextures=False) + if updateRenderTextures: + self._updateRenderTextures() - # Bind Display.softwareMode to SliceCanvas.softwareMode + if not display.isBound('softwareMode', self): display.bindProps('softwareMode', self) - - image .addListener('data', self.name, self._refresh) - display.addListener('imageType', self.name, genGLObject) - display.addListener('enabled', self.name, self._refresh) - display.addListener('transform', self.name, self._refresh) - display.addListener('softwareMode', self.name, self._refresh) - display.addListener('interpolation', self.name, self._refresh) - display.addListener('alpha', self.name, self._refresh) - display.addListener('brightness', self.name, self._refresh) - display.addListener('contrast', self.name, self._refresh) - display.addListener('resolution', self.name, self._refresh) - display.addListener('volume', self.name, self._refresh) + + display.addListener('overlayType', + self.name, + self.__overlayTypeChanged, + overwrite=True) + + display.addListener('enabled', + self.name, + self._refresh, + overwrite=True) + + return globj + + + def _overlayListChanged(self, *a): + """This method is called every time an overlay is added or removed + to/from the overlay list. + + For newly added overlays, it creates the appropriate :mod:`.GLObject` + type, which initialises the OpenGL data necessary to render the + overlay, and then triggers a refresh. + """ + + # Destroy any GL objects for overlays + # which are no longer in the list + for ovl, globj in self._glObjects.items(): + if ovl not in self.overlayList: + self._glObjects.pop(ovl) + if globj is not None: + globj.destroy() + + # Create a GL object for any new overlays, + # and attach a listener to their display + # properties so we know when to refresh + # the canvas. + for overlay in self.overlayList: + + # A GLObject already exists + # for this overlay + if overlay in self._glObjects: + continue + + self.__genGLObject(overlay, updateRenderTextures=False) self._updateRenderTextures() self._resolutionLimitChange() self._refresh() - def _imageBoundsChanged(self, *a): + def _overlayBoundsChanged(self, *a): """Called when the display bounds are changed. Updates the constraints on the :attr:`pos` property so it is @@ -615,16 +664,23 @@ class SliceCanvas(props.HasProperties): :meth:`_updateDisplayBounds` method. """ - imgBounds = self.displayCtx.bounds + ovlBounds = self.displayCtx.bounds - self.pos.setMin(0, imgBounds.getLo(self.xax)) - self.pos.setMax(0, imgBounds.getHi(self.xax)) - self.pos.setMin(1, imgBounds.getLo(self.yax)) - self.pos.setMax(1, imgBounds.getHi(self.yax)) - self.pos.setMin(2, imgBounds.getLo(self.zax)) - self.pos.setMax(2, imgBounds.getHi(self.zax)) + self.pos.setMin(0, ovlBounds.getLo(self.xax)) + self.pos.setMax(0, ovlBounds.getHi(self.xax)) + self.pos.setMin(1, ovlBounds.getLo(self.yax)) + self.pos.setMax(1, ovlBounds.getHi(self.yax)) + self.pos.setMin(2, ovlBounds.getLo(self.zax)) + self.pos.setMax(2, ovlBounds.getHi(self.zax)) self._updateDisplayBounds() + + + def _zoomChanged(self, *a): + """Called when the :attr:`.zoom` property changes. Updates the + display bounds. + """ + self._updateDisplayBounds() def _applyZoom(self, xmin, xmax, ymin, ymax): @@ -682,12 +738,12 @@ class SliceCanvas(props.HasProperties): def _updateDisplayBounds(self, xmin=None, xmax=None, ymin=None, ymax=None): - """Called on canvas resizes, image bound changes, and zoom changes. + """Called on canvas resizes, overlay bound changes, and zoom changes. Calculates the bounding box, in world coordinates, to be displayed on the canvas. Stores this bounding box in the displayBounds property. If - any of the parameters are not provided, the image list - :attr:`fsl.data.image.ImageList.bounds` are used. + any of the parameters are not provided, the + :attr:`.DisplayContext.bounds` are used. :arg xmin: Minimum x (horizontal) value to be in the display bounds. :arg xmax: Maximum x value to be in the display bounds. @@ -758,8 +814,8 @@ class SliceCanvas(props.HasProperties): """Sets up the GL canvas size, viewport, and projection. If any of the min/max parameters are not provided, they are - taken from the :attr:`displayBounds` (x/y), and the image - list :attr:`~fsl.data.image.ImageList.bounds` (z). + taken from the :attr:`displayBounds` (x/y), and the + :attr:`DisplayContext.bounds` (z). :arg xmin: Minimum x (horizontal) location :arg xmax: Maximum x location @@ -794,10 +850,10 @@ class SliceCanvas(props.HasProperties): gl.glEnable(gl.GL_BLEND) gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) - if (len(self.imageList) == 0) or \ - (width == 0) or \ - (height == 0) or \ - (xmin == xmax) or \ + if (len(self.overlayList) == 0) or \ + (width == 0) or \ + (height == 0) or \ + (xmin == xmax) or \ (ymin == ymax): return @@ -855,19 +911,22 @@ class SliceCanvas(props.HasProperties): def _drawOffscreenTextures(self): - """Draws all of the off-screen :class:`.ImageRenderTexture` instances to the - canvas. + """Draws all of the off-screen :class:`.GLObjectRenderTexture` instances to + the canvas. - This method is called by :meth:`_draw` if :attr:`twoStageRender` is - enabled. + This method is called by :meth:`_draw` if :attr:`renderMode` is + set to ``offscreen``. """ - log.debug('Combining off-screen image textures, and rendering ' + + log.debug('Combining off-screen render textures, and rendering ' 'to canvas (size {})'.format(self._getSize())) - for image in self.displayCtx.getOrderedImages(): - rt = self._offscreenTextures.get(image, None) - display = self.displayCtx.getDisplayProperties(image) - lo, hi = display.getDisplayBounds() + for overlay in self.displayCtx.getOrderedOverlays(): + + rt = self._offscreenTextures.get(overlay, None) + display = self.displayCtx.getDisplay(overlay) + opts = display.getDisplayOpts() + lo, hi = opts.getDisplayBounds() if rt is None or not display.enabled: continue @@ -875,9 +934,9 @@ class SliceCanvas(props.HasProperties): xmin, xmax = lo[self.xax], hi[self.xax] ymin, ymax = lo[self.yax], hi[self.yax] - log.debug('Drawing image {} texture to {:0.3f}-{:0.3f}, ' + log.debug('Drawing overlay {} texture to {:0.3f}-{:0.3f}, ' '{:0.3f}-{:0.3f}'.format( - image, xmin, xmax, ymin, ymax)) + overlay, xmin, xmax, ymin, ymax)) rt.drawOnBounds( self.pos.z, xmin, xmax, ymin, ymax, self.xax, self.yax) @@ -898,43 +957,49 @@ class SliceCanvas(props.HasProperties): if self.renderMode is not 'offscreen': self._setViewport() - for image in self.displayCtx.getOrderedImages(): + for overlay in self.displayCtx.getOrderedOverlays(): - display = self.displayCtx.getDisplayProperties(image) - globj = self._glObjects.get(image, None) - - if (globj is None) or (not display.enabled): + display = self.displayCtx.getDisplay(overlay) + opts = display.getDisplayOpts() + globj = self._glObjects.get(overlay, None) + + if not display.enabled: continue + + if globj is None: + globj = self.__genGLObject(overlay) # On-screen rendering - the globject is # rendered directly to the screen canvas if self.renderMode == 'onscreen': - log.debug('Drawing {} slice for image {} ' + log.debug('Drawing {} slice for overlay {} ' 'directly to canvas'.format( - self.zax, image.name)) + self.zax, display.name)) globj.preDraw() globj.draw(self.pos.z) globj.postDraw() - # Off-screen rendering - each image is + # Off-screen rendering - each overlay is # rendered to an off-screen texture - # these textures are combined below. # Set up the texture as the rendering # target, and draw to it elif self.renderMode == 'offscreen': - rt = self._offscreenTextures.get(image, None) - lo, hi = display.getDisplayBounds() + rt = self._offscreenTextures.get(overlay, None) + lo, hi = opts.getDisplayBounds() # Assume that all is well - the texture # just has not yet been created if rt is None: + log.debug('Render texture missing for overlay {}'.format( + overlay)) continue - log.debug('Drawing {} slice for image {} ' + log.debug('Drawing {} slice for overlay {} ' 'to off-screen texture'.format( - self.zax, image.name)) + self.zax, overlay.name)) rt.bindAsRenderTarget() rt.setRenderViewport(self.xax, self.yax, lo, hi) @@ -945,19 +1010,22 @@ class SliceCanvas(props.HasProperties): globj.draw(self.pos.z) globj.postDraw() + rt.unbindAsRenderTarget() + rt.restoreViewport() + # Pre-rendering - a pre-generated 2D # texture of the current z position # is rendered to the screen canvas elif self.renderMode == 'prerender': - rt, name = self._prerenderTextures.get(image, (None, None)) + rt, name = self._prerenderTextures.get(overlay, (None, None)) if rt is None: continue - log.debug('Drawing {} slice for image {} ' + log.debug('Drawing {} slice for overlay {} ' 'from pre-rendered texture'.format( - self.zax, image.name)) + self.zax, display.name)) rt.draw(self.pos.z) @@ -966,7 +1034,6 @@ class SliceCanvas(props.HasProperties): # those off-screen textures are all rendered on # to the screen canvas. if self.renderMode == 'offscreen': - textures.GLObjectRenderTexture.unbindAsRenderTarget() self._setViewport() self._drawOffscreenTextures() diff --git a/fsl/fslview/gl/textures/__init__.py b/fsl/fslview/gl/textures/__init__.py index a10bec9ac935815a9a1cb983b6339d76dcaebcd5..8ddaf9bc7007e1a1ca035f4efb893cd88a5c6a92 100644 --- a/fsl/fslview/gl/textures/__init__.py +++ b/fsl/fslview/gl/textures/__init__.py @@ -19,6 +19,7 @@ from texture import Texture from texture import Texture2D from imagetexture import ImageTexture from colourmaptexture import ColourMapTexture +from lookuptabletexture import LookupTableTexture from selectiontexture import SelectionTexture from rendertexture import RenderTexture from rendertexture import GLObjectRenderTexture diff --git a/fsl/fslview/gl/textures/colourmaptexture.py b/fsl/fslview/gl/textures/colourmaptexture.py index ef150ab3499212f7023d613d8ae4e87d0bf66730..93cbf010e3eebe42aece89b9f66d4e06abe92a67 100644 --- a/fsl/fslview/gl/textures/colourmaptexture.py +++ b/fsl/fslview/gl/textures/colourmaptexture.py @@ -27,6 +27,7 @@ class ColourMapTexture(texture.Texture): self.__resolution = resolution self.__cmap = None self.__invert = False + self.__interp = None self.__alpha = None self.__displayRange = None self.__border = None @@ -39,6 +40,7 @@ class ColourMapTexture(texture.Texture): def setColourMap( self, cmap): self.set(cmap=cmap) def setAlpha( self, alpha): self.set(alpha=alpha) def setInvert( self, invert): self.set(invert=invert) + def setInterp( self, interp): self.set(interp=interp) def setDisplayRange(self, drange): self.set(displayRange=drange) def setBorder( self, border): self.set(border=border) @@ -55,12 +57,14 @@ class ColourMapTexture(texture.Texture): # or not an attribute value was passed in cmap = kwargs.get('cmap', self) invert = kwargs.get('invert', self) + interp = kwargs.get('interp', self) alpha = kwargs.get('alpha', self) displayRange = kwargs.get('displayRange', self) border = kwargs.get('border', self) if cmap is not self: self.__cmap = cmap if invert is not self: self.__invert = invert + if interp is not self: self.__interp = interp if alpha is not self: self.__alpha = alpha if displayRange is not self: self.__displayRange = displayRange if border is not self: self.__border = border @@ -81,6 +85,7 @@ class ColourMapTexture(texture.Texture): else: cmap = self.__cmap invert = self.__invert + interp = self.__interp resolution = self.__resolution alpha = self.__alpha border = self.__border @@ -130,26 +135,33 @@ class ColourMapTexture(texture.Texture): self.bindTexture() if border is not None: - if alpha is not None: - border[3] = alpha + + if len(border) == 3: + newBorder = np.ones(4, dtype=np.float32) + newBorder[:3] = border + if alpha is not None: + newBorder[3] = alpha gl.glTexParameterfv(gl.GL_TEXTURE_1D, gl.GL_TEXTURE_BORDER_COLOR, border) gl.glTexParameteri( gl.GL_TEXTURE_1D, gl.GL_TEXTURE_WRAP_S, - gl.GL_CLAMP_TO_BORDER) + gl.GL_CLAMP_TO_BORDER) else: gl.glTexParameteri(gl.GL_TEXTURE_1D, gl.GL_TEXTURE_WRAP_S, gl.GL_CLAMP_TO_EDGE) + + if interp is None: + interp = gl.GL_NEAREST gl.glTexParameteri(gl.GL_TEXTURE_1D, gl.GL_TEXTURE_MAG_FILTER, - gl.GL_NEAREST) + interp) gl.glTexParameteri(gl.GL_TEXTURE_1D, gl.GL_TEXTURE_MIN_FILTER, - gl.GL_NEAREST) + interp) gl.glTexImage1D(gl.GL_TEXTURE_1D, 0, diff --git a/fsl/fslview/gl/textures/imagetexture.py b/fsl/fslview/gl/textures/imagetexture.py index 78d275bc8d6cb13a2071b377f8e6f52cd3063fc6..24c8d589fffcf8d1d19d436b137bb60b09cfd6c3 100644 --- a/fsl/fslview/gl/textures/imagetexture.py +++ b/fsl/fslview/gl/textures/imagetexture.py @@ -24,14 +24,6 @@ class ImageTexture(texture.Texture): """This class contains the logic required to create and manage a 3D texture which represents a :class:`~fsl.data.image.Image` instance. - On creation, an ``ImageTexture`` instance registers some listeners on the - properties of the :class:`~fsl.data.image.Image` instance (and the - :class:`~fsl.fslview.displaycontext.Display` instance if one is provided), - so that it may re-generate the texture data when these properties - change. For example, if the - :attr:`~fsl.fslview.displaycontext.Display.resolution` property changes, - the image data is re-sampled accordingly. - Once created, the following attributes are available on an :class:`ImageTexture` object: @@ -46,19 +38,15 @@ class ImageTexture(texture.Texture): def __init__(self, name, image, - display=None, nvals=1, normalise=False, - prefilter=None): + prefilter=None, + interp=gl.GL_NEAREST): """Create an :class:`ImageTexture`. :arg name: A name for the texture. :arg image: The :class:`~fsl.data.image.Image` instance. - - :arg display: A :class:`~fsl.fslview.displaycontext.Display` - instance which defines how the image is to be - displayed, or ``None`` if this image has no display. :arg nvals: Number of values per voxel. For example. a normal MRI or fMRI image contains only one value for each voxel. @@ -82,95 +70,97 @@ class ImageTexture(texture.Texture): 'size {} requested for ' 'image shape {}'.format(nvals, image.shape)) - self.image = image - self.display = display - self.nvals = nvals - self.prefilter = prefilter - - dtype = image.data.dtype - - # If the normalise flag is true, or the data is - # of a type which cannot be stored natively as - # an OpenGL texture, the data is cast to a - # standard type, and normalised - see - # _determineTextureType and _prepareTextureData - self.normalise = normalise or dtype not in (np.uint8, - np.int8, - np.uint16, - np.int16) - - texFmt, intFmt, texDtype, voxValXform = self._determineTextureType() - - self.texFmt = texFmt - self.texIntFmt = intFmt - self.texDtype = texDtype - self.voxValXform = voxValXform - self.invVoxValXform = transform.invert(voxValXform) - - self._addListeners() - self.refresh() + self.image = image + self.nvals = nvals + self.__interp = None + self.__resolution = None + self.__volume = None + self.__normalise = None + self.__prefilter = prefilter + # The __prefilter attribute is needed + # by the __imageDataChanged method, + # so we set it above. The other + # attributes are configured in the + # call to the set method, below. + + self.__name = '{}_{}'.format(type(self).__name__, id(self)) + self.image.addListener('data', + self.__name, + lambda *a: self.__imageDataChanged(), + weak=False) + + self.__imageDataChanged(False) + self.set(interp=interp, + prefilter=prefilter, + resolution=None, + volume=None, + normalise=normalise) def destroy(self): - """Deletes the texture identifier, and removes any property - listeners which were registered on the ``.Image`` and ``.Display`` - instances. - """ + """Deletes the texture identifier """ texture.Texture.destroy(self) - self._removeListeners() - - - def setPrefilter(self, prefilter): - """Updates the method used to pre-filter the data, and refreshes the - texture. + self.image.removeListener('data', self.__name) - See :meth:`__init__`. - """ - - changed = self.prefilter is not prefilter - self.prefilter = prefilter - if changed: - self.refresh() + def __imageDataChanged(self, refresh=True): - - def _addListeners(self): - """Adds listeners to some properties of the ``Image`` and ``Display`` - instances, so this ``ImageTexture`` can re-generate texture data - when necessary. - """ + data = self.image.data - display = self.display - image = self.image + if self.__prefilter is not None: + data = self.__prefilter(data) - def refreshInterp(*a): - self._updateInterpolationMethod() - - name = '{}_{}'.format(type(self).__name__, id(self)) - - image.addListener('data', name, self.refresh) + self.__dataMin = float(data.min()) + self.__dataMax = float(data.max()) - if display is not None: - display.addListener('interpolation', name, refreshInterp) - display.addListener('volume', name, self.refresh) - display.addListener('resolution', name, self.refresh) + if refresh: + self.refresh() - def _removeListeners(self): - """Called by the :meth:`destroy` method. Removes the property - listeners which were configured by the :meth:`_addListeners` - method. - """ - - name = '{}_{}'.format(type(self).__name__, id(self)) + def set(self, **kwargs): + interp = kwargs.get('interp', self.__interp) + prefilter = kwargs.get('prefilter', self.__prefilter) + resolution = kwargs.get('resolution', self.__resolution) + volume = kwargs.get('volume', self.__volume) + normalise = kwargs.get('normalise', self.__normalise) + + changed = (interp != self.__interp or + prefilter != self.__prefilter or + resolution != self.__resolution or + volume != self.__volume or + normalise != self.__normalise) + + if not changed: + return - self.image.removeListener('data', name) + self.__interp = interp + self.__prefilter = prefilter + self.__resolution = resolution + self.__volume = volume + + # If the data is of a type which cannot be + # stored natively as an OpenGL texture, the + # data is cast to a standard type, and + # normalised - see _determineTextureType + # and _prepareTextureData + dtype = self.image.data.dtype + self.__normalise = normalise or dtype not in (np.uint8, + np.int8, + np.uint16, + np.int16) + + if prefilter != self.__prefilter: + self.__imageDataChanged(False) + + self.refresh() + - if self.display is not None: - self.display.removeListener('interpolation', name) - self.display.removeListener('volume', name) - self.display.removeListener('resolution', name) + def setInterp( self, interp): self.set(interp=interp) + def setPrefilter( self, prefilter): self.set(prefilter=prefilter) + def setResolution(self, resolution): self.set(resolution=resolution) + def setVolume( self, volume): self.set(volume=volume) + def setNormalise( self, normalise): self.set(normalise=normalise) def _determineTextureType(self): @@ -202,28 +192,34 @@ class ImageTexture(texture.Texture): to the appropriate range, and calculate a transformation matrix to transform back to the data range. - This method returns a tuple containing the following: + This method sets the following attributes on thius ``ImageTexture`` + instance: - - The texture format (e.g. ``GL_RGB``, ``GL_LUMINANCE``) + - ``texFmt``: The texture format (e.g. ``GL_RGB``, + ``GL_LUMINANCE``) - - The internal texture format used by OpenGL for storage (e.g. - ``GL_RGB16``, ``GL_LUMINANCE8``). + - ``texIntFmt``: The internal texture format used by OpenGL for + storage (e.g. ``GL_RGB16``, ``GL_LUMINANCE8``). - - The raw type of the texture data (e.g. ``GL_UNSIGNED_SHORT``) + - ``texDtype``: The raw type of the texture data (e.g. + ``GL_UNSIGNED_SHORT``) - - An affine transformation matrix which encodes an offset and a - scale, which may be used to transform the texture data from - the range [0.0, 1.0] to its original range. + - ``voxValXform``: An affine transformation matrix which encodes + an offset and a scale, which may be used to + transform the texture data from the range + [0.0, 1.0] to its original range. + + - ``invVoxValXform``: Inverse of ``voxValXform``. """ data = self.image.data - if self.prefilter is not None: - data = self.prefilter(data) + if self.__prefilter is not None: + data = self.__prefilter(data) dtype = data.dtype - dmin = float(data.min()) - dmax = float(data.max()) + dmin = self.__dataMin + dmax = self.__dataMax # Signed data types are a pain in the arse. # @@ -232,7 +228,7 @@ class ImageTexture(texture.Texture): # for signed types. # Texture data type - if self.normalise: texDtype = gl.GL_UNSIGNED_BYTE + if self.__normalise: texDtype = gl.GL_UNSIGNED_SHORT elif dtype == np.uint8: texDtype = gl.GL_UNSIGNED_BYTE elif dtype == np.int8: texDtype = gl.GL_UNSIGNED_BYTE elif dtype == np.uint16: texDtype = gl.GL_UNSIGNED_SHORT @@ -251,28 +247,28 @@ class ImageTexture(texture.Texture): # Internal texture format if self.nvals == 1: - if self.normalise: intFmt = gl.GL_LUMINANCE8 + if self.__normalise: intFmt = gl.GL_LUMINANCE16 elif dtype == np.uint8: intFmt = gl.GL_LUMINANCE8 elif dtype == np.int8: intFmt = gl.GL_LUMINANCE8 elif dtype == np.uint16: intFmt = gl.GL_LUMINANCE16 elif dtype == np.int16: intFmt = gl.GL_LUMINANCE16 elif self.nvals == 2: - if self.normalise: intFmt = gl.GL_LUMINANCE8_ALPHA8 + if self.__normalise: intFmt = gl.GL_LUMINANCE16_ALPHA16 elif dtype == np.uint8: intFmt = gl.GL_LUMINANCE8_ALPHA8 elif dtype == np.int8: intFmt = gl.GL_LUMINANCE8_ALPHA8 elif dtype == np.uint16: intFmt = gl.GL_LUMINANCE16_ALPHA16 elif dtype == np.int16: intFmt = gl.GL_LUMINANCE16_ALPHA16 elif self.nvals == 3: - if self.normalise: intFmt = gl.GL_RGB8 + if self.__normalise: intFmt = gl.GL_RGB16 elif dtype == np.uint8: intFmt = gl.GL_RGB8 elif dtype == np.int8: intFmt = gl.GL_RGB8 elif dtype == np.uint16: intFmt = gl.GL_RGB16 elif dtype == np.int16: intFmt = gl.GL_RGB16 elif self.nvals == 4: - if self.normalise: intFmt = gl.GL_RGBA8 + if self.__normalise: intFmt = gl.GL_RGBA16 elif dtype == np.uint8: intFmt = gl.GL_RGBA8 elif dtype == np.int8: intFmt = gl.GL_RGBA8 elif dtype == np.uint16: intFmt = gl.GL_RGBA16 @@ -281,13 +277,13 @@ class ImageTexture(texture.Texture): # Offsets/scales which can be used to transform from # the texture data (which may be offset or normalised) # back to the original voxel data - if self.normalise: offset = dmin + if self.__normalise: offset = dmin elif dtype == np.uint8: offset = 0 elif dtype == np.int8: offset = -128 elif dtype == np.uint16: offset = 0 elif dtype == np.int16: offset = -32768 - if self.normalise: scale = dmax - dmin + if self.__normalise: scale = dmax - dmin elif dtype == np.uint8: scale = 255 elif dtype == np.int8: scale = 255 elif dtype == np.uint16: scale = 65535 @@ -334,11 +330,15 @@ class ImageTexture(texture.Texture): sTexDtype, sTexFmt, sIntFmt, - self.normalise, + self.__normalise, scale, offset)) - return texFmt, intFmt, texDtype, voxValXform + self.texFmt = texFmt + self.texIntFmt = intFmt + self.texDtype = texDtype + self.voxValXform = voxValXform + self.invVoxValXform = transform.invert(voxValXform) def _prepareTextureData(self): @@ -360,32 +360,34 @@ class ImageTexture(texture.Texture): be used as-is). """ - dtype = self.image.data.dtype + image = self.image + data = image.data + dtype = data.dtype + + volume = self.__volume + resolution = self.__resolution + prefilter = self.__prefilter + normalise = self.__normalise + + if volume is None: + volume = 0 - if self.display is None: - data = self.image.data + if image.is4DImage() and self.nvals == 1: + data = data[..., volume] + + if resolution is not None: + data = glroutines.subsample(data, resolution, image.pixdim)[0] - else: - if self.nvals == 1 and self.image.is4DImage(): - data = glroutines.subsample(self.image.data, - self.display.resolution, - self.image.pixdim, - self.display.volume)[0] - else: - data = glroutines.subsample(self.image.data, - self.display.resolution, - self.image.pixdim)[0] - - if self.prefilter is not None: - data = self.prefilter(data) + if prefilter is not None: + data = prefilter(data) - if self.normalise: - dmin = float(data.min()) - dmax = float(data.max()) + if normalise: + dmin = float(self.__dataMin) + dmax = float(self.__dataMax) if dmax != dmin: data = (data - dmin) / (dmax - dmin) - data = np.round(data * 255) - data = np.array(data, dtype=np.uint8) + data = np.round(data * 65535) + data = np.array(data, dtype=np.uint16) elif dtype == np.uint8: pass elif dtype == np.int8: data = np.array(data + 128, dtype=np.uint8) @@ -394,35 +396,13 @@ class ImageTexture(texture.Texture): return data - - def _updateInterpolationMethod(self, *a): - """Sets the interpolation method for the texture from the value of the - :attr:`~fsl.fslview.displaycontext.display.Display.interpolation` - property. - """ - - display = self.display - - # Set up image texture sampling thingos - # with appropriate interpolation method - if display is None or display.interpolation == 'none': - interp = gl.GL_NEAREST - else: - interp = gl.GL_LINEAR - - self.bindTexture() - - gl.glTexParameteri(gl.GL_TEXTURE_3D, gl.GL_TEXTURE_MAG_FILTER, interp) - gl.glTexParameteri(gl.GL_TEXTURE_3D, gl.GL_TEXTURE_MIN_FILTER, interp) - - self.unbindTexture() - def refresh(self, *a): """(Re-)generates the OpenGL image texture used to store the image data. """ + self._determineTextureType() data = self._prepareTextureData() # It is assumed that, for textures with more than one @@ -448,12 +428,16 @@ class ImageTexture(texture.Texture): gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) - # Set the texture filtering - # (interpolation) method - self._updateInterpolationMethod() - self.bindTexture() + # set interpolation routine + gl.glTexParameteri(gl.GL_TEXTURE_3D, + gl.GL_TEXTURE_MAG_FILTER, + self.__interp) + gl.glTexParameteri(gl.GL_TEXTURE_3D, + gl.GL_TEXTURE_MIN_FILTER, + self.__interp) + # Clamp texture borders to the edge # values - it is the responsibility # of the rendering logic to not draw diff --git a/fsl/fslview/gl/textures/lookuptabletexture.py b/fsl/fslview/gl/textures/lookuptabletexture.py new file mode 100644 index 0000000000000000000000000000000000000000..398abe091c16950ccfbcf371a6886f2b97723c44 --- /dev/null +++ b/fsl/fslview/gl/textures/lookuptabletexture.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python +# +# lookuptabletexture.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import logging + +import OpenGL.GL as gl +import numpy as np + + +import texture +import fsl.fslview.colourmaps as fslcmaps + + +log = logging.getLogger(__name__) + + +class LookupTableTexture(texture.Texture): + + def __init__(self, name): + + texture.Texture.__init__(self, name, 1) + + self.__lut = None + self.__alpha = None + self.__brightness = None + self.__contrast = None + + + def set(self, **kwargs): + + lut = kwargs.get('lut', self) + alpha = kwargs.get('alpha', self) + brightness = kwargs.get('brightness', self) + contrast = kwargs.get('contrast', self) + + if lut is not self: self.__lut = lut + if alpha is not self: self.__alpha = alpha + if brightness is not self: self.__brightness = brightness + if contrast is not self: self.__contrast = contrast + + self.__refresh() + + + def refresh(self): + self.__refresh() + + + def __refresh(self, *a): + + lut = self.__lut + alpha = self.__alpha + brightness = self.__brightness + contrast = self.__contrast + + if lut is None: + raise RuntimeError('Lookup table has not been defined') + + if brightness is None: brightness = 0.5 + if contrast is None: contrast = 0.5 + + # Enough memory is allocated for the lut texture + # so that shader programs can use label values + # as indices into the texture. Not very memory + # efficient, but greatly reduces complexity. + nvals = lut.max() + 1 + data = np.zeros((nvals, 4), dtype=np.uint8) + + for lbl in lut.labels: + + value = lbl.value() + colour = fslcmaps.applyBricon(lbl.colour(), brightness, contrast) + + data[value, :3] = [np.floor(c * 255) for c in colour] + + if not lbl.enabled(): data[value, 3] = 0 + elif alpha is not None: data[value, 3] = 255 * alpha + else: data[value, 3] = 255 + + data = data.ravel('C') + + self.bindTexture() + + # Values out of range are clipped + gl.glTexParameterfv(gl.GL_TEXTURE_1D, + gl.GL_TEXTURE_BORDER_COLOR, + np.array([0, 0, 0, 0], dtype=np.float32)) + gl.glTexParameteri( gl.GL_TEXTURE_1D, + gl.GL_TEXTURE_WRAP_S, + gl.GL_CLAMP_TO_BORDER) + + # Nearest neighbour interpolation + 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.glTexImage1D(gl.GL_TEXTURE_1D, + 0, + gl.GL_RGBA8, + nvals, + 0, + gl.GL_RGBA, + gl.GL_UNSIGNED_BYTE, + data) + self.unbindTexture() diff --git a/fsl/fslview/gl/textures/rendertexture.py b/fsl/fslview/gl/textures/rendertexture.py index fe216b85f59d95cc84bb22e622e165d316c26710..db49c50eb077c4fd8872010c6e0cc0017c8e1882 100644 --- a/fsl/fslview/gl/textures/rendertexture.py +++ b/fsl/fslview/gl/textures/rendertexture.py @@ -32,16 +32,28 @@ class RenderTexture(texture.Texture2D): """ texture.Texture2D.__init__(self, name, interp) + + self.__frameBuffer = glfbo.glGenFramebuffersEXT(1) + self.__renderBuffer = glfbo.glGenRenderbuffersEXT(1) - self.__frameBuffer = glfbo.glGenFramebuffersEXT(1) - log.debug('Created fbo: {}'.format(self.__frameBuffer)) + log.debug('Created fbo {} and render buffer {}'.format( + self.__frameBuffer, self.__renderBuffer)) + + self.__oldSize = None + self.__oldProjMat = None + self.__oldMVMat = None + self.__oldFrameBuffer = None + self.__oldRenderBuffer = None def destroy(self): texture.Texture.destroy(self) - log.debug('Deleting fbo {}'.format(self.__frameBuffer)) - glfbo.glDeleteFramebuffersEXT(gltypes.GLuint(self.__frameBuffer)) + log.debug('Deleting RB{}/FBO{}'.format( + self.__renderBuffer, + self.__frameBuffer)) + glfbo.glDeleteFramebuffersEXT( gltypes.GLuint(self.__frameBuffer)) + glfbo.glDeleteRenderbuffersEXT(1, gltypes.GLuint(self.__renderBuffer)) def setData(self, data): @@ -49,12 +61,19 @@ class RenderTexture(texture.Texture2D): 'instances'.format(type(self).__name__)) - def bindAsRenderTarget(self): - glfbo.glBindFramebufferEXT(glfbo.GL_FRAMEBUFFER_EXT, - self.__frameBuffer) + def setRenderViewport(self, xax, yax, lo, hi): + if self.__oldSize is not None or \ + self.__oldProjMat is not None or \ + self.__oldMVMat is not None: + raise RuntimeError('RenderTexture RB{}/FBO{} has already ' + 'configured the viewport'.format( + self.__renderBuffer, + self.__frameBuffer)) - def setRenderViewport(self, xax, yax, lo, hi): + log.debug('Configuring viewport for RB{}/FBO{}'.format( + self.__renderBuffer, + self.__frameBuffer)) width, height = self.getSize() @@ -67,32 +86,109 @@ class RenderTexture(texture.Texture2D): def restoreViewport(self): + if self.__oldSize is None or \ + self.__oldProjMat is None or \ + self.__oldMVMat is None: + raise RuntimeError('RenderTexture RB{}/FBO{} has not ' + 'configured the viewport'.format( + self.__renderBuffer, + self.__frameBuffer)) + + log.debug('Clearing viewport (from RB{}/FBO{})'.format( + self.__renderBuffer, + self.__frameBuffer)) + gl.glViewport(*self.__oldSize) gl.glMatrixMode(gl.GL_PROJECTION) gl.glLoadMatrixf(self.__oldProjMat) gl.glMatrixMode(gl.GL_MODELVIEW) - gl.glLoadMatrixf(self.__oldMVMat) + gl.glLoadMatrixf(self.__oldMVMat) + + self.__oldSize = None + self.__oldProjMat = None + self.__oldMVMat = None + + def bindAsRenderTarget(self): - @classmethod - def unbindAsRenderTarget(cls): - glfbo.glBindFramebufferEXT(glfbo.GL_FRAMEBUFFER_EXT, 0) + if self.__oldFrameBuffer is not None or \ + self.__oldRenderBuffer is not None: + raise RuntimeError('RenderTexture RB{}/FBO{} is not bound'.format( + self.__renderBuffer, + self.__frameBuffer)) + + self.__oldFrameBuffer = gl.glGetIntegerv( + glfbo.GL_FRAMEBUFFER_BINDING_EXT) + self.__oldRenderBuffer = gl.glGetIntegerv( + glfbo.GL_RENDERBUFFER_BINDING_EXT) + + log.debug('Setting RB{}/FBO{} as render target'.format( + self.__renderBuffer, + self.__frameBuffer)) + + glfbo.glBindFramebufferEXT( glfbo.GL_FRAMEBUFFER_EXT, + self.__frameBuffer) + glfbo.glBindRenderbufferEXT(glfbo.GL_RENDERBUFFER_EXT, + self.__renderBuffer) + + + def unbindAsRenderTarget(self): + + if self.__oldFrameBuffer is None or \ + self.__oldRenderBuffer is None: + raise RuntimeError('RenderTexture RB{}/FBO{} ' + 'has not been bound'.format( + self.__renderBuffer, + self.__frameBuffer)) + + log.debug('Restoring render target to RB{}/FBO{} ' + '(from RB{}/FBO{})'.format( + self.__oldRenderBuffer, + self.__oldFrameBuffer, + self.__renderBuffer, + self.__frameBuffer)) + + glfbo.glBindFramebufferEXT( glfbo.GL_FRAMEBUFFER_EXT, + self.__oldFrameBuffer) + glfbo.glBindRenderbufferEXT(glfbo.GL_RENDERBUFFER_EXT, + self.__oldRenderBuffer) + + self.__oldFrameBuffer = None + self.__oldRenderBuffer = None def refresh(self): texture.Texture2D.refresh(self) + width, height = self.getSize() + # Configure the frame buffer self.bindTexture() self.bindAsRenderTarget() - glfbo.glFramebufferTexture2DEXT(glfbo.GL_FRAMEBUFFER_EXT, - glfbo.GL_COLOR_ATTACHMENT0_EXT, - gl .GL_TEXTURE_2D, - self.getTextureHandle(), - 0) + glfbo.glFramebufferTexture2DEXT( + glfbo.GL_FRAMEBUFFER_EXT, + glfbo.GL_COLOR_ATTACHMENT0_EXT, + gl .GL_TEXTURE_2D, + self.getTextureHandle(), + 0) + + # and the render buffer + glfbo.glRenderbufferStorageEXT( + glfbo.GL_RENDERBUFFER_EXT, + gl.GL_DEPTH24_STENCIL8, + width, + height) + + glfbo.glFramebufferRenderbufferEXT( + glfbo.GL_FRAMEBUFFER_EXT, + gl.GL_DEPTH_STENCIL_ATTACHMENT, + glfbo.GL_RENDERBUFFER_EXT, + self.__renderBuffer) if glfbo.glCheckFramebufferStatusEXT(glfbo.GL_FRAMEBUFFER_EXT) != \ glfbo.GL_FRAMEBUFFER_COMPLETE_EXT: + self.unbindAsRenderTarget() + self.unbindTexture() raise RuntimeError('An error has occurred while ' 'configuring the frame buffer') @@ -144,13 +240,18 @@ class GLObjectRenderTexture(RenderTexture): resolution = globj.getDataResolution(self.__xax, self.__yax) + if resolution is None: + resolution = [256, 256, 256] + log.debug('Using default resolution ' + 'for GLObject {}'.format(type(globj).__name__)) + width = resolution[self.__xax] height = resolution[self.__yax] if width > maxRes or height > maxRes: oldWidth, oldHeight = width, height - ratio = min(width, height) / max(width, height) - + ratio = min(width, height) / float(max(width, height)) + if width > height: width = maxRes height = width * ratio diff --git a/fsl/fslview/gl/textures/rendertexturestack.py b/fsl/fslview/gl/textures/rendertexturestack.py index 560bca0beef92a2086733ff33a14f223674ec109..7fc99737a4016b430e0c3972a2b72b7ce03c59bd 100644 --- a/fsl/fslview/gl/textures/rendertexturestack.py +++ b/fsl/fslview/gl/textures/rendertexturestack.py @@ -7,7 +7,6 @@ import logging -import wx import numpy as np import OpenGL.GL as gl @@ -46,6 +45,7 @@ class RenderTextureStack(object): '{}_{}'.format(type(self).__name__, id(self)), self.__refreshAllTextures) + import wx wx.GetApp().Bind(wx.EVT_IDLE, self.__textureUpdateLoop) @@ -151,6 +151,8 @@ class RenderTextureStack(object): def __destroyTextures(self): + + import wx texes = self.__textures self.__textures = [] for tex in texes: diff --git a/fsl/fslview/gl/textures/texture.py b/fsl/fslview/gl/textures/texture.py index 0fac58404796aba126c27375f311d5c2b263c9dd..e5dca4732e99229e9f3f4dfbc1559c8936d1d86b 100644 --- a/fsl/fslview/gl/textures/texture.py +++ b/fsl/fslview/gl/textures/texture.py @@ -10,6 +10,8 @@ import logging import numpy as np import OpenGL.GL as gl +import fsl.utils.transform as transform + log = logging.getLogger(__name__) @@ -166,7 +168,6 @@ class Texture2D(Texture): data = self.__data if data is not None: - print data.shape, data.dtype data = data.ravel('F') log.debug('Configuring {} ({}) with size {}x{}'.format( @@ -209,11 +210,14 @@ class Texture2D(Texture): self.unbindTexture() - def draw(self, vertices): + def draw(self, vertices, xform=None): if vertices.shape != (6, 3): raise ValueError('Six vertices must be provided') + if xform is not None: + vertices = transform.transform(vertices, xform) + vertices = np.array(vertices, dtype=np.float32) texCoords = np.zeros((6, 2), dtype=np.float32) indices = np.arange(6, dtype=np.uint32) @@ -250,7 +254,7 @@ class Texture2D(Texture): gl.glDisableClientState(gl.GL_TEXTURE_COORD_ARRAY) - def drawOnBounds(self, zpos, xmin, xmax, ymin, ymax, xax, yax): + def drawOnBounds(self, zpos, xmin, xmax, ymin, ymax, xax, yax, xform=None): zax = 3 - xax - yax vertices = np.zeros((6, 3), dtype=np.float32) @@ -263,4 +267,4 @@ class Texture2D(Texture): vertices[ 4, [xax, yax]] = [xmin, ymax] vertices[ 5, [xax, yax]] = [xmax, ymax] - self.draw(vertices) + self.draw(vertices, xform) diff --git a/fsl/fslview/gl/wxglcolourbarcanvas.py b/fsl/fslview/gl/wxglcolourbarcanvas.py index 24b5e0b1f0b239eab504f4a4c3a5381c3ffa3021..5a1e2e07e0d1a24b18180d8d730261ee18133081 100644 --- a/fsl/fslview/gl/wxglcolourbarcanvas.py +++ b/fsl/fslview/gl/wxglcolourbarcanvas.py @@ -6,8 +6,7 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # """This module provides the :class:`WXGLColourBarCanvas`, for displaying a -:class:`~fsl.fslview.gl.colourbarcanvas.ColourBarCanvas` on a -:class:`wx.glcanvas.GLCanvas`. +:class:`.ColourBarCanvas` on a :class:`wx.glcanvas.GLCanvas`. """ import logging @@ -24,8 +23,8 @@ import fsl.fslview.gl.colourbarcanvas as cbarcanvas class WXGLColourBarCanvas(cbarcanvas.ColourBarCanvas, fslgl.WXGLCanvasTarget, wxgl.GLCanvas): - """A :class:`~fsl.fslview.gl.colourbarcanvas.ColourBarCanvas` which is also - a :class:`wx.glcanvas.GLCanvas`, for on screen rendering of colour bars. + """A :class:`.ColourBarCanvas` which is also a + :class:`wx.glcanvas.GLCanvas`, for on screen rendering of colour bars. """ def __init__(self, parent): diff --git a/fsl/fslview/gl/wxgllightboxcanvas.py b/fsl/fslview/gl/wxgllightboxcanvas.py index 06fedc2fa4a948422d8c6dac808720818568d6c7..75d1fe1589dbfce1e76f4ba5ce423e0fe40a0696 100644 --- a/fsl/fslview/gl/wxgllightboxcanvas.py +++ b/fsl/fslview/gl/wxgllightboxcanvas.py @@ -5,28 +5,26 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # """This module provides the :class:`WXGLLightBoxCanvas`, which is both a -:class:`~fsl.fslview.gl.lightboxcanvas.LightBoxCanvas`, and a -:class:`wx.glcanvas.GLCanvas`. +:class:`.LightBoxCanvas`, and a :class:`wx.glcanvas.GLCanvas`. """ -import logging -log = logging.getLogger(__name__) import wx -import wx.glcanvas as wxgl +import wx.glcanvas as wxgl + +import lightboxcanvas as lightboxcanvas +import fsl.fslview.gl as fslgl -import lightboxcanvas as lightboxcanvas -import fsl.fslview.gl as fslgl class WXGLLightBoxCanvas(lightboxcanvas.LightBoxCanvas, wxgl.GLCanvas, fslgl.WXGLCanvasTarget): - """A :class:`wx.glcanvas.GLCanvas` and a - :class:`~fsl.fslview.gl.slicecanvas.SliceCanvas`, for on-screen - interactive 2D slice rendering from a collection of 3D images. + """A :class:`wx.glcanvas.GLCanvas` and a :class:`.SliceCanvas`, for + on-screen interactive 2D slice rendering from a collection of 3D + overlays. """ - def __init__(self, parent, imageList, displayCtx, zax=0): + def __init__(self, parent, overlayList, displayCtx, zax=0): """Configures a few event handlers for cleaning up property listeners when the canvas is destroyed, and for redrawing on paint/resize events. @@ -34,60 +32,10 @@ class WXGLLightBoxCanvas(lightboxcanvas.LightBoxCanvas, wxgl.GLCanvas .__init__(self, parent) lightboxcanvas.LightBoxCanvas.__init__(self, - imageList, + overlayList, displayCtx, zax) fslgl.WXGLCanvasTarget .__init__(self) - - # the image list is probably going to outlive - # this SliceCanvas object, so we do the right - # thing and remove our listeners when we die - def onDestroy(ev): - ev.Skip() - - if ev.GetEventObject() is not self: - return - - self.removeListener('zax', self.name) - self.removeListener('pos', self.name) - self.removeListener('pos', - '{}_zPosChanged'.format(self.name)) - self.removeListener('displayBounds', self.name) - self.removeListener('showCursor', self.name) - self.removeListener('invertX', self.name) - self.removeListener('invertY', self.name) - self.removeListener('zoom', self.name) - self.removeListener('sliceSpacing', self.name) - self.removeListener('ncols', self.name) - self.removeListener('nrows', self.name) - self.removeListener('zrange', self.name) - self.removeListener('showGridLines', self.name) - self.removeListener('highlightSlice', self.name) - self.removeListener('topRow', self.name) - self.removeListener('renderMode', self.name) - - self.imageList .removeListener('images', self.name) - self.displayCtx.removeListener('bounds', self.name) - self.displayCtx.removeListener('imageOrder', self.name) - for image in self.imageList: - - disp = self.displayCtx.getDisplayProperties(image) - opts = disp.getDisplayOpts() - - image.removeListener('data', self.name) - disp .removeListener('imageType', self.name) - disp .removeListener('enabled', self.name) - disp .removeListener('transform', self.name) - disp .removeListener('softwareMode', self.name) - disp .removeListener('interpolation', self.name) - disp .removeListener('alpha', self.name) - disp .removeListener('brightness', self.name) - disp .removeListener('contrast', self.name) - disp .removeListener('resolution', self.name) - disp .removeListener('volume', self.name) - opts .removeGlobalListener( self.name) - - self.Bind(wx.EVT_WINDOW_DESTROY, onDestroy) # When the canvas is resized, we have to update # the display bounds to preserve the aspect ratio diff --git a/fsl/fslview/gl/wxglslicecanvas.py b/fsl/fslview/gl/wxglslicecanvas.py index 69b192001b942c0830a9fd4fee80e32302a73099..502679b7f21a6999e3875d391afd7efa1d9ffdda 100644 --- a/fsl/fslview/gl/wxglslicecanvas.py +++ b/fsl/fslview/gl/wxglslicecanvas.py @@ -5,17 +5,14 @@ # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -"""The :class:`WXGLSliceCanvas` class both is a -:class:`~fsl.fslview.gl.slicecanvas.SliceCanvas` and a +"""The :class:`WXGLSliceCanvas` class is both a :class:`.SliceCanvas` and a :class:`wx.glcanvas.GLCanvas` panel. It is the main class used for on-screen orthographic rendering of 3D image data (although most of the functionality is provided by the -:class:`~fsl.fslview.gl.slicecanvas.SliceCanvas` class). +:class:`.SliceCanvas` class). """ -import logging -log = logging.getLogger(__name__) import wx import wx.glcanvas as wxgl @@ -23,62 +20,24 @@ import wx.glcanvas as wxgl import slicecanvas as slicecanvas import fsl.fslview.gl as fslgl + class WXGLSliceCanvas(slicecanvas.SliceCanvas, wxgl.GLCanvas, fslgl.WXGLCanvasTarget): - """A :class:`wx.glcanvas.GLCanvas` and a - :class:`~fsl.fslview.gl.slicecanvas.SliceCanvas`, for on-screen - interactive 2D slice rendering from a collection of 3D images. + """A :class:`wx.glcanvas.GLCanvas` and a :class:`.SliceCanvas`, for + on-screen interactive 2D slice rendering from a collection of 3D + overlays. """ - def __init__(self, parent, imageList, displayCtx, zax=0): + def __init__(self, parent, overlayList, displayCtx, zax=0): """Configures a few event handlers for cleaning up property listeners when the canvas is destroyed, and for redrawing on paint/resize events. """ wxgl.GLCanvas .__init__(self, parent) - slicecanvas.SliceCanvas.__init__(self, imageList, displayCtx, zax) + slicecanvas.SliceCanvas.__init__(self, overlayList, displayCtx, zax) fslgl.WXGLCanvasTarget .__init__(self) - - # the image list is probably going to outlive - # this SliceCanvas object, so we do the right - # thing and remove our listeners when we die - def onDestroy(ev): - ev.Skip() - - if ev.GetEventObject() is not self: - return - - self.removeListener('zax', self.name) - self.removeListener('pos', self.name) - self.removeListener('displayBounds', self.name) - self.removeListener('showCursor', self.name) - self.removeListener('invertX', self.name) - self.removeListener('invertY', self.name) - self.removeListener('zoom', self.name) - self.removeListener('renderMode', self.name) - - self.imageList .removeListener('images', self.name) - self.displayCtx.removeListener('bounds', self.name) - self.displayCtx.removeListener('imageOrder', self.name) - for image in self.imageList: - disp = self.displayCtx.getDisplayProperties(image) - opts = disp.getDisplayOpts() - image.removeListener('data', self.name) - disp .removeListener('imageType', self.name) - disp .removeListener('enabled', self.name) - disp .removeListener('transform', self.name) - disp .removeListener('softwareMode', self.name) - disp .removeListener('interpolation', self.name) - disp .removeListener('alpha', self.name) - disp .removeListener('brightness', self.name) - disp .removeListener('contrast', self.name) - disp .removeListener('resolution', self.name) - disp .removeListener('volume', self.name) - opts .removeGlobalListener( self.name) - - self.Bind(wx.EVT_WINDOW_DESTROY, onDestroy) # When the canvas is resized, we have to update # the display bounds to preserve the aspect ratio diff --git a/fsl/fslview/layouts.py b/fsl/fslview/layouts.py index c7f6e5f09405a9d19b3c082b63d58b4d0166d14c..ae1147eca30f2cf32606d34aaf6b5a09327d4263 100644 --- a/fsl/fslview/layouts.py +++ b/fsl/fslview/layouts.py @@ -14,27 +14,8 @@ import fsl.utils.typedict as td import fsl.data.strings as strings import fsl.fslview.actions as actions -from fsl.fslview.profiles.orthoviewprofile import OrthoViewProfile -from fsl.fslview.profiles.orthoeditprofile import OrthoEditProfile - -from fsl.fslview.views.canvaspanel import CanvasPanel -from fsl.fslview.views.orthopanel import OrthoPanel -from fsl.fslview.views.lightboxpanel import LightBoxPanel -from fsl.fslview.views.histogrampanel import HistogramPanel - -from fsl.fslview.controls.orthotoolbar import OrthoToolBar -from fsl.fslview.controls.lightboxtoolbar import LightBoxToolBar -from fsl.fslview.controls.imagedisplaytoolbar import ImageDisplayToolBar - -from fsl.fslview.displaycontext import Display -from fsl.fslview.displaycontext.volumeopts import VolumeOpts -from fsl.fslview.displaycontext.maskopts import MaskOpts -from fsl.fslview.displaycontext.vectoropts import VectorOpts -from fsl.fslview.displaycontext.vectoropts import LineVectorOpts - -from fsl.fslview.displaycontext.sceneopts import SceneOpts -from fsl.fslview.displaycontext.orthoopts import OrthoOpts -from fsl.fslview.displaycontext.lightboxopts import LightBoxOpts +from fsl.fslview.profiles.orthoviewprofile import OrthoViewProfile +from fsl.fslview.profiles.orthoeditprofile import OrthoEditProfile def widget(labelCls, name, *args, **kwargs): @@ -48,15 +29,6 @@ def widget(labelCls, name, *args, **kwargs): ######################################## -OrthoToolBarLayout = [ - actions.ActionButton(OrthoPanel, 'screenshot'), - widget( OrthoOpts, 'zoom', spin=False, showLimits=False), - widget( OrthoOpts, 'layout'), - widget( OrthoOpts, 'showXCanvas'), - widget( OrthoOpts, 'showYCanvas'), - widget( OrthoOpts, 'showZCanvas'), - actions.ActionButton(OrthoToolBar, 'more')] - OrthoProfileToolBarViewLayout = [ actions.ActionButton(OrthoViewProfile, 'resetZoom'), @@ -90,189 +62,16 @@ OrthoProfileToolBarEditLayout = [ enabledWhen=lambda p: p.mode == 'selint')] -CanvasPanelLayout = props.VGroup(( - widget(CanvasPanel, - 'profile', - visibleWhen=lambda i: len(i.getProp('profile').getChoices(i)) > 1), - widget(CanvasPanel, 'syncImageOrder'), - widget(CanvasPanel, 'syncLocation'))) - -SceneOptsLayout = props.VGroup(( - widget(SceneOpts, 'showCursor'), - widget(SceneOpts, 'performance', spin=False, showLimits=False), - widget(SceneOpts, 'showColourBar'), - widget(SceneOpts, 'colourBarLabelSide'), - widget(SceneOpts, 'colourBarLocation'))) - - -OrthoPanelLayout = props.VGroup(( - widget(OrthoOpts, 'layout'), - widget(OrthoOpts, 'zoom', spin=False, showLimits=False), - widget(OrthoOpts, 'showLabels'), - props.HGroup((widget(OrthoOpts, 'showXCanvas'), - widget(OrthoOpts, 'showYCanvas'), - widget(OrthoOpts, 'showZCanvas'))))) - - -####################################### -# LightBoxPanel control panels/toolbars -####################################### - -LightBoxToolBarLayout = [ - actions.ActionButton(LightBoxPanel, 'screenshot'), - widget( LightBoxOpts, 'zax'), - - widget(LightBoxOpts, 'sliceSpacing', spin=False, showLimits=False), - widget(LightBoxOpts, 'zrange', spin=False, showLimits=False), - widget(LightBoxOpts, 'zoom', spin=False, showLimits=False), - actions.ActionButton(LightBoxToolBar, 'more')] - - -LightBoxPanelLayout = props.VGroup(( - widget(LightBoxOpts, 'zax'), - widget(LightBoxOpts, 'zoom'), - widget(LightBoxOpts, 'sliceSpacing'), - widget(LightBoxOpts, 'zrange'), - widget(LightBoxOpts, 'highlightSlice'), - widget(LightBoxOpts, 'showGridLines'))) - - - -######################################## -# Image display property panels/toolbars -######################################## - - -DisplayToolBarLayout = [ - widget(Display, 'name'), - widget(Display, 'imageType'), - widget(Display, 'alpha', spin=False, showLimits=False), - widget(Display, 'brightness', spin=False, showLimits=False), - widget(Display, 'contrast', spin=False, showLimits=False)] - - -VolumeOptsToolBarLayout = [ - widget(VolumeOpts, 'cmap'), - actions.ActionButton(ImageDisplayToolBar, 'more')] - - -MaskOptsToolBarLayout = [ - widget(MaskOpts, 'colour'), - actions.ActionButton(ImageDisplayToolBar, 'more')] - - -VectorOptsToolBarLayout = [ - widget(VectorOpts, 'modulate'), - widget(VectorOpts, 'modThreshold', showLimits=False, spin=False), - actions.ActionButton(ImageDisplayToolBar, 'more')] - - -DisplayLayout = props.VGroup( - (widget(Display, 'name'), - widget(Display, 'imageType'), - widget(Display, 'resolution', showLimits=False), - widget(Display, 'transform'), - widget(Display, 'interpolation'), - widget(Display, 'volume', showLimits=False), - widget(Display, 'enabled'), - widget(Display, 'alpha', showLimits=False, editLimits=False), - widget(Display, 'brightness', showLimits=False, editLimits=False), - widget(Display, 'contrast', showLimits=False, editLimits=False))) - - -VolumeOptsLayout = props.VGroup( - (widget(VolumeOpts, 'cmap'), - widget(VolumeOpts, 'invert'), - widget(VolumeOpts, 'displayRange', showLimits=False, slider=True), - widget(VolumeOpts, 'clippingRange', showLimits=False, slider=True))) - - -MaskOptsLayout = props.VGroup( - (widget(MaskOpts, 'colour'), - widget(MaskOpts, 'invert'), - widget(MaskOpts, 'threshold', showLimits=False))) - - -VectorOptsLayout = props.VGroup(( - props.HGroup(( - widget(VectorOpts, 'xColour'), - widget(VectorOpts, 'yColour'), - widget(VectorOpts, 'zColour')), - vertLabels=True), - props.HGroup(( - widget(VectorOpts, 'suppressX'), - widget(VectorOpts, 'suppressY'), - widget(VectorOpts, 'suppressZ')), - vertLabels=True), - widget(VectorOpts, 'modulate'), - widget(VectorOpts, 'modThreshold', showLimits=False, spin=False))) - -LineVectorOptsLayout = props.VGroup(( - props.HGroup(( - widget(LineVectorOpts, 'xColour'), - widget(LineVectorOpts, 'yColour'), - widget(LineVectorOpts, 'zColour')), - vertLabels=True), - props.HGroup(( - widget(LineVectorOpts, 'suppressX'), - widget(LineVectorOpts, 'suppressY'), - widget(LineVectorOpts, 'suppressZ')), - vertLabels=True), - widget(LineVectorOpts, 'directed'), - widget(LineVectorOpts, 'lineWidth', showLimits=False), - widget(LineVectorOpts, 'modulate'), - widget(LineVectorOpts, 'modThreshold', showLimits=False, spin=False))) - - -########################## -# Histogram toolbar/panels -########################## - - -# TODO add type-specific options here, to hide spin panels/limit -# buttons for the numeric sliders, when the props module supports it -HistogramToolBarLayout = [ - actions.ActionButton(HistogramPanel, 'screenshot'), - props.Widget('dataRange', showLimits=False), - props.Widget('nbins', - enabledWhen=lambda p: not p.autoHist, - spin=False, showLimits=False), - props.Widget('autoHist')] - - layouts = td.TypeDict({ - 'CanvasPanel' : CanvasPanelLayout, - 'OrthoPanel' : OrthoPanelLayout, - 'LightBoxPanel' : LightBoxPanelLayout, - - 'SceneOpts' : SceneOptsLayout, - - ('ImageDisplayToolBar', 'Display') : DisplayToolBarLayout, - ('ImageDisplayToolBar', 'VolumeOpts') : VolumeOptsToolBarLayout, - ('ImageDisplayToolBar', 'MaskOpts') : MaskOptsToolBarLayout, - ('ImageDisplayToolBar', 'VectorOpts') : VectorOptsToolBarLayout, - - ('ImageDisplayPanel', 'Display') : DisplayLayout, - ('ImageDisplayPanel', 'VolumeOpts') : VolumeOptsLayout, - ('ImageDisplayPanel', 'MaskOpts') : MaskOptsLayout, - ('ImageDisplayPanel', 'VectorOpts') : VectorOptsLayout, - ('ImageDisplayPanel', 'LineVectorOpts') : LineVectorOptsLayout, - - 'OrthoToolBar' : OrthoToolBarLayout, - 'LightBoxToolBar' : LightBoxToolBarLayout, - ('OrthoProfileToolBar', 'view') : OrthoProfileToolBarViewLayout, ('OrthoProfileToolBar', 'edit') : OrthoProfileToolBarEditLayout, - - 'HistogramToolBar' : HistogramToolBarLayout, }) locations = td.TypeDict({ 'LocationPanel' : aui.AUI_DOCK_BOTTOM, - 'ImageListPanel' : aui.AUI_DOCK_BOTTOM, + 'OverlayListPanel' : aui.AUI_DOCK_BOTTOM, 'AtlasPanel' : aui.AUI_DOCK_BOTTOM, 'ImageDisplayToolBar' : aui.AUI_DOCK_TOP, - }) diff --git a/fsl/fslview/luts/harvard-oxford-cortical.lut b/fsl/fslview/luts/harvard-oxford-cortical.lut new file mode 100644 index 0000000000000000000000000000000000000000..8863dc889a6b67d2d2195290b9d3754403678db3 --- /dev/null +++ b/fsl/fslview/luts/harvard-oxford-cortical.lut @@ -0,0 +1,48 @@ +1 0.00000 0.93333 0.00000 Frontal Pole +2 0.62745 0.32157 0.17647 Insular Cortex +3 1.00000 0.85490 0.72549 Superior Frontal Gyrus +4 0.00000 0.80784 0.81961 Middle Frontal Gyrus +5 0.49804 1.00000 0.83137 Inferior Frontal Gyrus, pars triangularis +6 0.69804 0.13333 0.13333 Inferior Frontal Gyrus, pars opercularis +7 0.93333 0.00000 0.00000 Precentral Gyrus +8 0.13333 0.54510 0.13333 Temporal Pole +9 0.81569 0.12549 0.56471 Superior Temporal Gyrus, anterior division +10 0.67843 1.00000 0.18431 Superior Temporal Gyrus, posterior division +11 0.94118 0.90196 0.54902 Middle Temporal Gyrus, anterior division +12 0.67843 0.84706 0.90196 Middle Temporal Gyrus, posterior division +13 0.93333 0.93333 0.00000 Middle Temporal Gyrus, temporooccipital part +14 0.19608 0.80392 0.19608 Inferior Temporal Gyrus, anterior division +15 1.00000 0.00000 1.00000 Inferior Temporal Gyrus, posterior division +16 0.69020 0.18824 0.37647 Inferior Temporal Gyrus, temporooccipital part +17 0.00000 1.00000 0.49804 Postcentral Gyrus +18 0.96078 0.87059 0.70196 Superior Parietal Lobule +19 1.00000 0.64706 0.00000 Supramarginal Gyrus, anterior division +20 1.00000 0.27059 0.00000 Supramarginal Gyrus, posterior division +21 0.80392 0.35686 0.27059 Angular Gyrus +22 1.00000 0.75294 0.79608 Lateral Occipital Cortex, superior division +23 0.59608 0.98431 0.59608 Lateral Occipital Cortex, inferior division +24 0.39216 0.58431 0.92941 Intracalcarine Cortex +25 0.62745 0.12549 0.94118 Frontal Medial Cortex +26 0.93333 0.50980 0.93333 Juxtapositional Lobule Cortex (formerly Supplementary Motor Cortex) +27 0.93333 0.78824 0.00000 Subcallosal Cortex +28 0.85490 0.43922 0.83922 Paracingulate Gyrus +29 1.00000 0.24314 0.58824 Cingulate Gyrus, anterior division +30 0.00000 0.00000 1.00000 Cingulate Gyrus, posterior division +31 0.15294 0.25098 0.54510 Precuneous Cortex +32 0.98039 0.50196 0.44706 Cuneal Cortex +33 1.00000 0.43137 0.70588 Frontal Orbital Cortex +34 1.00000 0.38824 0.27843 Parahippocampal Gyrus, anterior division +35 1.00000 1.00000 0.00000 Parahippocampal Gyrus, posterior division +36 0.00000 0.39216 0.00000 Lingual Gyrus +37 0.80392 0.36078 0.36078 Temporal Fusiform Cortex, anterior division +38 0.64706 0.16471 0.16471 Temporal Fusiform Cortex, posterior division +39 0.60000 0.19608 0.80000 Temporal Occipital Fusiform Cortex +40 0.00000 1.00000 1.00000 Occipital Fusiform Gyrus +41 0.86667 0.62745 0.86667 Frontal Operculum Cortex +42 0.52941 0.80784 0.92157 Central Opercular Cortex +43 0.82353 0.70588 0.54902 Parietal Operculum Cortex +44 1.00000 0.84314 0.00000 Planum Polare +45 0.00000 0.00000 0.50196 Heschl's Gyrus (includes H1 and H2) +46 0.18039 0.54510 0.34118 Planum Temporale +47 0.40000 0.80392 0.66667 Supracalcarine Cortex +48 0.00000 1.00000 0.00000 Occipital Pole diff --git a/fsl/fslview/luts/harvard-oxford-subcortical.lut b/fsl/fslview/luts/harvard-oxford-subcortical.lut new file mode 100644 index 0000000000000000000000000000000000000000..60e407b67806cabc7a7bb36bfe2dce13632cb358 --- /dev/null +++ b/fsl/fslview/luts/harvard-oxford-subcortical.lut @@ -0,0 +1,21 @@ +1 0.80392 0.24314 0.30588 Left Cerebral White Matter +2 0.96078 0.96078 0.96078 Left Cerebral Cortex +3 0.80392 0.24314 0.30588 Left Lateral Ventricle +4 0.47059 0.07059 0.52549 Left Thalamus +5 0.76863 0.22745 0.98039 Left Caudate +6 0.00000 0.58039 0.00000 Left Putamen +7 0.86275 0.97255 0.64314 Left Pallidum +8 0.90196 0.58039 0.13333 Brainstem +9 0.00000 0.46275 0.05490 Left Hippocampus +10 0.00000 0.46275 0.05490 Left Amygdala +11 0.47843 0.72941 0.86275 Left Accumbens +12 0.92549 0.05098 0.69020 Right Cerebral White Matter +13 0.04706 0.18824 1.00000 Right Cerebral Cortex +14 0.80000 0.71373 0.55686 Right Lateral Ventricle +15 0.16471 0.80000 0.64314 Right Thalamus +16 0.46667 0.62353 0.69020 Right Caudate +17 0.86275 0.84706 0.07843 Right Putamen +18 0.40392 1.00000 1.00000 Right Pallidum +19 0.31373 0.76863 0.38431 Right Hippocampus +20 0.23529 0.22745 0.82353 Right Amygdala +21 0.23529 0.22745 0.82353 Right Accumbens diff --git a/fsl/fslview/luts/order.txt b/fsl/fslview/luts/order.txt new file mode 100644 index 0000000000000000000000000000000000000000..95288198080085a5294bc62eea36bfd5bcdb2e40 --- /dev/null +++ b/fsl/fslview/luts/order.txt @@ -0,0 +1,3 @@ +random Random +harvard-oxford-cortical MGH Cortical +harvard-oxford-subcortical MGH Sub-cortical diff --git a/fsl/fslview/luts/random.lut b/fsl/fslview/luts/random.lut new file mode 100644 index 0000000000000000000000000000000000000000..ec8eb804ad2b49df7a5b9d086a8e845d842cab51 --- /dev/null +++ b/fsl/fslview/luts/random.lut @@ -0,0 +1,100 @@ +1 0.753957 0.867576 0.271705 1 +2 0.175670 0.556856 0.853074 2 +3 0.616579 0.428308 0.915121 3 +4 0.110243 0.436278 0.131227 4 +5 0.465684 0.976166 0.635108 5 +6 0.825495 0.771942 0.013743 6 +7 0.628049 0.636244 0.519229 7 +8 0.101028 0.565114 0.029126 8 +9 0.418215 0.220175 0.966078 9 +10 0.816653 0.373931 0.401798 10 +11 0.637509 0.120339 0.753382 11 +12 0.585776 0.032367 0.375861 12 +13 0.015209 0.335977 0.181752 13 +14 0.195217 0.360584 0.037638 14 +15 0.540792 0.393421 0.872021 15 +16 0.100611 0.922189 0.970646 16 +17 0.441413 0.720121 0.114097 17 +18 0.054189 0.802448 0.396482 18 +19 0.843831 0.212114 0.126534 19 +20 0.013238 0.661898 0.627306 20 +21 0.520004 0.060818 0.654433 21 +22 0.961100 0.857134 0.091915 22 +23 0.142877 0.869319 0.327252 23 +24 0.973514 0.177186 0.000637 24 +25 0.193389 0.669488 0.079120 25 +26 0.403754 0.632487 0.672783 26 +27 0.294108 0.238623 0.510592 27 +28 0.024675 0.966545 0.173122 28 +29 0.702642 0.120403 0.836783 29 +30 0.843735 0.511668 0.122329 30 +31 0.247394 0.487522 0.760667 31 +32 0.855482 0.534199 0.062248 32 +33 0.950164 0.137868 0.161542 33 +34 0.878682 0.145550 0.423443 34 +35 0.170771 0.538220 0.479534 35 +36 0.163487 0.638112 0.088006 36 +37 0.309358 0.472614 0.939749 37 +38 0.251450 0.852559 0.238243 38 +39 0.322004 0.339350 0.723849 39 +40 0.073325 0.336263 0.243346 40 +41 0.142180 0.275155 0.774014 41 +42 0.418987 0.583517 0.318479 42 +43 0.995191 0.990123 0.965973 43 +44 0.642541 0.758896 0.143941 44 +45 0.834454 0.761811 0.138111 45 +46 0.501360 0.738872 0.998445 46 +47 0.520952 0.734247 0.817867 47 +48 0.108258 0.250040 0.155309 48 +49 0.325019 0.944486 0.010224 49 +50 0.424140 0.102665 0.490829 50 +51 0.370078 0.800670 0.486968 51 +52 0.065647 0.370630 0.369046 52 +53 0.673859 0.336860 0.451118 53 +54 0.896240 0.208660 0.311405 54 +55 0.032992 0.388272 0.141788 55 +56 0.597351 0.525615 0.506587 56 +57 0.956410 0.771918 0.753641 57 +58 0.120402 0.858108 0.991605 58 +59 0.877427 0.082101 0.234755 59 +60 0.477014 0.491835 0.259360 60 +61 0.542788 0.672746 0.682472 61 +62 0.318897 0.978855 0.982079 62 +63 0.086253 0.190322 0.972290 63 +64 0.312732 0.646115 0.808268 64 +65 0.014660 0.166523 0.833427 65 +66 0.606085 0.568510 0.155079 66 +67 0.012467 0.244901 0.116065 67 +68 0.318608 0.668963 0.480013 68 +69 0.306045 0.464696 0.394668 69 +70 0.379937 0.371335 0.764122 70 +71 0.745970 0.730465 0.745599 71 +72 0.904569 0.522597 0.279317 72 +73 0.217184 0.694758 0.909990 73 +74 0.750476 0.633568 0.058840 74 +75 0.844020 0.863140 0.462612 75 +76 0.281654 0.832169 0.510462 76 +77 0.425268 0.686997 0.222606 77 +78 0.326425 0.540490 0.280333 78 +79 0.590842 0.528112 0.636941 79 +80 0.681903 0.369310 0.461865 80 +81 0.860682 0.849561 0.089141 81 +82 0.619122 0.775355 0.780499 82 +83 0.038706 0.201079 0.453245 83 +84 0.094238 0.713224 0.259966 84 +85 0.179885 0.400481 0.529972 85 +86 0.252183 0.183222 0.057824 86 +87 0.983813 0.054503 0.859952 87 +88 0.762208 0.630860 0.404059 88 +89 0.698418 0.577782 0.759200 89 +90 0.543927 0.277553 0.912846 90 +91 0.716609 0.380220 0.050177 91 +92 0.098866 0.533259 0.482783 92 +93 0.509431 0.652996 0.522129 93 +94 0.703915 0.764983 0.865093 94 +95 0.481741 0.000886 0.392668 95 +96 0.345841 0.333430 0.202012 96 +97 0.950121 0.987155 0.160110 97 +98 0.884708 0.861041 0.278094 98 +99 0.653307 0.993621 0.772908 99 +100 0.670894 0.057756 0.683406 100 \ No newline at end of file diff --git a/fsl/fslview/overlay.py b/fsl/fslview/overlay.py new file mode 100644 index 0000000000000000000000000000000000000000..a7f1fd7bb54ab14fcd5adbc4bc764dae3e63b214 --- /dev/null +++ b/fsl/fslview/overlay.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python +# +# overlay.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# +"""This module defines the :class:`OverlayList` class, which is a simple but +fundamental class in FSLView - it is a container for all displayed overlays. + +Only one ``OverlayList`` ever exists, and it is shared throughout the entire +application. +""" + +import logging +import os +import os.path as op + +import props + +import fsl.data.image as fslimage +import fsl.data.featresults as featresults +import fsl.data.featimage as fslfeatimage +import fsl.data.strings as strings +import fsl.data.model as fslmodel +import fsl.fslview.settings as fslsettings + + +log = logging.getLogger(__name__) + + +class OverlayList(props.HasProperties): + """Class representing a collection of overlays to be displayed together. + + Contains a :class:`props.properties_types.List` property called + ``overlays``, containing overlay objects (e.g. :class:`.Image` or + :class:`VTKModel`objects). + + An :class:`OverlayList` object has a few wrapper methods around the + :attr:`overlays` property, allowing the :class:`OverlayList` to be used + as if it were a list itself. + + There are no restrictions on the type of objects which may be contained + in the ``OverlayList``, but all objects must have a few attributes: + + - ``name`` ... + + - ``dataSource`` .. + + + Furthermore, all overlay types must be able to be created with a single + __init__ parameter, which is a string specifying the data source location + (e.g. a file). + """ + + + def __validateOverlay(self, atts, overlay): + return (hasattr(overlay, 'name') and + hasattr(overlay, 'dataSource')) + + + overlays = props.List( + listType=props.Object(allowInvalid=False, + validateFunc=__validateOverlay)) + """A list of overlay objects to be displayed""" + + + def __init__(self, overlays=None): + """Create an ``OverlayList`` object from the given sequence of + overlays.""" + + if overlays is None: overlays = [] + self.overlays.extend(overlays) + + + def addOverlays(self, fromDir=None, addToEnd=True): + """Convenience method for interactively adding overlays to this + :class:`OverlayList`. + """ + + overlays = interactiveLoadOverlays(fromDir) + + if addToEnd: self.extend( overlays) + else: self.insertAll(0, overlays) + + + def find(self, name): + """Returns the first overlay with the given ``name`` or ``dataSource``, + or ``None`` if there is no overlay with said ``name``/``dataSource``. + """ + for overlay in self.overlays: + if overlay.name == name or overlay.dataSource == name: + return overlay + return None + + + # Wrappers around the overlays list property, allowing this + # OverlayList object to be used as if it is actually a list. + def __len__(self): + return self.overlays.__len__() + + def __getitem__(self, key): + return self.overlays.__getitem__(key) + + def __iter__(self): + return self.overlays.__iter__() + + def __contains__(self, item): + return self.overlays.__contains__(item) + + def __setitem__(self, key, val): + return self.overlays.__setitem__(key, val) + + def __delitem__(self, key): + return self.overlays.__delitem__(key) + + def index(self, item): + return self.overlays.index(item) + + def count(self, item): + return self.overlays.count(item) + + def append(self, item): + return self.overlays.append(item) + + def extend(self, iterable): + return self.overlays.extend(iterable) + + def pop(self, index=-1): + return self.overlays.pop(index) + + def move(self, from_, to): + return self.overlays.move(from_, to) + + def remove(self, item): + return self.overlays.remove(item) + + def insert(self, index, item): + return self.overlays.insert(index, item) + + def insertAll(self, index, items): + return self.overlays.insertAll(index, items) + + +def guessDataSourceType(filename): + """A convenience function which, given the name of a file or directory, + figures out a suitable data source type. + + Returns a tuple containing two values - a type which should be able to + load the filename, and the filename, possibly adjusted. If the file type + is unrecognised, the first tuple value will be ``None``. + """ + + if filename.endswith('.vtk'): + return fslmodel.Model, filename + + else: + + if op.isdir(filename): + if featresults.isFEATDir(filename): + return fslfeatimage.FEATImage, filename + else: + + filename = fslimage.addExt(filename, False) + if any([filename.endswith(e) + for e in fslimage.ALLOWED_EXTENSIONS]): + + if featresults.isFEATDir(filename): + return fslfeatimage.FEATImage, filename + else: + return fslimage.Image, filename + + return None, filename + + +def makeWildcard(): + """Returns a wildcard string for use in a file dialog, to limit + the acceptable file types. + + :arg allowedExts: A list of strings containing the allowed file + extensions. + """ + + allowedExts = fslimage.ALLOWED_EXTENSIONS + \ + fslmodel.ALLOWED_EXTENSIONS + descs = fslimage.EXTENSION_DESCRIPTIONS + \ + fslmodel.EXTENSION_DESCRIPTIONS + + exts = ['*{}'.format(ext) for ext in allowedExts] + exts = [';'.join(exts)] + exts + descs = ['All supported files'] + descs + + wcParts = ['|'.join((desc, ext)) for (desc, ext) in zip(descs, exts)] + + return '|'.join(wcParts) + + +def loadOverlays(paths, loadFunc='default', errorFunc='default', saveDir=True): + """Loads all of the overlays specified in the sequence of files + contained in ``paths``. + + :param loadFunc: A function which is called just before each overlay + is loaded, and is passed the overlay path. The default + load function uses a :mod:`wx` popup frame to display + the name of the overlay currently being loaded. Pass in + ``None`` to disable this default behaviour. + + :param errorFunc: A function which is called if an error occurs while + loading an overlay, being passed the name of the + overlay, and either the :class:`Exception` which + occurred, or a string containing an error message. The + default function pops up a :class:`wx.MessageBox` with + an error message. Pass in ``None`` to disable this + default behaviour. + + :param saveDir: If ``True`` (the default), the directory of the last + overlay in the list of ``paths`` is saved, and used + later on as the default load directory. + + :Returns a list of overlay objects + """ + + defaultLoad = loadFunc == 'default' + + # If the default load function is + # being used, create a dialog window + # to show the currently loading image + if defaultLoad: + import wx + import fsl.utils.dialog as fsldlg + loadDlg = fsldlg.SimpleMessageDialog(wx.GetApp().GetTopWindow()) + + # The default load function updates + # the dialog window created above + def defaultLoadFunc(s): + msg = strings.messages['overlay.loadOverlays.loading'].format(s) + loadDlg.SetMessage(msg) + + # The default error function + # shows an error dialog + def defaultErrorFunc(s, e): + import wx + e = str(e) + msg = strings.messages['overlay.loadOverlays.error'].format(s, e) + title = strings.titles[ 'overlay.loadOverlays.error'] + log.debug('Error loading overlay ({}), ({})'.format(s, e)) + wx.MessageBox(msg, title, wx.ICON_ERROR | wx.OK) + + # If loadFunc or errorFunc are explicitly set to + # None, use these no-op load/error functions + if loadFunc is None: loadFunc = lambda s: None + if errorFunc is None: errorFunc = lambda s, e: None + + # Or if not provided, use the + # default functions defined above + if loadFunc == 'default': loadFunc = defaultLoadFunc + if errorFunc == 'default': errorFunc = defaultErrorFunc + + overlays = [] + + # If using the default load + # function, show the dialog + if defaultLoad: + loadDlg.CentreOnParent() + loadDlg.Show() + + # Load the images + for path in paths: + + loadFunc(path) + + dtype, path = guessDataSourceType(path) + + if dtype is None: + errorFunc( + path, + strings.messages['overlay.loadOverlays.unknownType']) + continue + + log.debug('Loading overlay {} (guessed data type: {})'.format( + path, dtype.__name__)) + try: overlays.append(dtype(path)) + except Exception as e: errorFunc(path, e) + + if defaultLoad: + loadDlg.Close() + + if saveDir and len(paths) > 0: + fslsettings.write('loadOverlayLastDir', op.dirname(paths[-1])) + + return overlays + + +def interactiveLoadOverlays(fromDir=None, **kwargs): + """Convenience method for interactively loading one or more overlays. + + If the :mod:`wx` package is available, pops up a file dialog + prompting the user to select one or more overlays to load. + + :param str fromDir: Directory in which the file dialog should start. + If ``None``, the most recently visited directory + (via this method) is used, or a directory from + an already loaded overlay, or the current working + directory. + + Returns: A list containing the overlays that were loaded. + + :raise ImportError: if :mod:`wx` is not present. + :raise RuntimeError: if a :class:`wx.App` has not been created. + """ + import wx + + app = wx.GetApp() + + if app is None: + raise RuntimeError('A wx.App has not been created') + + saveFromDir = False + if fromDir is None: + + saveFromDir = True + fromDir = fslsettings.read('loadOverlayLastDir') + + if fromDir is None: + fromDir = os.getcwd() + + dlg = wx.FileDialog(app.GetTopWindow(), + message=strings.titles['overlay.addOverlays.dialog'], + defaultDir=fromDir, + wildcard=makeWildcard(), + style=wx.FD_OPEN | wx.FD_MULTIPLE) + + if dlg.ShowModal() != wx.ID_OK: + return [] + + paths = dlg.GetPaths() + images = loadOverlays(paths, saveDir=saveFromDir, **kwargs) + + return images + + +def saveOverlay(overlay, fromDir=None): + """Convenience function for interactively saving changes to an overlay. + + .. note:: Only :class:`.Image` overlays are supported at the moment. + + :param overlay: The overlay instance to be saved. + + :param fromDir: Directory in which the file dialog should start. + If ``None``, the most recently visited directory + (via this method) is used, or the directory from + the given image, or the current working directory. + + :raise ImportError: if :mod:`wx` is not present. + :raise RuntimeError: if a :class:`wx.App` has not been created. + """ + + if not isinstance(overlay, fslimage.Image): + raise ValueError('Only Image overlays are supported') + + fslimage.saveImage(overlay, fromDir) diff --git a/fsl/fslview/panel.py b/fsl/fslview/panel.py index 58319928ebf2c6c2a38d67aaad9db81360feefeb..66df10f143b68d1a58f1595938416abf7289ba3b 100644 --- a/fsl/fslview/panel.py +++ b/fsl/fslview/panel.py @@ -4,30 +4,26 @@ # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -"""This module provides two classes - the :class:`FSLViewPanel`, and the -:class:`FSLViewToolBar`. - -A :class:`FSLViewPanel` object is a :class:`wx.Panel` which provides some -sort of view of a collection of :class:`~fsl.data.image.Image` objects, -contained within an :class:`~fsl.data.image.ImageList`. Similarly, a -:class:`FSLViewToolBar` is a :class:`wx.lib.agw.aui.AuiToolBar` which -provides some sort of control over the view. - -Instances of these classes are also -:class:`~fsl.fslview.actions.ActionProvider` instances - any actions which -are specified during construction may be exposed to the user. Furthermore, -any display configuration options which should be made available available -to the user should be added as :class:`~props.PropertyBase` attributes of -the :class:`FSLViewPanel` subclass. +"""This module provides an important class - the :class:`FSLViewPanel`. + +A :class:`FSLViewPanel` object is a :class:`wx.Panel` which provides some sort +of view of a collection of overlay objects, contained within an +:class:`.OverlayList`. + +``FSLViewPanel`` instances are also :class:`.ActionProvider` instances - any +actions which are specified during construction may (or may not ) be exposed +to the user. Furthermore, any display configuration options which should be +made available available to the user should be added as :class:`.PropertyBase` +attributes of the :class:`FSLViewPanel` subclass. See the following for examples of :class:`FSLViewPanel` subclasses: - - :class:`~fsl.fslview.views.OrthoPanel` - - :class:`~fsl.fslview.views.LightBoxPanel` - - :class:`~fsl.fslview.views.TimeSeriesPanel` - - :class:`~fsl.fslview.controls.ImageListPanel` - - :class:`~fsl.fslview.controls.ImageDisplayPanel` - - :class:`~fsl.fslview.controls.LocationPanel` + - :class:`.OrthoPanel` + - :class:`.LightBoxPanel` + - :class:`.TimeSeriesPanel` + - :class:`.OverlayListPanel` + - :class:`.OverlayDisplayPanel` + - :class:`.LocationPanel` """ @@ -35,8 +31,6 @@ import logging import wx -import fsl.data.image as fslimage - import actions import displaycontext @@ -50,49 +44,49 @@ class _FSLViewPanel(actions.ActionProvider): A :class:`ViewPanel` has the following attributes, intended to be used by subclasses: - - :attr:`_imageList`: A reference to the - :class:`~fsl.data.image.ImageList` instance which contains the images - to be displayed. + - :attr:`_overlayList`: A reference to the :class:`.OverlayList` + instance which contains the images to be displayed. - :attr:`_displayCtx`: A reference to the :class:`~fsl.fslview.displaycontext.DisplayContext` instance, which - contains display related properties about the :attr:`_imageList`. + contains display related properties about the :attr:`_overlayList`. - :attr:`_name`: A unique name for this :class:`ViewPanel`. + + + TODO Important notes about: + + - :meth:`destroy` + + - :meth:`__del__` """ def __init__(self, - imageList, + overlayList, displayCtx, actionz=None): """Create a :class:`ViewPanel`. - :arg imageList: A :class:`~fsl.data.image.ImageList` instance. + :arg overlayList: A :class:`.OverlayList` instance. - :arg displayCtx: A :class:`~fsl.fslview.displaycontext.DisplayContext` - instance. + :arg displayCtx: A :class:`.DisplayContext` instance. - :arg actionz: A dictionary containing ``{name -> function}`` - actions (see - :class:`~fsl.fslview.actions.ActionProvider`). + :arg actionz: A dictionary containing ``{name -> function}`` + actions (see :class:`.ActionProvider`). """ - actions.ActionProvider.__init__(self, imageList, displayCtx, actionz) - - if not isinstance(imageList, fslimage.ImageList): - raise TypeError( - 'imageList must be a fsl.data.image.ImageList instance') + actions.ActionProvider.__init__(self, overlayList, displayCtx, actionz) if not isinstance(displayCtx, displaycontext.DisplayContext): raise TypeError( 'displayCtx must be a ' - 'fsl.fslview.displaycontext.DisplayContext instance') + '{} instance'.format( displaycontext.DisplayContext.__name__)) - self._imageList = imageList - self._displayCtx = displayCtx - self._name = '{}_{}'.format(self.__class__.__name__, id(self)) - self.__destroyed = False + self._overlayList = overlayList + self._displayCtx = displayCtx + self._name = '{}_{}'.format(self.__class__.__name__, id(self)) + self.__destroyed = False def destroy(self): @@ -117,33 +111,31 @@ class _FSLViewPanel(actions.ActionProvider): called. So this method *must* be called by managing code when a panel is deleted. - Overriding subclass implementations should also call this base class - method, otherwise warnings will probably be output to the log (see - :meth:`__del__`) + Overriding subclass implementations must call this base class + method, otherwise memory leaks will probably occur, and warnings will + probably be output to the log (see :meth:`__del__`). This + implememtation should be called after the subclass has performed its + own clean-up, as this method expliciltly clears the ``_overlayList`` + and ``_displayCtx`` references. """ - self.__destroyed = True + actions.ActionProvider.destroy(self) + self._displayCtx = None + self._overlayList = None + self.__destroyed = True def __del__(self): - if not self.__destroyed: log.warning('The {}.destroy() method has not been called ' '- unless the application is shutting down, ' 'this is probably a bug!'.format(type(self).__name__)) - actions.ActionProvider.__del__(self) - -class FSLViewPanel(_FSLViewPanel, wx.Panel): +class FSLViewPanel(_FSLViewPanel, wx.PyPanel): """ """ - def __init__(self, parent, imageList, displayCtx, actionz=None): - wx.Panel.__init__(self, parent) - _FSLViewPanel.__init__(self, imageList, displayCtx, actionz) - - - def __del__(self): - wx.Panel .__del__(self) - _FSLViewPanel.__del__(self) + def __init__(self, parent, overlayList, displayCtx, actionz=None): + wx.PyPanel.__init__(self, parent) + _FSLViewPanel.__init__(self, overlayList, displayCtx, actionz) diff --git a/fsl/fslview/profiles/__init__.py b/fsl/fslview/profiles/__init__.py index a8e955de2e7dbfc8b1bad020dab9fb74fe8c820f..f074175b530fb16d7c3d0700d71ecfa4f414fbf9 100644 --- a/fsl/fslview/profiles/__init__.py +++ b/fsl/fslview/profiles/__init__.py @@ -5,22 +5,19 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # """The :mod:`profiles` module contains logic for mouse/keyboard interaction -with :class:`~fsl.fslview.views.canvaspanel.CanvasPanel` panels. +with :class:`.ViewPanel` panels. This logic is encapsulated in two classes: - The :class:`Profile` class is intended to be subclassed. A :class:`Profile` instance contains the mouse/keyboard event handlers for a particular type - of ``CanvasPanel`` to allow the user to interact with the canvas in a - particular way. For example, the - :class:`~fsl.fslview.profiles.orthoviewprofile.OrthoViewProfile` class - allows the user to navigate through the image space in an - :class:`~fsl.fslview.views.orthopanel.OrthoPanel` canvas, wherease the - :class:`~fsl.fslview.profiles.orthoeditprofile.OrthoEditProfile` class - contains interaction logic for selecting and editing image voxels in an - ``OrthoPanel``. - - - The :class:`ProfileManager` class is used by ``CanvasPanel`` instances to + of ``ViewPanel`` to allow the user to interact with the view in a + particular way. For example, the :class:`.OrthoViewProfile` class allows + the user to navigate through the display space in an :class:`.OrthoPanel` + canvas, wherease the :class:`.OrthoEditProfile` class contains interaction + logic for selecting and editing image voxels in an ``OrthoPanel``. + + - The :class:`ProfileManager` class is used by ``ViewPanel`` instances to create and change the ``Profile`` instance currently in use. """ @@ -37,14 +34,16 @@ import fsl.fslview.actions as actions class Profile(actions.ActionProvider): """A :class:`Profile` class implements keyboard/mouse interaction behaviour - for a :class:`~fsl.fslview.views.canvaspanel.CanvasPanel` instance. + for a :class:`.ViewPanel` instance. + Subclasses should specify at least one 'mode' of operation, which defines a sort of sub-profile. The user is able to change the mode via the :attr:`mode` property. Subclasses must also override the :meth:`getEventTargets` method, to return the :mod:`wx` objects that are to be the targets for mouse/keyboard interaction. + In order to receive mouse or keyboard events, subclasses simply need to implement methods which handle the events of interest for the relevant mode, and name them appropriately. The name of a method handler must be @@ -67,31 +66,33 @@ class Profile(actions.ActionProvider): - ``MouseWheel`` - ``Char`` + For example, if a particular profile has defined a mode called ``nav``, and is interested in left clicks, the profile class must provide a method called `_navModeLeftMouseDown`. Then, whenever the profile is in the ``nav`` mode, this method will be called on left mouse clicks. - The :mod:`~fsl.fslview.profilemap` module contains a ``tempModeMap`` - which, for each profile and each mode, defines a keyboard modifier which - may be used to temporarily redirect mouse/keyboard events to the handlers - for a different mode. For example, if while in ``nav`` mode, you would - like the user to be able to switch to ``zoom`` mode with the control key, - you can add a temporary mode map in the - :attr:`~fsl.fslview.profilemap.tempModeMap` - - - The :mod:`~fsl.fslview.profilemap` contains another dictionary, called - the ``altHandlerMap``. This dictionary allows you to re-use event - handlers that have been defined for one mode in another mode. For example, - if you would like right clicks in ``zoom`` mode to behave like left clicks - in ``nav`` mode, you can set up such a mapping using the - :attr:`~fsl.fslview.profilemap.altHandlerMap`` dictionary. - As the :class:`Profile` class derives from the - :class:`~fsl.fslview.actions.ActionProvider` class, :class:`Profile` - subclasses may define properties and actions for the user to configure - the profile behaviour, and/or to perform any relevant actions. + The :mod:`.profilemap` module contains a ``tempModeMap`` which, for each + profile and each mode, defines a keyboard modifier which may be used to + temporarily redirect mouse/keyboard events to the handlers for a different + mode. For example, if while in ``nav`` mode, you would like the user to be + able to switch to ``zoom`` mode with the control key, you can add a + temporary mode map in the ``tempModeMap``. + + + The :mod:`.profilemap` module contains another dictionary, called the + ``altHandlerMap``. This dictionary allows you to re-use event handlers + that have been defined for one mode in another mode. For example, if you + would like right clicks in ``zoom`` mode to behave like left clicks in + ``nav`` mode, you can set up such a mapping using the + ``altHandlerMap`` dictionary. + + + As the :class:`Profile` class derives from the :class:`.ActionProvider` + class, :class:`Profile` subclasses may define properties and actions for + the user to configure the profile behaviour, and/or to perform any + relevant actions. """ @@ -102,46 +103,42 @@ class Profile(actions.ActionProvider): def __init__(self, - canvasPanel, - imageList, + viewPanel, + overlayList, displayCtx, modes=None, actionz=None): """Create a :class:`Profile` instance. - :arg canvasPanel: The - :class:`~fsl.fslview.views.canvaspanel.CanvasPanel` - instance for which this :class:`Profile` instance - defines mouse/keyboard interaction behaviour. + :arg viewPanel: The :class:`.ViewPanel` instance for which this + :class:`Profile` instance defines mouse/keyboard + interaction behaviour. - :arg imageList: The :class:`~fsl.data.image.ImageList` instance - which contains the list of images being displayed. + :arg overlayList: The :class:`.OverlayList` instance which contains + the list of overlays being displayed. - :arg displayCtx: The - :class:`~fsl.fslview.displaycontext.DisplayContext` - instance which defines how the images are to be - displayed. + :arg displayCtx: The :class:`.DisplayContext` instance which defines + how the overlays are to be displayed. :arg modes: A sequence of strings, containing the mode identifiers for this profile. - :arg actionz: A dictionary of ``{name : function}`` mappings - defining any actions provided by this instance; see - the :class:`~fsl.fslview.actions.ActionProvider` - class. + :arg actionz: A dictionary of ``{name : function}`` mappings + defining any actions provided by this instance; + see the :class:`.ActionProvider` class. """ if actionz is not None: for name, func in actionz.items(): def wrap(f=func): f() - canvasPanel.Refresh() + viewPanel.Refresh() actionz[name] = wrap - actions.ActionProvider.__init__(self, imageList, displayCtx, actionz) + actions.ActionProvider.__init__(self, overlayList, displayCtx, actionz) - self._canvasPanel = canvasPanel - self._imageList = imageList + self._viewPanel = viewPanel + self._overlayList = overlayList self._displayCtx = displayCtx self._name = '{}_{}'.format(self.__class__.__name__, id(self)) @@ -189,13 +186,24 @@ class Profile(actions.ActionProvider): for (mode, handler), (altMode, altHandler) in altHandlers.items(): self.addAltHandler(mode, handler, altMode, altHandler) + + def destroy(self): + """Calls the :meth:`deregister` method, and clears references to + the display context, view panel, and overlay list. This method + is called by the :class:`ProfileManager` when this ``Profile`` + instance is no longer needed. + """ + self._viewPanel = None + self._overlayList = None + self._displayCtx = None + def getEventTargets(self): """Must be overridden by subclasses, to return a sequence of :mod:`wx` objects that are the targets of mouse/keyboard interaction. It is assumed that all of the objects in the sequence derive from the - :class:`~fsl.fslview.gl.slicecanvas.SliceCanvas` class. + :class:`.SliceCanvas` class. """ raise NotImplementedError('Profile subclasses must implement ' 'the getEventTargets method') @@ -268,7 +276,7 @@ class Profile(actions.ActionProvider): t.Bind(wx.EVT_MOTION, None) t.Bind(wx.EVT_MOUSEWHEEL, None) t.Bind(wx.EVT_CHAR, None) - self._canvasPanel.Refresh() + self._viewPanel.Refresh() def __getTempMode(self, ev): @@ -395,7 +403,7 @@ class Profile(actions.ActionProvider): wheel, canvas.name)) handler(ev, canvas, wheel, mouseLoc, canvasLoc) - self._canvasPanel.Refresh() + self._viewPanel.Refresh() def __onMouseDown(self, ev): @@ -422,7 +430,7 @@ class Profile(actions.ActionProvider): mouseLoc, canvasLoc, canvas.name)) handler(ev, canvas, mouseLoc, canvasLoc) - self._canvasPanel.Refresh() + self._viewPanel.Refresh() self.__lastMousePos = mouseLoc self.__lastCanvasPos = canvasLoc @@ -449,7 +457,7 @@ class Profile(actions.ActionProvider): mouseLoc, canvasLoc, canvas.name)) handler(ev, canvas, mouseLoc, canvasLoc) - self._canvasPanel.Refresh() + self._viewPanel.Refresh() self.__mouseDownPos = None self.__canvasDownPos = None @@ -477,7 +485,7 @@ class Profile(actions.ActionProvider): mouseLoc, canvasLoc, canvas.name)) handler(ev, canvas, mouseLoc, canvasLoc) - self._canvasPanel.Refresh() + self._viewPanel.Refresh() self.__lastMousePos = mouseLoc self.__lastCanvasPos = canvasLoc @@ -502,7 +510,7 @@ class Profile(actions.ActionProvider): mouseLoc, canvasLoc, canvas.name)) handler(ev, canvas, mouseLoc, canvasLoc) - self._canvasPanel.Refresh() + self._viewPanel.Refresh() self.__lastMousePos = mouseLoc self.__lastCanvasPos = canvasLoc @@ -524,57 +532,70 @@ class Profile(actions.ActionProvider): log.debug('Keyboard event ({}) on canvas {}'.format(key, canvas.name)) handler(ev, canvas, key) - self._canvasPanel.Refresh() + self._viewPanel.Refresh() class ProfileManager(object): - """Manages creation/registration/de-regsistration of - :class:`Profile` instances for a - :class:`~fsl.fslview.views.canvaspanel.CanvasPanel` instance. + """Manages creation/registration/de-regsistration of :class:`Profile` + instances for a :class:`.ViewPanel` instance. A :class:`ProfileManager` instance is created and used by every - :class:~fsl.fslview.views.canvaspanel.CanvasPanel` instance. The - :mod:`~fsl.fslview.profilemap` defines the :class:`Profile` types - which should be used for specific - :class:`~fsl.fslview.views.canvaspanel.CanvasPanel` types. + :class:`.ViewPanel` instance. The :mod:`.profilemap` defines the + :class:`Profile` types which should be used for specific + :class:`.ViewPanel` types. """ - def __init__(self, canvasPanel, imageList, displayCtx): + def __init__(self, viewPanel, overlayList, displayCtx): """Create a :class:`ProfileManager`. - :arg canvasPanel: The - :class:`~fsl.fslview.views.canvaspanel.CanvasPanel` - instance which this :class:`ProfileManager` is - to manage. + :arg viewPanel: The :class:`.ViewPanel` instance which this + :class:`ProfileManager` is to manage. - :arg imageList: The :class:`~fsl.data.image.ImageList` instance - containing the images that are being displayed. + :arg overlayList: The :class:`.OverlayList` instance containing the + overlays that are being displayed. - :arg displayCtx: The - :class:`~fsl.fslview.displaycontext.DisplayContext` - instance which defines how images are being - displayed. + :arg displayCtx: The :class:`.DisplayContext` instance which defines + how overlays are being displayed. + """ import profilemap - self._canvasPanel = canvasPanel - self._canvasCls = canvasPanel.__class__ - self._imageList = imageList + self._viewPanel = viewPanel + self._viewCls = viewPanel.__class__ + self._overlayList = overlayList self._displayCtx = displayCtx self._currentProfile = None - profileProp = canvasPanel.getProp('profile') - profilez = profilemap.profiles.get(canvasPanel.__class__, []) + profileProp = viewPanel.getProp('profile') + profilez = profilemap.profiles.get(viewPanel.__class__, []) for profile in profilez: profileProp.addChoice( profile, - strings.profiles[canvasPanel, profile], - canvasPanel) + strings.profiles[viewPanel, profile], + viewPanel) if len(profilez) > 0: - canvasPanel.profile = profilez[0] + viewPanel.profile = profilez[0] + + + def destroy(self): + """This method should be called by the owning :class:`.ViewPanel` when + it is about to be destroyed (or when it no longer needs a + ``ProfileManager``). + + This method destros the current profile (if any), and clears some + important object references to avoid memory leaks. + """ + if self._currentProfile is not None: + self._currentProfile.deregister() + self._currentProfile.destroy() + + self._currentProfile = None + self._viewPanel = None + self._overlayList = None + self._overlaydisplayCtx = None def getCurrentProfile(self): @@ -589,7 +610,7 @@ class ProfileManager(object): import profilemap - profileCls = profilemap.profileHandlers[self._canvasCls, profile] + profileCls = profilemap.profileHandlers[self._viewCls, profile] # the current profile is the requested profile if (self._currentProfile is not None) and \ @@ -599,15 +620,16 @@ class ProfileManager(object): if self._currentProfile is not None: log.debug('Deregistering {} profile from {}'.format( self._currentProfile.__class__.__name__, - self._canvasCls.__name__)) + self._viewCls.__name__)) self._currentProfile.deregister() + self._currentProfile.destroy() - self._currentProfile = profileCls(self._canvasPanel, - self._imageList, + self._currentProfile = profileCls(self._viewPanel, + self._overlayList, self._displayCtx) log.debug('Registering {} profile with {}'.format( self._currentProfile.__class__.__name__, - self._canvasCls.__name__)) + self._viewCls.__name__)) self._currentProfile.register() diff --git a/fsl/fslview/profiles/lightboxviewprofile.py b/fsl/fslview/profiles/lightboxviewprofile.py index 35de05fb95040af875540b77f247dacb221f9fab..338e75564a5eb4fe98ffaf9d6886436863dffcc8 100644 --- a/fsl/fslview/profiles/lightboxviewprofile.py +++ b/fsl/fslview/profiles/lightboxviewprofile.py @@ -14,15 +14,16 @@ import fsl.fslview.profiles as profiles class LightBoxViewProfile(profiles.Profile): - def __init__(self, canvasPanel, imageList, displayCtx): + def __init__(self, viewPanel, overlayList, displayCtx): profiles.Profile.__init__(self, - canvasPanel, - imageList, + viewPanel, + overlayList, displayCtx, modes=['view', 'zoom']) - self._canvas = canvasPanel.getCanvas() + self._canvas = viewPanel.getCanvas() + def getEventTargets(self): return [self._canvas] @@ -41,7 +42,7 @@ class LightBoxViewProfile(profiles.Profile): if wheel > 0: wheel = -1 elif wheel < 0: wheel = 1 - self._canvasPanel.getCanvas().topRow += wheel + self._viewPanel.getCanvas().topRow += wheel def _viewModeLeftMouseDrag(self, ev, canvas, mousePos, canvasPos): @@ -69,4 +70,4 @@ class LightBoxViewProfile(profiles.Profile): if wheel > 0: wheel = 50 elif wheel < 0: wheel = -50 - self._canvasPanel.getSceneOptions().zoom += wheel + self._viewPanel.getSceneOptions().zoom += wheel diff --git a/fsl/fslview/profiles/orthoeditprofile.py b/fsl/fslview/profiles/orthoeditprofile.py index 72ca026bd623db3147f2b84979718140a7dfbfdc..a03734ca419a31bd1ddca38fa6ee6ae8ec4ccf7c 100644 --- a/fsl/fslview/profiles/orthoeditprofile.py +++ b/fsl/fslview/profiles/orthoeditprofile.py @@ -13,6 +13,7 @@ import numpy as np import props import fsl.utils.transform as transform +import fsl.data.image as fslimage import fsl.fslview.editor.editor as editor import fsl.fslview.gl.annotations as annotations @@ -71,16 +72,16 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): self._selAnnotation.texture.refresh() - def __init__(self, canvasPanel, imageList, displayCtx): + def __init__(self, viewPanel, overlayList, displayCtx): - self._editor = editor.Editor(imageList, displayCtx) - self._xcanvas = canvasPanel.getXCanvas() - self._ycanvas = canvasPanel.getYCanvas() - self._zcanvas = canvasPanel.getZCanvas() + self._editor = editor.Editor(overlayList, displayCtx) + self._xcanvas = viewPanel.getXCanvas() + self._ycanvas = viewPanel.getYCanvas() + self._zcanvas = viewPanel.getZCanvas() self._selAnnotation = None self._selecting = False self._lastDist = None - self._currentImage = None + self._currentOverlay = None actions = { 'undo' : self.undo, @@ -92,18 +93,18 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): orthoviewprofile.OrthoViewProfile.__init__( self, - canvasPanel, - imageList, + viewPanel, + overlayList, displayCtx, ['sel', 'desel', 'selint'], actions) - displayCtx.addListener('selectedImage', - self._name, - self._selectedImageChanged) - imageList.addListener( 'images', - self._name, - self._selectedImageChanged) + displayCtx .addListener('selectedOverlay', + self._name, + self._selectedOverlayChanged) + overlayList.addListener('overlays', + self._name, + self._selectedOverlayChanged) self._editor.addListener('canUndo', self._name, @@ -119,11 +120,23 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): self._name, self._selectionColoursChanged) - self._selectedImageChanged() + self._selectedOverlayChanged() self._selectionChanged() self._undoStateChanged() + def destroy(self): + + self._displayCtx .removeListener('selectedOverlay', self._name) + self._overlayList.removeListener('overlays', self._name) + self._editor .removeListener('canUndo', self._name) + self._editor .removeListener('canRedo', self._name) + + self._editor = None + + orthoviewprofile.OrthoViewProfile.destroy(self) + + def _undoStateChanged(self, *a): self.enable('undo', self._editor.canUndo) self.enable('redo', self._editor.canRedo) @@ -134,17 +147,17 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): self._selAnnotation.colour = self.selectionOverlayColour - def _selectedImageChanged(self, *a): + def _selectedOverlayChanged(self, *a): - image = self._displayCtx.getSelectedImage() + overlay = self._displayCtx.getSelectedOverlay() selection = self._editor.getSelection() xannot = self._xcanvas.getAnnotations() yannot = self._ycanvas.getAnnotations() zannot = self._zcanvas.getAnnotations() - # If the selected image hasn't changed, + # If the selected overlay hasn't changed, # we don't need to do anything - if image == self._currentImage: + if overlay == self._currentOverlay: return # If there's already an existing @@ -157,22 +170,24 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): self._selAnnotation.destroy() self._selAnnotation = None - self._currentImage = image + self._currentOverlay = overlay - # If there is no selected image (the image + # If there is no selected overlay (the overlay # list is empty), don't do anything. - if image is None: + if overlay is None: return - display = self._displayCtx.getDisplayProperties(image) + display = self._displayCtx.getDisplay(overlay) + opts = display.getDisplayOpts() # Edit mode is only supported on images with # the 'volume' type, in 'id' or 'pixdim' # transformation for the time being - if image.imageType != 'volume' or \ - display.transform not in ('id', 'pixdim'): + if not isinstance(overlay, fslimage.Image) or \ + display.overlayType != 'volume' or \ + opts.transform not in ('id', 'pixdim'): - self._currentImage = None + self._currentOverlay = None log.warn('Editing is only possible on volume ' 'images, in ID or pixdim space.') return @@ -184,14 +199,14 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): self._selAnnotation = annotations.VoxelSelection( selection, - display.getTransform('display', 'voxel'), - display.getTransform('voxel', 'display'), + opts.getTransform('display', 'voxel'), + opts.getTransform('voxel', 'display'), colour=self.selectionOverlayColour) xannot.obj(self._selAnnotation, hold=True) yannot.obj(self._selAnnotation, hold=True) zannot.obj(self._selAnnotation, hold=True) - self._canvasPanel.Refresh() + self._viewPanel.Refresh() def _selectionChanged(self, *a): @@ -216,13 +231,13 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): def _getVoxelLocation(self, canvasPos): - """Returns the voxel location, for the currently selected image, + """Returns the voxel location, for the currently selected overlay, which corresponds to the specified canvas position. """ - display = self._displayCtx.getDisplayProperties(self._currentImage) + opts = self._displayCtx.getOpts(self._currentOverlay) voxel = transform.transform( - [canvasPos], display.getTransform('display', 'voxel'))[0] + [canvasPos], opts.getTransform('display', 'voxel'))[0] # Using floor(voxel+0.5) because, when at the # midpoint, I want to round up. np.round rounds @@ -239,8 +254,8 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): were to click. """ - display = self._displayCtx.getDisplayProperties(self._currentImage) - shape = self._currentImage.shape + opts = self._displayCtx.getOpts(self._currentOverlay) + shape = self._currentOverlay.shape if self.selectionIs3D: axes = (0, 1, 2) else: axes = (canvas.xax, canvas.yax) @@ -257,8 +272,8 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): for canvas in [self._xcanvas, self._ycanvas, self._zcanvas]: canvas.getAnnotations().grid( block, - display.getTransform('display', 'voxel'), - display.getTransform('voxel', 'display'), + opts.getTransform('display', 'voxel'), + opts.getTransform('voxel', 'display'), offsets=offset, colour=colour) @@ -362,9 +377,8 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): if not self._selecting: return - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) - opts = display.getDisplayOpts() + overlay = self._displayCtx.getSelectedOverlay() + opts = self._displayCtx.getOpts(overlay) step = opts.displayRange.xlen / 50.0 @@ -378,13 +392,15 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): def _selintSelect(self, voxel): - image = self._displayCtx.getSelectedImage() + + overlay = self._displayCtx.getSelectedOverlay() + if self.searchRadius == 0: searchRadius = None else: - searchRadius = (self.searchRadius / image.pixdim[0], - self.searchRadius / image.pixdim[1], - self.searchRadius / image.pixdim[2]) + searchRadius = (self.searchRadius / overlay.pixdim[0], + self.searchRadius / overlay.pixdim[1], + self.searchRadius / overlay.pixdim[2]) # If the last selection covered a bigger radius # than this selection, clear the whole selection diff --git a/fsl/fslview/profiles/orthoviewprofile.py b/fsl/fslview/profiles/orthoviewprofile.py index 7589bd647b0123adc9511a2e7c3c5f04fc04722c..5c54b525097a44c96ff545eef76a0e909f54d124 100644 --- a/fsl/fslview/profiles/orthoviewprofile.py +++ b/fsl/fslview/profiles/orthoviewprofile.py @@ -5,7 +5,7 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # """This module defines a mouse/keyboard interaction 'view' profile for the -:class:`~fsl.fslview.views.orthopanel.OrthoPanel'` class. +:class:`.OrthoPanel'` class. There are three view 'modes' available in this profile: @@ -25,26 +25,27 @@ switch into one mode from another; these temporary modes are defined in the """ import logging -log = logging.getLogger(__name__) - import wx import fsl.fslview.profiles as profiles +log = logging.getLogger(__name__) + + class OrthoViewProfile(profiles.Profile): def __init__(self, - canvasPanel, - imageList, + viewPanel, + overlayList, displayCtx, extraModes=None, extraActions=None): """Creates an :class:`OrthoViewProfile`, which can be registered - with the given ``canvasPanel`` which is assumed to be a - :class:`~fsl.fslview.views.orthopanel.OrthoPanel` instance. + with the given ``viewPanel`` which is assumed to be an + :class:`.OrthoPanel` instance. """ if extraModes is None: extraModes = [] @@ -60,15 +61,15 @@ class OrthoViewProfile(profiles.Profile): actionz = dict(actionz.items() + extraActions.items()) profiles.Profile.__init__(self, - canvasPanel, - imageList, + viewPanel, + overlayList, displayCtx, modes, actionz) - self._xcanvas = canvasPanel.getXCanvas() - self._ycanvas = canvasPanel.getYCanvas() - self._zcanvas = canvasPanel.getZCanvas() + self._xcanvas = viewPanel.getXCanvas() + self._ycanvas = viewPanel.getYCanvas() + self._zcanvas = viewPanel.getZCanvas() # This attribute will occasionally store a # reference to a gl.annotations.Rectangle - @@ -84,7 +85,7 @@ class OrthoViewProfile(profiles.Profile): def resetZoom(self, *a): - opts = self._canvasPanel.getSceneOptions() + opts = self._viewPanel.getSceneOptions() opts.zoom = 100 opts.xzoom = 100 @@ -107,11 +108,37 @@ class OrthoViewProfile(profiles.Profile): # Navigate mode handlers ######################## + def __getNavOffsets(self): + + overlay = self._displayCtx.getReferenceImage( + self._displayCtx.getSelectedOverlay()) + + # The currently selected overlay is non-volumetric, + # and does not have a reference image + if overlay is None: + offsets = [1, 1, 1] + + # We have a voluemtric reference image to play with + else: + + opts = self._displayCtx.getOpts(overlay) + + # If we're displaying voxel space, + # we want a keypress to move one + # voxel in the appropriate direction + if opts.transform == 'id': offsets = [1, 1, 1] + elif opts.transform == 'pixdim': offsets = overlay.pixdim + + # Otherwise we'll just move an arbitrary + # amount in the image world space - 1mm + else: offsets = [1, 1, 1] + + return offsets + def _navModeLeftMouseDrag(self, ev, canvas, mousePos, canvasPos): """Left mouse drags in location mode update the - :attr:`~fsl.fslview.displaycontext.DisplayContext.location` to follow - the mouse location. + :attr:`.DisplayContext.location` to follow the mouse location. """ if canvasPos is None: @@ -122,29 +149,18 @@ class OrthoViewProfile(profiles.Profile): def _navModeChar(self, ev, canvas, key): """Left mouse drags in location mode update the - :attr:`~fsl.fslview.displaycontext.DisplayContext.location`. + :attr:`.DisplayContext.location`. Arrow keys map to the horizontal/vertical axes, and -/+ keys map to the depth axis of the canvas which was the target of the event. - """ + """ - image = self._displayCtx.getSelectedImage() - if image is None: + if len(self._overlayList) == 0: return - - display = self._displayCtx.getDisplayProperties(image) - pos = self._displayCtx.location.xyz - - # If we're displaying voxel space, - # we want a keypress to move one - # voxel in the appropriate direction - if display.transform == 'id': offsets = [1, 1, 1] - elif display.transform == 'pixdim': offsets = image.pixdim - # Otherwise we'll just move an arbitrary - # amount in the image world space - 2mm - else: offsets = [2, 2, 2] + pos = self._displayCtx.location.xyz + offsets = self.__getNavOffsets() try: ch = chr(key) except: ch = None @@ -158,6 +174,20 @@ class OrthoViewProfile(profiles.Profile): self._displayCtx.location.xyz = pos + + def _navModeMouseWheel(self, ev, canvas, wheel, mousePos, canvasPos): + + if len(self._overlayList) == 0: + return + + pos = self._displayCtx.location.xyz + offsets = self.__getNavOffsets() + + if wheel > 0: pos[canvas.zax] -= offsets[canvas.zax] + elif wheel < 0: pos[canvas.zax] += offsets[canvas.zax] + + self._displayCtx.location.xyz = pos + #################### # Zoom mode handlers diff --git a/fsl/fslview/profiles/profilemap.py b/fsl/fslview/profiles/profilemap.py index 43826f1e732c37c908701a9d2f371a2fc92464f2..1d863b7c805823d28ef0103203ee252bad730b2b 100644 --- a/fsl/fslview/profiles/profilemap.py +++ b/fsl/fslview/profiles/profilemap.py @@ -4,17 +4,15 @@ # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -"""This module is used by the :class:`~fsl.fslview.proflies.Profile` and -:class:`~fsl.fslview.proflies.ProfileManager` classes. +"""This module is used by the :class:`.Profile` and :class:`.ProfileManager` +classes. It defines a few dictionaries which define the profile type to use for each -:class:`~fsl.fslview.views.canvaspanel.CanvasPanel` type, temporary -mouse/keyboard interaction modes, and alternate mode handlers for the -profiles contained in the profiles package. +:class:`.CanvasPanel` type, temporary mouse/keyboard interaction modes, and +alternate mode handlers for the profiles contained in the profiles package. """ import logging -log = logging.getLogger(__name__) from collections import OrderedDict @@ -27,13 +25,16 @@ from fsl.fslview.profiles.orthoviewprofile import OrthoViewProfile from fsl.fslview.profiles.orthoeditprofile import OrthoEditProfile from fsl.fslview.profiles.lightboxviewprofile import LightBoxViewProfile + +log = logging.getLogger(__name__) + + profiles = { OrthoPanel : ['view', 'edit'], LightBoxPanel : ['view'] } -"""This dictionary is used by the :class:`~fsl.fslview.profiles.ProfileManager` -to figure out which profiles are available for each -:class:`~fsl.fslview.views.canvaspanel.CanvasPanel`. +"""This dictionary is used by the :class:`.ProfileManager` to figure out which +profiles are available for each :class:`.CanvasPanel`. """ @@ -42,10 +43,9 @@ profileHandlers = { (OrthoPanel, 'edit') : OrthoEditProfile, (LightBoxPanel, 'view') : LightBoxViewProfile } -"""This dictionary is used by the :class:`~fsl.fslview.profiles.ProfileManager` -class to figure out which :class:`~fsl.fslview.profiles.Profile` instance to -create for a given :class:`~fsl.fslview.views.canvaspanel.CanvasPanel` instance -and profile identifier. +"""This dictionary is used by the :class:`.ProfileManager` class to figure out +which :class:`.Profile` instance to create for a given :class:`.CanvasPanel` +instance and profile identifier. """ @@ -95,6 +95,7 @@ tempModeMap = { (('view', wx.WXK_CONTROL), 'zoom'), )) } + altHandlerMap = { OrthoViewProfile : OrderedDict(( diff --git a/fsl/fslview/settings.py b/fsl/fslview/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..d2d1347dfdd406d703be4df91f1e75b79edafbc4 --- /dev/null +++ b/fsl/fslview/settings.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +# +# settings.py - Persistent application settings. +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# +import logging + +log = logging.getLogger(__name__) + + +def read(name, default=None): + + try: import wx + except: return None + + config = wx.Config('fslview') + + value = config.Read(name) + + if value == '': return default + else: return value + + +def write(name, value): + + try: import wx + except: return None + + value = str(value) + config = wx.Config('fslview') + + log.debug('Saving {}: {}'.format(name, value)) + + config.Write(name, value) diff --git a/fsl/fslview/splash.py b/fsl/fslview/splash.py index 9273b9ff9443c95a57963d89aa41c0ac98db06e5..a5586b24243be98b9154d2a2def36e4a36321ee7 100644 --- a/fsl/fslview/splash.py +++ b/fsl/fslview/splash.py @@ -26,7 +26,7 @@ class FSLViewSplash(wx.Frame): splashimg = splashbmp.ConvertToImage() splashPanel = imagepanel.ImagePanel(self, splashimg) - self.statusBar = wx.StaticText(self) + self.statusBar = wx.StaticText(self, style=wx.ELLIPSIZE_MIDDLE) self.statusBar.SetLabel(strings.messages[self, 'default']) self.statusBar.SetBackgroundColour('white') diff --git a/fsl/fslview/toolbar.py b/fsl/fslview/toolbar.py index 26a2dd595cf83305f812a764e94f06a2afbef277..b56f4836f04d89e2483bd7fea31e9b6c03dad03d 100644 --- a/fsl/fslview/toolbar.py +++ b/fsl/fslview/toolbar.py @@ -70,10 +70,17 @@ class FSLViewToolBar(fslpanel._FSLViewPanel, wx.PyPanel): type(self.label).__name__, self.labelText) + def Enable(self, *args, **kwargs): + wx.Panel.Enable(self, *args, **kwargs) + self.tool.Enable(*args, **kwargs) - def __init__(self, parent, imageList, displayCtx, actionz=None): + if self.label is not None: + self.label.Enable(*args, **kwargs) + + + def __init__(self, parent, overlayList, displayCtx, actionz=None): wx.PyPanel.__init__(self, parent) - fslpanel._FSLViewPanel.__init__(self, imageList, displayCtx, actionz) + fslpanel._FSLViewPanel.__init__(self, overlayList, displayCtx, actionz) self.__tools = [] self.__index = 0 @@ -187,6 +194,12 @@ class FSLViewToolBar(fslpanel._FSLViewPanel, wx.PyPanel): self.Layout() + def Enable(self, *args, **kwargs): + wx.PyPanel.Enable(self, *args, **kwargs) + for t in self.__tools: + t.Enable(*args, **kwargs) + + def GenerateTools(self, toolSpecs, targets, add=True): """ Targets may be a single object, or a dict of [toolSpec : target] @@ -203,7 +216,7 @@ class FSLViewToolBar(fslpanel._FSLViewPanel, wx.PyPanel): tool = props.buildGUI( self, targets[toolSpec.key], toolSpec, showUnlink=False) - if isinstance(toolSpec, actions.ActionButton): + if isinstance(toolSpec, props.Button): label = None else: @@ -356,8 +369,3 @@ class FSLViewToolBar(fslpanel._FSLViewPanel, wx.PyPanel): if postevent: wx.PostEvent(self, ToolBarEvent()) - - - def __del__(self): - wx.Panel .__del__(self) - fslpanel._FSLViewPanel.__del__(self) diff --git a/fsl/fslview/views/__init__.py b/fsl/fslview/views/__init__.py index a6b437da38eb47a45feb2579349df6751630ebe6..c41b8f997efb4a7a452adfdcabbcb94765a94d3e 100644 --- a/fsl/fslview/views/__init__.py +++ b/fsl/fslview/views/__init__.py @@ -18,18 +18,18 @@ dynamic lookup of all :class:`~fsl.fslview.views.viewpanel.ViewPanel` types. import fsl.fslview.panel as fslpanel +import canvaspanel import orthopanel import lightboxpanel import timeseriespanel import histogrampanel -import spacepanel FSLViewPanel = fslpanel .FSLViewPanel +CanvasPanel = canvaspanel .CanvasPanel OrthoPanel = orthopanel .OrthoPanel LightBoxPanel = lightboxpanel .LightBoxPanel TimeSeriesPanel = timeseriespanel.TimeSeriesPanel HistogramPanel = histogrampanel .HistogramPanel -SpacePanel = spacepanel .SpacePanel def listViewPanels(): @@ -47,7 +47,8 @@ def listViewPanels(): if not isinstance(val, type): continue if val == FSLViewPanel: continue - if issubclass(val, FSLViewPanel): + if issubclass(val, FSLViewPanel) and \ + val != CanvasPanel : viewPanels.append(val) return viewPanels diff --git a/fsl/fslview/views/canvaspanel.py b/fsl/fslview/views/canvaspanel.py index 42d12d57bcaff97e8bb91b26402769d0b5c71f3a..0c7f9d3f226dfaf79c47730f56e82dd876fa0bc8 100644 --- a/fsl/fslview/views/canvaspanel.py +++ b/fsl/fslview/views/canvaspanel.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# canvaspanel.py - Base class for all panels that display image data. +# canvaspanel.py - Base class for all panels that display overlay data. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # @@ -15,155 +15,41 @@ import logging import wx +import props + import fsl -import fsl.tools.fslview_parseargs as fslview_parseargs -import fsl.data.imageio as iio -import fsl.data.strings as strings -import fsl.fslview.displaycontext as displayctx -import fsl.fslview.displaycontext.orthoopts as orthoopts -import fsl.fslview.controls.imagelistpanel as imagelistpanel -import fsl.fslview.controls.imagedisplaytoolbar as imagedisplaytoolbar -import fsl.fslview.controls.locationpanel as locationpanel -import fsl.fslview.controls.atlaspanel as atlaspanel -import colourbarpanel -import viewpanel +import fsl.tools.fslview_parseargs as fslview_parseargs +import fsl.utils.dialog as fsldlg +import fsl.data.image as fslimage +import fsl.data.strings as strings +import fsl.fslview.overlay as fsloverlay +import fsl.fslview.displaycontext as displayctx +import fsl.fslview.controls as fslcontrols +import colourbarpanel +import viewpanel log = logging.getLogger(__name__) -def _takeScreenShot(imageList, displayCtx, canvas): - - # Check to make sure that all images are saved - # on disk, and ask the user what they want to - # do about the ones that aren't. - for image in displayCtx.getOrderedImages(): - - # If the image is not saved, popup a dialog - # telling the user they must save the image - # before the screenshot can proceed - if not image.saved: - title = strings.titles[ 'CanvasPanel.screenshot.notSaved'] - msg = strings.messages['CanvasPanel.screenshot.notSaved'] - msg = msg.format(image.name) - - dlg = wx.MessageDialog(canvas, - message=msg, - caption=title, - style=(wx.CENTRE | - wx.YES_NO | - wx.CANCEL | - wx.ICON_QUESTION)) - dlg.SetYesNoCancelLabels( - strings.labels['CanvasPanel.screenshot.notSaved.save'], - strings.labels['CanvasPanel.screenshot.notSaved.skip'], - strings.labels['CanvasPanel.screenshot.notSaved.cancel']) - - result = dlg.ShowModal() - - # The user chose to save the image - if result == wx.ID_YES: - iio.saveImage(image) - - # The user chose to skip the image - elif result == wx.ID_NO: - continue - - # the user clicked cancel, or closed the dialog - else: - return - - # Ask the user where they want - # the screenshot to be saved - dlg = wx.FileDialog(canvas, - message=strings.messages['CanvasPanel.screenshot'], - style=wx.FD_SAVE) - - if dlg.ShowModal() != wx.ID_OK: - return - - filename = dlg.GetPath() - - # Make the dialog go away before - # the screenshot gets taken - dlg.Destroy() - wx.Yield() - - # Screnshot size and scene options - sceneOpts = canvas.getSceneOptions() - width, height = canvas.getCanvasPanel().GetClientSize().Get() - - # Generate command line arguments for - # a callout to render.py - start with - # the render.py specific options - argv = [] - argv += ['--outfile', filename] - argv += ['--size', '{}'.format(width), '{}'.format(height)] - argv += ['--background', '0', '0', '0', '255'] - - # Add scene options - argv += fslview_parseargs.generateSceneArgs( - imageList, displayCtx, sceneOpts) - - # Add ortho specific options, if it's - # an orthopanel we're dealing with - if isinstance(sceneOpts, orthoopts.OrthoOpts): - - xcanvas = canvas.getXCanvas() - ycanvas = canvas.getYCanvas() - zcanvas = canvas.getZCanvas() - - argv += ['--{}'.format(fslview_parseargs.ARGUMENTS[sceneOpts, - 'xcentre'][1])] - argv += ['{}'.format(c) for c in xcanvas.pos.xy] - argv += ['--{}'.format(fslview_parseargs.ARGUMENTS[sceneOpts, - 'ycentre'][1])] - argv += ['{}'.format(c) for c in ycanvas.pos.xy] - argv += ['--{}'.format(fslview_parseargs.ARGUMENTS[sceneOpts, - 'zcentre'][1])] - argv += ['{}'.format(c) for c in zcanvas.pos.xy] - - # Add display options for each image - for image in displayCtx.getOrderedImages(): - - display = displayCtx.getDisplayProperties(image) - fname = image.imageFile - - # Skip invisible/unsaved/in-memory images - if not (display.enabled and image.saved): - continue - - imgArgv = fslview_parseargs.generateImageArgs(image, displayCtx) - argv += [fname] + imgArgv - - log.debug('Generating screenshot with call ' - 'to render: {}'.format(' '.join(argv))) - - # Run render.py to generate the screenshot - msg = strings.messages['CanvasPanel.screenshot.pleaseWait'] - busyDlg = wx.BusyInfo(msg, canvas) - result = fsl.runTool('render', argv) - - busyDlg.Destroy() - - if result != 0: - title = strings.titles[ 'CanvasPanel.screenshot.error'] - msg = strings.messages['CanvasPanel.screenshot.error'] - msg = msg.format(' '.join(['render'] + argv)) - wx.MessageBox(msg, title, wx.ICON_ERROR | wx.OK) - class CanvasPanel(viewpanel.ViewPanel): """ """ - syncLocation = displayctx.DisplayContext.getSyncProperty('location') - syncImageOrder = displayctx.DisplayContext.getSyncProperty('imageOrder') - syncVolume = displayctx.DisplayContext.getSyncProperty('volume') + syncLocation = props.Boolean(default=True) + syncOverlayOrder = props.Boolean(default=True) + syncOverlayDisplay = props.Boolean(default=True) + movieMode = props.Boolean(default=False) + movieRate = props.Int(minval=100, + maxval=1000, + default=250, + clamped=True) + def __init__(self, parent, - imageList, + overlayList, displayCtx, sceneOpts, extraActions=None): @@ -173,18 +59,23 @@ class CanvasPanel(viewpanel.ViewPanel): actionz = dict({ 'screenshot' : self.screenshot, - 'toggleImageList' : lambda *a: self.togglePanel( - imagelistpanel.ImageListPanel), + 'showCommandLineArgs' : self.showCommandLineArgs, + 'toggleOverlayList' : lambda *a: self.togglePanel( + fslcontrols.OverlayListPanel), 'toggleAtlasPanel' : lambda *a: self.togglePanel( - atlaspanel.AtlasPanel), + fslcontrols.AtlasPanel), 'toggleDisplayProperties' : lambda *a: self.togglePanel( - imagedisplaytoolbar.ImageDisplayToolBar, False, self), + fslcontrols.OverlayDisplayToolBar, False, self), 'toggleLocationPanel' : lambda *a: self.togglePanel( - locationpanel.LocationPanel), + fslcontrols.LocationPanel), + 'toggleClusterPanel' : lambda *a: self.togglePanel( + fslcontrols.ClusterPanel), + 'toggleLookupTablePanel' : lambda *a: self.togglePanel( + fslcontrols.LookupTablePanel), }.items() + extraActions.items()) viewpanel.ViewPanel.__init__( - self, parent, imageList, displayCtx, actionz) + self, parent, overlayList, displayCtx, actionz) self.__opts = sceneOpts @@ -196,26 +87,34 @@ class CanvasPanel(viewpanel.ViewPanel): self.bindProps('syncLocation', displayCtx, displayCtx.getSyncPropertyName('location')) - self.bindProps('syncImageOrder', + self.bindProps('syncOverlayOrder', displayCtx, - displayCtx.getSyncPropertyName('imageOrder')) - self.bindProps('syncVolume', - displayCtx, - displayCtx.getSyncPropertyName('volume')) + displayCtx.getSyncPropertyName('overlayOrder')) + self.bindProps('syncOverlayDisplay', displayCtx) # If the displayCtx instance does not # have a parent, this means that it is # a top level instance else: self.disableProperty('syncLocation') - self.disableProperty('syncImageOrder') - self.disableProperty('syncVolume') + self.disableProperty('syncOverlayOrder') self.__canvasContainer = wx.Panel(self) self.__canvasPanel = wx.Panel(self.__canvasContainer) self.setCentrePanel(self.__canvasContainer) + # Stores a reference to a wx.Timer + # when movie mode is enabled + self.__movieTimer = None + + self.addListener('movieMode', + self._name, + self.__movieModeChanged) + self.addListener('movieRate', + self._name, + self.__movieRateChanged) + # Canvas/colour bar layout is managed in # the _layout/_toggleColourBar methods self.__canvasSizer = None @@ -230,24 +129,28 @@ class CanvasPanel(viewpanel.ViewPanel): self.__layout() - def getSceneOptions(self): - return self.__opts - - def destroy(self): """Makes sure that any remaining control panels are destroyed cleanly. """ - viewpanel.ViewPanel.destroy(self) - if self.__colourBar is not None: self.__colourBar.destroy() + + viewpanel.ViewPanel.destroy(self) def screenshot(self, *a): - _takeScreenShot(self._imageList, self._displayCtx, self) + _screenshot(self._overlayList, self._displayCtx, self) + + def showCommandLineArgs(self, *a): + _showCommandLineArgs(self._overlayList, self._displayCtx, self) + + + def getSceneOptions(self): + return self.__opts + def getCanvasPanel(self): return self.__canvasPanel @@ -276,7 +179,7 @@ class CanvasPanel(viewpanel.ViewPanel): if self.__colourBar is None: self.__colourBar = colourbarpanel.ColourBarPanel( - self.__canvasContainer, self._imageList, self._displayCtx) + self.__canvasContainer, self._overlayList, self._displayCtx) self.__opts.bindProps('colourBarLabelSide', self.__colourBar, @@ -305,3 +208,202 @@ class CanvasPanel(viewpanel.ViewPanel): # Force the canvas panel to resize itself self.PostSizeEvent() + + + def __movieModeChanged(self, *a): + + if self.__movieTimer is not None: + self.__movieTimer.Stop() + self.__movieTimer = None + + if not self.movieMode: + return + + self.__movieTimer = wx.Timer(self) + self.Bind(wx.EVT_TIMER, self.__movieUpdate) + self.__movieTimer.Start(self.movieRate) + + + def __movieRateChanged(self, *a): + if not self.movieMode: + return + + self.__movieModeChanged() + + + def __movieUpdate(self, ev): + + overlay = self._displayCtx.getSelectedOverlay() + + if overlay is None: + return + + if not isinstance(overlay, fslimage.Image): + return + + if not overlay.is4DImage(): + return + + opts = self._displayCtx.getOpts(overlay) + + if not isinstance(opts, displayctx.VolumeOpts): + return + + limit = overlay.shape[3] + + if opts.volume == limit - 1: opts.volume = 0 + else: opts.volume += 1 + + + +def _genCommandLineArgs(overlayList, displayCtx, canvas): + + argv = [] + + # Add scene options + sceneOpts = canvas.getSceneOptions() + argv += fslview_parseargs.generateSceneArgs( + overlayList, displayCtx, sceneOpts) + + # Add ortho specific options, if it's + # an orthopanel we're dealing with + if isinstance(sceneOpts, displayctx.OrthoOpts): + + xcanvas = canvas.getXCanvas() + ycanvas = canvas.getYCanvas() + zcanvas = canvas.getZCanvas() + + argv += ['--{}'.format(fslview_parseargs.ARGUMENTS[sceneOpts, + 'xcentre'][1])] + argv += ['{}'.format(c) for c in xcanvas.pos.xy] + argv += ['--{}'.format(fslview_parseargs.ARGUMENTS[sceneOpts, + 'ycentre'][1])] + argv += ['{}'.format(c) for c in ycanvas.pos.xy] + argv += ['--{}'.format(fslview_parseargs.ARGUMENTS[sceneOpts, + 'zcentre'][1])] + argv += ['{}'.format(c) for c in zcanvas.pos.xy] + + # Add display options for each overlay + for overlay in overlayList: + + fname = overlay.dataSource + ovlArgv = fslview_parseargs.generateOverlayArgs(overlay, displayCtx) + argv += [fname] + ovlArgv + + return argv + + +def _showCommandLineArgs(overlayList, displayCtx, canvas): + + args = _genCommandLineArgs(overlayList, displayCtx, canvas) + dlg = fsldlg.TextEditDialog( + canvas, + title=strings.messages[ canvas, 'showCommandLineArgs', 'title'], + message=strings.messages[canvas, 'showCommandLineArgs', 'message'], + text=' '.join(args), + icon=wx.ICON_INFORMATION, + style=(fsldlg.TED_OK | + fsldlg.TED_READONLY | + fsldlg.TED_MULTILINE | + fsldlg.TED_COPY)) + + dlg.CentreOnParent() + + dlg.ShowModal() + + +def _screenshot(overlayList, displayCtx, canvas): + + overlays = displayCtx.getOrderedOverlays() + ovlCopy = list(overlays) + + # Check to make sure that all overlays are saved + # on disk, and ask the user what they want to + # do about the ones that aren't. + for overlay in overlays: + + # Skip disabled overlays + display = displayCtx.getDisplay(overlay) + + if not display.enabled: + ovlCopy.remove(overlay) + continue + + # If the image is not saved, popup a dialog + # telling the user they must save the image + # before the screenshot can proceed + if isinstance(overlay, fslimage.Image) and not overlay.saved: + title = strings.titles[ 'CanvasPanel.screenshot.notSaved'] + msg = strings.messages['CanvasPanel.screenshot.notSaved'] + msg = msg.format(overlay.name) + + dlg = wx.MessageDialog(canvas, + message=msg, + caption=title, + style=(wx.CENTRE | + wx.YES_NO | + wx.CANCEL | + wx.ICON_QUESTION)) + dlg.SetYesNoCancelLabels( + strings.labels['CanvasPanel.screenshot.notSaved.save'], + strings.labels['CanvasPanel.screenshot.notSaved.skip'], + strings.labels['CanvasPanel.screenshot.notSaved.cancel']) + + result = dlg.ShowModal() + + # The user chose to save the image + if result == wx.ID_YES: + fsloverlay.saveOverlay(overlay) + + # The user chose to skip the image + elif result == wx.ID_NO: + ovlCopy.remove(overlay) + continue + + # the user clicked cancel, or closed the dialog + else: + return + + overlays = ovlCopy + + # Ask the user where they want + # the screenshot to be saved + dlg = wx.FileDialog(canvas, + message=strings.messages['CanvasPanel.screenshot'], + style=wx.FD_SAVE) + + if dlg.ShowModal() != wx.ID_OK: + return + + filename = dlg.GetPath() + + # Make the dialog go away before + # the screenshot gets taken + dlg.Destroy() + wx.Yield() + + width, height = canvas.getCanvasPanel().GetClientSize().Get() + + # generate command line arguments for + # a callout to render.py - start with + # the render.py specific options + argv = [] + argv += ['--outfile', filename] + argv += ['--size', '{}'.format(width), '{}'.format(height)] + argv += ['--background', '0', '0', '0', '255'] + + argv += _genCommandLineArgs(overlays, displayCtx, canvas) + + log.debug('Generating screenshot with call ' + 'to render: {}'.format(' '.join(argv))) + + # Run render.py to generate the screenshot + msg = strings.messages['CanvasPanel.screenshot.pleaseWait'] + dlg = fsldlg.ProcessingDialog(canvas, msg, fsl.runTool, 'render', argv) + result = dlg.Run() + + if result != 0: + title = strings.titles[ 'CanvasPanel.screenshot.error'] + msg = strings.messages['CanvasPanel.screenshot.error'] + msg = msg.format(' '.join(['render'] + argv)) + wx.MessageBox(msg, title, wx.ICON_ERROR | wx.OK) diff --git a/fsl/fslview/views/colourbarpanel.py b/fsl/fslview/views/colourbarpanel.py index b1305b3832180e1b7fcf83441ea881fdaac18f66..a15a2074c2dc2a24344b31311d28f353d5e1a324 100644 --- a/fsl/fslview/views/colourbarpanel.py +++ b/fsl/fslview/views/colourbarpanel.py @@ -6,11 +6,7 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # """A :class:`~fsl.fslview.panel.ViewPanel` which renders a colour bar -depicting the colour range of the currently selected image. - -This panel is not directly accessible by users (see the -:mod:`~fsl.fslview.views` package ``__init__.py`` file), but is embedded -within other view panels. +depicting the colour range of the currently selected overlay (if applicable). """ import logging @@ -18,30 +14,34 @@ log = logging.getLogger(__name__) import wx +import fsl.data.image as fslimage import fsl.fslview.panel as fslpanel +import fsl.fslview.displaycontext as fsldc import fsl.fslview.displaycontext.volumeopts as volumeopts import fsl.fslview.gl.wxglcolourbarcanvas as cbarcanvas class ColourBarPanel(fslpanel.FSLViewPanel): """A panel which shows a colour bar, depicting the data range of the - currently selected image. + currently selected overlay. """ + orientation = cbarcanvas.ColourBarCanvas.orientation """Draw the colour bar horizontally or vertically. """ + labelSide = cbarcanvas.ColourBarCanvas.labelSide """Draw colour bar labels on the top/left/right/bottom.""" def __init__(self, parent, - imageList, + overlayList, displayCtx, orientation='horizontal'): - fslpanel.FSLViewPanel.__init__(self, parent, imageList, displayCtx) + fslpanel.FSLViewPanel.__init__(self, parent, overlayList, displayCtx) self._cbPanel = cbarcanvas.ColourBarCanvas(self) @@ -56,41 +56,46 @@ class ColourBarPanel(fslpanel.FSLViewPanel): self.addListener('orientation', self._name, self._layout) - self._imageList .addListener('images', - self._name, - self._selectedImageChanged) - - self._displayCtx.addListener('selectedImage', - self._name, - self._selectedImageChanged) - - self._selectedImage = None + self._overlayList.addListener('overlays', + self._name, + self._selectedOverlayChanged) + self._displayCtx .addListener('selectedOverlay', + self._name, + self._selectedOverlayChanged) + + self._selectedOverlay = None self._layout() - self._selectedImageChanged() + self._selectedOverlayChanged() def destroy(self): - """Removes all registered listeners from the image list, display - context, and individual images. + """Removes all registered listeners from the overlay list, display + context, and individual overlays. """ - fslpanel.FSLViewPanel.destroy(self) - self._imageList .removeListener('images', self._name) - self._displayCtx.removeListener('selectedImage', self._name) + self._overlayList.removeListener('overlays', self._name) + self._displayCtx .removeListener('selectedOverlay', self._name) - image = self._selectedImage + overlay = self._selectedOverlay - if image is not None: - display = self._displayCtx.getDisplayProperties(image) - opts = display.getDisplayOpts() + if overlay is not None: + try: + display = self._displayCtx.getDisplay(overlay) + opts = display.getDisplayOpts() - if isinstance(opts, volumeopts.VolumeOpts): - image .removeListener('name', self._name) - opts .removeListener('cmap', self._name) - opts .removeListener('displayRange', self._name) + if isinstance(opts, volumeopts.VolumeOpts): + display.removeListener('name', self._name) + opts .removeListener('cmap', self._name) + opts .removeListener('displayRange', self._name) + + except fsldc.InvalidOverlayError: + pass + self._cbPanel .destroy() + fslpanel.FSLViewPanel.destroy(self) + def _layout(self, *a): """ @@ -106,58 +111,76 @@ class ColourBarPanel(fslpanel.FSLViewPanel): self._refreshColourBar() - def _selectedImageChanged(self, *a): + def _selectedOverlayChanged(self, *a): """ """ - image = self._selectedImage - - if image is not None: - display = self._displayCtx.getDisplayProperties(image) - opts = display.getDisplayOpts() + overlay = self._selectedOverlay + + if overlay is not None: + try: + display = self._displayCtx.getDisplay(overlay) + opts = display.getDisplayOpts() - if isinstance(opts, volumeopts.VolumeOpts): opts .removeListener('displayRange', self._name) opts .removeListener('cmap', self._name) - image .removeListener('name', self._name) - - self._selectedImage = self._displayCtx.getSelectedImage() - image = self._selectedImage - - # TODO register on imageType property, in - # case the image type changes to a type + display.removeListener('name', self._name) + + # The previously selected overlay + # has been removed from the list, + # so its Display/Opts instances + # have been thrown away + except fsldc.InvalidOverlayError: + pass + + self._selectedOverlay = None + + overlay = self._displayCtx.getSelectedOverlay() + + if overlay is None: + self._refreshColourBar() + return + + display = self._displayCtx.getDisplay(overlay) + opts = display.getDisplayOpts() + + # TODO support for other overlay types + # TODO support for other types (where applicable) + if not isinstance(overlay, fslimage.Image) or \ + not isinstance(opts, volumeopts.VolumeOpts): + self._refreshColourBar() + return + + self._selectedOverlay = overlay + + # TODO register on overlayType property, in + # case the overlay type changes to a type # that has a display range and colour map - if image is not None: - display = self._displayCtx.getDisplayProperties(image) - opts = display.getDisplayOpts() - - if isinstance(opts, volumeopts.VolumeOpts): - - opts .addListener('displayRange', - self._name, - self._displayRangeChanged) - opts .addListener('cmap', - self._name, - self._refreshColourBar) - image .addListener('name', - self._name, - self._imageNameChanged) - - else: - self._selectedImage = None - - self._imageNameChanged() + opts .addListener('displayRange', + self._name, + self._displayRangeChanged) + opts .addListener('cmap', + self._name, + self._refreshColourBar) + display.addListener('name', + self._name, + self._overlayNameChanged) + + self._overlayNameChanged() self._displayRangeChanged() self._refreshColourBar() - def _imageNameChanged(self, *a): + def _overlayNameChanged(self, *a): """ """ - if self._selectedImage is not None: label = self._selectedImage.name - else: label = '' + if self._selectedOverlay is not None: + label = self._selectedOverlay.name + else: + label = '' + self._cbPanel.label = label @@ -165,11 +188,11 @@ class ColourBarPanel(fslpanel.FSLViewPanel): """ """ - if self._selectedImage is not None: + overlay = self._selectedOverlay + + if overlay is not None: - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) - opts = display.getDisplayOpts() + opts = self._displayCtx.getOpts(overlay) dmin, dmax = opts.displayRange.getRange(0) else: dmin, dmax = 0.0, 0.0 @@ -181,11 +204,11 @@ class ColourBarPanel(fslpanel.FSLViewPanel): """ """ - if self._selectedImage is not None: - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) - opts = display.getDisplayOpts() - cmap = opts.cmap + overlay = self._selectedOverlay + + if overlay is not None: + opts = self._displayCtx.getOpts(overlay) + cmap = opts.cmap else: cmap = None diff --git a/fsl/fslview/views/histogrampanel.py b/fsl/fslview/views/histogrampanel.py index b0d387630a673512e807f0498636608581ac2bb0..d28dbe7d3074bd5b8559c2eebd98df94dfa7a1a9 100644 --- a/fsl/fslview/views/histogrampanel.py +++ b/fsl/fslview/views/histogrampanel.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # histogrampanel.py - A panel which plots a histogram for the data from the -# currently selected image. +# currently selected overlay. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # @@ -13,246 +13,558 @@ import numpy as np import props -import fsl.data.strings as strings -import fsl.fslview.controls.histogramtoolbar as histogramtoolbar -import plotpanel +import fsl.data.image as fslimage +import fsl.data.strings as strings +import fsl.utils.dialog as fsldlg +import fsl.fslview.controls as fslcontrols +import plotpanel log = logging.getLogger(__name__) -# -# Ideas: -# -# - Log scale -# -# - Plot histogram for multiple images (how to select them?) -# -# - Ability to apply a label mask image, and plot separate -# histograms for each label -# -# - Ability to put an overlay on the display, showing the -# voxels that are within the histogram range -# -# - For 4D images, add an option to plot the histogram for -# the current volume only, or for all volumes -# -# - For different image types (e.g. vector), add anoption -# to plot the histogram of calculated values, e.g. -# magnitude, or separate histogram lines for xyz -# components? -# -class HistogramPanel(plotpanel.PlotPanel): + +def autoBin(data, dataRange): + # Automatic histogram bin calculation + # as implemented in the original FSLView + + dMin, dMax = dataRange + dRange = dMax - dMin + + binSize = np.power(10, np.ceil(np.log10(dRange) - 1) - 1) + + nbins = dRange / binSize - dataRange = props.Bounds( + while nbins < 100: + binSize /= 2 + nbins = dRange / binSize + + if issubclass(data.dtype.type, np.integer): + binSize = max(1, np.ceil(binSize)) + + adjMin = np.floor(dMin / binSize) * binSize + adjMax = np.ceil( dMax / binSize) * binSize + + nbins = int((adjMax - adjMin) / binSize) + 1 + + return nbins + + +class HistogramSeries(plotpanel.DataSeries): + + nbins = props.Int(minval=10, + maxval=500, + default=100, + clamped=True) + ignoreZeros = props.Boolean(default=True) + showOverlay = props.Boolean(default=False) + includeOutliers = props.Boolean(default=False) + volume = props.Int(minval=0, maxval=0, clamped=True) + dataRange = props.Bounds( ndims=1, labels=[strings.choices['HistogramPanel.dataRange.min'], strings.choices['HistogramPanel.dataRange.max']]) - - nbins = props.Int( minval=10, maxval=500, default=100, clamped=True) - autoHist = props.Boolean(default=True) - def __init__(self, parent, imageList, displayCtx): + def __init__(self, + overlay, + hsPanel, + displayCtx, + overlayList, + volume=0, + baseHs=None): + + log.debug('New HistogramSeries instance for {} ' + '(based on existing instance: {})'.format( + overlay.name, baseHs is not None)) + + plotpanel.DataSeries.__init__(self, overlay) + self.hsPanel = hsPanel + self.name = '{}_{}'.format(type(self).__name__, id(self)) + self.volume = volume + + self.displayCtx = displayCtx + self.overlayList = overlayList + self.overlay3D = None + + if overlay.is4DImage(): + self.setConstraint('volume', 'maxval', overlay.shape[3] - 1) + + if baseHs is not None: + self.dataRange.xmin = baseHs.dataRange.xmin + self.dataRange.xmax = baseHs.dataRange.xmax + self.dataRange.x = baseHs.dataRange.x + self.nbins = baseHs.nbins + self.volume = baseHs.volume + self.ignoreZeros = baseHs.ignoreZeros + self.includeOutliers = baseHs.includeOutliers + self.nvals = baseHs.nvals + self.xdata = np.array(baseHs.xdata) + self.ydata = np.array(baseHs.ydata) + self.finiteData = np.array(baseHs.finiteData) + self.nonZeroData = np.array(baseHs.nonZeroData) + self.clippedFiniteData = np.array(baseHs.finiteData) + self.clippedNonZeroData = np.array(baseHs.nonZeroData) + + else: + self.initProperties() + + overlayList.addListener('overlays', self.name, self.overlaysChanged) - actionz = {'toggleToolbar' : lambda *a: self.togglePanel( - histogramtoolbar.HistogramToolBar, False, self)} + self.addListener('volume', self.name, self.volumeChanged) + self.addListener('dataRange', self.name, self.dataRangeChanged) + self.addListener('nbins', self.name, self.histPropsChanged) + self.addListener('ignoreZeros', self.name, self.histPropsChanged) + self.addListener('includeOutliers', self.name, self.histPropsChanged) + self.addListener('showOverlay', self.name, self.showOverlayChanged) - plotpanel.PlotPanel.__init__( - self, parent, imageList, displayCtx, actionz) + + def initProperties(self): - figure = self.getFigure() - canvas = self.getCanvas() + log.debug('Performining initial histogram ' + 'calculations for overlay {}'.format( + self.overlay.name)) + + data = self.overlay.data[:] - figure.subplots_adjust( - top=1.0, bottom=0.0, left=0.0, right=1.0) + finData = data[np.isfinite(data)] + dmin = finData.min() + dmax = finData.max() + dist = (dmax - dmin) / 10000.0 + + nzData = finData[finData != 0] + nzmin = nzData.min() + nzmax = nzData.max() - figure.patch.set_visible(False) + self.dataRange.xmin = dmin + self.dataRange.xmax = dmax + dist + self.dataRange.xlo = nzmin + self.dataRange.xhi = nzmax + dist + + self.nbins = autoBin(nzData, self.dataRange.x) + + if not self.overlay.is4DImage(): + self.finiteData = finData + self.nonZeroData = nzData + self.dataRangeChanged(callHistPropsChanged=False) + else: + self.volumeChanged(callHistPropsChanged=False) + + self.histPropsChanged() - self._imageList.addListener( - 'images', - self._name, - self._selectedImageChanged) - self._displayCtx.addListener( - 'selectedImage', - self._name, - self._selectedImageChanged) - - self._mouseDown = False - canvas.mpl_connect('button_press_event', self._onPlotMouseDown) - canvas.mpl_connect('button_release_event', self._onPlotMouseUp) - canvas.mpl_connect('motion_notify_event', self._onPlotMouseMove) - - self.addListener('dataRange', self._name, self._drawPlot) - self.addListener('nbins', self._name, self._drawPlot) - self.addListener('autoHist', self._name, self._drawPlot) - - self._domainHighlight = None - self._selectedImageChanged() + def volumeChanged( + self, + ctx=None, + value=None, + valid=None, + name=None, + callHistPropsChanged=True): - self.Layout() + if self.overlay.is4DImage(): data = self.overlay.data[..., self.volume] + else: data = self.overlay.data[:] + + data = data[np.isfinite(data)] + + self.finiteData = data + self.nonZeroData = data[data != 0] + + self.dataRangeChanged(callHistPropsChanged=False) + + if callHistPropsChanged: + self.histPropsChanged() + + + def dataRangeChanged( + self, + ctx=None, + value=None, + valid=None, + name=None, + callHistPropsChanged=True): + finData = self.finiteData + nzData = self.nonZeroData + + self.clippedFiniteData = finData[(finData >= self.dataRange.xlo) & + (finData < self.dataRange.xhi)] + self.clippedNonZeroData = nzData[ (nzData >= self.dataRange.xlo) & + (nzData < self.dataRange.xhi)] + + if callHistPropsChanged: + self.histPropsChanged() def destroy(self): - """De-registers property listeners. """ - plotpanel.PlotPanel.destroy(self) + """This needs to be called when this ``HistogramSeries`` instance + is no longer being used. + """ + self .removeListener('nbins', self.name) + self .removeListener('ignoreZeros', self.name) + self .removeListener('includeOutliers', self.name) + self .removeListener('volume', self.name) + self .removeListener('dataRange', self.name) + self .removeListener('nbins', self.name) + self.overlayList.removeListener('overlays', self.name) + + if self.overlay3D is not None: + self.overlayList.remove(self.overlay3D) + self.overlay3D = None + + + def histPropsChanged(self, *a): - self .removeListener('dataRange', self._name) - self .removeListener('nbins', self._name) - self .removeListener('autoHist', self._name) - self._imageList .removeListener('images', self._name) - self._displayCtx.removeListener('selectedImage', self._name) + log.debug('Calculating histogram for ' + 'overlay {}'.format(self.overlay.name)) + if self.dataRange.xhi - self.dataRange.xlo < 0.00000001: + self.xdata = np.array([]) + self.ydata = np.array([]) + self.nvals = 0 + return + + if self.ignoreZeros: + if self.includeOutliers: data = self.nonZeroData + else: data = self.clippedNonZeroData + else: + if self.includeOutliers: data = self.finiteData + else: data = self.clippedFiniteData - def _autoHistogramBins(self, data): + if self.hsPanel.autoBin: + nbins = autoBin(data, self.dataRange.x) + + self.disableListener('nbins', self.name) + self.nbins = nbins + self.enableListener('nbins', self.name) + + # Calculate bin edges + bins = np.linspace(self.dataRange.xlo, + self.dataRange.xhi, + self.nbins + 1) + + if self.includeOutliers: + bins[ 0] = self.dataRange.xmin + bins[-1] = self.dataRange.xmax + + # Calculate the histogram + histX = bins + histY, _ = np.histogram(data.flat, bins=bins) + + self.xdata = histX + self.ydata = histY + self.nvals = histY.sum() + + log.debug('Calculated histogram for overlay ' + '{} (number of values: {}, number ' + 'of bins: {})'.format( + self.overlay.name, + self.nvals, + self.nbins)) + + + def showOverlayChanged(self, *a): + + if not self.showOverlay: + if self.overlay3D is not None: + + log.debug('Removing 3D histogram overlay mask for {}'.format( + self.overlay.name)) + self.overlayList.remove(self.overlay3D) + self.overlay3D = None + + else: + + log.debug('Creating 3D histogram overlay mask for {}'.format( + self.overlay.name)) + + self.overlay3D = fslimage.Image( + self.overlay.data, + name='{}/histogram/mask'.format(self.overlay.name), + header=self.overlay.nibImage.get_header()) - # Automatic histogram bin calculation - # as implemented in the original FSLView + self.overlayList.append(self.overlay3D) - dMin, dMax = self.dataRange.x - dRange = dMax - dMin + opts = self.displayCtx.getOpts(self.overlay3D, overlayType='mask') - binSize = np.power(10, np.ceil(np.log10(dRange) - 1) - 1) + opts.bindProps('volume', self) + opts.bindProps('colour', self) + opts.bindProps('threshold', self, 'dataRange') - nbins = dRange / binSize + + def overlaysChanged(self, *a): - while nbins < 100: - binSize /= 2 - nbins = dRange / binSize + if self.overlay3D is None: + return + + # If a 3D overlay was being shown, and it + # has been removed from the overlay list + # by the user, turn the showOverlay property + # off + if self.overlay3D not in self.overlayList: + + self.disableListener('showOverlay', self.name) + self.showOverlay = False + self.showOverlayChanged() + self.enableListener('showOverlay', self.name) + + + def getData(self): + + if len(self.xdata) == 0 or \ + len(self.ydata) == 0: + return self.xdata, self.ydata + + # If smoothing is not enabled, we'll + # munge the histogram data a bit so + # that plt.plot(drawstyle='steps-pre') + # plots it nicely. + if not self.hsPanel.smooth: - if issubclass(data.dtype.type, np.integer): - binSize = max(1, np.ceil(binSize)) + xdata = np.zeros(len(self.xdata) + 1, dtype=np.float32) + ydata = np.zeros(len(self.ydata) + 2, dtype=np.float32) - adjMin = np.floor(dMin / binSize) * binSize - adjMax = np.ceil( dMax / binSize) * binSize + xdata[ :-1] = self.xdata + xdata[ -1] = self.xdata[-1] + ydata[1:-1] = self.ydata + + + # If smoothing is enabled, the above munge + # is not necessary, and will probably cause + # the spline interpolation to fail + else: + xdata = np.array(self.xdata[:-1], dtype=np.float32) + ydata = np.array(self.ydata, dtype=np.float32) + + nvals = self.nvals + histType = self.hsPanel.histType + + if histType == 'count': return xdata, ydata + elif histType == 'probability': return xdata, ydata / nvals + + +class HistogramPanel(plotpanel.PlotPanel): - nbins = int((adjMax - adjMin) / binSize) + 1 - return nbins + autoBin = props.Boolean(default=True) + showCurrent = props.Boolean(default=True) + histType = props.Choice( + ('probability', 'count'), + labels=[strings.choices['HistogramPanel.histType.probability'], + strings.choices['HistogramPanel.histType.count']]) + selectedSeries = props.Int(minval=0, clamped=True) - def _calcHistogram(self, data): + + def __init__(self, parent, overlayList, displayCtx): + + actionz = { + 'toggleHistogramList' : self.toggleHistogramList, + 'toggleHistogramControl' : lambda *a: self.togglePanel( + fslcontrols.HistogramControlPanel, False, self) + } + + plotpanel.PlotPanel.__init__( + self, parent, overlayList, displayCtx, actionz) + + figure = self.getFigure() - if self.autoHist: nbins = self._autoHistogramBins(data) - else: nbins = self.nbins + figure.subplots_adjust( + top=1.0, bottom=0.0, left=0.0, right=1.0) + + figure.patch.set_visible(False) + + self._overlayList.addListener('overlays', + self._name, + self.__overlaysChanged) + self._displayCtx .addListener('selectedOverlay', + self._name, + self.__selectedOverlayChanged) + + self.addListener('showCurrent', self._name, self.draw) + self.addListener('histType', self._name, self.draw) + self.addListener('autoBin', self._name, self.__autoBinChanged) + self.addListener('dataSeries', self._name, self.__dataSeriesChanged) + + self.__histCache = {} + self.__current = None + self.__updateCurrent() + + self.Layout() + + + def toggleHistogramList(self, *a): + self.togglePanel(fslcontrols.HistogramListPanel, False, self) + + panel = self.getPanel(fslcontrols.HistogramListPanel) + + if panel is None: + return + + def listSelect(ev): + ev.Skip() + self.selectedSeries = panel.GetSelection() + + + def destroy(self): + """De-registers property listeners. """ - histY, histX = np.histogram(data.flat, - bins=nbins, - range=self.dataRange.x) + self.removeListener('showCurrent', self._name) + self.removeListener('histType', self._name) + self.removeListener('autoBin', self._name) + self.removeListener('dataSeries', self._name) - # np.histogram returns all bin - # edges, including the right hand - # side of the final bin. Remove it. - # And also shift the remaining - # bin edges so they are centred - # within each bin - histX = histX[:-1] - histX += (histX[1] - histX[0]) / 2.0 + self._overlayList.removeListener('overlays', self._name) + self._displayCtx .removeListener('selectedOverlay', self._name) - return histX, histY + for hs in set(self.dataSeries[:] + self.__histCache.values()): + hs.destroy() - - def _selectedImageChanged(self, *a): + plotpanel.PlotPanel.destroy(self) - if len(self._imageList) == 0: - return - image = self._displayCtx.getSelectedImage() + def __dataSeriesChanged(self, *a): + self.setConstraint('selectedSeries', + 'maxval', + len(self.dataSeries) - 1) + + listPanel = self.getPanel(fslcontrols.HistogramListPanel) - minval = float(image.data.min()) - maxval = float(image.data.max()) + if listPanel is None: + self.selectedSeries = 0 + else: + self.selectedSeries = listPanel.getListBox().GetSelection() - # update the histgram range from the data range - self.disableListener('dataRange', self._name) + + def __overlaysChanged(self, *a): - self.dataRange.setMin( 0, minval) - self.dataRange.setMax( 0, maxval) - self.dataRange.setRange(0, minval, maxval) + self.disableListener('dataSeries', self._name) - self.enableListener('dataRange', self._name) + for ds in self.dataSeries: + if ds.overlay not in self._overlayList: + self.dataSeries.remove(ds) + + self.enableListener('dataSeries', self._name) + + # Remove any dead overlays + # from the histogram cache + for overlay in list(self.__histCache.keys()): + if overlay not in self._overlayList: + log.debug('Removing cached histogram series ' + 'for overlay {}'.format(overlay.name)) + hs = self.__histCache.pop(overlay) + hs.destroy() + + self.__selectedOverlayChanged() - self._drawPlot() + + def __selectedOverlayChanged(self, *a): + self.__updateCurrent() + self.draw() - def _onPlotMouseDown(self, ev): - if ev.inaxes != self.getAxis(): - return - if self._displayCtx.getSelectedImage() is None: - return + def __autoBinChanged(self, *a): + """Called when the :attr:`autoBin` property changes. Makes sure that + all existing :class:`HistogramSeries` instances are updated before + the plot is refreshed. + """ - self._mouseDown = True - self._domainHighlight = [ev.xdata, ev.xdata] + for ds in self.dataSeries: + ds.histPropsChanged() - - def _onPlotMouseMove(self, ev): - if not self._mouseDown: - return + if self.__current is not None: + self.__current.histPropsChanged() + + self.draw() - if ev.inaxes != self.getAxis(): - return - self._domainHighlight[1] = ev.xdata - self._drawPlot() + def __updateCurrent(self): - - def _onPlotMouseUp(self, ev): + # Make sure that the previous HistogramSeries + # cleans up after itself, unless it has been + # cached + if self.__current is not None and \ + self.__current not in self.__histCache.values(): + self.__current.destroy() + + self.__current = None + overlay = self._displayCtx.getSelectedOverlay() - if not self._mouseDown or self._domainHighlight is None: + if len(self._overlayList) == 0 or \ + not isinstance(overlay, fslimage.Image): return - # Sort the domain min/max in case the mouse was - # dragged from right to left, in which case the - # second value would be less than the first - newRange = sorted(self._domainHighlight) - self._mouseDown = False - self._domainHighlight = None - self.dataRange.x = newRange + # See if there is already a HistogramSeries based on the + # current overlay - if there is, use it as the 'base' HS + # for the new one, as it will save us some processing time + if overlay in self.__histCache: + log.debug('Creating new histogram series for overlay {} ' + 'from cached copy'.format(overlay.name)) + baseHs = self.__histCache[overlay] + else: + baseHs = None + + def loadHs(): + return HistogramSeries(overlay, + self, + self._displayCtx, + self._overlayList, + baseHs=baseHs) + + # We are creating a new HS instance, so it + # needs to do some initla data range/histogram + # calculations. Show a message while this is + # happening. + if baseHs is None: + hs = fsldlg.ProcessingDialog( + self, + strings.messages[self, 'calcHist'].format(overlay.name), + loadHs).Run() + + # Put the initial HS instance for this + # overlay in the cache so we don't have + # to re-calculate it later + log.debug('Caching histogram series for ' + 'overlay {}'.format(overlay.name)) + self.__histCache[overlay] = hs + + # The new HS instance is being based on the + # current instance, so it can just copy the + # histogram data over - no message dialog + # is needed + else: + hs = loadHs() - - def _drawPlot(self, *a): + hs.colour = [0, 0, 0] + hs.alpha = 1 + hs.lineWidth = 2 + hs.lineStyle = ':' + hs.label = None - axis = self.getAxis() - image = self._displayCtx.getSelectedImage() - x, y = self._calcHistogram(image.data) + self.__current = hs - axis.clear() - axis.step(x, y) - axis.grid(True) - - xmin = x.min() - xmax = x.max() - ymin = y.min() - ymax = y.max() - xlen = xmax - xmin - ylen = ymax - ymin + def getCurrent(self): + if self.__current is None: + self.__updateCurrent() - axis.grid(True) - axis.set_xlim((xmin - xlen * 0.05, xmax + xlen * 0.05)) - axis.set_ylim((ymin - ylen * 0.05, ymax + ylen * 0.05)) + if self.__current is None: + return None - if ymin != ymax: yticks = np.linspace(ymin, ymax, 5) - else: yticks = [ymin] + return HistogramSeries(self.__current.overlay, + self, + self._displayCtx, + self._overlayList, + baseHs=self.__current) - axis.set_yticks(yticks) - for tick in axis.yaxis.get_major_ticks(): - tick.set_pad(-15) - tick.label1.set_horizontalalignment('left') - - for tick in axis.xaxis.get_major_ticks(): - tick.set_pad(-20) + def draw(self, *a): + extra = None - if self._domainHighlight is not None: - axis.axvspan(self._domainHighlight[0], - self._domainHighlight[1], - fill=True, - facecolor='#000080', - edgecolor='none', - alpha=0.4) + if self.showCurrent: + + if self.__current is not None: + extra = [self.__current] - self.getCanvas().draw() - self.Refresh() + if self.smooth: self.drawDataSeries(extra) + else: self.drawDataSeries(extra, drawstyle='steps-pre') diff --git a/fsl/fslview/views/lightboxpanel.py b/fsl/fslview/views/lightboxpanel.py index 4c63de0ef7a2f1965315f5c2d4b56e115ef2a56d..616e3ee7a165dc6064082c5686202b6a2396b132 100644 --- a/fsl/fslview/views/lightboxpanel.py +++ b/fsl/fslview/views/lightboxpanel.py @@ -1,13 +1,13 @@ #!/usr/bin/env python # # lightboxpanel.py - A panel which contains a LightBoxCanvas, for displaying -# multiple slices from a collection of images. +# multiple slices from a collection of overlays. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # """This module defines the :class:`LightBoxPanel, a panel which contains a :class:`~fsl.fslview.gl.LightBoxCanvas`, for displaying multiple slices from a -collection of images. +collection of overlays. """ import logging @@ -31,7 +31,7 @@ class LightBoxPanel(canvaspanel.CanvasPanel): """ - def __init__(self, parent, imageList, displayCtx): + def __init__(self, parent, overlayList, displayCtx): """ """ @@ -44,30 +44,21 @@ class LightBoxPanel(canvaspanel.CanvasPanel): canvaspanel.CanvasPanel.__init__(self, parent, - imageList, + overlayList, displayCtx, sceneOpts, actionz) - imageList = self._imageList - displayCtx = self._displayCtx - self._scrollbar = wx.ScrollBar( self.getCanvasPanel(), style=wx.SB_VERTICAL) self._lbCanvas = lightboxcanvas.LightBoxCanvas( self.getCanvasPanel(), - imageList, + overlayList, displayCtx) - # My properties are the canvas properties self._lbCanvas.bindProps('zax', sceneOpts) - self._lbCanvas.bindProps('nrows', sceneOpts) - self._lbCanvas.bindProps('ncols', sceneOpts) - self._lbCanvas.bindProps('topRow', sceneOpts) - self._lbCanvas.bindProps('sliceSpacing', sceneOpts) - self._lbCanvas.bindProps('zrange', sceneOpts) self._lbCanvas.bindProps('showCursor', sceneOpts) self._lbCanvas.bindProps('showGridLines', sceneOpts) self._lbCanvas.bindProps('highlightSlice', sceneOpts) @@ -75,6 +66,18 @@ class LightBoxPanel(canvaspanel.CanvasPanel): self._lbCanvas.bindProps('softwareMode', sceneOpts) self._lbCanvas.bindProps('resolutionLimit', sceneOpts) + # Bind these properties the other way around, + # so that the sensible values calcualted by + # the LBCanvas during its initialisation are + # propagated to the LBOpts instance, rather + # than the non-sensible default values in the + # LBOpts instance. + sceneOpts .bindProps('nrows', self._lbCanvas) + sceneOpts .bindProps('ncols', self._lbCanvas) + sceneOpts .bindProps('topRow', self._lbCanvas) + sceneOpts .bindProps('sliceSpacing', self._lbCanvas) + sceneOpts .bindProps('zrange', self._lbCanvas) + self._canvasSizer = wx.BoxSizer(wx.HORIZONTAL) self.getCanvasPanel().SetSizer(self._canvasSizer) @@ -84,15 +87,15 @@ class LightBoxPanel(canvaspanel.CanvasPanel): # When the display context location changes, # make sure the location is shown on the canvas self._lbCanvas.pos.xyz = self._displayCtx.location - self._displayCtx.addListener('location', - self._name, - self._onLocationChange) - self._displayCtx.addListener('selectedImage', - self._name, - self._selectedImageChanged) - self._imageList.addListener('images', - self._name, - self._selectedImageChanged) + self._displayCtx .addListener('location', + self._name, + self._onLocationChange) + self._displayCtx .addListener('selectedOverlay', + self._name, + self._selectedOverlayChanged) + self._overlayList.addListener('overlays', + self._name, + self._selectedOverlayChanged) sceneOpts.zoom = 750 @@ -124,72 +127,80 @@ class LightBoxPanel(canvaspanel.CanvasPanel): self.Layout() - self._selectedImageChanged() + self._selectedOverlayChanged() self.initProfile() def destroy(self): """Removes property listeners""" - canvaspanel.CanvasPanel.destroy(self) - self._displayCtx.removeListener('location', self._name) - self._displayCtx.removeListener('selectedImage', self._name) - self._imageList .removeListener('images', self._name) + self._displayCtx .removeListener('location', self._name) + self._displayCtx .removeListener('selectedOverlay', self._name) + self._overlayList.removeListener('overlays', self._name) + + self._lbCanvas.destroy() + + canvaspanel.CanvasPanel.destroy(self) - def _selectedImageChanged(self, *a): - """Called when the selected image changes. + def _selectedOverlayChanged(self, *a): + """Called when the selected overlay changes. - Registers a listener on the - :attr:`~fsl.fslview.displaycontext.ImageDisplay.transform` property - associated with the selected image, so that the + Registers a listener on the :attr:`.Display.transform` property + associated with the selected overlay, so that the :meth:`_transformChanged` method will be called on ``transform`` changes. """ - image = self._displayCtx.getSelectedImage() - - # do nothing if the image list is empty - if image is None: + if len(self._overlayList) == 0: return - for img in self._imageList: + selectedOverlay = self._displayCtx.getSelectedOverlay() - display = self._displayCtx.getDisplayProperties(img) + for overlay in self._overlayList: - display.removeListener('transform', self._name) + refImage = self._displayCtx.getReferenceImage(overlay) - if img == image: - display.addListener('transform', - self._name, - self._transformChanged) + if refImage is None: + continue + + opts = self._displayCtx.getOpts(refImage) + + opts.removeListener('transform', self._name) + + if overlay == selectedOverlay: + opts.addListener('transform', + self._name, + self._transformChanged) self._transformChanged() def _transformChanged(self, *a): - """Called when the transform for the currently selected image changes. + """Called when the transform for the currently selected overlay + changes. Updates the ``sliceSpacing`` and ``zrange`` properties to values - sensible to the new image display space. + sensible to the new overlay display space. """ - - image = self._displayCtx.getSelectedImage() - opts = self.getSceneOptions() - if image is None: + sceneOpts = self.getSceneOptions() + overlay = self._displayCtx.getReferenceImage( + self._displayCtx.getSelectedOverlay()) + + if overlay is None: return - display = self._displayCtx.getDisplayProperties(image) - - loBounds, hiBounds = display.getDisplayBounds() + opts = self._displayCtx.getOpts(overlay) + loBounds, hiBounds = opts.getDisplayBounds() - if display.transform == 'id': - opts.sliceSpacing = 1 - opts.zrange.x = (0, image.shape[opts.zax] - 1) + if opts.transform == 'id': + sceneOpts.sliceSpacing = 1 + sceneOpts.zrange.x = (0, overlay.shape[sceneOpts.zax] - 1) else: - opts.sliceSpacing = image.pixdim[opts.zax] - opts.zrange.x = (loBounds[opts.zax], hiBounds[opts.zax]) + sceneOpts.sliceSpacing = overlay.pixdim[sceneOpts.zax] + sceneOpts.zrange.x = (loBounds[sceneOpts.zax], + hiBounds[sceneOpts.zax]) self._onResize() diff --git a/fsl/fslview/views/orthopanel.py b/fsl/fslview/views/orthopanel.py index acd51969ef640dcd2c7dca9f24b1e8f1e94e2dd6..567f29e840895d9dc4b9c1e95a9cc1896d470547 100644 --- a/fsl/fslview/views/orthopanel.py +++ b/fsl/fslview/views/orthopanel.py @@ -1,28 +1,23 @@ #!/usr/bin/env python # # orthopanel.py - A wx/OpenGL widget for displaying and interacting with a -# collection of 3D images. +# collection of 3D overlays. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # """A :mod:`wx`/:mod:`OpenGL` widget for displaying and interacting with a -collection of 3D images (see :class:`~fsl.data.image.ImageList`). +collection of 3D overlays. -Displays three canvases, each of which shows the same image(s) on a +Displays three canvases, each of which shows the same overlay(s) on a different orthogonal plane. The displayed location is driven by the -:attr:`fsl.fslview.displaycontext.DisplayContext.location` property. +:attr:`.DisplayContext.location` property. """ import logging -log = logging.getLogger(__name__) - -# import copy import wx -# import props - -import fsl.data.strings as strings +import fsl.data.strings as strings import fsl.data.constants as constants import fsl.utils.layout as fsllayout import fsl.fslview.gl as fslgl @@ -32,10 +27,14 @@ import fsl.fslview.controls.orthoprofiletoolbar as orthoprofiletoolbar import fsl.fslview.displaycontext.orthoopts as orthoopts import canvaspanel + +log = logging.getLogger(__name__) + + class OrthoPanel(canvaspanel.CanvasPanel): - def __init__(self, parent, imageList, displayCtx): + def __init__(self, parent, overlayList, displayCtx): """ Creates three SliceCanvas objects, each displaying the images in the given image list along a different axis. @@ -53,27 +52,25 @@ class OrthoPanel(canvaspanel.CanvasPanel): canvaspanel.CanvasPanel.__init__(self, parent, - imageList, + overlayList, displayCtx, sceneOpts, actionz) canvasPanel = self.getCanvasPanel() - imageList = self._imageList - displayCtx = self._displayCtx # The canvases themselves - each one displays a # slice along each of the three world axes self._xcanvas = slicecanvas.WXGLSliceCanvas(canvasPanel, - imageList, + overlayList, displayCtx, zax=0) self._ycanvas = slicecanvas.WXGLSliceCanvas(canvasPanel, - imageList, + overlayList, displayCtx, zax=1) self._zcanvas = slicecanvas.WXGLSliceCanvas(canvasPanel, - imageList, + overlayList, displayCtx, zax=2) @@ -123,10 +120,6 @@ class OrthoPanel(canvaspanel.CanvasPanel): self._zcanvas.bindProps('resolutionLimit', sceneOpts) # And a global zoom which controls all canvases at once - def onZoom(*a): - sceneOpts.xzoom = sceneOpts.zoom - sceneOpts.yzoom = sceneOpts.zoom - sceneOpts.zzoom = sceneOpts.zoom minZoom = sceneOpts.getConstraint('xzoom', 'minval') maxZoom = sceneOpts.getConstraint('xzoom', 'maxval') @@ -134,18 +127,21 @@ class OrthoPanel(canvaspanel.CanvasPanel): sceneOpts.setConstraint('zoom', 'minval', minZoom) sceneOpts.setConstraint('zoom', 'maxval', maxZoom) - sceneOpts.addListener('zoom', self._name, onZoom) - - # Callbacks for image list/selected image changes - self._imageList.addListener( 'images', - self._name, - self._imageListChanged) - self._displayCtx.addListener('bounds', - self._name, - self._refreshLayout) - self._displayCtx.addListener('selectedImage', - self._name, - self._imageListChanged) + sceneOpts.addListener('zoom', self._name, self.__onZoom) + + # Callbacks for overlay list/selected overlay changes + self._overlayList.addListener('overlays', + self._name, + self._overlayListChanged) + self._displayCtx .addListener('bounds', + self._name, + self._refreshLayout) + self._displayCtx .addListener('selectedOverlay', + self._name, + self._overlayListChanged) + self._displayCtx .addListener('overlayOrder', + self._name, + self._overlayListChanged) # Callback for the display context location - when it # changes, update the displayed canvas locations @@ -156,13 +152,16 @@ class OrthoPanel(canvaspanel.CanvasPanel): # Callbacks for toggling x/y/z canvas display sceneOpts.addListener('showXCanvas', self._name, - lambda *a: self._toggleCanvas('x')) + lambda *a: self._toggleCanvas('x'), + weak=False) sceneOpts.addListener('showYCanvas', self._name, - lambda *a: self._toggleCanvas('y')) + lambda *a: self._toggleCanvas('y'), + weak=False) sceneOpts.addListener('showZCanvas', self._name, - lambda *a: self._toggleCanvas('z')) + lambda *a: self._toggleCanvas('z'), + weak=False) # Call the _resize method to refresh # the slice canvases when the canvas @@ -172,7 +171,7 @@ class OrthoPanel(canvaspanel.CanvasPanel): # Initialise the panel self._refreshLayout() - self._imageListChanged() + self._overlayListChanged() self._refreshLabels() self._locationChanged() self.initProfile() @@ -185,19 +184,38 @@ class OrthoPanel(canvaspanel.CanvasPanel): this OrthoPanel. So when this panel is destroyed, all those registered listeners are removed. """ - canvaspanel.CanvasPanel.destroy(self) - self._displayCtx.removeListener('location', self._name) - self._displayCtx.removeListener('bounds', self._name) - self._displayCtx.removeListener('selectedImage', self._name) - self._imageList .removeListener('images', self._name) + self._displayCtx .removeListener('location', self._name) + self._displayCtx .removeListener('bounds', self._name) + self._displayCtx .removeListener('selectedOverlay', self._name) + self._displayCtx .removeListener('overlayOrder', self._name) + self._overlayList.removeListener('overlays', self._name) - # The _imageListChanged method adds - # listeners to individual images, + self._xcanvas.destroy() + self._ycanvas.destroy() + self._zcanvas.destroy() + + # The _overlayListChanged method adds + # listeners to individual overlays, # so we have to remove them too - for img in self._imageList: - display = self._displayCtx.getDisplayProperties(img) - display.removeListener('transform', self._name) + for ovl in self._overlayList: + opts = self._displayCtx.getOpts(ovl) + opts.removeGlobalListener(self._name) + + canvaspanel.CanvasPanel.destroy(self) + + + def __onZoom(self, *a): + """Called when the :attr:`.SceneOpts.zoom` property changes. + Propagates the change to the :attr:`.OrthoOpts.xzoom`, ``yzoom``, + and ``zzoom`` properties. + """ + opts = self.getSceneOptions() + opts.xzoom = opts.zoom + opts.yzoom = opts.zoom + opts.zzoom = opts.zoom + + def getXCanvas(self): @@ -257,26 +275,25 @@ class OrthoPanel(canvaspanel.CanvasPanel): self.PostSizeEvent() - def _imageListChanged(self, *a): - """Called when the image list or selected image is changed. + def _overlayListChanged(self, *a): + """Called when the overlay list or selected overlay is changed. - Adds a listener to the currently selected image, to listen - for changes on its affine transformation matrix. + Adds a listener to the currently selected overlay, to listen + for changes on its transformation matrix. """ - for i, img in enumerate(self._imageList): + for i, ovl in enumerate(self._overlayList): - display = self._displayCtx.getDisplayProperties(img) + opts = self._displayCtx.getOpts(ovl) - # Update anatomy labels when the image - # transformation matrix changes - if i == self._displayCtx.selectedImage: - display.addListener('transform', - self._name, - self._refreshLabels, - overwrite=True) + # Update anatomy labels when any + # overlay display properties change + if i == self._displayCtx.selectedOverlay: + opts.addGlobalListener(self._name, + self._refreshLabels, + overwrite=True) else: - display.removeListener('transform', self._name) + opts.removeGlobalListener(self._name) # anatomical orientation may have changed with an image change self._refreshLabels() @@ -300,7 +317,14 @@ class OrthoPanel(canvaspanel.CanvasPanel): self._zLabels.values() # Are we showing or hiding the labels? - if len(self._imageList) == 0: show = False + if len(self._overlayList) == 0: show = False + + overlay = self._displayCtx.getReferenceImage( + self._displayCtx.getSelectedOverlay()) + + # Labels are only supported if we + # have a volumetric reference image + if overlay is None: show = False elif self.getSceneOptions().showLabels: show = True else: show = False @@ -315,26 +339,25 @@ class OrthoPanel(canvaspanel.CanvasPanel): # Default colour is white - if the orientation labels # cannot be determined, the foreground colour will be # changed to red - colour = 'white' + colour = 'white' - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) + opts = self._displayCtx.getOpts(overlay) # The image is being displayed as it is stored on # disk - the image.getOrientation method calculates # and returns labels for each voxelwise axis. - if display.transform in ('pixdim', 'id'): - xorient = image.getVoxelOrientation(0) - yorient = image.getVoxelOrientation(1) - zorient = image.getVoxelOrientation(2) + if opts.transform in ('pixdim', 'id'): + xorient = overlay.getVoxelOrientation(0) + yorient = overlay.getVoxelOrientation(1) + zorient = overlay.getVoxelOrientation(2) - # The image is being displayed in 'real world' space - + # The overlay is being displayed in 'real world' space - # the definition of this space may be present in the - # image meta data + # overlay meta data else: - xorient = image.getWorldOrientation(0) - yorient = image.getWorldOrientation(1) - zorient = image.getWorldOrientation(2) + xorient = overlay.getWorldOrientation(0) + yorient = overlay.getWorldOrientation(1) + zorient = overlay.getWorldOrientation(2) if constants.ORIENT_UNKNOWN in (xorient, yorient, zorient): colour = 'red' @@ -380,9 +403,9 @@ class OrthoPanel(canvaspanel.CanvasPanel): canvases = [self._xcanvas, self._ycanvas, self._zcanvas] labels = [self._xLabels, self._yLabels, self._zLabels] - if width == 0 or height == 0: return - if len(self._imageList) == 0: return - if not any(show): return + if width == 0 or height == 0: return + if len(self._overlayList) == 0: return + if not any(show): return canvases, labels, _ = zip(*filter(lambda (c, l, s): s, zip(canvases, labels, show))) @@ -595,11 +618,7 @@ class OrthoPanel(canvaspanel.CanvasPanel): flag = wx.ALIGN_CENTRE_HORIZONTAL | wx.ALIGN_CENTRE_VERTICAL for w in widgets: - - if w in [self._xcanvas, self._ycanvas, self._zcanvas]: - self._canvasSizer.Add(w, flag=flag) - else: - self._canvasSizer.Add(w, flag=flag) + self._canvasSizer.Add(w, flag=flag) self.getCanvasPanel().SetSizer(self._canvasSizer) @@ -638,14 +657,14 @@ class OrthoFrame(wx.Frame): Convenience class for displaying an OrthoPanel in a standalone window. """ - def __init__(self, parent, imageList, displayCtx, title=None): + def __init__(self, parent, overlayList, displayCtx, title=None): wx.Frame.__init__(self, parent, title=title) ctx, dummyCanvas = fslgl.getWXGLContext() fslgl.bootstrap() - self.panel = OrthoPanel(self, imageList, displayCtx) + self.panel = OrthoPanel(self, overlayList, displayCtx) self.Layout() if dummyCanvas is not None: @@ -658,7 +677,12 @@ class OrthoDialog(wx.Dialog): dialog window. """ - def __init__(self, parent, imageList, displayCtx, title=None, style=None): + def __init__(self, + parent, + overlayList, + displayCtx, + title=None, + style=None): if style is None: style = wx.DEFAULT_DIALOG_STYLE else: style |= wx.DEFAULT_DIALOG_STYLE @@ -668,7 +692,7 @@ class OrthoDialog(wx.Dialog): ctx, dummyCanvas = fslgl.getWXGLContext() fslgl.bootstrap() - self.panel = OrthoPanel(self, imageList, displayCtx) + self.panel = OrthoPanel(self, overlayList, displayCtx) self.Layout() if dummyCanvas is not None: diff --git a/fsl/fslview/views/plotpanel.py b/fsl/fslview/views/plotpanel.py index c520164551d1995470a45834c638a4503b424606..ca8e2ac1bca52dbf0b9ba72a02cea9beb8578ce6 100644 --- a/fsl/fslview/views/plotpanel.py +++ b/fsl/fslview/views/plotpanel.py @@ -7,15 +7,23 @@ import logging -import matplotlib as mpl +import wx + +import matplotlib as mpl +import numpy as np +import scipy.interpolate as interp + mpl.use('WxAgg') + import matplotlib.pyplot as plt from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as Canvas -from mpl_toolkits.mplot3d import Axes3D +from matplotlib.backends.backend_wx import NavigationToolbar2Wx +from mpl_toolkits.mplot3d import Axes3D +import props import viewpanel import fsl.data.strings as strings @@ -23,8 +31,72 @@ import fsl.data.strings as strings log = logging.getLogger(__name__) +class DataSeries(props.HasProperties): + + colour = props.Colour() + alpha = props.Real(minval=0.0, maxval=1.0, default=1.0, clamped=True) + label = props.String() + lineWidth = props.Choice((0.5, 1, 2, 3, 4, 5)) + lineStyle = props.Choice( + *zip(*[('-', 'Solid line'), + ('--', 'Dashed line'), + ('-.', 'Dash-dot line'), + (':', 'Dotted line')])) + + + def __init__(self, overlay): + self.overlay = overlay + + + def __copy__(self): + return type(self)(self.overlay) + + + def getData(self): + raise NotImplementedError('The getData method must be ' + 'implemented by subclasses') + + class PlotPanel(viewpanel.ViewPanel): - def __init__(self, parent, imageList, displayCtx, actionz=None, proj=None): + """A ``PlotPanel`` instance adds a listener to every one of its properties, + using :attr:`FSLViewPanel._name` as the listener name. + + Therefore, If ``PlotPanel`` subclasses add a listener to any of their + properties, they should use ``overwrite=True``, and should ensure that the + custom listener calls the :meth:`draw` method. + """ + + + dataSeries = props.List() + legend = props.Boolean(default=True) + autoScale = props.Boolean(default=True) + xLogScale = props.Boolean(default=False) + yLogScale = props.Boolean(default=False) + ticks = props.Boolean(default=True) + grid = props.Boolean(default=True) + smooth = props.Boolean(default=False) + xlabel = props.String() + ylabel = props.String() + limits = props.Bounds(ndims=2) + + + def importDataSeries(self, *a): + # TODO import data series from text file + pass + + + def exportDataSeries(self, *a): + # TODO export all displayed data series to text file + pass + + + def __init__(self, + parent, + overlayList, + displayCtx, + actionz=None, + proj=None, + interactive=True): if actionz is None: actionz = {} @@ -32,21 +104,81 @@ class PlotPanel(viewpanel.ViewPanel): actionz = dict([('screenshot', self.screenshot)] + actionz.items()) viewpanel.ViewPanel.__init__( - self, parent, imageList, displayCtx, actionz) + self, parent, overlayList, displayCtx, actionz) + + figure = plt.Figure() + axis = figure.add_subplot(111, projection=proj) + canvas = Canvas(self, -1, figure) + + self.setCentrePanel(canvas) - # There is currently no screenshot functionality - # because I haven't gotten around to implementing - # it ... - self.disable('screenshot') + self.__figure = figure + self.__axis = axis + self.__canvas = canvas - self.__figure = plt.Figure() - self.__axis = self.__figure.add_subplot(111, projection=proj) - self.__canvas = Canvas(self, -1, self.__figure) + if interactive: + + # Pan/zoom functionality is implemented + # by the NavigationToolbar2Wx, but the + # toolbar is not actually shown. + self.__mouseDown = False + self.__toolbar = NavigationToolbar2Wx(canvas) + self.__toolbar.Show(False) + self.__toolbar.pan() + + canvas.mpl_connect('button_press_event', self.__onMouseDown) + canvas.mpl_connect('motion_notify_event', self.__onMouseMove) + canvas.mpl_connect('button_release_event', self.__onMouseUp) + canvas.mpl_connect('axes_leave_event', self.__onMouseUp) - self.setCentrePanel(self.__canvas) + # Redraw whenever any property changes, + for propName in ['legend', + 'autoScale', + 'xLogScale', + 'yLogScale', + 'ticks', + 'grid', + 'smooth', + 'xlabel', + 'ylabel']: + self.addListener(propName, self._name, self.draw) + + # custom listeners for a couple of properties + self.__name = '{}_{}'.format(self._name, id(self)) + self.addListener('dataSeries', + self.__name, + self.__dataSeriesChanged) + self.addListener('limits', + self.__name, + self.__limitsChanged) + + self.Bind(wx.EVT_SIZE, lambda *a: self.draw()) + + + def draw(self, *a): + raise NotImplementedError('The draw method must be ' + 'implemented by PlotPanel subclasses') + + + def __dataSeriesChanged(self, *a): + for ds in self.dataSeries: + ds.addGlobalListener(self._name, self.draw, overwrite=True) + self.draw() def destroy(self): + self.removeListener('dataSeries', self.__name) + self.removeListener('limits', self.__name) + for propName in ['legend', + 'autoScale', + 'xLogScale', + 'yLogScale', + 'ticks', + 'grid', + 'smooth', + 'xlabel', + 'ylabel']: + self.removeListener(propName, self._name) viewpanel.ViewPanel.destroy(self) @@ -61,6 +193,277 @@ class PlotPanel(viewpanel.ViewPanel): def getCanvas(self): return self.__canvas + + def __onMouseDown(self, ev): + self.__mouseDown = True + + def __onMouseUp(self, ev): + self.__mouseUp = False + + + def __onMouseMove(self, ev): + + if not self.__mouseDown: + return + + xlims = list(self.__axis.get_xlim()) + ylims = list(self.__axis.get_ylim()) + + self.disableListener('limits', self.__name) + self.limits.x = xlims + self.limits.y = ylims + self.enableListener( 'limits', self.__name) + + + def __limitsChanged(self, *a): + + axis = self.getAxis() + axis.set_xlim(self.limits.x) + axis.set_ylim(self.limits.y) + + self.draw() + + + def __calcLimits(self, + dataxlims, + dataylims, + axisxlims, + axisylims, + axWidth, + axHeight): + + if self.autoScale: + + xmin = min([lim[0] for lim in dataxlims]) + xmax = max([lim[1] for lim in dataxlims]) + ymin = min([lim[0] for lim in dataylims]) + ymax = max([lim[1] for lim in dataylims]) + + bPad = (ymax - ymin) * (50.0 / axHeight) + tPad = (ymax - ymin) * (50.0 / axHeight) + lPad = (xmax - xmin) * (50.0 / axWidth) + rPad = (xmax - xmin) * (50.0 / axWidth) + + xmin = xmin - lPad + xmax = xmax + rPad + ymin = ymin - bPad + ymax = ymax + tPad + + else: + xmin = axisxlims[0] + xmax = axisxlims[1] + ymin = axisylims[0] + ymax = axisylims[1] + + self.disableListener('limits', self.__name) + self.limits[:] = [xmin, xmax, ymin, ymax] + self.enableListener('limits', self.__name) + + return (xmin, xmax), (ymin, ymax) + + + def drawDataSeries(self, extraSeries=None, **plotArgs): + + if extraSeries is None: + extraSeries = [] + + axis = self.getAxis() + canvas = self.getCanvas() + width, height = canvas.get_width_height() + + # Before clearing/redrawing, save + # a copy of the x/y axis limits - + # the user may have changed them + # via panning/zooming, and we may + # want to preserve the limits that + # the user set + axxlim = axis.get_xlim() + axylim = axis.get_ylim() + + axis.clear() + + toPlot = self.dataSeries[:] + toPlot = extraSeries + toPlot + + if len(toPlot) == 0: + canvas.draw() + self.Refresh() + return + + xlims = [] + ylims = [] + + for ds in toPlot: + xlim, ylim = self.__drawOneDataSeries(ds, **plotArgs) + xlims.append(xlim) + ylims.append(ylim) + + (xmin, xmax), (ymin, ymax) = self.__calcLimits( + xlims, ylims, axxlim, axylim, width, height) + + if xmax - xmin < 0.0000000001 or \ + ymax - ymin < 0.0000000001: + axis.clear() + canvas.draw() + self.Refresh() + return + + # x/y axis labels + xlabel = self.xlabel + ylabel = self.ylabel + + if xlabel is None: xlabel = '' + if ylabel is None: ylabel = '' + + xlabel = xlabel.strip() + ylabel = ylabel.strip() + + if xlabel != '': + axis.set_xlabel(self.xlabel, va='bottom') + axis.xaxis.set_label_coords(0.5, 10.0 / height) + + if ylabel != '': + axis.set_ylabel(self.ylabel, va='top') + axis.yaxis.set_label_coords(10.0 / width, 0.5) + + # Ticks + if self.ticks: + axis.tick_params(direction='in', pad=-5) + + for ytl in axis.yaxis.get_ticklabels(): + ytl.set_horizontalalignment('left') + + for xtl in axis.xaxis.get_ticklabels(): + xtl.set_verticalalignment('bottom') + else: + axis.set_xticks([]) + axis.set_yticks([]) + + # Limits + axis.set_xlim((xmin, xmax)) + axis.set_ylim((ymin, ymax)) + + # legend + labels = [ds.label for ds in toPlot if ds.label is not None] + if len(labels) > 0 and self.legend: + handles, labels = axis.get_legend_handles_labels() + legend = axis.legend( + handles, + labels, + loc='upper right', + fontsize=12, + fancybox=True) + legend.get_frame().set_alpha(0.6) + + axis.grid(self.grid) + + canvas.draw() + self.Refresh() + + + def __drawOneDataSeries(self, ds, **plotArgs): + + if ds.alpha == 0: + return (0, 0), (0, 0) + + log.debug('Drawing plot for {}'.format(ds.overlay)) + + xdata, ydata = ds.getData() + + if len(xdata) != len(ydata) or len(xdata) == 0: + return (0, 0), (0, 0) + + # Note to self: If the smoothed data is + # filled with NaNs, it is possibly due + # to duplicate values in the x data, which + # are not handled very well by splrep. + if self.smooth: + + tck = interp.splrep(xdata, ydata) + xdata = np.linspace(xdata[0], + xdata[-1], + len(xdata) * 5, + dtype=np.float32) + ydata = interp.splev(xdata, tck) + + nans = ~(np.isfinite(xdata) & np.isfinite(ydata)) + xdata[nans] = np.nan + ydata[nans] = np.nan + + if self.xLogScale: xdata[xdata <= 0] = np.nan + if self.yLogScale: ydata[ydata <= 0] = np.nan + + if np.all(np.isnan(xdata) | np.isnan(ydata)): + return (0, 0), (0, 0) + + kwargs = plotArgs + + kwargs['lw'] = kwargs.get('lw', ds.lineWidth) + kwargs['alpha'] = kwargs.get('alpha', ds.alpha) + kwargs['color'] = kwargs.get('color', ds.colour) + kwargs['label'] = kwargs.get('label', ds.label) + kwargs['ls'] = kwargs.get('ls', ds.lineStyle) + + axis = self.getAxis() + + axis.plot(xdata, ydata, **kwargs) + + if self.xLogScale: + axis.set_xscale('log') + posx = xdata[xdata > 0] + xlimits = np.nanmin(posx), np.nanmax(posx) + + else: + xlimits = np.nanmin(xdata), np.nanmax(xdata) + + if self.yLogScale: + axis.set_yscale('log') + posy = ydata[ydata > 0] + ylimits = np.nanmin(posy), np.nanmax(posy) + else: + ylimits = np.nanmin(ydata), np.nanmax(ydata) + + return xlimits, ylimits + + def screenshot(self, *a): - pass + + formats = self.__canvas.get_supported_filetypes().items() + + wildcard = ['{}|*.{}'.format(desc, fmt) for fmt, desc in formats] + wildcard = '|'.join(wildcard) + + dlg = wx.FileDialog(self, + message=strings.messages[self, 'screenshot'], + wildcard=wildcard, + style=wx.FD_SAVE) + + if dlg.ShowModal() != wx.ID_OK: + return + + path = dlg.GetPath() + + try: + self.__figure.savefig(path) + except Exception as e: + wx.MessageBox( + strings.messages[self, 'screenshot', 'error'].format(str(e)), + strings.titles[ self, 'screenshot', 'error'], + wx.ICON_ERROR) + + + def message(self, msg): + + axis = self.getAxis() + axis.clear() + axis.set_xlim((0.0, 1.0)) + axis.set_ylim((0.0, 1.0)) + + if isinstance(axis, Axes3D): + axis.text(0.5, 0.5, 0.5, msg, ha='center', va='center') + else: + axis.text(0.5, 0.5, msg, ha='center', va='center') + + self.getCanvas().draw() + self.Refresh() diff --git a/fsl/fslview/views/spacepanel.py b/fsl/fslview/views/spacepanel.py deleted file mode 100644 index 43426494f96bbcacdbba84d70273aa8ad5f520dd..0000000000000000000000000000000000000000 --- a/fsl/fslview/views/spacepanel.py +++ /dev/null @@ -1,209 +0,0 @@ -#!/usr/bin/env python -# -# SpacePanel.py - -# -# Author: Paul McCarthy <pauldmccarthy@gmail.com> -# - -import logging -log = logging.getLogger(__name__) - -import numpy as np - -import fsl.data.strings as strings -import fsl.utils.transform as transform -import plotpanel - -class SpacePanel(plotpanel.PlotPanel): - - - def __init__(self, parent, imageList, displayCtx): - plotpanel.PlotPanel.__init__( - self, parent, imageList, displayCtx, proj='3d') - - figure = self.getFigure() - canvas = self.getCanvas() - axis = self.getAxis() - - axis.mouse_init() - - # the canvas doesn't seem to refresh itself, - # so we'll do it manually on mouse events - def draw(*a): - canvas.draw() - - canvas.mpl_connect('button_press_event', draw) - canvas.mpl_connect('motion_notify_event', draw) - canvas.mpl_connect('button_release_event', draw) - - figure.subplots_adjust( - top=1.0, bottom=0.0, left=0.0, right=1.0) - - figure.patch.set_visible(False) - - self._imageList .addListener('images', self._name, - self._selectedImageChanged) - self._displayCtx.addListener('selectedImage', self._name, - self._selectedImageChanged) - - self._selectedImageChanged() - - - def destroy(self): - """De-registers property listeners.""" - - plotpanel.PlotPanel.destroy(self) - - self._imageList .removeListener('images', self._name) - self._displayCtx.removeListener('selectedImage', self._name) - - for image in self._imageList: - display = self._displayCtx.getDisplayProperties(image) - display.removeListener('transform', self._name) - - - def _selectedImageChanged(self, *a): - - axis = self.getAxis() - canvas = self.getCanvas() - - axis.clear() - - if len(self._imageList) == 0: - canvas.draw() - return - - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) - - display.addListener('transform', - self._name, - self._selectedImageChanged, - overwrite=True) - - axis.set_title(image.name) - axis.set_xlabel('X') - axis.set_ylabel('Y') - axis.set_zlabel('Z') - - self._plotImageCorners() - self._plotImageBounds() - self._plotImageLabels() - self._plotAxisLengths() - - axis.legend() - canvas.draw() - - - def _plotImageBounds(self): - - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) - v2DMat = display.getTransform('voxel', 'display') - - xlo, xhi = transform.axisBounds(image.shape, v2DMat, 0) - ylo, yhi = transform.axisBounds(image.shape, v2DMat, 1) - zlo, zhi = transform.axisBounds(image.shape, v2DMat, 2) - - points = np.zeros((8, 3), dtype=np.float32) - points[0, :] = [xlo, ylo, zlo] - points[1, :] = [xlo, ylo, zhi] - points[2, :] = [xlo, yhi, zlo] - points[3, :] = [xlo, yhi, zhi] - points[4, :] = [xhi, ylo, zlo] - points[5, :] = [xhi, ylo, zhi] - points[6, :] = [xhi, yhi, zlo] - points[7, :] = [xhi, yhi, zhi] - - self.getAxis().scatter(points[:, 0], points[:, 1], points[:, 2], - color='r', s=40) - - - def _plotImageLabels(self): - - axis = self.getAxis() - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) - - centre = np.array(image.shape[:3]) / 2.0 - - for ax, colour in zip(range(3), ['r', 'g', 'b']): - - voxSpan = np.vstack((centre, centre)) - - voxSpan[0, ax] = 0 - voxSpan[1, ax] = image.shape[ax] - - orient = image.getVoxelOrientation(ax) - - lblLo = strings.anatomy['Image', 'lowshort', orient] - lblHi = strings.anatomy['Image', 'highshort', orient] - - wldSpan = transform.transform( - voxSpan, display.getTransform('voxel', 'display')) - - axis.plot(wldSpan[:, 0], - wldSpan[:, 1], - wldSpan[:, 2], - lw=2, - color=colour) - - axis.text(wldSpan[0, 0], wldSpan[0, 1], wldSpan[0, 2], lblLo) - axis.text(wldSpan[1, 0], wldSpan[1, 1], wldSpan[1, 2], lblHi) - - - def _plotAxisLengths(self): - - axis = self.getAxis() - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) - - xform = display.getTransform('voxel', 'display') - - for ax, colour, label in zip(range(3), - ['r', 'g', 'b'], - ['X', 'Y', 'Z']): - - points = np.zeros((2, 3), dtype=np.float32) - points[:] = [-0.5, -0.5, -0.5] - points[1, ax] = image.shape[ax] - 0.5 - - tx = transform.transform(points, xform) - axlen = transform.axisLength(image.shape, xform, ax) - - axis.plot(tx[:, 0], - tx[:, 1], - tx[:, 2], - lw=1, - color=colour, - alpha=0.5, - label='Axis {} (length {:0.2f})'.format(label, axlen)) - - - def _plotImageCorners(self): - - image = self._displayCtx.getSelectedImage() - display = self._displayCtx.getDisplayProperties(image) - - x, y, z = image.shape[:3] - - x = x - 0.5 - y = y - 0.5 - z = z - 0.5 - - points = np.zeros((8, 3), dtype=np.float32) - - points[0, :] = [-0.5, -0.5, -0.5] - points[1, :] = [-0.5, -0.5, z] - points[2, :] = [-0.5, y, -0.5] - points[3, :] = [-0.5, y, z] - points[4, :] = [x, -0.5, -0.5] - points[5, :] = [x, -0.5, z] - points[6, :] = [x, y, -0.5] - points[7, :] = [x, y, z] - - points = transform.transform( - points, display.getTransform('voxel', 'display')) - - self.getAxis().scatter(points[:, 0], points[:, 1], points[:, 2], - color='b', s=40) diff --git a/fsl/fslview/views/timeseriespanel.py b/fsl/fslview/views/timeseriespanel.py index 52d13d2b09d010897ebb33587fe10521b9390d9b..dbd25172c2ee682cb7603d6f4f14082333a6a1b8 100644 --- a/fsl/fslview/views/timeseriespanel.py +++ b/fsl/fslview/views/timeseriespanel.py @@ -1,206 +1,598 @@ #!/usr/bin/env python # # timeseriespanel.py - A panel which plots time series/volume data from a -# collection of images. +# collection of overlays. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -"""A :class:`wx.Panel` which plots time series/volume data from a -collection of :class:`~fsl.data.image.Image` objects stored in an -:class:`~fsl.data.image.ImageList`. +"""A :class:`wx.Panel` which plots time series/volume data from a collection +of overlay objects stored in an :class:`.OverlayList`. :mod:`matplotlib` is used for plotting. + """ +import copy import logging -log = logging.getLogger(__name__) import numpy as np -import plotpanel -import fsl.utils.transform as transform +import props -# TODO -# - Whack a scrollbar in there, to allow -# zooming/scrolling on the horizontal axis +import plotpanel +import fsl.data.featimage as fslfeatimage +import fsl.data.image as fslimage +import fsl.data.strings as strings +import fsl.fslview.displaycontext as fsldisplay +import fsl.fslview.controls as fslcontrols +import fsl.utils.transform as transform -class TimeSeriesPanel(plotpanel.PlotPanel): - """A panel with a :mod:`matplotlib` canvas embedded within. +log = logging.getLogger(__name__) + + +class TimeSeries(plotpanel.DataSeries): + + + def __init__(self, tsPanel, overlay, coords): + plotpanel.DataSeries.__init__(self, overlay) + + self.tsPanel = tsPanel + self.coords = map(int, coords) + self.data = overlay.data[coords[0], coords[1], coords[2], :] + + + def __copy__(self): + + return type(self)(self.tsPanel, self.overlay, self.coords) + + + def update(self, coords): + """This method is only intended for use on the 'current' time series, + not for time series instances which have been added to the + TimeSeries.dataSeries list + """ + + coords = map(int, coords) + if coords == self.coords: + return False + + self.coords = coords + self.data = self.overlay.data[coords[0], coords[1], coords[2], :] + return True + + + def getData(self, xdata=None, ydata=None): + """ + + :arg xdata: + :arg ydata: Used by subclasses in case they have already done some + processing on the data. + """ - The volume data for each of the :class:`~fsl.data.image.Image` - objects in the :class:`~fsl.data.image.ImageList`, at the current - :attr:`~fsl.data.image.ImageList.location` is plotted on the canvas. + if xdata is None: xdata = np.arange(len(self.data), dtype=np.float32) + if ydata is None: ydata = np.array( self.data, dtype=np.float32) + + if self.tsPanel.usePixdim: + xdata *= self.overlay.pixdim[3] + + if self.tsPanel.plotMode == 'demean': + ydata = ydata - ydata.mean() + + elif self.tsPanel.plotMode == 'normalise': + ymin = ydata.min() + ymax = ydata.max() + ydata = 2 * (ydata - ymin) / (ymax - ymin) - 1 + + elif self.tsPanel.plotMode == 'percentChange': + mean = ydata.mean() + ydata = 100 * (ydata / mean) - 100 + + return xdata, ydata + + + +class FEATTimeSeries(TimeSeries): + """A :Class:`TimeSeries` class for use with :class:`FEATImage` instances, + containing some extra FEAT specific options. """ - def __init__(self, parent, imageList, displayCtx): - plotpanel.PlotPanel.__init__(self, parent, imageList, displayCtx) + plotData = props.Boolean(default=True) + plotFullModelFit = props.Boolean(default=False) + plotResiduals = props.Boolean(default=False) + plotEVs = props.List(props.Boolean(default=False)) + plotPEFits = props.List(props.Boolean(default=False)) + plotCOPEFits = props.List(props.Boolean(default=False)) + plotReduced = props.Choice() + - figure = self.getFigure() - canvas = self.getCanvas() + def __init__(self, *args, **kwargs): + TimeSeries.__init__(self, *args, **kwargs) + self.name = '{}_{}'.format(type(self).__name__, id(self)) - figure.subplots_adjust( - top=1.0, bottom=0.0, left=0.0, right=1.0) + numEVs = self.overlay.numEVs() + numCOPEs = self.overlay.numContrasts() + copeNames = self.overlay.contrastNames() + + reduceOpts = ['none'] + \ + ['PE{}'.format(i + 1) for i in range(numEVs)] - figure.patch.set_visible(False) + for i in range(numCOPEs): + name = 'COPE{} ({})'.format(i + 1, copeNames[i]) + reduceOpts.append(name) + + self.getProp('plotReduced').setChoices(reduceOpts, instance=self) - self._mouseDown = False - canvas.mpl_connect('button_press_event', self._onPlotMouseDown) - canvas.mpl_connect('button_release_event', self._onPlotMouseUp) - canvas.mpl_connect('motion_notify_event', self._onPlotMouseMove) - - self._imageList.addListener( - 'images', - self._name, - self._selectedImageChanged) - self._displayCtx.addListener( - 'selectedImage', - self._name, - self._selectedImageChanged) - self._displayCtx.addListener( - 'location', - self._name, - self._locationChanged) - self._displayCtx.addListener( - 'volume', - self._name, - self._locationChanged) - - self._selectedImageChanged() + for i in range(numEVs): + self.plotPEFits.append(False) + self.plotEVs .append(False) + + for i in range(numCOPEs): + self.plotCOPEFits.append(False) + + self.__fullModelTs = None + self.__reducedTs = None + self.__resTs = None + self.__evTs = [None] * numEVs + self.__peTs = [None] * numEVs + self.__copeTs = [None] * numCOPEs - self.Layout() + self.addListener('plotFullModelFit', + self.name, + self.__plotFullModelFitChanged) + self.addListener('plotResiduals', + self.name, + self.__plotResidualsChanged) + self.addListener('plotReduced', + self.name, + self.__plotReducedChanged) + + for i, pv in enumerate(self.plotEVs.getPropertyValueList()): + def onChange(ctx, value, valid, name, pe=i): + self.__plotEVChanged(pe) + pv.addListener(self.name, onChange, weak=False) + + for i, pv in enumerate(self.plotPEFits.getPropertyValueList()): + def onChange(ctx, value, valid, name, pe=i): + self.__plotPEFitChanged(pe) + pv.addListener(self.name, onChange, weak=False) + for i, pv in enumerate(self.plotCOPEFits.getPropertyValueList()): + def onChange(ctx, value, valid, name, cope=i): + self.__plotCOPEFitChanged(cope) + pv.addListener(self.name, onChange, weak=False) - def destroy(self): - plotpanel.PlotPanel.destroy(self) - self._imageList .removeListener('images', self._name) - self._displayCtx.removeListener('selectedImage', self._name) - self._displayCtx.removeListener('location', self._name) - self._displayCtx.removeListener('volume', self._name) + def __copy__(self): + + copy = type(self)(self.tsPanel, self.overlay, self.coords) + + copy.colour = self.colour + copy.alpha = self.alpha + copy.label = self.label + copy.lineWidth = self.lineWidth + copy.lineStyle = self.lineStyle + + # When these properties are changed + # on the copy instance, it will create + # its own FEATModelFitTimeSeries + # instances accordingly + copy.plotFullModelFit = self.plotFullModelFit + copy.plotEVs[ :] = self.plotEVs[ :] + copy.plotPEFits[ :] = self.plotPEFits[ :] + copy.plotCOPEFits[:] = self.plotCOPEFits[:] + copy.plotReduced = self.plotReduced + copy.plotResiduals = self.plotResiduals + + return copy + + + def getModelTimeSeries(self): + modelts = [] + + if self.plotData: modelts.append(self) + if self.plotFullModelFit: modelts.append(self.__fullModelTs) + if self.plotResiduals: modelts.append(self.__resTs) + if self.plotReduced != 'none': modelts.append(self.__reducedTs) - def _selectedImageChanged(self, *a): + for i in range(self.overlay.numEVs()): + if self.plotPEFits[i]: + modelts.append(self.__peTs[i]) + + for i in range(self.overlay.numEVs()): + if self.plotEVs[i]: + modelts.append(self.__evTs[i]) + + for i in range(self.overlay.numContrasts()): + if self.plotCOPEFits[i]: + modelts.append(self.__copeTs[i]) + + return modelts + + + def __getContrast(self, fitType, idx): + + if fitType == 'full': + return [1] * self.overlay.numEVs() + elif fitType == 'pe': + con = [0] * self.overlay.numEVs() + con[idx] = 1 + return con + elif fitType == 'cope': + return self.overlay.contrasts()[idx] + + + def __createModelTs(self, tsType, *args, **kwargs): + + ts = tsType(self.tsPanel, self.overlay, self.coords, *args, **kwargs) + + ts.alpha = self.alpha + ts.label = self.label + ts.lineWidth = self.lineWidth + ts.lineStyle = self.lineStyle + + if isinstance(ts, FEATReducedTimeSeries): ts.colour = (0, 0.6, 0.6) + elif isinstance(ts, FEATResidualTimeSeries): ts.colour = (0.8, 0.4, 0) + elif isinstance(ts, FEATEVTimeSeries): ts.colour = (0, 0.7, 0.35) + elif isinstance(ts, FEATModelFitTimeSeries): + if ts.fitType == 'full': ts.colour = (0, 0, 1) + elif ts.fitType == 'cope': ts.colour = (0, 1, 0) + elif ts.fitType == 'pe': ts.colour = (0.7, 0, 0) + + return ts + + + def __plotReducedChanged(self, *a): + + reduced = self.plotReduced + + if reduced == 'none' and self.__reducedTs is not None: + self.__reducedTs = None + return + + reduced = reduced.split()[0] + + # fitType is either 'cope' or 'pe' + fitType = reduced[:-1].lower() + idx = int(reduced[-1]) - 1 + + self.__reducedTs = self.__createModelTs( + FEATReducedTimeSeries, + self.__getContrast(fitType, idx), + fitType, + idx) + + + def __plotResidualsChanged(self, *a): + + if not self.plotResiduals: + self.__resTs = None + return + + self.__resTs = self.__createModelTs(FEATResidualTimeSeries) + + + def __plotEVChanged(self, evnum): + + if not self.plotEVs[evnum]: + self.__evTs[evnum] = None + return + + self.__evTs[evnum] = self.__createModelTs(FEATEVTimeSeries, evnum) + + + def __plotCOPEFitChanged(self, copenum): - self.getAxis().clear() + if not self.plotCOPEFits[copenum]: + self.__copeTs[copenum] = None + return + + self.__copeTs[copenum] = self.__createModelTs( + FEATModelFitTimeSeries, + self.__getContrast('cope', copenum), + 'cope', + copenum) + - if len(self._imageList) == 0: + def __plotPEFitChanged(self, evnum): + + if not self.plotPEFits[evnum]: + self.__peTs[evnum] = None return - image = self._displayCtx.getSelectedImage() + self.__peTs[evnum] = self.__createModelTs( + FEATModelFitTimeSeries, + self.__getContrast('pe', evnum), + 'pe', + evnum) + - if not image.is4DImage(): + def __plotFullModelFitChanged(self, *a): + + if not self.plotFullModelFit: + self.__fullModelTs = None return - self._drawPlot() + self.__fullModelTs = self.__createModelTs( + FEATModelFitTimeSeries, + self.__getContrast('full', -1), + 'full', + -1) - def _locationChanged(self, *a): + def update(self, coords): - self.getAxis().clear() + if not TimeSeries.update(self, coords): + return False + + for modelTs in self.getModelTimeSeries(): + if modelTs is self: + continue + modelTs.update(coords) - if len(self._imageList) == 0: - return + return True + + +class FEATReducedTimeSeries(TimeSeries): + def __init__(self, tsPanel, overlay, coords, contrast, fitType, idx): + TimeSeries.__init__(self, tsPanel, overlay, coords) + + self.contrast = contrast + self.fitType = fitType + self.idx = idx + + def getData(self): + + data = self.overlay.reducedData(self.coords, self.contrast, False) + return TimeSeries.getData(self, ydata=data) + + +class FEATEVTimeSeries(TimeSeries): + def __init__(self, tsPanel, overlay, coords, idx): + TimeSeries.__init__(self, tsPanel, overlay, coords) + self.idx = idx + + def getData(self): + data = self.overlay.getDesign()[:, self.idx] + return TimeSeries.getData(self, ydata=data) + + +class FEATResidualTimeSeries(TimeSeries): + def getData(self): + x, y, z = self.coords + data = self.overlay.getResiduals().data[x, y, z, :] + + return TimeSeries.getData(self, ydata=np.array(data)) + + +class FEATModelFitTimeSeries(TimeSeries): + + def __init__(self, tsPanel, overlay, coords, contrast, fitType, idx): - image = self._displayCtx.getSelectedImage() + if fitType not in ('full', 'cope', 'pe'): + raise ValueError('Unknown model fit type {}'.format(fitType)) + + TimeSeries.__init__(self, tsPanel, overlay, coords) + self.fitType = fitType + self.idx = idx + self.contrast = contrast + self.updateModelFit() - if not image.is4DImage(): + + def update(self, coords): + if not TimeSeries.update(self, coords): return + self.updateModelFit() + - self._drawPlot() + def updateModelFit(self): + fitType = self.fitType + contrast = self.contrast + xyz = self.coords + self.data = self.overlay.fit(contrast, xyz, fitType == 'full') - def _drawPlot(self): +class TimeSeriesPanel(plotpanel.PlotPanel): + """A panel with a :mod:`matplotlib` canvas embedded within. - axis = self.getAxis() - canvas = self.getCanvas() - x, y, z = self._displayCtx.location.xyz - vol = self._displayCtx.volume + The volume data for each of the overlay objects in the + :class:`.OverlayList`, at the current :attr:`.DisplayContext.location` + is plotted on the canvas. + """ - mins = [] - maxs = [] - vols = [] + + usePixdim = props.Boolean(default=False) + showCurrent = props.Boolean(default=True) + plotMode = props.Choice( + ('normal', 'demean', 'normalise', 'percentChange'), + labels=[strings.choices['TimeSeriesPanel.plotMode.normal'], + strings.choices['TimeSeriesPanel.plotMode.demean'], + strings.choices['TimeSeriesPanel.plotMode.normalise'], + strings.choices['TimeSeriesPanel.plotMode.percentChange']]) - for image in self._imageList: + currentColour = copy.copy(TimeSeries.colour) + currentAlpha = copy.copy(TimeSeries.alpha) + currentLineWidth = copy.copy(TimeSeries.lineWidth) + currentLineStyle = copy.copy(TimeSeries.lineStyle) - display = self._displayCtx.getDisplayProperties(image) - xform = display.getTransform('display', 'voxel') - ix, iy, iz = transform.transform([[x, y, z]], xform)[0] + def __init__(self, parent, overlayList, displayCtx): - ix = round(ix) - iy = round(iy) - iz = round(iz) + self.currentColour = (0, 0, 0) + self.currentAlpha = 1 + self.currentLineWidth = 1 + self.currentLineStyle = ':' - minmaxvol = self._drawPlotOneImage(image, ix, iy, iz) + actionz = { + 'toggleTimeSeriesList' : lambda *a: self.togglePanel( + fslcontrols.TimeSeriesListPanel, False, self), + 'toggleTimeSeriesControl' : lambda *a: self.togglePanel( + fslcontrols.TimeSeriesControlPanel, False, self) + } - if minmaxvol is not None: - mins.append(minmaxvol[0]) - maxs.append(minmaxvol[1]) - vols.append(minmaxvol[2]) + plotpanel.PlotPanel.__init__( + self, parent, overlayList, displayCtx, actionz=actionz) - axis.axvline(vol, c='#000080', lw=2, alpha=0.4) + figure = self.getFigure() - if len(mins) > 0: + figure.subplots_adjust( + top=1.0, bottom=0.0, left=0.0, right=1.0) + + figure.patch.set_visible(False) - xmin = 0 - xmax = max(vols) - 1 - ymin = min(mins) - ymax = max(maxs) + overlayList.addListener('overlays', + self._name, + self.__overlaysChanged) + + displayCtx .addListener('selectedOverlay', self._name, self.draw) + displayCtx .addListener('location', self._name, self.draw) + + self.addListener('plotMode', self._name, self.draw) + self.addListener('usePixdim', self._name, self.draw) + self.addListener('showCurrent', self._name, self.draw) + + csc = self.__currentSettingsChanged + self.addListener('currentColour', self._name, csc) + self.addListener('currentAlpha', self._name, csc) + self.addListener('currentLineWidth', self._name, csc) + self.addListener('currentLineStyle', self._name, csc) + + self.__currentOverlay = None + self.__currentTs = None + self.Layout() + self.draw() - xlen = xmax - xmin - ylen = ymax - ymin + + def __currentSettingsChanged(self, *a): + if self.__currentTs is None: + return - axis.grid(True) - axis.set_xlim((xmin - xlen * 0.05, xmax + xlen * 0.05)) - axis.set_ylim((ymin - ylen * 0.05, ymax + ylen * 0.05)) + tss = [self.__currentTs] + + if isinstance(self.__currentTs, FEATTimeSeries): + tss = self.__currentTs.getModelTimeSeries() - if ymin != ymax: yticks = np.linspace(ymin, ymax, 5) - else: yticks = [ymin] + for ts in tss: - axis.set_yticks(yticks) + if ts is self.__currentTs: + continue - for tick in axis.yaxis.get_major_ticks(): - tick.set_pad(-15) - tick.label1.set_horizontalalignment('left') - - for tick in axis.xaxis.get_major_ticks(): - tick.set_pad(-20) + # Don't change the colour for associated + # time courses (e.g. model fits) + if ts is self.__currentTs: + ts.colour = self.currentColour + + ts.alpha = self.currentAlpha + ts.lineWidth = self.currentLineWidth + ts.lineStyle = self.currentLineStyle - canvas.draw() - self.Refresh() + def destroy(self): + + self.removeListener('plotMode', self._name) + self.removeListener('usePixdim', self._name) + self.removeListener('showCurrent', self._name) - def _drawPlotOneImage(self, image, x, y, z): + self._overlayList.removeListener('overlays', self._name) + self._displayCtx .removeListener('selectedOverlay', self._name) + self._displayCtx .removeListener('location', self._name) - display = self._displayCtx.getDisplayProperties(image) + plotpanel.PlotPanel.destroy(self) - if not image.is4DImage(): return None - if not display.enabled: return None - for vox, shape in zip((x, y, z), image.shape): - if vox >= shape or vox < 0: - return None + def __overlaysChanged(self, *a): - data = image.data[x, y, z, :] - self.getAxis().plot(data, lw=2) + self.disableListener('dataSeries', self._name) + for ds in self.dataSeries: + if ds.overlay not in self._overlayList: + self.dataSeries.remove(ds) + self.enableListener('dataSeries', self._name) + self.draw() + + + def __calcCurrent(self): - return data.min(), data.max(), len(data) + prevTs = self.__currentTs + prevOverlay = self.__currentOverlay + if prevTs is not None: + prevTs.removeGlobalListener(self._name) + + self.__currentTs = None + self.__currentOverlay = None + + if len(self._overlayList) == 0: + return - def _onPlotMouseDown(self, ev): - if ev.inaxes != self.getAxis(): return - self._mouseDown = True - self._displayCtx.volume = np.floor(ev.xdata) + x, y, z = self._displayCtx.location.xyz + overlay = self._displayCtx.getSelectedOverlay() + opts = self._displayCtx.getOpts(overlay) + + if not isinstance(overlay, fslimage.Image) or \ + not isinstance(opts, fsldisplay.VolumeOpts) or \ + not overlay.is4DImage(): + return + + xform = opts.getTransform('display', 'voxel') + vox = transform.transform([[x, y, z]], xform)[0] + vox = np.floor(vox + 0.5) + + if vox[0] < 0 or \ + vox[1] < 0 or \ + vox[2] < 0 or \ + vox[0] >= overlay.shape[0] or \ + vox[1] >= overlay.shape[1] or \ + vox[2] >= overlay.shape[2]: + return + + if overlay is prevOverlay: + self.__currentOverlay = prevOverlay + self.__currentTs = prevTs + prevTs.update(vox) + + else: + if isinstance(overlay, fslfeatimage.FEATImage): + ts = FEATTimeSeries(self, overlay, vox) + else: + ts = TimeSeries(self, overlay, vox) + ts.colour = self.currentColour + ts.alpha = self.currentAlpha + ts.lineWidth = self.currentLineWidth + ts.lineStyle = self.currentLineStyle + ts.label = None - def _onPlotMouseUp(self, ev): - self._mouseDown = False + ts.bindProps('colour' , self, 'currentColour') + ts.bindProps('alpha' , self, 'currentAlpha') + ts.bindProps('lineWidth', self, 'currentLineWidth') + ts.bindProps('lineStyle', self, 'currentLineStyle') + + self.__currentTs = ts + self.__currentOverlay = overlay + + self.__currentTs.addGlobalListener(self._name, self.draw) - def _onPlotMouseMove(self, ev): - if not self._mouseDown: return - if ev.inaxes != self.getAxis(): return - self._displayCtx.volume = np.floor(ev.xdata) + def getCurrent(self): + return self.__currentTs + + + def draw(self, *a): + + self.__calcCurrent() + current = self.__currentTs + + if self.showCurrent and \ + current is not None: + + if isinstance(current, FEATTimeSeries): + extras = current.getModelTimeSeries() + else: + extras = [current] + + self.drawDataSeries(extras) + else: + self.drawDataSeries() diff --git a/fsl/fslview/views/viewpanel.py b/fsl/fslview/views/viewpanel.py index ba46496e8a1768ce2eaa91caca3f47e0ccc4c7ef..cd44d8392f1b6371ab7ad206f552c9369f8ea9c4 100644 --- a/fsl/fslview/views/viewpanel.py +++ b/fsl/fslview/views/viewpanel.py @@ -19,6 +19,7 @@ import props import fsl.fslview.panel as fslpanel import fsl.fslview.toolbar as fsltoolbar import fsl.fslview.profiles as profiles +import fsl.data.image as fslimage import fsl.data.strings as strings @@ -81,13 +82,13 @@ class ViewPanel(fslpanel.FSLViewPanel): profile = props.Choice() - def __init__(self, parent, imageList, displayCtx, actionz=None): + def __init__(self, parent, overlayList, displayCtx, actionz=None): fslpanel.FSLViewPanel.__init__( - self, parent, imageList, displayCtx, actionz) + self, parent, overlayList, displayCtx, actionz) self.__profileManager = profiles.ProfileManager( - self, imageList, displayCtx) + self, overlayList, displayCtx) self.__panels = {} @@ -102,14 +103,14 @@ class ViewPanel(fslpanel.FSLViewPanel): self.addListener('profile', lName, self.__profileChanged) - imageList .addListener('images', - lName, - self.__selectedImageChanged) - displayCtx.addListener('selectedImage', - lName, - self.__selectedImageChanged) + overlayList.addListener('overlays', + lName, + self.__selectedOverlayChanged) + displayCtx .addListener('selectedOverlay', + lName, + self.__selectedOverlayChanged) - self.__selectedImageChanged() + self.__selectedOverlayChanged() # A very shitty necessity. When panes are floated, # the AuiManager sets the size of the floating frame @@ -135,17 +136,37 @@ class ViewPanel(fslpanel.FSLViewPanel): def destroy(self): - fslpanel.FSLViewPanel.destroy(self) + """ + """ # Make sure that any control panels are correctly destroyed for panelType, panel in self.__panels.items(): panel.destroy() - + + # Remove listeners from the overlay + # list and display context lName = 'ViewPanel_{}'.format(self._name) - self .removeListener('profile', lName) - self._imageList .removeListener('images', lName) - self._displayCtx.removeListener('selectedImage', lName) + self .removeListener('profile', lName) + self._overlayList.removeListener('overlays', lName) + self._displayCtx .removeListener('selectedOverlay', lName) + + # Disable the ProfileManager + self.__profileManager.destroy() + + # Un-initialise the AUI manager + self.__auiMgr.UnInit() + + # The AUI manager does not clear its + # reference to this panel, so let's + # do it here. + self.__auiMgr._frame = None + self.__profileManager = None + self.__auiMgr = None + self.__panels = None + + fslpanel.FSLViewPanel.destroy(self) + def setCentrePanel(self, panel): @@ -165,12 +186,13 @@ class ViewPanel(fslpanel.FSLViewPanel): else: - window = panelType( - self, self._imageList, self._displayCtx, *args, **kwargs) - paneInfo = aui.AuiPaneInfo() + window = panelType( + self, self._overlayList, self._displayCtx, *args, **kwargs) if isinstance(window, fsltoolbar.FSLViewToolBar): + + # ToolbarPane sets the panel layer to 10 paneInfo.ToolbarPane() # We are going to put any new toolbars on @@ -197,8 +219,6 @@ class ViewPanel(fslpanel.FSLViewPanel): info.dock_direction == aui.AUI_DOCK_TOP: info.Layer(info.dock_layer + 1) - paneInfo.Layer(0) - # When the toolbar contents change, # update the layout, so that the # toolbar's new size is accommodated @@ -236,24 +256,33 @@ class ViewPanel(fslpanel.FSLViewPanel): self.__auiMgr.AddPane(window, paneInfo) self.__panels[panelType] = window self.__auiMgrUpdate() + + + def getPanel(self, panelType): + """If an instance of ``panelType`` exists, it is returned. + Otherwise ``None`` is returned. + """ + if panelType in self.__panels: return self.__panels[panelType] + else: return None - def __selectedImageChanged(self, *a): - """Called when the image list or selected image changed. + def __selectedOverlayChanged(self, *a): + """Called when the overlay list or selected overlay changed. This method is slightly hard-coded and hacky. For the time being, edit profiles are only going to be supported for ``volume`` image types, which are being displayed in ``id`` or ``pixdim`` space.. - This method checks the type of the selected image, and disables + This method checks the type of the selected overlay, and disables the ``edit`` profile option (if it is an option), so the user can only choose an ``edit`` profile on ``volume`` image types. """ - image = self._displayCtx.getSelectedImage() + overlay = self._displayCtx.getSelectedOverlay() - if image is None: + if overlay is None: return - display = self._displayCtx.getDisplayProperties(image) + display = self._displayCtx.getDisplay(overlay) + opts = display.getDisplayOpts() profileProp = self.getProp('profile') # edit profile is not an option - @@ -261,8 +290,9 @@ class ViewPanel(fslpanel.FSLViewPanel): if 'edit' not in profileProp.getChoices(self): return - if image.imageType != 'volume' or \ - display.transform not in ('id', 'pixdim'): + if not isinstance(overlay, fslimage.Image) or \ + display.overlayType != 'volume' or \ + opts.transform not in ('id', 'pixdim'): # change profile if needed, if self.profile == 'edit': diff --git a/fsl/tools/bet.py b/fsl/tools/bet.py index 69f519311cbc18507cb944f7b7f792f597970305..56657b7ef97db011bf506dfeb83ab6a77b3aeca3 100644 --- a/fsl/tools/bet.py +++ b/fsl/tools/bet.py @@ -9,8 +9,12 @@ from collections import OrderedDict import props +# The colour maps module must be initialised +# before the displaycontext module can be loaded +import fsl.fslview.colourmaps as colourmaps +colourmaps.init() + import fsl.data.image as fslimage -import fsl.data.imageio as iio import fsl.utils.transform as transform import fsl.fslview.displaycontext as displaycontext import fsl.fslview.gl as fslgl @@ -35,12 +39,12 @@ class Options(props.HasProperties): inputImage = props.FilePath( exists=True, - suffixes=iio.ALLOWED_EXTENSIONS, + suffixes=fslimage.ALLOWED_EXTENSIONS, required=True) outputImage = props.FilePath(required=True) t2Image = props.FilePath( exists=True, - suffixes=iio.ALLOWED_EXTENSIONS, + suffixes=fslimage.ALLOWED_EXTENSIONS, required=lambda i: i.runChoice == '-A2') runChoice = props.Choice(runChoices) @@ -67,7 +71,7 @@ class Options(props.HasProperties): """ if not valid: return - value = iio.removeExt(value) + value = fslimage.removeExt(value) self.outputImage = value + '_brain' @@ -195,7 +199,7 @@ def selectHeadCentre(opts, button): image = fslimage.Image(opts.inputImage) imageList = fslimage.ImageList([image]) displayCtx = displaycontext.DisplayContext(imageList) - display = displayCtx.getDisplayProperties(image) + display = displayCtx.getDisplay(image) parent = button.GetTopLevelParent() frame = orthopanel.OrthoDialog(parent, imageList, @@ -223,7 +227,7 @@ def selectHeadCentre(opts, button): opts.yCoordinate = round(y) opts.zCoordinate = round(z) - displayCtx.addListener('location', 'BETHeadCentre', updateOpts) + displayCtx.addListener('location', 'BETHeadCentre', updateOpts, weak=False) # Set the initial location on the orthopanel. voxCoords = [opts.xCoordinate, @@ -303,7 +307,7 @@ def runBet(parent, opts): imageList = fslimage.ImageList([inImage, outImage]) displayCtx = displaycontext.DisplayContext(imageList) - outDisplay = displayCtx.getDisplayProperties(outImage) + outDisplay = displayCtx.getDisplay(outImage) outOpts = outDisplay.getDisplayOpts() outOpts.cmap = 'Red' diff --git a/fsl/tools/feat.py b/fsl/tools/feat.py index 6c40c4e10350c4e792b3a37c855037d830671755..e430d3a46b8d90719997a708bc2cb973ee357ea0 100644 --- a/fsl/tools/feat.py +++ b/fsl/tools/feat.py @@ -228,7 +228,8 @@ class Options(props.HasProperties): Options.analysisType.addListener(self, 'updateAnalysisStage', - updateAnalysisStage) + updateAnalysisStage, + weak=False) labels = { # misc diff --git a/fsl/tools/fslview.py b/fsl/tools/fslview.py index 438e319e46a183d682a3243ac513a25cb59200e2..ddb9e83f658c8e1ef91b276cb9e58253673c0f9c 100644 --- a/fsl/tools/fslview.py +++ b/fsl/tools/fslview.py @@ -9,7 +9,6 @@ details. The command line interface is defined (and parsed) by the :mod:`fslview_parseargs` module. """ - import time import logging import argparse @@ -17,7 +16,7 @@ import argparse import fslview_parseargs import fsl.fslview.displaycontext as displaycontext -import fsl.data.image as fslimage +import fsl.fslview.overlay as fsloverlay log = logging.getLogger(__name__) @@ -28,7 +27,7 @@ def interface(parent, args, ctx): import fsl.fslview.frame as fslviewframe import fsl.fslview.views as views - imageList, displayCtx, splashFrame = ctx + overlayList, displayCtx, splashFrame = ctx # If a scene has not been specified, the default # behaviour is to restore the previous frame layout @@ -36,7 +35,7 @@ def interface(parent, args, ctx): else: restore = False frame = fslviewframe.FSLViewFrame( - parent, imageList, displayCtx, restore) + parent, overlayList, displayCtx, restore) # Otherwise, we add the scene # specified by the user @@ -49,7 +48,7 @@ def interface(parent, args, ctx): viewPanel = frame.getViewPanels()[0][0] viewOpts = viewPanel.getSceneOptions() - fslview_parseargs.applySceneArgs(args, imageList, displayCtx, viewOpts) + fslview_parseargs.applySceneArgs(args, overlayList, displayCtx, viewOpts) if args.scene == 'ortho': @@ -125,27 +124,39 @@ def context(args): fslgl.getWXGLContext(frame) fslgl.bootstrap(args.glversion) - def status(image): - frame.SetStatus(strings.messages['fslview.loading'].format(image)) + def status(overlay): + frame.SetStatus(strings.messages['fslview.loading'].format(overlay)) wx.Yield() - # Create the image list - only one of these - # ever exists; and the master DisplayContext. + # Create the overlay list (only one of these + # ever exists) and the master DisplayContext. # A new DisplayContext instance will be # created for every new view that is opened # in the FSLViewFrame (which is created in # the interface function, above), but all # child DisplayContext instances will be # linked to this master one. - imageList = fslimage.ImageList() - displayCtx = displaycontext.DisplayContext(imageList) + overlayList = fsloverlay.OverlayList() + displayCtx = displaycontext.DisplayContext(overlayList) + + + # While the DisplayContext may refer to + # multiple overlay groups, we are currently + # using just one, allowing the user to specify + # a set of overlays for which their display + # properties are 'locked'. + lockGroup = displaycontext.OverlayGroup(displayCtx, overlayList) + displayCtx.overlayGroups.append(lockGroup) + + log.debug('Created overlay list and master DisplayContext ({})'.format( + id(displayCtx))) # Load the images - the splash screen status will - # be updated with the currently loading image name - fslview_parseargs.applyImageArgs( - args, imageList, displayCtx, loadFunc=status) + # be updated with the currently loading overlay name + fslview_parseargs.applyOverlayArgs( + args, overlayList, displayCtx, loadFunc=status) - return imageList, displayCtx, frame + return overlayList, displayCtx, frame FSL_TOOLNAME = 'FSLView' diff --git a/fsl/tools/fslview_parseargs.py b/fsl/tools/fslview_parseargs.py index ce24cf676fcf6af1b34e9ea26a14b3df967efa4a..b0ef0ddd808372eee4e3c321c19a59d043b74273 100644 --- a/fsl/tools/fslview_parseargs.py +++ b/fsl/tools/fslview_parseargs.py @@ -9,7 +9,7 @@ which specify a scene to be displayed in FSLView. This logic is shared between fslview.py and render.py. The functions in this module make use of the command line generation -featuresd of the :mod:`props` package. +features of the :mod:`props` package. There are a lot of command line arguments made available to the user, broadly split into the following groups: @@ -18,8 +18,8 @@ broadly split into the following groups: display type (orthographic or lightbox), the displayed location, and whether to show a colour bar. - - *Display* arguments control the display for a single image file, - such as interpolation, colour map, etc. + - *Display* arguments control the display for a single overlay file (e.g. + an image), such as interpolation, colour map, etc. The main entry points of this module are: @@ -34,9 +34,9 @@ The main entry points of this module are: :class:`~fsl.fslview.displaycontext.DisplayContext` instances according to the arguments contained in a given :class:`~argparse.Namespace` object. - - :func:`handleImageArgs`: + - :func:`handleOverlayArgs`: - Loads and configures the display of any image files specified by a given + Loads and configures the display of any overlay files specified by a given :class:`~argparse.Namespace` object. """ @@ -48,30 +48,29 @@ import logging import props import fsl.utils.typedict as td -import fsl.data.imageio as iio import fsl.data.image as fslimage +import fsl.data.model as fslmodel +import fsl.fslview.overlay as fsloverlay import fsl.utils.transform as transform - # The colour maps module needs to be imported # before the displaycontext.opts modules are # imported, as some of their class definitions # rely on the colourmaps being initialised import fsl.fslview.colourmaps as colourmaps -colourmaps.initColourMaps() +colourmaps.init() +import fsl.fslview.displaycontext as fsldisplay -import fsl.fslview.displaycontext.display as fsldisplay -import fsl.fslview.displaycontext.volumeopts as volumeopts -import fsl.fslview.displaycontext.vectoropts as vectoropts -import fsl.fslview.displaycontext.maskopts as maskopts -import fsl.fslview.displaycontext.sceneopts as sceneopts -import fsl.fslview.displaycontext.orthoopts as orthoopts -import fsl.fslview.displaycontext.lightboxopts as lightboxopts +log = logging.getLogger(__name__) -log = logging.getLogger(__name__) +def concat(lists): + """Concatenates a list of lists. Used a few times, and writing + concat(lists) is nicer-looking than writing lambda blah blah each time. + """ + return reduce(lambda a, b: a + b, lists) # Names of all of the property which are @@ -81,18 +80,18 @@ OPTIONS = td.TypeDict({ 'Main' : ['scene', 'voxelLoc', 'worldLoc', - 'selectedImage'], + 'selectedOverlay'], + + # From here on, all of the keys are + # the names of HasProperties classes, + # and all of the values are the + # names of properties on them. 'SceneOpts' : ['showCursor', 'showColourBar', 'colourBarLocation', 'colourBarLabelSide', 'performance'], - - # From here on, all of the keys are - # the names of HasProperties classes, - # and all of the values are the - # names of properties on them. 'OrthoOpts' : ['xzoom', 'yzoom', 'zzoom', @@ -110,46 +109,59 @@ OPTIONS = td.TypeDict({ 'zax'], # The order in which properties are listed - # here is the order in which they are applied - # - so make sure transform is listed before - # interpolation! - 'Display' : ['name', - 'transform', - 'imageType', - 'interpolation', - 'resolution', - 'volume', - 'alpha', - 'brightness', - 'contrast'], - 'VolumeOpts' : ['displayRange', - 'clippingRange', - 'invert', - 'cmap'], - 'MaskOpts' : ['colour', - 'invert', - 'threshold'], - 'VectorOpts' : ['xColour', - 'yColour', - 'zColour', - 'suppressX', - 'suppressY', - 'suppressZ', - 'modulate', - 'modThreshold'], + # here is the order in which they are applied. + 'Display' : ['name', + 'overlayType', + 'alpha', + 'brightness', + 'contrast'], + 'ImageOpts' : ['transform', + 'resolution', + 'volume'], + 'VolumeOpts' : ['cmap', + 'displayRange', + 'clippingRange', + 'invert', + 'invertClipping', + 'interpolation'], + 'MaskOpts' : ['colour', + 'invert', + 'threshold'], + 'VectorOpts' : ['xColour', + 'yColour', + 'zColour', + 'suppressX', + 'suppressY', + 'suppressZ', + 'modulate', + 'modThreshold'], + 'LineVectorOpts' : ['lineWidth', + 'directed'], + 'RGBVectorOpts' : ['interpolation'], + 'ModelOpts' : ['colour', + 'outline', + 'outlineWidth', + 'refImage'], + 'LabelOpts' : ['lut', + 'outline', + 'outlineWidth'], }) # Headings for each of the option groups GROUPNAMES = td.TypeDict({ - 'SceneOpts' : 'Scene options', - 'OrthoOpts' : 'Ortho display options', - 'LightBoxOpts' : 'LightBox display options', + 'SceneOpts' : 'Scene options', + 'OrthoOpts' : 'Ortho display options', + 'LightBoxOpts' : 'LightBox display options', - 'Display' : 'Image display options', - 'VolumeOpts' : 'Volume options', - 'VectorOpts' : 'Vector options', - 'MaskOpts' : 'Mask options', - + 'Display' : 'Overlay display options', + 'ImageOpts' : 'Options for NIFTI images', + 'VolumeOpts' : 'Volume options', + 'MaskOpts' : 'Mask options', + 'VectorOpts' : 'Vector options', + 'LineVectorOpts' : 'Line vector options', + 'RGBVectorOpts' : 'RGB vector options', + 'ModelOpts' : 'Model options', + 'LabelOpts' : 'Label options', }) # Short/long arguments for all of those options @@ -162,10 +174,10 @@ GROUPNAMES = td.TypeDict({ # Display options and the *Opts options. ARGUMENTS = td.TypeDict({ - 'Main.scene' : ('s', 'scene'), - 'Main.voxelLoc' : ('v', 'voxelloc'), - 'Main.worldLoc' : ('w', 'worldloc'), - 'Main.selectedImage' : ('i', 'selectedImage'), + 'Main.scene' : ('s', 'scene'), + 'Main.voxelLoc' : ('v', 'voxelloc'), + 'Main.worldLoc' : ('w', 'worldloc'), + 'Main.selectedOverlay' : ('o', 'selectedOverlay'), 'SceneOpts.showColourBar' : ('cb', 'showColourBar'), 'SceneOpts.colourBarLocation' : ('cbl', 'colourBarLocation'), @@ -195,21 +207,23 @@ ARGUMENTS = td.TypeDict({ 'LightBoxOpts.zax' : ('zx', 'zaxis'), 'Display.name' : ('n', 'name'), - 'Display.interpolation' : ('in', 'interp'), - 'Display.resolution' : ('r', 'resolution'), - 'Display.transform' : ('tf', 'transform'), - 'Display.imageType' : ('it', 'imageType'), - 'Display.volume' : ('vl', 'volume'), + 'Display.overlayType' : ('ot', 'overlayType'), 'Display.alpha' : ('a', 'alpha'), 'Display.brightness' : ('b', 'brightness'), 'Display.contrast' : ('c', 'contrast'), - 'VolumeOpts.displayRange' : ('dr', 'displayRange'), - 'VolumeOpts.clippingRange' : ('cr', 'clippingRange'), - 'VolumeOpts.cmap' : ('cm', 'cmap'), - 'VolumeOpts.invert' : ('ci', 'cmapInvert'), + 'ImageOpts.resolution' : ('r', 'resolution'), + 'ImageOpts.transform' : ('tf', 'transform'), + 'ImageOpts.volume' : ('vl', 'volume'), + + 'VolumeOpts.displayRange' : ('dr', 'displayRange'), + 'VolumeOpts.interpolation' : ('in', 'interp'), + 'VolumeOpts.invertClipping' : ('ic', 'invertClipping'), + 'VolumeOpts.clippingRange' : ('cr', 'clippingRange'), + 'VolumeOpts.cmap' : ('cm', 'cmap'), + 'VolumeOpts.invert' : ('ci', 'cmapInvert'), - 'MaskOpts.colour' : ('co', 'colour'), + 'MaskOpts.colour' : ('co', 'maskColour'), 'MaskOpts.invert' : ('mi', 'maskInvert'), 'MaskOpts.threshold' : ('t', 'threshold'), @@ -222,6 +236,18 @@ ARGUMENTS = td.TypeDict({ 'VectorOpts.modulate' : ('m', 'modulate'), 'VectorOpts.modThreshold': ('mt', 'modThreshold'), + 'LineVectorOpts.lineWidth' : ('lvw', 'lineWidth'), + 'LineVectorOpts.directed' : ('lvi', 'directed'), + 'RGBVectorOpts.interpolation' : ('rvi', 'rvInterpolation'), + + 'ModelOpts.colour' : ('mc', 'modelColour'), + 'ModelOpts.outline' : ('mo', 'modelOutline'), + 'ModelOpts.outlineWidth' : ('mw', 'modelOutlineWidth'), + 'ModelOpts.refImage' : ('mr', 'modelRefImage'), + + 'LabelOpts.lut' : ('ll', 'lut'), + 'LabelOpts.outline' : ('lo', 'labelOutline'), + 'LabelOpts.outlineWidth' : ('lw', 'labelOutlineWidth'), }) # Help text for all of the options @@ -229,11 +255,13 @@ HELP = td.TypeDict({ 'Main.scene' : 'Scene to show. If not provided, the ' 'previous scene layout is restored.', - 'Main.voxelLoc' : 'Location to show (voxel coordinates of ' - 'first image)', - 'Main.worldLoc' : 'Location to show (world coordinates, ' - 'takes precedence over --voxelloc)', - 'Main.selectedImage' : 'Selected image (default: last)', + + # TODO how about other overlay types? + 'Main.voxelLoc' : 'Location to show (voxel coordinates of ' + 'first overlay)', + 'Main.worldLoc' : 'Location to show (world coordinates, ' + 'takes precedence over --voxelloc)', + 'Main.selectedOverlay' : 'Selected overlay (default: last)', 'SceneOpts.showCursor' : 'Do not display the green cursor ' 'highlighting the current location', @@ -264,20 +292,22 @@ HELP = td.TypeDict({ 'LightBoxOpts.highlightSlice' : 'Highlight current slice', 'LightBoxOpts.zax' : 'Z axis', - 'Display.name' : 'Image name', - 'Display.interpolation' : 'Interpolation', - 'Display.resolution' : 'Resolution', - 'Display.transform' : 'Transformation', - 'Display.imageType' : 'Image type', - 'Display.volume' : 'Volume', + 'Display.name' : 'Overlay name', + 'Display.overlayType' : 'Overlay type', 'Display.alpha' : 'Opacity', 'Display.brightness' : 'Brightness', 'Display.contrast' : 'Contrast', - 'VolumeOpts.displayRange' : 'Display range', - 'VolumeOpts.clippingRange' : 'Clipping range', - 'VolumeOpts.cmap' : 'Colour map', - 'VolumeOpts.invert' : 'Invert colour map', + 'ImageOpts.resolution' : 'Resolution', + 'ImageOpts.transform' : 'Transformation', + 'ImageOpts.volume' : 'Volume', + + 'VolumeOpts.displayRange' : 'Display range', + 'VolumeOpts.clippingRange' : 'Clipping range', + 'VolumeOpts.invertClipping' : 'Invert clipping', + 'VolumeOpts.cmap' : 'Colour map', + 'VolumeOpts.interpolation' : 'Interpolation', + 'VolumeOpts.invert' : 'Invert colour map', 'MaskOpts.colour' : 'Colour', 'MaskOpts.invert' : 'Invert', @@ -293,8 +323,33 @@ HELP = td.TypeDict({ 'VectorOpts.modThreshold' : 'Hide voxels where modulation ' 'value is below this threshold ' '(expressed as a percentage)', + + 'LineVectorOpts.lineWidth' : 'Line width', + 'LineVectorOpts.directed' : 'Interpret vectors as directed', + 'RGBVectorOpts.interpolation' : 'Interpolation', + + 'ModelOpts.colour' : 'Model colour', + 'ModelOpts.outline' : 'Show model outline', + 'ModelOpts.outlineWidth' : 'Model outline width', + 'ModelOpts.refImage' : 'Reference image for model', + + 'LabelOpts.lut' : 'Label image LUT', + 'LabelOpts.outline' : 'Show label outlines', + 'LabelOpts.outlineWidth' : 'Label outline width', }) + +# Extra settings for some properties, passed through +# to the props.cli.addParserArguments function. +EXTRA = td.TypeDict({ + 'Display.overlayType' : {'choices' : fsldisplay.ALL_OVERLAY_TYPES, + 'default' : fsldisplay.ALL_OVERLAY_TYPES[0]}, + + 'LabelOpts.lut' : { + 'choices' : [l.name for l in colourmaps.getLookupTables()] + } +}) + # Transform functions for properties where the # value passed in on the command line needs to # be manipulated before the property value is @@ -304,9 +359,14 @@ HELP = td.TypeDict({ # complicated property transformations (i.e. # non-reversible ones), you'll need to have # an inverse transforms dictionary -def _modTrans(i): +def _imageTrans(i): if i == 'none': return None - else: return i.imageFile + else: return i.dataSource + +def _lutTrans(i): + if isinstance(i, basestring): return colourmaps.getLookupTable(i) + else: return i.name + TRANSFORMS = td.TypeDict({ 'SceneOpts.showCursor' : lambda b: not b, @@ -315,11 +375,14 @@ TRANSFORMS = td.TypeDict({ 'OrthoOpts.showZCanvas' : lambda b: not b, 'OrthoOpts.showLabels' : lambda b: not b, - # The modulate property is handled specially + 'LabelOpts.lut' : _lutTrans, + + # These properties are handled specially # when reading in command line arguments - - # this transform function is only used when - # generating arguments - 'VectorOpts.modulate' : _modTrans, + # the transform function specified here + # is only used when generating arguments + 'VectorOpts.modulate' : _imageTrans, + 'ModelOpts.refImage' : _imageTrans, }) @@ -360,9 +423,9 @@ def _configMainParser(mainParser): type=float, nargs=3, help=mainHelp['worldLoc']) - sceneParser.add_argument(*mainArgs['selectedImage'], + sceneParser.add_argument(*mainArgs['selectedOverlay'], type=int, - help=mainHelp['selectedImage']) + help=mainHelp['selectedOverlay']) # Separate parser groups for ortho/lightbox, and for colour bar options sceneParser = mainParser.add_argument_group(GROUPNAMES['SceneOpts']) @@ -381,29 +444,35 @@ def _configParser(target, parser, propNames=None): shortArgs = {} longArgs = {} helpTexts = {} + extra = {} for propName in propNames: - shortArg, longArg = ARGUMENTS[target, propName] - helpText = HELP[ target, propName] + shortArg, longArg = ARGUMENTS[ target, propName] + helpText = HELP[ target, propName] + propExtra = EXTRA.get((target, propName), None) shortArgs[propName] = shortArg longArgs[ propName] = longArg helpTexts[propName] = helpText + if propExtra is not None: + extra[propName] = propExtra + props.addParserArguments(target, parser, cliProps=propNames, shortArgs=shortArgs, longArgs=longArgs, - propHelp=helpTexts) + propHelp=helpTexts, + extra=extra) def _configSceneParser(sceneParser): """Adds options to the given argument parser which allow the user to specify colour bar properties. """ - _configParser(sceneopts.SceneOpts, sceneParser) + _configParser(fsldisplay.SceneOpts, sceneParser) def _configOrthoParser(orthoParser): @@ -411,7 +480,7 @@ def _configOrthoParser(orthoParser): configure an orthographic display. """ - OrthoOpts = orthoopts.OrthoOpts + OrthoOpts = fsldisplay.OrthoOpts _configParser(OrthoOpts, orthoParser) # Extra configuration options that are @@ -438,49 +507,73 @@ def _configLightBoxParser(lbParser): """Adds options to the given parser allowing the user to configure a lightbox display. """ - _configParser(lightboxopts.LightBoxOpts, lbParser) + _configParser(fsldisplay.LightBoxOpts, lbParser) -def _configImageParser(imgParser): - """Adds options to the given image allowing the user to - configure the display of a single image. +def _configOverlayParser(ovlParser): + """Adds options to the given parser allowing the user to + configure the display of a single overlay. """ - Display = fsldisplay.Display - VolumeOpts = volumeopts.VolumeOpts - VectorOpts = vectoropts.VectorOpts - MaskOpts = maskopts .MaskOpts + Display = fsldisplay.Display + ImageOpts = fsldisplay.ImageOpts + VolumeOpts = fsldisplay.VolumeOpts + VectorOpts = fsldisplay.VectorOpts + RGBVectorOpts = fsldisplay.RGBVectorOpts + LineVectorOpts = fsldisplay.LineVectorOpts + MaskOpts = fsldisplay.MaskOpts + ModelOpts = fsldisplay.ModelOpts + LabelOpts = fsldisplay.LabelOpts dispDesc = 'Each display option will be applied to the '\ - 'image which is listed before that option.' - - dispParser = imgParser.add_argument_group(GROUPNAMES[Display], - dispDesc) - volParser = imgParser.add_argument_group(GROUPNAMES[VolumeOpts]) - vecParser = imgParser.add_argument_group(GROUPNAMES[VectorOpts]) - maskParser = imgParser.add_argument_group(GROUPNAMES[MaskOpts]) - - for target, parser in zip( - [Display, VolumeOpts, VectorOpts, MaskOpts], - [dispParser, volParser, vecParser, maskParser]): - - propNames = list(OPTIONS[target]) - - # The VectorOpts.modulate option needs - # special treatment - see below - addModulate = False + 'overlay which is listed before that option.' + + dispParser = ovlParser.add_argument_group(GROUPNAMES[Display], + dispDesc) + imgParser = ovlParser.add_argument_group(GROUPNAMES[ImageOpts]) + volParser = ovlParser.add_argument_group(GROUPNAMES[VolumeOpts]) + vecParser = ovlParser.add_argument_group(GROUPNAMES[VectorOpts]) + lvParser = ovlParser.add_argument_group(GROUPNAMES[LineVectorOpts]) + rvParser = ovlParser.add_argument_group(GROUPNAMES[RGBVectorOpts]) + maskParser = ovlParser.add_argument_group(GROUPNAMES[MaskOpts]) + modelParser = ovlParser.add_argument_group(GROUPNAMES[ModelOpts]) + labelParser = ovlParser.add_argument_group(GROUPNAMES[LabelOpts]) + + targets = [(Display, dispParser), + (ImageOpts, imgParser), + (VolumeOpts, volParser), + (VectorOpts, vecParser), + (LineVectorOpts, lvParser), + (RGBVectorOpts, rvParser), + (MaskOpts, maskParser), + (ModelOpts, modelParser), + (LabelOpts, labelParser)] + + for target, parser in targets: + + propNames = list(OPTIONS[target]) + specialOptions = [] + + # The VectorOpts.modulate + # option needs special treatment if target == VectorOpts and 'modulate' in propNames: - addModulate = True + specialOptions.append('modulate') propNames.remove('modulate') + # The same goes for the + # ModelOpts.refImage option + if target == ModelOpts and 'refImage' in propNames: + specialOptions.append('refImage') + propNames.remove('refImage') + _configParser(target, parser, propNames) - # We need to process the modulate option + # We need to process the special options # manually, rather than using the props.cli - # module - see the handleImageArgs function. - if addModulate: - shortArg, longArg = ARGUMENTS[target, 'modulate'] - helpText = HELP[ target, 'modulate'] + # module - see the handleOverlayArgs function. + for opt in specialOptions: + shortArg, longArg = ARGUMENTS[target, opt] + helpText = HELP[ target, opt] shortArg = '-{}'.format(shortArg) longArg = '--{}'.format(longArg) @@ -495,12 +588,12 @@ def parseArgs(mainParser, argv, name, desc, toolOptsDesc='[options]'): """Parses the given command line arguments, returning an :class:`argparse.Namespace` object containing all the arguments. - The display options for individual images are parsed separately. The - :class:`~argparse.Namespace` objects for each image are returned in a - list, stored as an attribute, called ``images``, of the returned - top-level ``Namespace`` instance. Each of the image ``Namespace`` - instances also has an attribute, called ``image``, which contains the - full path of the image file that was speciied. + The display options for individual overlays are parsed separately. The + :class:`~argparse.Namespace` objects for each overlay are returned in a + list, stored as an attribute, called ``overlays``, of the returned + top-level ``Namespace`` instance. Each of the overlay ``Namespace`` + instances also has an attribute, called ``overlay``, which contains the + full path of the overlay file that was speciied. - mainParser: A :class:`argparse.ArgumentParser` which should be used as the top level parser. @@ -522,8 +615,8 @@ def parseArgs(mainParser, argv, name, desc, toolOptsDesc='[options]'): # I hate argparse. By default, it does not support # the command line interface that I want to provide, # as demonstrated in this usage string. - usageStr = '{} {} [imagefile [displayOpts]] '\ - '[imagefile [displayOpts]] ...'.format( + usageStr = '{} {} [overlayfile [displayOpts]] '\ + '[overlayfile [displayOpts]] ...'.format( name, toolOptsDesc) @@ -535,19 +628,19 @@ def parseArgs(mainParser, argv, name, desc, toolOptsDesc='[options]'): _configMainParser(mainParser) - # And the imgParser parses image display options - # for a single image - below we're going to + # And the ovlParser parses overlay display options + # for a single overlay - below we're going to # manually step through the list of arguments, - # and pass each block of arguments to the imgParser + # and pass each block of arguments to the ovlParser # one at a time - imgParser = argparse.ArgumentParser(add_help=False) + ovlParser = argparse.ArgumentParser(add_help=False) # Because I'm splitting the argument parsing across two # parsers, I'm using a custom print_help function def printHelp(shortHelp=False): # Print help for the main parser first, - # and then separately for the image parser + # and then separately for the overlay parser if shortHelp: mainParser.print_usage() else: mainParser.print_help() @@ -555,129 +648,124 @@ def parseArgs(mainParser, argv, name, desc, toolOptsDesc='[options]'): # can't we customise the help text? dispGroup = GROUPNAMES[fsldisplay.Display] if shortHelp: - imgHelp = imgParser.format_usage() - imgHelp = imgHelp.split('\n') + ovlHelp = ovlParser.format_usage() + ovlHelp = ovlHelp.split('\n') # Argparse usage text starts with 'usage [toolname]:', # and then proceeds to give short help for all the # possible arguments. Here, we're removing this # 'usage [toolname]:' section, and replacing it with - # spaces. We're also adding the image display argument + # spaces. We're also adding the overlay display argument # group title to the beginning of the usage text - start = ' '.join(imgHelp[0].split()[:2]) - imgHelp[0] = imgHelp[0].replace(start, ' ' * len(start)) + start = ' '.join(ovlHelp[0].split()[:2]) + ovlHelp[0] = ovlHelp[0].replace(start, ' ' * len(start)) - imgHelp.insert(0, dispGroup) + ovlHelp.insert(0, dispGroup) - imgHelp = '\n'.join(imgHelp) + ovlHelp = '\n'.join(ovlHelp) else: # Here we're skipping over the first section of - # the image parser help text, everything before - # where the help text contains the image display + # the overlay parser help text, everything before + # where the help text contains the overlay display # options (which were identifying by searching # through the text for the argument group title) - imgHelp = imgParser.format_help() - imgHelp = imgHelp[imgHelp.index(dispGroup):] + ovlHelp = ovlParser.format_help() + ovlHelp = ovlHelp[ovlHelp.index(dispGroup):] print - print imgHelp + print ovlHelp - # And I want to handle image argument errors, - # rather than having the image parser force + # And I want to handle overlay argument errors, + # rather than having the overlay parser force # the program to exit - def imageArgError(message): + def ovlArgError(message): raise RuntimeError(message) - imgParser.error = imageArgError + ovlParser.error = ovlArgError - _configImageParser(imgParser) + _configOverlayParser(ovlParser) - # Figure out where the image files + # Figure out where the overlay files # are in the argument list, accounting - # for any options which accept image - # files as arguments. + # for any options which accept file + # names as arguments. # # Make a list of all the options which # accept filenames, and which we need # to account for when we're searching - # for image files, flattening the + # for overaly files, flattening the # short/long arguments into a 1D list. fileOpts = [] # The VectorOpts.modulate option allows # the user to specify another image file # by which the vector image colours are - # to be modulated - fileOpts.append(ARGUMENTS[vectoropts.VectorOpts, 'modulate']) + # to be modulated. The same goes for the + # ModelOpts.refImage option + fileOpts.append(ARGUMENTS[fsldisplay.VectorOpts, 'modulate']) + fileOpts.append(ARGUMENTS[fsldisplay.ModelOpts, 'refImage']) # There is a possibility that the user - # may specify an image name which is the - # same as the image file - so we make + # may specify an overlay name which is the + # same as the overlay file - so we make # sure that such situations don't result - # in an image file match. + # in an overlay file match. fileOpts.append(ARGUMENTS[fsldisplay.Display, 'name']) fileOpts = reduce(lambda a, b: list(a) + list(b), fileOpts, []) - imageIdxs = [] + ovlIdxs = [] for i in range(len(argv)): - try: - # imageio.addExt will raise an error if - # the argument is not a valid image file - argv[i] = iio.addExt(op.expanduser(argv[i]), mustExist=True) - - # Check that this image file was not a - # parameter to a file option - if i > 0 and argv[i - 1].strip('-') in fileOpts: - continue - - # Otherwise, it's an image - # file that needs to be loaded - imageIdxs.append(i) - except: + + # See if the current argument looks like a data source + dtype, fname = fsloverlay.guessDataSourceType(argv[i]) + + # If the file name refers to a file that + # does not exist, assume it is an argument + if not op.exists(fname): continue + + # Check that this overlay file was + # not a parameter to a file option + if i > 0 and argv[i - 1].strip('-') in fileOpts: + continue + + # Unrecognised overlay type - + # I don't know what to do + if dtype is None: + raise RuntimeError('Unrecognised overlay type: {}'.format(fname)) + + # Otherwise, it's an overlay + # file that needs to be loaded + ovlIdxs.append(i) - imageIdxs.append(len(argv)) + ovlIdxs.append(len(argv)) # Separate the program arguments - # from the image display arguments - progArgv = argv[:imageIdxs[0]] - imgArgv = argv[ imageIdxs[0]:] + # from the overlay display arguments + progArgv = argv[:ovlIdxs[0]] + ovlArgv = argv[ ovlIdxs[0]:] # Parse the application options with the mainParser namespace = mainParser.parse_args(progArgv) - # If the user asked for help, print some help and exit - def print_help(): - mainParser.print_help() - - # Did I mention that I hate argparse? Why - # can't we customise the help text? Here - # we're skipping over the top section of - # the image parser help text - imgHelp = imgParser.format_help() - dispGroup = GROUPNAMES[fsldisplay.Display] - print - print imgHelp[imgHelp.index(dispGroup):] - sys.exit(0) - if namespace.help: printHelp() sys.exit(0) # Then parse each block of # display options one by one. - namespace.images = [] - for i in range(len(imageIdxs) - 1): + namespace.overlays = [] + for i in range(len(ovlIdxs) - 1): - imgArgv = argv[imageIdxs[i]:imageIdxs[i + 1]] - imgFile = imgArgv[0] - imgArgv = imgArgv[1:] + ovlArgv = argv[ovlIdxs[i]:ovlIdxs[i + 1]] + ovlFile = ovlArgv[0] + ovlArgv = ovlArgv[1:] try: - imgNamespace = imgParser.parse_args(imgArgv) - imgNamespace.image = imgFile + ovlNamespace = ovlParser.parse_args(ovlArgv) + ovlNamespace.overlay = ovlFile except Exception as e: printHelp(shortHelp=True) @@ -685,9 +773,9 @@ def parseArgs(mainParser, argv, name, desc, toolOptsDesc='[options]'): sys.exit(1) # We just add a list of argparse.Namespace - # objects, one for each image, to the + # objects, one for each overlay, to the # parent Namespace object. - namespace.images.append(imgNamespace) + namespace.overlays.append(ovlNamespace) return namespace @@ -696,7 +784,7 @@ def _applyArgs(args, target, propNames=None): """Applies the given command line arguments to the given target object.""" if propNames is None: - propNames = OPTIONS[target] + propNames = concat(OPTIONS.get(target, allhits=True)) longArgs = {name : ARGUMENTS[target, name][1] for name in propNames} xforms = {} @@ -706,6 +794,10 @@ def _applyArgs(args, target, propNames=None): if xform is not None: xforms[name] = xform + log.debug('Applying arguments to {}: {}'.format( + type(target).__name__, + propNames)) + props.applyArguments(target, args, propNames=propNames, @@ -720,7 +812,7 @@ def _generateArgs(source, propNames=None): """ if propNames is None: - propNames = OPTIONS[source] + propNames = concat(OPTIONS.get(source, allhits=True)) longArgs = {name : ARGUMENTS[source, name][1] for name in propNames} xforms = {} @@ -736,37 +828,36 @@ def _generateArgs(source, propNames=None): longArgs=longArgs) -def applySceneArgs(args, imageList, displayCtx, sceneOpts): +def applySceneArgs(args, overlayList, displayCtx, sceneOpts): """Configures the scene displayed by the given :class:`~fsl.fslview.displaycontext.DisplayContext` instance according to the arguments that were passed in on the command line. - :arg args: :class:`argparse.Namespace` object containing the parsed - command line arguments. + :arg args: :class:`argparse.Namespace` object containing the parsed + command line arguments. - :arg imageList: A :class:`~fsl.data.image.ImageList` instance. + :arg overlayList: A :class:`.OverlayList` instance. - :arg displayCtx: A :class:`~fsl.fslview.displaycontext.DisplayContext` - instance. + :arg displayCtx: A :class:`.DisplayContext` instance. :arg sceneOpts: """ # First apply all command line options # related to the display context - if args.selectedImage is not None: - if args.selectedImage < len(imageList): - displayCtx.selectedImage = args.selectedImage + if args.selectedOverlay is not None: + if args.selectedOverlay < len(overlayList): + displayCtx.selectedOverlay = args.selectedOverlay else: - if len(imageList) > 0: - displayCtx.selectedImage = len(imageList) - 1 + if len(overlayList) > 0: + displayCtx.selectedOverlay = len(overlayList) - 1 # voxel/world location - if len(imageList) > 0: + if len(overlayList) > 0: if args.worldloc: loc = args.worldloc elif args.voxelloc: - display = displayCtx.getDisplayProperties(imageList[0]) + display = displayCtx.getDisplay(overlayList[0]) xform = display.getTransform('voxel', 'display') loc = transform.transform([args.voxelloc], xform)[0] @@ -777,89 +868,88 @@ def applySceneArgs(args, imageList, displayCtx, sceneOpts): displayCtx.location.xyz = loc - # It is assuemd that the given sceneOpts - # object is a subclass of SceneOpts - sceneProps = OPTIONS['SceneOpts'] + OPTIONS[sceneOpts] - _applyArgs(args, sceneOpts, sceneProps) + _applyArgs(args, sceneOpts) -def generateSceneArgs(imageList, displayCtx, sceneOpts): +def generateSceneArgs(overlayList, displayCtx, sceneOpts): """Generates command line arguments which describe the current state of the provided ``displayCtx`` and ``sceneOpts`` instances. """ args = [] - args += ['--{}'.format(ARGUMENTS['Main.scene'][1])] - if isinstance(sceneOpts, orthoopts .OrthoOpts): args += ['ortho'] - elif isinstance(sceneOpts, lightboxopts.LightBoxOpts): args += ['lightbox'] + if isinstance(sceneOpts, fsldisplay.OrthoOpts): args += ['ortho'] + elif isinstance(sceneOpts, fsldisplay.LightBoxOpts): args += ['lightbox'] else: raise ValueError('Unrecognised SceneOpts ' 'type: {}'.format(type(sceneOpts).__name__)) # main options - if len(imageList) > 0: + if len(overlayList) > 0: args += ['--{}'.format(ARGUMENTS['Main.worldLoc'][1])] args += ['{}'.format(c) for c in displayCtx.location.xyz] - if displayCtx.selectedImage is not None: - args += ['--{}'.format(ARGUMENTS['Main.selectedImage'][1])] - args += ['{}'.format(displayCtx.selectedImage)] + if displayCtx.selectedOverlay is not None: + args += ['--{}'.format(ARGUMENTS['Main.selectedOverlay'][1])] + args += ['{}'.format(displayCtx.selectedOverlay)] - args += _generateArgs(sceneOpts, OPTIONS['SceneOpts']) - args += _generateArgs(sceneOpts, OPTIONS[ sceneOpts]) + props = OPTIONS.get(sceneOpts, allhits=True) + args += _generateArgs(sceneOpts, concat(props)) return args -def generateImageArgs(image, displayCtx): - """ +def generateOverlayArgs(overlay, displayCtx): + """Generates command line arguments which describe the display + of the current overlay. """ - display = displayCtx.getDisplayProperties(image) + display = displayCtx.getDisplay(overlay) opts = display .getDisplayOpts() args = _generateArgs(display) + _generateArgs(opts) return args -def applyImageArgs(args, imageList, displayCtx, **kwargs): - """Loads and configures any images which were specified on the +def applyOverlayArgs(args, overlayList, displayCtx, **kwargs): + """Loads and configures any overlays which were specified on the command line. - :arg args: A :class:`~argparse.Namespace` instance, as returned - by the :func:`parseArgs` function. + :arg args: A :class:`~argparse.Namespace` instance, as returned + by the :func:`parseArgs` function. - :arg imageList: An :class:`~fsl.data.image.ImageList` instance, to - which the images should be added. + :arg overlayList: An :class:`.OverlayList` instance, to which the + overlays should be added. - :arg displayCtx: A :class:`~fsl.fslview.displaycontext.DisplayContext` - instance, which manages the scene and image display. + :arg displayCtx: A :class:`.DisplayContext` instance, which manages the + scene and overlay display. - :arg kwargs: Passed through to the - :func:`fsl.data.imageio.loadImages` function. + :arg kwargs: Passed through to the :func:`.Overlay.loadOverlays` + function. """ - paths = [i.image for i in args.images] - images = iio.loadImages(paths, **kwargs) - - imageList.extend(images) + paths = [o.overlay for o in args.overlays] + + if len(paths) > 0: + overlays = fsloverlay.loadOverlays(paths, **kwargs) + overlayList.extend(overlays) - # per-image display arguments - for i, image in enumerate(imageList): + # per-overlay display arguments + for i, overlay in enumerate(overlayList): - display = displayCtx.getDisplayProperties(imageList[i]) - _applyArgs(args.images[i], display) + display = displayCtx.getDisplay(overlay) + + _applyArgs(args.overlays[i], display) # Retrieve the DisplayOpts instance # after applying arguments to the - # Display instance - if the image type - # is set on the command line, the + # Display instance - if the overlay + # type is set on the command line, the # DisplayOpts instance will be replaced opts = display.getDisplayOpts() # VectorOpts.modulate is a Choice property, # where the valid choices are defined by - # the current contents of the image list. + # the current contents of the overlay list. # So when the user specifies a modulation # image, we need to do an explicit check # to see if the specified image is vaid @@ -870,29 +960,57 @@ def applyImageArgs(args, imageList, displayCtx, **kwargs): # If it can, I add it to the image list - the # applyArguments function will apply the # value. If the modulate file is not valid, - # I print a warning, and clear the modulate - # option. - if isinstance(opts, vectoropts.VectorOpts) and \ - args.images[i].modulate is not None: - - try: - modImage = fslimage.Image(args.images[i].modulate) - - if modImage.shape != image.shape[ :3]: - raise RuntimeError( - 'Image {} cannot be used to modulate {} - ' - 'dimensions don\'t match'.format(modImage, image)) - - imageList.insert(0, modImage) - opts.modulate = modImage - args.images[i].modulate = None - - log.debug('Set {} to be modulated by {}'.format( - image, modImage)) - - except Exception as e: - log.warn(e) - - # After handling the special cases above, we can - # apply the CLI options to the Opts instance - _applyArgs(args.images[i], opts) + # an error is raised. + if isinstance(opts, fsldisplay.VectorOpts) and \ + args.overlays[i].modulate is not None: + + modImage = _findOrLoad(overlayList, + args.overlays[i].modulate, + fslimage.Image) + + if modImage.shape != overlay.shape[ :3]: + raise RuntimeError( + 'Image {} cannot be used to modulate {} - ' + 'dimensions don\'t match'.format(modImage, overlay)) + + opts.modulate = modImage + args.overlays[i].modulate = None + + log.debug('Set {} to be modulated by {}'.format( + overlay, modImage)) + + # A similar process is followed for + # the ModelOpts.refImage property + if isinstance(overlay, fslmodel.Model) and \ + isinstance(opts, fsldisplay.ModelOpts) and \ + args.overlays[i].modelRefImage is not None: + + refImage = _findOrLoad(overlayList, + args.overlays[i].modelRefImage, + fslimage.Image) + + opts.refImage = refImage + args.overlays[i].modelRefImage = None + + log.debug('Set {} reference image to {}'.format( + overlay, refImage)) + + # After handling the special cases + # above, we can apply the CLI + # options to the Opts instance + _applyArgs(args.overlays[i], opts) + + +def _findOrLoad(overlayList, overlayFile, overlayType): + """Searches for the given ``overlayFile`` in the ``overlayList``. If not + present, it is created using the given ``overlayType`` constructor, and + inserted into the ``overlayList``. + """ + + overlay = overlayList.find(overlayFile) + + if overlay is None: + overlay = overlayType(overlayFile) + overlayList.insert(0, overlay) + + return overlay diff --git a/fsl/tools/render.py b/fsl/tools/render.py index 37c7c7d32e4178806ce5cf25b43ec972f04ab593..21770c808b288bf7427154279ef305e72dd218fc 100644 --- a/fsl/tools/render.py +++ b/fsl/tools/render.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# render.py - Generate screenshots of images using OpenGL. +# render.py - Generate screenshots of overlays using OpenGL. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # @@ -33,9 +33,9 @@ import fsl import fsl.utils.layout as fsllayout import fsl.utils.colourbarbitmap as cbarbitmap import fsl.utils.textbitmap as textbitmap -import fsl.data.image as fslimage import fsl.data.strings as strings import fsl.data.constants as constants +import fsl.fslview.overlay as fsloverlay import fsl.fslview.displaycontext as displaycontext import fsl.fslview.displaycontext.orthoopts as orthoopts import fsl.fslview.displaycontext.lightboxopts as lightboxopts @@ -52,7 +52,7 @@ CBAR_SIZE = 75 LABEL_SIZE = 20 -def buildLabelBitmaps(imageList, +def buildLabelBitmaps(overlayList, displayCtx, canvasAxes, canvasBmps, @@ -70,24 +70,34 @@ def buildLabelBitmaps(imageList, # changed to red fgColour = 'white' - image = displayCtx.getSelectedImage() - display = displayCtx.getDisplayProperties(image) + overlay = displayCtx.getReferenceImage(displayCtx.getSelectedOverlay()) - # The image is being displayed as it is stored on - # disk - the image.getOrientation method calculates - # and returns labels for each voxelwise axis. - if display.transform in ('pixdim', 'id'): - xorient = image.getVoxelOrientation(0) - yorient = image.getVoxelOrientation(1) - zorient = image.getVoxelOrientation(2) - - # The image is being displayed in 'real world' space - - # the definition of this space may be present in the - # image meta data + # There's no reference image for the selected overlay, + # so we cannot calculate orientation labels + if overlay is None: + xorient = constants.ORIENT_UNKNOWN + yorient = constants.ORIENT_UNKNOWN + zorient = constants.ORIENT_UNKNOWN else: - xorient = image.getWorldOrientation(0) - yorient = image.getWorldOrientation(1) - zorient = image.getWorldOrientation(2) + + display = displayCtx.getDisplay(overlay) + opts = display.getDisplayOpts() + + # The overlay is being displayed as it is stored on + # disk - the image.getOrientation method calculates + # and returns labels for each voxelwise axis. + if opts.transform in ('pixdim', 'id'): + xorient = overlay.getVoxelOrientation(0) + yorient = overlay.getVoxelOrientation(1) + zorient = overlay.getVoxelOrientation(2) + + # The overlay is being displayed in 'real world' space - + # the definition of this space may be present in the + # overlay meta data + else: + xorient = overlay.getWorldOrientation(0) + yorient = overlay.getWorldOrientation(1) + zorient = overlay.getWorldOrientation(2) if constants.ORIENT_UNKNOWN in [xorient, yorient, zorient]: fgColour = 'red' @@ -136,18 +146,26 @@ def buildLabelBitmaps(imageList, return labelBmps -def buildColourBarBitmap(imageList, +def buildColourBarBitmap(overlayList, displayCtx, width, height, cbarLocation, cbarLabelSide, bgColour): - """Creates and returns a bitmap containing a colour bar. + """If the currently selected overlay has a display range, + creates and returns a bitmap containing a colour bar. Returns + ``None`` otherwise. """ - - display = displayCtx.getDisplayProperties(displayCtx.selectedImage) + + overlay = displayCtx.getSelectedOverlay() + display = displayCtx.getDisplay(overlay) opts = display.getDisplayOpts() + + # TODO Support other overlay types which + # have a display range (when they exist). + if not isinstance(opts, displaycontext.VolumeOpts): + return None if cbarLocation in ('top', 'bottom'): orient = 'horizontal' elif cbarLocation in ('left', 'right'): orient = 'vertical' @@ -169,6 +187,11 @@ def buildColourBarBitmap(imageList, orient, labelSide, bgColour=map(lambda c: c / 255.0, bgColour)) + + # The colourBarBitmap function returns a w*h*4 + # array, but the fsl.utils.layout.Bitmap (see + # the next function) assumes a h*w*4 array + cbarBmp = cbarBmp.transpose((1, 0, 2)) return cbarBmp @@ -217,7 +240,7 @@ def adjustSizeForColourBar(width, height, showColourBar, colourBarLocation): def calculateOrthoCanvasSizes( - imageList, + overlayList, displayCtx, width, height, @@ -298,12 +321,12 @@ def run(args, context): fslgl.getOSMesaContext() fslgl.bootstrap((1, 4)) - imageList, displayCtx = context + overlayList, displayCtx = context if args.scene == 'ortho': sceneOpts = orthoopts .OrthoOpts() elif args.scene == 'lightbox': sceneOpts = lightboxopts.LightBoxOpts() - fslview_parseargs.applySceneArgs(args, imageList, displayCtx, sceneOpts) + fslview_parseargs.applySceneArgs(args, overlayList, displayCtx, sceneOpts) # Calculate canvas and colour bar sizes # so that the entire scene will fit in @@ -320,7 +343,7 @@ def run(args, context): # Lightbox view -> only one canvas if args.scene == 'lightbox': c = lightboxcanvas.OSMesaLightBoxCanvas( - imageList, + overlayList, displayCtx, zax=sceneOpts.zax, width=width, @@ -361,7 +384,7 @@ def run(args, context): centres = [centres[ 1], centres[ 0], centres[ 2]] zooms = [zooms[ 1], zooms[ 0], zooms[ 2]] - sizes = calculateOrthoCanvasSizes(imageList, + sizes = calculateOrthoCanvasSizes(overlayList, displayCtx, width, height, @@ -380,7 +403,7 @@ def run(args, context): centre = (displayCtx.location[xax], displayCtx.location[yax]) c = slicecanvas.OSMesaSliceCanvas( - imageList, + overlayList, displayCtx, zax=zax, width=int(width), @@ -410,7 +433,7 @@ def run(args, context): if args.scene == 'lightbox' or not sceneOpts.showLabels: labelBmps = None else: - labelBmps = buildLabelBitmaps(imageList, + labelBmps = buildLabelBitmaps(overlayList, displayCtx, canvasAxes, canvases, @@ -429,17 +452,18 @@ def run(args, context): # Render a colour bar if required if sceneOpts.showColourBar: - cbarBmp = buildColourBarBitmap(imageList, + cbarBmp = buildColourBarBitmap(overlayList, displayCtx, cbarWidth, cbarHeight, sceneOpts.colourBarLocation, sceneOpts.colourBarLabelSide, args.background) - layout = buildColourBarLayout(layout, - cbarBmp, - sceneOpts.colourBarLocation, - sceneOpts.colourBarLabelSide) + if cbarBmp is not None: + layout = buildColourBarLayout(layout, + cbarBmp, + sceneOpts.colourBarLocation, + sceneOpts.colourBarLabelSide) if args.outfile is not None: @@ -489,28 +513,30 @@ def parseArgs(argv): def context(args): # Create an image list and display context - imageList = fslimage.ImageList() - displayCtx = displaycontext.DisplayContext(imageList) + overlayList = fsloverlay.OverlayList() + displayCtx = displaycontext.DisplayContext(overlayList) - # The handleImageArgs function uses the - # fsl.data.imageio.loadImages function, + # TODO rewrite for non-volumetric + # + # The handleOverlayArgs function uses the + # fsl.fslview.overlay.loadOverlays function, # which will call these functions as it - # goes through the list of images to be + # goes through the list of overlay to be # loaded. - def load(image): - log.info('Loading image {} ...'.format(image)) - def error(image, error): - log.info('Error loading image {}: '.format(image, error)) + def load(ovl): + log.info('Loading overlay {} ...'.format(ovl)) + def error(ovl, error): + log.info('Error loading overlay {}: '.format(ovl, error)) - # Load the images specified on the command + # Load the overlays specified on the command # line, and configure their display properties - fslview_parseargs.applyImageArgs( - args, imageList, displayCtx, loadFunc=load, errorFunc=error) + fslview_parseargs.applyOverlayArgs( + args, overlayList, displayCtx, loadFunc=load, errorFunc=error) - if len(imageList) == 0: - raise RuntimeError('At least one image must be specified') + if len(overlayList) == 0: + raise RuntimeError('At least one overlay must be specified') - return imageList, displayCtx + return overlayList, displayCtx FSL_TOOLNAME = 'Render' diff --git a/fsl/utils/dialog.py b/fsl/utils/dialog.py new file mode 100644 index 0000000000000000000000000000000000000000..94779a5d24861e9776f035976e65fcc99c0478e5 --- /dev/null +++ b/fsl/utils/dialog.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python +# +# dialog.py - Miscellaneous dialogs. +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import wx + +import fsl.data.strings as strings + + +class SimpleMessageDialog(wx.Dialog): + + + def __init__(self, parent=None, message=''): + + wx.Dialog.__init__(self, parent, style=wx.STAY_ON_TOP) + + self.__message = wx.StaticText( + self, + style=(wx.ST_ELLIPSIZE_MIDDLE | + wx.ALIGN_CENTRE_HORIZONTAL | + wx.ALIGN_CENTRE_VERTICAL)) + + self.__sizer = wx.BoxSizer(wx.HORIZONTAL) + self.__sizer.Add(self.__message, + border=25, + proportion=1, + flag=wx.CENTRE | wx.ALL) + + self.SetBackgroundColour((225, 225, 200)) + + self.SetSizer(self.__sizer) + self.SetMessage(message) + + + def SetMessage(self, msg): + + msg = str(msg) + + self.__message.SetLabel(msg) + + # Figure out the dialog size + # required to fit the message + dc = wx.ClientDC(self.__message) + + defWidth, defHeight = 25, 25 + msgWidth, msgHeight = dc.GetTextExtent(msg) + + if msgWidth > defWidth: width = msgWidth + 25 + else: width = defWidth + + if msgHeight > defHeight: height = msgHeight + 25 + else: height = defHeight + + self.__message.SetMinSize((width, height)) + + self.Fit() + self.Refresh() + self.Update() + + +class TimeoutDialog(SimpleMessageDialog): + + + def __init__(self, parent, message, timeout=1000): + + SimpleMessageDialog.__init__(self, parent, message) + self.__timeout = timeout + + + def __close(self): + self.Close() + self.Destroy() + + + def Show(self): + wx.CallLater(self.__timeout, self.__close) + SimpleMessageDialog.Show(self) + + + def ShowModal(self): + wx.CallLater(self.__timeout, self.__close) + SimpleMessageDialog.ShowModal(self) + + +class ProcessingDialog(SimpleMessageDialog): + + def __init__(self, parent, message, task, *args, **kwargs): + """ + + :arg message: + + :arg task: + + :arg passFuncs: + + :arg messageFunc: + + :arg errorFunc: + """ + + passFuncs = kwargs.get('passFuncs', False) + + if not passFuncs: + kwargs.pop('messageFunc', None) + kwargs.pop('errorFunc', None) + else: + kwargs['messageFunc'] = kwargs.get('messageFunc', + self.__defaultMessageFunc) + kwargs['errortFunc'] = kwargs.get('errorFunc', + self.__defaultErrorFunc) + + self.task = task + self.args = args + self.kwargs = kwargs + + SimpleMessageDialog.__init__(self, parent, message) + + + def Run(self): + + disable = wx.WindowDisabler() + + self.CentreOnParent() + self.Show() + self.SetFocus() + self.Update() + + result = self.task(*self.args, **self.kwargs) + + self.Close() + self.Destroy() + + del disable + + return result + + + def __defaultMessageFunc(self, msg): + self.SetMessage(msg) + + + def __defaultErrorFunc(self, msg, err): + err = str(err) + msg = strings.messages[self, 'error'].format(msg, err) + title = strings.titles[ self, 'error'] + wx.MessageBox(msg, title, wx.ICON_ERROR | wx.OK) + + + +TED_READONLY = 1 +TED_MULTILINE = 2 +TED_OK = 4 +TED_CANCEL = 8 +TED_OK_CANCEL = 12 +TED_COPY = 16 + + +class TextEditDialog(wx.Dialog): + """A dialog which shows an editable/selectable text field.""" + + def __init__(self, + parent, + title='', + message='', + text='', + icon=None, + style=TED_OK): + + wx.Dialog.__init__(self, + parent, + title=title, + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) + + textStyle = 0 + if style & TED_READONLY: textStyle |= wx.TE_READONLY + if style & TED_MULTILINE: textStyle |= wx.TE_MULTILINE + + self.__message = wx.StaticText(self) + self.__textEdit = wx.TextCtrl( self, style=textStyle) + + self.__message .SetLabel(message) + self.__textEdit.SetValue(text) + + # set the min size of the text + # ctrl so it can fit a few lines + self.__textEdit.SetMinSize((-1, 120)) + + self.__ok = (-1, -1) + self.__copy = (-1, -1) + self.__cancel = (-1, -1) + self.__icon = (-1, -1) + + if icon is not None: + + icon = wx.ArtProvider.GetMessageBoxIcon(icon) + bmp = wx.EmptyBitmap(icon.GetWidth(), icon.GetHeight()) + bmp.CopyFromIcon(icon) + self.__icon = wx.StaticBitmap(self) + self.__icon.SetBitmap(bmp) + + if style & TED_OK: + self.__ok = wx.Button(self, id=wx.ID_OK) + self.__ok.Bind(wx.EVT_BUTTON, self.__onOk) + + if style & TED_CANCEL: + self.__cancel = wx.Button(self, id=wx.ID_CANCEL) + self.__cancel.Bind(wx.EVT_BUTTON, self.__onCancel) + + if style & TED_COPY: + self.__copy = wx.Button(self, label='Copy to clipboard') + self.__copy.Bind(wx.EVT_BUTTON, self.__onCopy) + + textSizer = wx.BoxSizer(wx.VERTICAL) + iconSizer = wx.BoxSizer(wx.HORIZONTAL) + btnSizer = wx.BoxSizer(wx.HORIZONTAL) + mainSizer = wx.BoxSizer(wx.VERTICAL) + + textSizer.Add(self.__message, + flag=wx.ALL | wx.CENTRE, + border=20) + textSizer.Add(self.__textEdit, + flag=wx.ALL | wx.EXPAND, + border=20, + proportion=1) + + iconSizer.Add(self.__icon, flag=wx.ALL | wx.CENTRE, border=20) + iconSizer.Add(textSizer, flag=wx.EXPAND, proportion=1) + + btnSizer.AddStretchSpacer() + btnSizer.Add(self.__ok, + flag=wx.ALL | wx.CENTRE, + border=10) + btnSizer.Add(self.__copy, + flag=wx.ALL | wx.CENTRE, + border=10) + btnSizer.Add(self.__cancel, + flag=wx.ALL | wx.CENTRE, + border=10) + btnSizer.Add((-1, 20)) + + mainSizer.Add(iconSizer, flag=wx.EXPAND, proportion=1) + mainSizer.Add(btnSizer, flag=wx.EXPAND) + + self.SetSizer(mainSizer) + self.Fit() + + + def __onOk(self, ev): + self.EndModal(wx.ID_OK) + + + def __onCancel(self, ev): + self.EndModal(wx.ID_CANCEL) + + + def __onCopy(self, ev): + text = self.__textEdit.GetValue() + + cb = wx.TheClipboard + + if cb.Open(): + cb.SetData(wx.TextDataObject(text)) + cb.Close() + td = TimeoutDialog(self, 'Copied!') + td.CentreOnParent() + td.Show() + + + def SetMessage(self, message): + self.__message.SetLabel(message) + + + def SetOkLabel(self, label): + self.__ok.SetLabel(label) + + def SetCopyLabel(self, label): + self.__copy.SetLabel(label) + + + def SetCancelLabel(self, label): + self.__cancel.SetLabel(label) + + + def SetText(self, text): + self.__textEdit.SetValue(text) + + + def GetText(self): + return self.__textEdit.GetValue() diff --git a/fsl/utils/fsldirdlg.py b/fsl/utils/fsldirdlg.py new file mode 100644 index 0000000000000000000000000000000000000000..86fe646d3a54c451ed94c149037391783c180537 --- /dev/null +++ b/fsl/utils/fsldirdlg.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python +# +# fsldirdlg.py +# +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# +""" +This module defines a dialog which can be used, when the ``$FSLDIR`` +environment variable is not set, to prompt the user to identify the +FSL installation location. +""" + + +import wx + +import fsl.data.strings as strings + + +class FSLDirDialog(wx.Dialog): + + def __init__(self, parent, toolName): + + wx.Dialog.__init__(self, parent, title=strings.titles[self]) + + self.__fsldir = None + self.__icon = wx.StaticBitmap(self) + self.__message = wx.StaticText( self, style=wx.ALIGN_CENTRE) + self.__locate = wx.Button( self, id=wx.ID_OK) + self.__skip = wx.Button( self, id=wx.ID_CANCEL) + + icon = wx.ArtProvider.GetMessageBoxIcon(wx.ICON_EXCLAMATION) + bmp = wx.EmptyBitmap(icon.GetWidth(), icon.GetHeight()) + bmp.CopyFromIcon(icon) + + self.__icon.SetBitmap(bmp) + self.__message.SetLabel( + strings.messages[self, 'FSLDirNotSet'].format(toolName)) + self.__locate .SetLabel(strings.labels[self, 'locate']) + self.__skip .SetLabel(strings.labels[self, 'skip']) + + self.__skip .Bind(wx.EVT_BUTTON, self.__onSkip) + self.__locate.Bind(wx.EVT_BUTTON, self.__onLocate) + + self.__sizer = wx.BoxSizer(wx.VERTICAL) + self.__labelSizer = wx.BoxSizer(wx.HORIZONTAL) + self.__buttonSizer = wx.BoxSizer(wx.HORIZONTAL) + + self.__labelSizer.Add(self.__icon, flag=wx.ALL | wx.CENTRE, + border=20) + self.__labelSizer.Add(self.__message, + flag=wx.ALL | wx.CENTRE, + proportion=1, + border=20) + + self.__buttonSizer.AddStretchSpacer() + self.__buttonSizer.Add(self.__locate, + flag=wx.ALL | wx.CENTRE, + border=10, + proportion=1) + self.__buttonSizer.Add(self.__skip, + flag=wx.ALL | wx.CENTRE, + border=10, + proportion=1) + self.__buttonSizer.Add((20, -1)) + + self.__sizer.Add(self.__labelSizer, flag=wx.EXPAND, proportion=1) + self.__sizer.Add(self.__buttonSizer, flag=wx.EXPAND) + self.__sizer.Add((-1, 20)) + + self.SetSizer(self.__sizer) + self.Fit() + + + def GetFSLDir(self): + return self.__fsldir + + + def __onSkip(self, ev): + self.EndModal(wx.ID_CANCEL) + + + def __onLocate(self, ev): + + dlg = wx.DirDialog( + self, + message=strings.messages[self, 'selectFSLDir'], + style=wx.DD_DEFAULT_STYLE | wx.DD_DIR_MUST_EXIST) + + if dlg.ShowModal() != wx.ID_OK: + self.EndModal(wx.ID_CANCEL) + + self.__fsldir = dlg.GetPath() + + self.EndModal(wx.ID_OK) diff --git a/fsl/utils/layout.py b/fsl/utils/layout.py index 6e40170a998c394c406247d465a7f83009a51360..49ce138897ef84187ac91deb6b5cac0aaa02bf9e 100644 --- a/fsl/utils/layout.py +++ b/fsl/utils/layout.py @@ -240,7 +240,9 @@ def buildOrthoLayout(canvasBmps, bitmaps, and colour bar bitmap. """ - if labelBmps is None: labelBmps = [None] * len(canvasBmps) + if labelBmps is None: + labelBmps = [None] * len(canvasBmps) + showLabels = False canvasBoxes = map(lambda cbmp, lbmps: buildCanvasBox(cbmp, lbmps, diff --git a/fsl/utils/trace.py b/fsl/utils/trace.py index bfbfa5fbd3668a209c2736b0052fe3adba7c8ed1..707c71ff708a1e7ed55e568caca7a2d4bb790734 100644 --- a/fsl/utils/trace.py +++ b/fsl/utils/trace.py @@ -5,13 +5,118 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -import logging -import inspect +import logging +import inspect +import os.path as op + log = logging.getLogger(__name__) + +# If trace debugging is enabled, we're going to +# do some funky stuff to the props callqueue +# object, so we can get some extra information +# in the propchange function. +if log.getEffectiveLevel() == logging.DEBUG: + + log.debug('Monkey-patching props.properties_value.queue instance') + + import props + import Queue + + # The problem that I am addressing here is the + # fact that, when a property value listener is + # called, the cause of the call (i.e. the point + # at which the property value was changed) does + # not necessarily exist in the stack. This is + # because when a PV instance passes all of its + # listeners to the CallQueue (CQ) to call, the + # CQ will queue them immediately, but will not + # necessarily call them - they are only called + # if the CQ is not already processing the + # listeners from another PV change. + + # A single CallQueue instance is shared + # by all PropertyValue instances to queue/ + # call property value listeners. + theQ = props.properties_value.PropertyValue.queue + + + def tracePop(*args, **kwargs): + + # When the CallQueue calls its _pop + # method, it will call the returned + # function. For debugging purposes, + # we'll pop the associated cause + # (enqueued in the tracePush function + # below), so the propchange function + # can print it out. + # + # This will throw Queue.EmptyError if + # the _causes queue is empty, but + # that is what the real _pop method + # does anyway. + try: theQ._currentCause = theQ._causes.get_nowait() + except: theQ._currentCause = None + + return theQ._realPop(*args, **kwargs) + + + def tracePush(*args, **kwargs): + + pushed = theQ._realPush(*args, **kwargs) + + # If the real _push method returns False, + # it means that the function was not enqueued + if not pushed: + return False + + frames = inspect.stack()[1:] + + # We search for the first frame in the stack + # which is not in the props package - this + # will be the point of the PV change which + # caused this listener to be enqueued + triggerFrame = None + for frame in frames: + if 'props/props' not in frame[1]: + triggerFrame = frame + break + + # This should never happen, + # but in case it does, we + # put a dummy value into + # the causes queue, so it + # is the same length as the + # reall call queue + if triggerFrame is None: + theQ._causes.put_nowait(None) + + # Store the cause of the listener + # push on the causes queue + else: + cause = (triggerFrame[1], + triggerFrame[2], + triggerFrame[3], + triggerFrame[4][triggerFrame[5]]) + theQ._causes.put_nowait(cause) + + return True + + # Patch the CallQueue instance with + # our push/pop implementations + theQ._causes = Queue.Queue() + theQ._realPush = theQ._CallQueue__push + theQ._realPop = theQ._CallQueue__pop + theQ._CallQueue__push = tracePush + theQ._CallQueue__pop = tracePop + + def trace(desc): + if log.getEffectiveLevel() != logging.DEBUG: + return + stack = inspect.stack()[1:] lines = '{}\n'.format(desc) @@ -31,3 +136,112 @@ def trace(desc): log.debug(lines) return lines + + +def setcause(desc): + + if log.getEffectiveLevel() != logging.DEBUG: + return + + stack = inspect.stack()[1:] + causeFrame = None + ultCauseFrame = None + + for i, frame in enumerate(stack): + + if 'props/props' not in frame[1]: + causeFrame = frame + break + + if causeFrame is not None: + for i, frame in reversed(list(enumerate(stack))): + if 'props/props' in frame[1]: + ultCauseFrame = stack[i + 1] + break + + if causeFrame is None: + log.debug('{}: Unknown cause'.format(desc)) + else: + causeFile = causeFrame[1] + causeLine = causeFrame[2] + causeFunc = causeFrame[3] + causeSrc = causeFrame[4][causeFrame[5]] + + line = '{}: Caused by {} ({}:{}:{})'.format( + desc, + causeFunc, + op.basename(causeFile), + causeLine, + causeSrc.strip()) + + if ultCauseFrame is not None: + + causeFile = ultCauseFrame[1] + causeLine = ultCauseFrame[2] + causeFunc = ultCauseFrame[3] + causeSrc = ultCauseFrame[4][ultCauseFrame[5]] + line = '{} (ultimately caused by {} ({}:{}:{})'.format( + line, + causeFunc, + op.basename(causeFile), + causeLine, + causeSrc.strip()) + + log.debug(line) + + +def propchange(*args): + + if log.getEffectiveLevel() != logging.DEBUG: + return + + import props + + theQ = props.properties_value.PropertyValue.queue + stack = inspect.stack() + + listenerFile = stack[1][1] + listenerLine = stack[1][2] + listenerFunc = stack[1][3] + + triggerFile = None + + if len(args) != 4: + triggerFile = stack[2][1] + triggerLine = stack[2][2] + triggerFunc = stack[2][3] + triggerSrc = stack[2][4][stack[2][5]] + else: + triggerFile = theQ._currentCause[0] + triggerLine = theQ._currentCause[1] + triggerFunc = theQ._currentCause[2] + triggerSrc = theQ._currentCause[3] + + if triggerFile is None: + log.debug('Listener {} ({}:{}) was called ' + 'due to an unknown process'.format( + listenerFunc, + op.basename(listenerFile), + listenerLine)) + + else: + + if len(args) != 4: + reason = 'manually called from' + else: + value, valid, ctx, name = args + reason = 'called due to a value change of {}.{} ({}) at'.format( + type(ctx).__name__, + name, + value) + + log.debug('Listener {} ({}:{}) was {} ' + '{} ({}:{}:{})'.format( + listenerFunc, + op.basename(listenerFile), + listenerLine, + reason, + triggerFunc, + op.basename(triggerFile), + triggerLine, + triggerSrc.strip())) diff --git a/fsl/utils/typedict.py b/fsl/utils/typedict.py index d93c7910f49821eecbd24e733897fb10352cf0d2..b6c682a2a3f6ad0a0b41df40efd645826d27ea02 100644 --- a/fsl/utils/typedict.py +++ b/fsl/utils/typedict.py @@ -6,6 +6,8 @@ # import logging + + log = logging.getLogger(__name__) @@ -33,7 +35,9 @@ class TypeDict(object): def __str__( self): return self.__dict.__str__() def __repr__(self): return self.__dict.__repr__() - + def keys( self): return self.__dict.keys() + def values( self): return self.__dict.values() + def items( self): return self.__dict.items() def __setitem__(self, key, value): self.__dict[self.__tokenifyKey(key)] = value @@ -47,12 +51,28 @@ class TypeDict(object): return key - def get(self, key, default): - try: return self.__getitem__(key) + def get(self, key, default=None, allhits=False, bykey=False): + """Retrieve the value associated with the given key. If + no value is present, return the specified ``default`` value, + which itself defaults to ``None``. + + If the specified key contains a class or instance, and the + ``allhits`` argument evaluates to ``True``, the entire class + hierarchy is searched, and all values present for the class, + and any base class, are returned as a sequence. + + If ``allhits`` is ``True`` and the ``bykey`` parameter is also + set to ``True``, a dictionary is returned rather than a sequence, + where the dictionary contents are the subset of this dictionary, + containing the keys which equated to the given key, and their + corresponding values. + """ + + try: return self.__getitem__(key, allhits, bykey) except KeyError: return default - def __getitem__(self, key): + def __getitem__(self, key, allhits=False, bykey=False): origKey = key key = self.__tokenifyKey(key) @@ -81,7 +101,10 @@ class TypeDict(object): newKey.append(elem) bases .append(None) - key = newKey + key = newKey + + keys = [] + hits = [] while True: @@ -92,9 +115,19 @@ class TypeDict(object): else: lKey = tuple(key) val = self.__dict.get(lKey, None) - + + # We've found a value for the key if val is not None: - return val + + # If allhits is false, just return the value + if not allhits: return val + + # Otherwise, accumulate the value, and keep + # searching + else: + hits.append(val) + if bykey: + keys.append(lKey) # No more base classes to search for - there # really is no value associated with this key @@ -114,10 +147,35 @@ class TypeDict(object): newKey = list(key) newKey[i] = elemBase + if len(newKey) == 1: newKey = newKey[0] + else: newKey = tuple(newKey) + try: - return self.__getitem__(tuple(newKey)) + newVal = self.__getitem__(newKey, allhits, bykey) except KeyError: continue + if not allhits: + return newVal + else: + if bykey: + newKeys, newVals = zip(*newVal.items()) + keys.extend(newKeys) + hits.extend(newVals) + else: + hits.extend(newVal) + # No value for any base classes either - raise KeyError(origKey) + if len(hits) == 0: + raise KeyError(origKey) + + # if bykey is true, return a dict + # containing the values and their + # corresponding keys + if bykey: + return dict(zip(keys, hits)) + + # otherwise just return the + # list of matched values + else: + return hits