diff --git a/fsl/data/featresults.py b/fsl/data/featresults.py index 719092a03771d78867d36c69ae7d241fa2231b59..138c0379e11563504f39f4aac6ece0cb44db0036 100644 --- a/fsl/data/featresults.py +++ b/fsl/data/featresults.py @@ -134,7 +134,7 @@ def loadDesign(featdir): if line.strip() == '/Matrix': break - matrix = np.loadtxt(f) + matrix = np.loadtxt(f, ndmin=2) if matrix is None or matrix.size == 0: raise RuntimeError('{} does not appear to be a ' diff --git a/fsl/data/image.py b/fsl/data/image.py index 31988dc6ed7cfa5d770a1cde2152b00804bf0d89..3dda62827dec53ca10ec928cf5cbaea68875a5f0 100644 --- a/fsl/data/image.py +++ b/fsl/data/image.py @@ -333,9 +333,14 @@ class Image(props.HasProperties): return int(code) - def getWorldOrientation(self, axis, code=None): - """Returns a code representing the orientation of the specified axis - in world space. + def getOrientation(self, axis, xform): + """Returns a code representing the orientation of the specified data + axis in the coordinate system defined by the given transformation + matrix. + + :arg xform: A transformation matrix which is assumed to transform + coordinates from the image world coordinate system to + some other coordinate system. This method returns one of the following values, indicating the direction in which coordinates along the specified axis increase: @@ -357,45 +362,16 @@ class Image(props.HasProperties): increases from inferior to superior). """ - if self.getXFormCode(code) == constants.NIFTI_XFORM_UNKNOWN: - return constants.ORIENT_UNKNOWN - - if axis == 0: return constants.ORIENT_L2R - elif axis == 1: return constants.ORIENT_P2A - elif axis == 2: return constants.ORIENT_I2S - - else: return constants.ORIENT_UNKNOWN - - - def getVoxelOrientation(self, axis, code=None): - """Returns a code representing the (estimated) orientation of the - specified data axis. - - :arg code: May be either ``qform`` or ``sform``, specifying which - transformation to use. - - See the :meth:`getWorldOrientation` method for a description - of the return value. - """ - - if self.getXFormCode(code) == constants.NIFTI_XFORM_UNKNOWN: - return constants.ORIENT_UNKNOWN - - if code is None: xform = self.nibImage.get_affine() - elif code == 'sform': xform = self.nibImage.get_sform() - elif code == 'qform': xform = self.nibImage.get_qform() - else: raise ValueError('code must be None, qform, or sform') + if self.getXFormCode() == constants.NIFTI_XFORM_UNKNOWN: + return constants.ORIENT_UNKNOWN - # the aff2axcodes returns one code for each - # axis in the image array (i.e. in voxel space), - # which denotes the real world direction import nibabel as nib code = nib.orientations.aff2axcodes( xform, ((constants.ORIENT_R2L, constants.ORIENT_L2R), (constants.ORIENT_A2P, constants.ORIENT_P2A), (constants.ORIENT_S2I, constants.ORIENT_I2S)))[axis] - + return code diff --git a/fsl/data/melodicimage.py b/fsl/data/melodicimage.py index 6bdc254805eb25fd0fe3e967901f081e6ae5b08b..3a2087da9a028717e832374be773ff89d3d07dfc 100644 --- a/fsl/data/melodicimage.py +++ b/fsl/data/melodicimage.py @@ -8,30 +8,58 @@ """This module provides the :class:`MelodicImage` class, an :class:`.Image` sub-class which encapsulates data from a MELODIC analysis. """ + + import os.path as op +import props + import image as fslimage import melodicresults as melresults class MelodicImage(fslimage.Image): + """The ``MelodicImage`` class is an :class:`.Image` which encapsulates + the results of a FSL MELODIC analysis. A ``MelodicImage`` corresponds to + the spatial component map file, generally called ``melodic_IC.nii.gz``. + + The ``MelodicImage`` class provides a few MELODIC-specific attributes and + methods: + + .. autosummary:: + + tr + getComponentTimeSeries + numComponents + getTopLevelAnalysisDir + getDataFile """ + + + tr = props.Real(default=1.0) + """The TR time of the raw data from which this ``MelodicImage`` was + generated. If it is possible to do so, this is automatically initialised + from the data file (see the :meth:`getDataFile` method). """ + - def __init__(self, image, *args, **kwargs): - """ - """ + def __init__(self, path, *args, **kwargs): + """Create a ``MelodicImage``. + :arg path: A path specifying the ``melodic_IC`` image file, or the + ``.ica`` directory. - if op.isdir(image): + All other arguments are passed through to the :meth:`.Image.__init__` + method. + """ - dirname = image + if op.isdir(path): + dirname = path filename = 'melodic_IC' - else: - dirname = op.dirname( image) - filename = op.basename(image) + dirname = op.dirname( path) + filename = op.basename(path) dirname = dirname.rstrip(op.sep) @@ -48,12 +76,41 @@ class MelodicImage(fslimage.Image): *args, **kwargs) + self.__meldir = dirname self.__melmix = melresults.getComponentTimeSeries(dirname) + # Automatically set the + # TR value if possible + dataFile = self.getDataFile() + + if dataFile is not None: + dataImage = fslimage.Image(dataFile, loadData=False) + if dataImage.is4DImage(): + self.tr = dataImage.pixdim[3] + def getComponentTimeSeries(self, component): + """Returns the time course for the specified (0-indexed) component. """ return self.__melmix[:, component] def numComponents(self): + """Returns the number of components in this ``MelodicImage``. """ return self.shape[3] + + + def getTopLevelAnalysisDir(self): + """Returns the top level analysis, if the melodic analysis for this + ``MelodicImage`` is contained within another analysis. Otherwise, + returnsa ``None``. See the + :func:`.melodicresults.getTopLevelAnalysisDir` function. + """ + return melresults.getTopLevelAnalysisDir(self.__meldir) + + + def getDataFile(self): + """Returns the file name of the data image from which this + ``MelodicImage`` was generated, if possible. See the + :func:`.melodicresults.getDataFile` function. + """ + return melresults.getDataFile(self.__meldir) diff --git a/fsl/data/melodicresults.py b/fsl/data/melodicresults.py index d05a25ab309e369353cd1b637cb884e1194f67f2..c31811723bd78e2ce8036072d3c5d3f0e19734d5 100644 --- a/fsl/data/melodicresults.py +++ b/fsl/data/melodicresults.py @@ -15,49 +15,46 @@ following functions are provided: isMelodicDir getMelodicDir + getTopLevelAnalysisDir + getDataFile getICFile + getMixFile getNumComponents getComponentTimeSeries """ -import os import os.path as op import numpy as np -import fsl.data.image as fslimage +import fsl.data.image as fslimage +import fsl.data.featresults as featresults def isMelodicDir(path): - """ + """Returns ``True`` if the given path looks like it is contained within + a MELODIC directory, ``False`` otherwise. """ # Must be named *.ica or *.gica - meldir = getMelodicDir(path) - - if meldir is None: - return False - - # Must contain an image file called melodic_IC - try: - fslimage.addExt(op.join(meldir, 'melodic_IC'), mustExist=True) - except ValueError: - return False - - # Must contain a file called melodic_mix - if not op.exists(op.join(meldir, 'melodic_mix')): - return False - - return True + return getMelodicDir(path) is not None def getMelodicDir(path): - """ + """Returns the MELODIC directory in which the given path is contained, + or ``None`` if it is not contained within a MELODIC directory. A melodic + directory: + + - Must be named ``*.ica`` or ``*.gica`` + - Must contain a file called ``melodic_IC.nii.gz`` + - Must contain a file called ``melodic_mix``. """ # TODO This code is identical to featresults.getFEATDir. # Can you generalise it and put it somewhere in fsl.utils? + path = op.abspath(path) + sufs = ['.ica', '.gica'] idxs = [(path.rfind(s), s) for s in sufs] idx, suf = max(idxs, key=lambda (i, s): i) @@ -66,28 +63,92 @@ def getMelodicDir(path): return None idx += len(suf) - path = path[:idx] + path = path[:idx].rstrip(op.sep) - if path.endswith(suf) or path.endswith('{}{}'.format(suf, op.sep)): - return path + if not path.endswith(suf): + return None + + # Must contain an image file called melodic_IC + try: + fslimage.addExt(op.join(path, 'melodic_IC'), mustExist=True) + except ValueError: + return None + + # Must contain a file called melodic_mix + if not op.exists(op.join(path, 'melodic_mix')): + return None - return None + return path -def getICFile(meldir): +def getTopLevelAnalysisDir(path): + """If the given path is a MELODIC directory, and it is contained within + a FEAT directory, or another MELODIC directory, the path to the latter + directory is returned. Otherwise, ``None`` is returned. """ + + meldir = getMelodicDir(path) + sufs = ['.feat', '.gfeat', '.ica', '.gica'] + + if meldir is None: + return None + + if featresults.isFEATDir(meldir): + return featresults.getFEATDir(meldir) + + parentDir = op.dirname(meldir) + parentDir = parentDir.rstrip(op.sep) + + if not any([parentDir.endswith(s) for s in sufs]): + return None + + # Must contain a file called filtered_func_data.nii.gz + dataFile = op.join(parentDir, 'filtered_func_data') + + try: + dataFile = fslimage.addExt(dataFile, mustExist=True) + except ValueError: + return None + + return parentDir + + +def getDataFile(meldir): + """If the given melodic directory is contained within another analysis + directory, the path to the data file is returned. Otherwise ``None`` is + returned. """ + + topDir = getTopLevelAnalysisDir(meldir) + + if topDir is None: + return None + + dataFile = op.join(topDir, 'filtered_func_data') + + try: + return fslimage.addExt(dataFile, mustExist=True) + except ValueError: + return None + + +def getICFile(meldir): + """Returns the path to the melodic IC image. """ return fslimage.addExt(op.join(meldir, 'melodic_IC')) def getMixFile(meldir): - """ - """ + """Returns the path to the melodic mix file. """ return op.join(meldir, 'melodic_mix') +def getReportFile(meldir): + pass + + def getNumComponents(meldir): - """ + """Returns the number of components generated in the melodic analysis + contained in the given directrory. """ icImg = fslimage.Image(getICFile(meldir), loadData=False) @@ -95,7 +156,8 @@ def getNumComponents(meldir): def getComponentTimeSeries(meldir): - """ + """Returns a ``numpy`` array containing the melodic mix for the given + directory. """ mixfile = getMixFile(meldir) diff --git a/fsl/data/strings.py b/fsl/data/strings.py index 27ec5ed61ebbe336a80ad5fae945d5651d4660c7..a3846b2c83f02d6e3ac6394ebd5567a5fcff6ce7 100644 --- a/fsl/data/strings.py +++ b/fsl/data/strings.py @@ -325,13 +325,17 @@ labels = TypeDict({ 'OverlayInfoPanel.Image.transform' : 'Transform/space', 'OverlayInfoPanel.Image.orient' : 'Orientation', - 'OverlayInfoPanel.Image' : 'NIFTI1 image', - 'OverlayInfoPanel.FEATImage' : 'NIFTI1 image (FEAT analysis)', - 'OverlayInfoPanel.FEATImage.featInfo' : 'FEAT information', - 'OverlayInfoPanel.Model' : 'VTK model', - 'OverlayInfoPanel.Model.numVertices' : 'Number of vertices', - 'OverlayInfoPanel.Model.numIndices' : 'Number of indices', - 'OverlayInfoPanel.dataSource' : 'Data source', + 'OverlayInfoPanel.Image' : 'NIFTI1 image', + 'OverlayInfoPanel.FEATImage' : 'NIFTI1 image ' + '(FEAT analysis)', + 'OverlayInfoPanel.FEATImage.featInfo' : 'FEAT information', + 'OverlayInfoPanel.MelodicImage' : 'NIFTI1 image ' + '(MELODIC analysis)', + 'OverlayInfoPanel.MelodicImage.melodicInfo' : 'MELODIC information', + 'OverlayInfoPanel.Model' : 'VTK model', + 'OverlayInfoPanel.Model.numVertices' : 'Number of vertices', + 'OverlayInfoPanel.Model.numIndices' : 'Number of indices', + 'OverlayInfoPanel.dataSource' : 'Data source', }) @@ -612,12 +616,9 @@ nifti = TypeDict({ 'voxOrient.0' : 'X voxel orientation', 'voxOrient.1' : 'Y voxel orientation', 'voxOrient.2' : 'Z voxel orientation', - 'sformOrient.0' : 'X sform orientation', - 'sformOrient.1' : 'Y sform orientation', - 'sformOrient.2' : 'Z sform orientation', - 'qformOrient.0' : 'X qform orientation', - 'qformOrient.1' : 'Y qform orientation', - 'qformOrient.2' : 'Z qform orientation', + 'worldOrient.0' : 'X world orientation', + 'worldOrient.1' : 'Y world orientation', + 'worldOrient.2' : 'Z world orientation', 'qform' : 'QForm matrix', 'sform' : 'SForm matrix', @@ -708,4 +709,14 @@ feat = TypeDict({ 'numPoints' : 'Number of volumes', 'numEVs' : 'Number of EVs', 'numContrasts' : 'Number of contrasts', + 'report' : 'Link to report', +}) + + +melodic = TypeDict({ + 'dataFile' : 'Data file', + 'partOfAnalysis' : 'Part of analysis', + 'numComponents' : 'Number of ICs', + 'tr' : 'TR time', + 'report' : 'Link to report', }) diff --git a/fsl/fsleyes/colourmaps.py b/fsl/fsleyes/colourmaps.py index 46e1a2f1b07ceb429367d8cfc18b728280ac1d5f..0589151de45b3242394a6b55c768d8af5991bd9c 100644 --- a/fsl/fsleyes/colourmaps.py +++ b/fsl/fsleyes/colourmaps.py @@ -626,7 +626,7 @@ def randomBrightColour(): def randomDarkColour(): """Generates a random saturated and darkened RGB colour.""" - return applyBricon(randomBrightColour(), 0.25, 0.5) + return applyBricon(randomBrightColour(), 0.35, 0.5) def complementaryColour(rgb): diff --git a/fsl/fsleyes/controls/overlayinfopanel.py b/fsl/fsleyes/controls/overlayinfopanel.py index 8f15f85342208b794a0aca449b20e9df794764a2..7d340e1718408977a36cd0bf239d9da1fa3df3ea 100644 --- a/fsl/fsleyes/controls/overlayinfopanel.py +++ b/fsl/fsleyes/controls/overlayinfopanel.py @@ -14,9 +14,11 @@ import collections import wx import wx.html as wxhtml -import fsl.data.strings as strings -import fsl.data.constants as constants -import fsl.fsleyes.panel as fslpanel +import numpy as np + +import fsl.data.strings as strings +import fsl.data.constants as constants +import fsl.fsleyes.panel as fslpanel class OverlayInfoPanel(fslpanel.FSLEyesPanel): @@ -30,14 +32,15 @@ class OverlayInfoPanel(fslpanel.FSLEyesPanel): :scale: 50% :align: center - Slightly different informtion is shown depending on the overlay type, + Slightly different information is shown depending on the overlay type, and is generated by the following methods: - =================== ========================== - :class:`.Image` :meth:`__getImageInfo` - :class:`.FEATImage` :meth:`__getFEATImageInfo` - :class:`.Model` :meth:`__getModelInfo` - =================== ========================== + ====================== ============================= + :class:`.Image` :meth:`__getImageInfo` + :class:`.FEATImage` :meth:`__getFEATImageInfo` + :class:`.MelodicImage` :meth:`__getMelodicImageInfo` + :class:`.Model` :meth:`__getModelInfo` + ====================== ============================= """ @@ -177,8 +180,10 @@ class OverlayInfoPanel(fslpanel.FSLEyesPanel): info = OverlayInfo('{} - {}'.format( display.name, strings.labels[self, overlay])) + img = overlay.nibImage hdr = img.get_header() + opts = display.getDisplayOpts() voxUnits, timeUnits = hdr.get_xyzt_units() qformCode = int(hdr['qform_code']) @@ -239,7 +244,8 @@ class OverlayInfoPanel(fslpanel.FSLEyesPanel): section=xformSect) for i in range(3): - orient = overlay.getVoxelOrientation(i) + xform = opts.getTransform('world', 'id') + orient = overlay.getOrientation(i, xform) orient = '{} - {}'.format( strings.anatomy['Image', 'lowlong', orient], strings.anatomy['Image', 'highlong', orient]) @@ -248,23 +254,15 @@ class OverlayInfoPanel(fslpanel.FSLEyesPanel): section=orientSect) for i in range(3): - orient = overlay.getWorldOrientation(i, code='sform') + xform = np.eye(4) + orient = overlay.getOrientation(i, xform) orient = '{} - {}'.format( strings.anatomy['Image', 'lowlong', orient], strings.anatomy['Image', 'highlong', orient]) - info.addInfo(strings.nifti['sformOrient.{}'.format(i)], + info.addInfo(strings.nifti['worldOrient.{}'.format(i)], orient, section=orientSect) - for i in range(3): - orient = overlay.getWorldOrientation(i, code='qform') - orient = '{} - {}'.format( - strings.anatomy['Image', 'lowlong', orient], - strings.anatomy['Image', 'highlong', orient]) - info.addInfo(strings.nifti['qformOrient.{}'.format(i)], - orient, - section=orientSect) - return info @@ -293,6 +291,33 @@ class OverlayInfoPanel(fslpanel.FSLEyesPanel): return info + + def __getMelodicImageInfo(self, overlay, display): + """Creates and returns an :class:`OverlayInfo` object containing + information about the given :class:`.MelodicImage` overlay. + + :arg overlay: A :class:`.MelodicImage` instance. + :arg display: The :class:`.Display` instance assocated with the + ``MelodicImage``. + """ + + info = self.__getImageInfo(overlay, display) + + melInfo = collections.OrderedDict([ + ('tr', overlay.tr), + ('dataFile', overlay.getDataFile()), + ('partOfAnalysis', overlay.getTopLevelAnalysisDir()), + ('numComponents', overlay.numComponents()), + ]) + + secName = strings.labels[self, overlay, 'melodicInfo'] + info.addSection(secName) + + for k, v in melInfo.items(): + info.addInfo(strings.melodic[k], v, section=secName) + + return info + def __getModelInfo(self, overlay, display): """Creates and returns an :class:`OverlayInfo` object containing diff --git a/fsl/fsleyes/controls/timeserieslistpanel.py b/fsl/fsleyes/controls/timeserieslistpanel.py index c3d500fb48be3bc98aa70c8d1b0947d43b7ea45d..c211d35b9c7f7d5c0d98ebf94ddf4c53ac4b15c4 100644 --- a/fsl/fsleyes/controls/timeserieslistpanel.py +++ b/fsl/fsleyes/controls/timeserieslistpanel.py @@ -183,7 +183,7 @@ class TimeSeriesListPanel(fslpanel.FSLEyesPanel): copy.setData(*ts.getData()) # This is hacky, and is here in order to - # make the __onLIstSelect method work. + # make the __onListSelect method work. if isinstance(ts, timeseries.MelodicTimeSeries): copy.tsLoc = 'volume' copy.coord = ts.getComponent() diff --git a/fsl/fsleyes/overlay.py b/fsl/fsleyes/overlay.py index 62d6c1890340963d47df8ff687d053a4a68d9d83..053801d5f8db5d2e1667920a47045449a2a696af 100644 --- a/fsl/fsleyes/overlay.py +++ b/fsl/fsleyes/overlay.py @@ -182,12 +182,12 @@ class OverlayList(props.HasProperties): return self.overlays.insertAll(index, items) -def guessDataSourceType(filename): +def guessDataSourceType(path): """A convenience function which, given the name of a file or directory, figures out a suitable overlay 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 + load the path, and the path itself, possibly adjusted. If the type is unrecognised, the first tuple value will be ``None``. """ @@ -198,30 +198,53 @@ def guessDataSourceType(filename): import fsl.data.melodicresults as melresults import fsl.data.featresults as featresults - filename = op.abspath(filename) - - if filename.endswith('.vtk'): - return fslmodel.Model, filename - - else: - if op.isdir(filename): - if featresults.isFEATDir(filename): - return fslfeatimage.FEATImage, filename - elif melresults.isMelodicDir(filename): - return fslmelimage.MelodicImage, filename - else: - - try: filename = fslimage.addExt(filename, True) - except ValueError: return None, filename + path = op.abspath(path) + + # VTK files are easy + if path.endswith('.vtk'): + return fslmodel.Model, path + + # Now, we check to see if the given + # path is part of a FEAT or MELODIC + # analysis. The way we go about this is + # a bit silly, but is necessary due to + # the fact thet a melodic analysis can + # be contained within a feat analysis + # (or another melodic analysis). So we + # check for all analysis types and, if + # more than one analysis type matches, + # we return the one with the longest + # path name. + analyses = [ + (fslfeatimage.FEATImage, featresults.getFEATDir( path)), + (fslmelimage .MelodicImage, melresults .getMelodicDir(path))] + + # Remove the analysis types that didn't match + # (the get*Dir function returned None) + analyses = [(t, d) for (t, d) in analyses if d is not None] + + # If we have one or more matches for + # an analysis directory, we return + # the one with the longest path + if len(analyses) > 0: + + dirlens = map(len, [d for (t, d) in analyses]) + maxidx = dirlens.index(max(dirlens)) + + return analyses[maxidx] - if featresults.isFEATDir(filename): - return fslfeatimage.FEATImage, filename - elif melresults.isMelodicDir(filename): - return fslmelimage.MelodicImage, filename - else: - return fslimage.Image, filename + # If the path is not an analysis directory, + # see if it is a regular nifti image + try: + path = fslimage.addExt(path, mustExist=True) + return fslimage.Image, path + + except ValueError: + pass - return None, filename + # Otherwise, I don't + # know what to do + return None, path def makeWildcard(): diff --git a/fsl/fsleyes/plotting/timeseries.py b/fsl/fsleyes/plotting/timeseries.py index 8d484aa16aed370f6a7cdb12149774f16aeff847..64c1631098279f21448f5f4de1bc096b6ba16545 100644 --- a/fsl/fsleyes/plotting/timeseries.py +++ b/fsl/fsleyes/plotting/timeseries.py @@ -71,8 +71,7 @@ class TimeSeries(dataseries.DataSeries): def getData(self, xdata=None, ydata=None): """Overrides :meth:`.DataSeries.getData`. Returns the data associated - with this ``TimeSeries`` instance, pre-processed according to the - current :class:`.TimeSeriesPanel` settings. + with this ``TimeSeries`` instance. The ``xdata`` and ``ydata`` arguments may be used by sub-classes to override the x/y data in the event that they have already performed @@ -88,22 +87,7 @@ class TimeSeries(dataseries.DataSeries): xdata = np.array(xdata, dtype=np.float32) ydata = np.array(ydata, 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 @@ -499,7 +483,7 @@ class FEATTimeSeries(VoxelTimeSeries): copenum) - def __plotPEFitChanged(self, evnum): + def __plotPEFitChanged(self, *a): """Called when the :attr:`plotPEFits` setting changes. If necessary, creates and caches one or more diff --git a/fsl/fsleyes/profiles/orthoviewprofile.py b/fsl/fsleyes/profiles/orthoviewprofile.py index aa90f209924d48c1d611facc11e10abbf9f67e58..8554f225965a2f1a92cab803f5d9f42fa22834d7 100644 --- a/fsl/fsleyes/profiles/orthoviewprofile.py +++ b/fsl/fsleyes/profiles/orthoviewprofile.py @@ -24,24 +24,27 @@ class OrthoViewProfile(profiles.Profile): allow the user to navigate through the ``OrthoPanel`` display of the overlays in the :class:`.OverlayList`. - ``OrthoViewProfile`` defines three *modes* (see the :class:`.Profile` - class documentation): + ``OrthoViewProfile`` defines the following *modes* (see the + :class:`.Profile` class documentation): - ======== ============================================================== - ``nav`` The user can change the currently displayed location. This is - accomplished by updating the :attr:`.DisplayContext.location` - property on left mouse drags. + ========== ============================================================== + ``nav`` The user can change the currently displayed location. This is + accomplished by updating the :attr:`.DisplayContext.location` + property on left mouse drags. - ``zoom`` The user can zoom in/out of a canvas with the mouse wheel, and - draw a rectangle on a canvas in which to zoom. This is - accomplished by updating the :attr:`.SliceCanvasOpts.zoom` - property on mouse wheel changes, and displaying a - :class:`~.annotations.Rect` annotation on left mouse drags. + ``zoom`` The user can zoom in/out of a canvas with the mouse wheel, and + draw a rectangle on a canvas in which to zoom. This is + accomplished by updating the :attr:`.SliceCanvasOpts.zoom` + property on mouse wheel changes, and displaying a + :class:`~.annotations.Rect` annotation on left mouse drags. - ``pan`` The user can pan around a canvas (if the canvas is zoomed in). - This is accomplished by calling the - :meth:`.SliceCanvas.panDisplayBy` on left mouse drags. - ======== ============================================================== + ``pan`` The user can pan around a canvas (if the canvas is zoomed in). + This is accomplished by calling the + :meth:`.SliceCanvas.panDisplayBy` on left mouse drags. + + ``bricon`` The user can drag the mouse along a canvas to change the + brightness/contrast of the currently selected overlay. + ========== ============================================================== The ``OrthoViewProfile`` class also defines a few actions: @@ -86,7 +89,7 @@ class OrthoViewProfile(profiles.Profile): if extraModes is None: extraModes = [] if extraActions is None: extraActions = {} - modes = ['nav', 'pan', 'zoom'] + modes = ['nav', 'pan', 'zoom', 'bricon'] actionz = { 'resetZoom' : self.resetZoom, 'centreCursor' : self.centreCursor, @@ -309,7 +312,7 @@ class OrthoViewProfile(profiles.Profile): if zoom == 0: return - self._zoomModeMouseWheel(canvas, zoom) + self._zoomModeMouseWheel(None, canvas, zoom) def _zoomModeLeftMouseDrag(self, ev, canvas, mousePos, canvasPos): @@ -424,3 +427,33 @@ class OrthoViewProfile(profiles.Profile): else: return canvas.panDisplayBy(xoff, yoff) + + + def _briconModeLeftMouseDrag(self, ev, canvas, mousePos, canvasPos): + """Handles left mouse drags in ``bricon`` mode. + + The brightness and contrast of the currently selected overlay are + adjusted according to the location of the mouse, relative to the + canvas. + """ + + overlay = self._displayCtx.getSelectedOverlay() + + if overlay is None: + return + + display = self._displayCtx.getDisplay(overlay) + w, h = canvas.GetSize().Get() + x, y = mousePos + + brightness = float(x) / w + contrast = float(y) / h + + log.debug('Adjusting bricon for {} ' + '(brightness: {}, contrast: {})'.format( + overlay.name, + brightness, + contrast)) + + display.brightness = 100 * brightness + display.contrast = 100 * contrast diff --git a/fsl/fsleyes/profiles/profilemap.py b/fsl/fsleyes/profiles/profilemap.py index 36554a6af266991408f34656dcfcbb50e4ee59c5..747841b8e67671b4928d1c8da420249bf104faab 100644 --- a/fsl/fsleyes/profiles/profilemap.py +++ b/fsl/fsleyes/profiles/profilemap.py @@ -68,7 +68,8 @@ tempModeMap = { (('nav', wx.WXK_CONTROL), 'zoom'), (('pan', wx.WXK_CONTROL), 'zoom'), (('nav', wx.WXK_ALT), 'pan'), - (('zoom', wx.WXK_ALT), 'pan'))), + (('zoom', wx.WXK_ALT), 'pan'), + (('nav', wx.WXK_SHIFT), 'bricon'))), # OrthoEditProfile inherits all of the # settings for OrthoViewProfile above, diff --git a/fsl/fsleyes/splash.py b/fsl/fsleyes/splash.py index 3a4faef6ccd4600f327e5912381ae9da1ef64dcf..a3b8b8a6bc4492f64a85aaf442bcbb615827559e 100644 --- a/fsl/fsleyes/splash.py +++ b/fsl/fsleyes/splash.py @@ -50,7 +50,7 @@ class FSLEyesSplash(wx.Frame): splashimg = splashbmp.ConvertToImage() self.__splashPanel = imagepanel.ImagePanel(self, splashimg) - self.__statusBar = wx.StaticText(self, style=wx.ELLIPSIZE_MIDDLE) + self.__statusBar = wx.StaticText(self, style=wx.ST_ELLIPSIZE_MIDDLE) self.__statusBar.SetLabel(strings.messages[self, 'default']) diff --git a/fsl/fsleyes/views/canvaspanel.py b/fsl/fsleyes/views/canvaspanel.py index 6e95555ef665d4d8015325f7b61c49493826ae47..4ca4ad4068d0bdd2d7ecd5ff81c84f746edc768e 100644 --- a/fsl/fsleyes/views/canvaspanel.py +++ b/fsl/fsleyes/views/canvaspanel.py @@ -36,8 +36,8 @@ import fsl.fsleyes.controls.clusterpanel as clusterpanel import fsl.fsleyes.controls.lookuptablepanel as lookuptablepanel import fsl.fsleyes.controls.shellpanel as shellpanel -import colourbarpanel -import viewpanel +import colourbarpanel +import viewpanel log = logging.getLogger(__name__) @@ -64,10 +64,16 @@ class CanvasPanel(viewpanel.ViewPanel): Sub-classes of the ``CanvasPanel`` must do the following: 1. Add their content to the panel that is accessible via the - :meth:`getCanvasPanel` method (see the note on + :meth:`getContentPanel` method (see the note on :ref:`adding content <canvaspanel-adding-content>`). 2. Override the :meth:`getGLCanvases` method. + + 3. Call the :meth:`centrePanelLayout` method in their ``__init__`` + method. + + 4. Override the :meth:`centrePanelLayout` method if any custom layout is + necessary. **Actions** @@ -104,14 +110,14 @@ class CanvasPanel(viewpanel.ViewPanel): .. _canvaspanel-adding-content: + **Adding content** - To support colour bar functionality, the ``CanvasPanel`` uses a hierarchy - of ``wx.Panel`` instances, depicted in the following containment - hierarchy diagram: - + To support colour bar and screenshot functionality, the ``CanvasPanel`` + uses a hierarchy of ``wx.Panel`` instances, depicted in the following + containment hierarchy diagram: .. graphviz:: @@ -127,23 +133,49 @@ class CanvasPanel(viewpanel.ViewPanel): rankdir="BT"; 1 [label="CanvasPanel"]; - 2 [label="Canvas container"]; - 3 [label="ColourBarPanel"]; - 4 [label="Centre panel"]; - 5 [label="Content added by sub-classes"]; + 2 [label="Centre panel"]; + 3 [label="Custom content (for complex layouts)"]; + 4 [label="Container panel"]; + 5 [label="ColourBarPanel"]; + 6 [label="Content panel"]; + 7 [label="Content added by sub-classes"]; 2 -> 1; 3 -> 2; 4 -> 2; 5 -> 4; + 6 -> 4; + 7 -> 6; } As depicted in the diagram, sub-classes need to add their content to the - *centre panel*. This panel is accessible via the :meth:`getCanvasPanel` - method. The *container panel* is what gets passed to the + *content panel*. This panel is accessible via the :meth:`getContentPanel` + method. + + + The *centre panel* is what gets passed to the :meth:`.ViewPanel.setCentrePanel` method, and is accessible via the - :meth:`getCanvasContainer` method, if necessary. + :meth:`getCentrePanel` method, if necessary. The *container panel* is + also available, via the :meth:`getContainerPanel`. Everything in the + container panel will appear in screenshots (see the :meth:`screenshot` + method). + + + The :meth:`centrePanelLayout` method lays out the centre panel, using the + :meth:`layoutContainerPanel` method to lay out the colour bar and the + content panel. The ``centrePanelLayout`` method simply adds the canvas + container directly to the centre panel. Sub-classes which have more + advanced layout requirements (e.g. the :class:`.LightBoxPanel` needs a + scrollbar) may override the :meth:`centrePanelLayout` method to implement + their own layout. These sub-class implementations must: + + 1. Call the :meth:`layoutContainerPanel` method. + + 2. Add the container panel (accessed via :meth:`getContainerPanel`) + to the centre panel (accessed via :meth:`getCentrePanel`). + + 3. Add any other custom content to the centre panel. """ @@ -274,10 +306,11 @@ class CanvasPanel(viewpanel.ViewPanel): self.disableProperty('syncOverlayOrder') self.disableProperty('syncOverlayDisplay') - self.__canvasContainer = wx.Panel(self) - self.__canvasPanel = wx.Panel(self.__canvasContainer) + self.__centrePanel = wx.Panel(self) + self.__containerPanel = wx.Panel(self.__centrePanel) + self.__contentPanel = wx.Panel(self.__containerPanel) - self.setCentrePanel(self.__canvasContainer) + self.setCentrePanel(self.__centrePanel) # Stores a reference to a wx.Timer # when movie mode is enabled @@ -291,17 +324,18 @@ class CanvasPanel(viewpanel.ViewPanel): self.__movieRateChanged) # Canvas/colour bar layout is managed in - # the _layout/_toggleColourBar methods - self.__canvasSizer = None - self.__colourBar = None + # the layoutColourBarAndCanvas method + self.__colourBar = None # Use a different listener name so that subclasses # can register on the same properties with self._name lName = 'CanvasPanel_{}'.format(self._name) - self.__opts.addListener('colourBarLocation', lName, self.__layout) - self.__opts.addListener('showColourBar', lName, self.__layout) - - self.__layout() + self.__opts.addListener('colourBarLocation', + lName, + self.__colourBarPropsChanged) + self.__opts.addListener('showColourBar', + lName, + self.__colourBarPropsChanged) def destroy(self): @@ -337,20 +371,28 @@ class CanvasPanel(viewpanel.ViewPanel): return self.__opts - def getCanvasPanel(self): + def getCentrePanel(self): + """Returns the ``wx.Panel`` which is passed to + :meth:`.ViewPanel.setCentrePanel`. See the note on + :ref:`adding content <canvaspanel-adding-content>`. + """ + return self.__centrePanel + + + def getContentPanel(self): """Returns the ``wx.Panel`` to which sub-classes must add their content. See the note on :ref:`adding content <canvaspanel-adding-content>`. """ - return self.__canvasPanel + return self.__contentPanel - def getCanvasContainer(self): + def getContainerPanel(self): """Returns the ``wx.Panel`` which contains the - :class:`.ColourBarPanel` if it is being displayed, and the canvas + :class:`.ColourBarPanel` if it is being displayed, and the content panel. See the note on :ref:`adding content <canvaspanel-adding-content>`. """ - return self.__canvasContainer + return self.__containerPanel def getGLCanvases(self): @@ -375,11 +417,27 @@ class CanvasPanel(viewpanel.ViewPanel): return None - def __layout(self, *a): - """Called when any colour bar display properties are changed (see - :class:`.SceneOpts`). Lays out the container panel, which contains - the :class:`.ColourBarPanel` and all content added by the - ``CanvasPanel`` sub-class implementation. + def centrePanelLayout(self): + """Lays out the centre panel. This method may be overridden by + sub-classes which need more advanced layout logic. See the note on + :ref:`adding content <canvaspanel-adding-content>` + """ + + self.layoutContainerPanel() + + sizer = wx.BoxSizer(wx.HORIZONTAL) + sizer.Add(self.__containerPanel, flag=wx.EXPAND, proportion=1) + self.__centrePanel.SetSizer(sizer) + + self.PostSizeEvent() + + + def layoutContainerPanel(self): + """Creates a ``wx.Sizer``, and uses it to lay out the colour bar panel + and canvas panel. The sizer object is returned. + + This method is used by the default :meth:`centrePanelLayout` method, + and is available for custom sub-class implementations to use. """ if not self.__opts.showColourBar: @@ -391,19 +449,15 @@ class CanvasPanel(viewpanel.ViewPanel): self.__colourBar.destroy() self.__colourBar.Destroy() self.__colourBar = None - - self.__canvasSizer = wx.BoxSizer(wx.HORIZONTAL) - self.__canvasSizer.Add(self.__canvasPanel, - flag=wx.EXPAND, - proportion=1) - self.__canvasContainer.SetSizer(self.__canvasSizer) - self.PostSizeEvent() + sizer = wx.BoxSizer(wx.HORIZONTAL) + sizer.Add(self.__contentPanel, flag=wx.EXPAND, proportion=1) + self.__containerPanel.SetSizer(sizer) return if self.__colourBar is None: self.__colourBar = colourbarpanel.ColourBarPanel( - self.__canvasContainer, self._overlayList, self._displayCtx) + self.__containerPanel, self._overlayList, self._displayCtx) self.__opts.bindProps('colourBarLabelSide', self.__colourBar, @@ -415,23 +469,18 @@ class CanvasPanel(viewpanel.ViewPanel): self.__colourBar.orientation = 'vertical' if self.__opts.colourBarLocation in ('top', 'bottom'): - self.__canvasSizer = wx.BoxSizer(wx.VERTICAL) + sizer = wx.BoxSizer(wx.VERTICAL) else: - self.__canvasSizer = wx.BoxSizer(wx.HORIZONTAL) - - self.__canvasContainer.SetSizer(self.__canvasSizer) + sizer = wx.BoxSizer(wx.HORIZONTAL) if self.__opts.colourBarLocation in ('top', 'left'): - self.__canvasSizer.Add(self.__colourBar, flag=wx.EXPAND) - self.__canvasSizer.Add(self.__canvasPanel, flag=wx.EXPAND, - proportion=1) + sizer.Add(self.__colourBar, flag=wx.EXPAND) + sizer.Add(self.__contentPanel, flag=wx.EXPAND, proportion=1) else: - self.__canvasSizer.Add(self.__canvasPanel, flag=wx.EXPAND, - proportion=1) - self.__canvasSizer.Add(self.__colourBar, flag=wx.EXPAND) + sizer.Add(self.__contentPanel, flag=wx.EXPAND, proportion=1) + sizer.Add(self.__colourBar, flag=wx.EXPAND) - # Force the canvas panel to resize itself - self.PostSizeEvent() + self.__containerPanel.SetSizer(sizer) def __movieModeChanged(self, *a): @@ -455,6 +504,13 @@ class CanvasPanel(viewpanel.ViewPanel): self.__movieTimer = wx.Timer(self) self.Bind(wx.EVT_TIMER, self.__movieUpdate) self.__movieTimer.Start(rate) + + + def __colourBarPropsChanged(self, *a): + """Called when any colour bar display properties are changed (see + :class:`.SceneOpts`). Calls :meth:`canvasPanelLayout`. + """ + self.centrePanelLayout() def __movieRateChanged(self, *a): @@ -653,7 +709,7 @@ def _screenshot(overlayList, displayCtx, canvasPanel): # direct parent of the colour bar # canvas, and an ancestor of the # other GL canvases - parent = canvasPanel.getCanvasContainer() + parent = canvasPanel.getContainerPanel() width, height = parent.GetClientSize().Get() windowDC = wx.WindowDC(parent) memoryDC = wx.MemoryDC() @@ -679,6 +735,9 @@ def _screenshot(overlayList, displayCtx, canvasPanel): rgb = bmp.ConvertToImage().GetData() rgb = np.fromstring(rgb, dtype=np.uint8) + log.debug('Creating bitmap {} * {} for {} screenshot'.format( + width, height, type(canvasPanel).__name__)) + data[:, :, :3] = rgb.reshape(height, width, 3) # Patch in bitmaps for every GL canvas @@ -689,6 +748,11 @@ def _screenshot(overlayList, displayCtx, canvasPanel): if glCanvas is None: continue + # Hidden wx objects will + # still return a size + if not glCanvas.IsShown(): + continue + pos = relativePosition(glCanvas, parent) size = glCanvas.GetClientSize().Get() @@ -710,6 +774,9 @@ def _screenshot(overlayList, displayCtx, canvasPanel): w = xend - xstart h = yend - ystart bmp = bmp[:h, :w, :] + + log.debug('Patching {} in at [{} - {}], [{} - {}]'.format( + type(glCanvas).__name__, xstart, xend, ystart, yend)) data[ystart:yend, xstart:xend] = bmp diff --git a/fsl/fsleyes/views/lightboxpanel.py b/fsl/fsleyes/views/lightboxpanel.py index 0d3de04f3e24cb7b70e36179650bff1d5b8d9e2e..7ac448133b5a65dfbbb221ac93bab5c4ddb434fb 100644 --- a/fsl/fsleyes/views/lightboxpanel.py +++ b/fsl/fsleyes/views/lightboxpanel.py @@ -86,11 +86,11 @@ class LightBoxPanel(canvaspanel.CanvasPanel): actionz) self.__scrollbar = wx.ScrollBar( - self.getCanvasPanel(), + self.getCentrePanel(), style=wx.SB_VERTICAL) self.__lbCanvas = lightboxcanvas.WXGLLightBoxCanvas( - self.getCanvasPanel(), + self.getContentPanel(), overlayList, displayCtx) @@ -117,10 +117,9 @@ class LightBoxPanel(canvaspanel.CanvasPanel): sceneOpts.bindProps('zrange', self.__lbCanvas) self.__canvasSizer = wx.BoxSizer(wx.HORIZONTAL) - self.getCanvasPanel().SetSizer(self.__canvasSizer) + self.getContentPanel().SetSizer(self.__canvasSizer) - self.__canvasSizer.Add(self.__lbCanvas, flag=wx.EXPAND, proportion=1) - self.__canvasSizer.Add(self.__scrollbar, flag=wx.EXPAND) + self.__canvasSizer.Add(self.__lbCanvas, flag=wx.EXPAND, proportion=1) # When the display context location changes, # make sure the location is shown on the canvas @@ -162,8 +161,9 @@ class LightBoxPanel(canvaspanel.CanvasPanel): self.__onLightBoxChange() self.__onZoom() + self.__selectedOverlayChanged() - self.Layout() + self.centrePanelLayout() self.initProfile() # The ViewPanel AuiManager seems to @@ -204,6 +204,25 @@ class LightBoxPanel(canvaspanel.CanvasPanel): """Returns a reference to the :class:`.LightBoxCanvas` instance. """ return self.__lbCanvas + + def centrePanelLayout(self): + """Overrides :meth:`.CanvasPanel.centrePanelLayout`. Adds the + scrollbar to the centre panel. + """ + + self.layoutContainerPanel() + + centrePanel = self.getCentrePanel() + containerPanel = self.getContainerPanel() + sizer = wx.BoxSizer(wx.HORIZONTAL) + + centrePanel.SetSizer(sizer) + + sizer.Add(containerPanel, flag=wx.EXPAND, proportion=1) + sizer.Add(self.__scrollbar, flag=wx.EXPAND) + + self.PostSizeEvent() + def __selectedOverlayChanged(self, *a): """Called when the :attr:`.DisplayContext.selectedOverlay` changes. diff --git a/fsl/fsleyes/views/orthopanel.py b/fsl/fsleyes/views/orthopanel.py index 5ae778dac51596a86598ae20ea6aa8ac5f54b167..25ca142f550b4effa1afd5c67b9266b4161f80eb 100644 --- a/fsl/fsleyes/views/orthopanel.py +++ b/fsl/fsleyes/views/orthopanel.py @@ -148,19 +148,19 @@ class OrthoPanel(canvaspanel.CanvasPanel): sceneOpts, actionz) - canvasPanel = self.getCanvasPanel() + contentPanel = self.getContentPanel() # The canvases themselves - each one displays a # slice along each of the three world axes - self.__xcanvas = slicecanvas.WXGLSliceCanvas(canvasPanel, + self.__xcanvas = slicecanvas.WXGLSliceCanvas(contentPanel, overlayList, displayCtx, zax=0) - self.__ycanvas = slicecanvas.WXGLSliceCanvas(canvasPanel, + self.__ycanvas = slicecanvas.WXGLSliceCanvas(contentPanel, overlayList, displayCtx, zax=1) - self.__zcanvas = slicecanvas.WXGLSliceCanvas(canvasPanel, + self.__zcanvas = slicecanvas.WXGLSliceCanvas(contentPanel, overlayList, displayCtx, zax=2) @@ -172,9 +172,9 @@ class OrthoPanel(canvaspanel.CanvasPanel): self.__zLabels = {} for side in ('left', 'right', 'top', 'bottom'): - self.__xLabels[side] = wx.StaticText(canvasPanel) - self.__yLabels[side] = wx.StaticText(canvasPanel) - self.__zLabels[side] = wx.StaticText(canvasPanel) + self.__xLabels[side] = wx.StaticText(contentPanel) + self.__yLabels[side] = wx.StaticText(contentPanel) + self.__zLabels[side] = wx.StaticText(contentPanel) self.__xcanvas.bindProps('showCursor', sceneOpts) self.__ycanvas.bindProps('showCursor', sceneOpts) @@ -236,13 +236,14 @@ class OrthoPanel(canvaspanel.CanvasPanel): # the slice canvases when the canvas # panel is resized, so aspect ratio # is maintained - canvasPanel.Bind(wx.EVT_SIZE, self.__onResize) + contentPanel.Bind(wx.EVT_SIZE, self.__onResize) # Initialise the panel self.__refreshLayout() self.__bgColourChanged() self.__overlayListChanged() self.__locationChanged() + self.centrePanelLayout() self.initProfile() # The ViewPanel AuiManager seems to @@ -328,8 +329,8 @@ class OrthoPanel(canvaspanel.CanvasPanel): bg = [int(round(c * 255)) for c in bg] fg = [int(round(c * 255)) for c in fg] - self.getCanvasPanel().SetBackgroundColour(bg) - self.getCanvasPanel().SetForegroundColour(fg) + self.getContentPanel().SetBackgroundColour(bg) + self.getContentPanel().SetForegroundColour(fg) self.__xcanvas.SetBackgroundColour(bg) self.__ycanvas.SetBackgroundColour(bg) @@ -358,16 +359,12 @@ class OrthoPanel(canvaspanel.CanvasPanel): self.__zLabels.values() if overlay is not None: - opts = self._displayCtx.getOpts(overlay) + opts = self._displayCtx.getOpts(overlay) + xform = opts.getTransform('world', 'display') - if opts.transform in ('pixdim', 'id'): - xorient = overlay.getVoxelOrientation(0) - yorient = overlay.getVoxelOrientation(1) - zorient = overlay.getVoxelOrientation(2) - else: - xorient = overlay.getWorldOrientation(0) - yorient = overlay.getWorldOrientation(1) - zorient = overlay.getWorldOrientation(2) + xorient = overlay.getOrientation(0, xform) + yorient = overlay.getOrientation(1, xform) + zorient = overlay.getOrientation(2, xform) if constants.ORIENT_UNKNOWN in (xorient, yorient, zorient): @@ -428,13 +425,10 @@ class OrthoPanel(canvaspanel.CanvasPanel): # Update anatomy labels when # overlay bounds change - if i == self._displayCtx.selectedOverlay: - opts.addListener('bounds', - self._name, - self.__refreshLabels, - overwrite=True) - else: - opts.removeListener('bounds', self._name) + opts.addListener('bounds', + self._name, + self.__refreshLabels, + overwrite=True) # anatomical orientation may have changed with an image change self.__refreshLabels() @@ -479,23 +473,17 @@ class OrthoPanel(canvaspanel.CanvasPanel): self.PostSizeEvent() return - 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 opts.transform in ('pixdim', 'id'): - xorient = overlay.getVoxelOrientation(0) - yorient = overlay.getVoxelOrientation(1) - zorient = overlay.getVoxelOrientation(2) + log.debug('Refreshing orientation labels ' + 'according to {}'.format(overlay.name)) - # 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) + # Figure out the orientation of the + # image in the display coordinate system + opts = self._displayCtx.getOpts(overlay) + xform = opts.getTransform('world', 'display') + xorient = overlay.getOrientation(0, xform) + yorient = overlay.getOrientation(1, xform) + zorient = overlay.getOrientation(2, xform) xlo = strings.anatomy['Image', 'lowshort', xorient] ylo = strings.anatomy['Image', 'lowshort', yorient] @@ -504,6 +492,10 @@ class OrthoPanel(canvaspanel.CanvasPanel): yhi = strings.anatomy['Image', 'highshort', yorient] zhi = strings.anatomy['Image', 'highshort', zorient] + log.debug('X orientation: {} - {}'.format(xlo, xhi)) + log.debug('Y orientation: {} - {}'.format(ylo, yhi)) + log.debug('Z orientation: {} - {}'.format(zlo, zhi)) + bg = sceneOpts.bgColour fg = colourmaps.complementaryColour(bg) bg = [int(round(c * 255)) for c in bg] @@ -513,16 +505,16 @@ class OrthoPanel(canvaspanel.CanvasPanel): self.__xLabels['left'] .SetLabel(ylo) self.__xLabels['right'] .SetLabel(yhi) - self.__xLabels['top'] .SetLabel(zlo) - self.__xLabels['bottom'].SetLabel(zhi) + self.__xLabels['bottom'].SetLabel(zlo) + self.__xLabels['top'] .SetLabel(zhi) self.__yLabels['left'] .SetLabel(xlo) self.__yLabels['right'] .SetLabel(xhi) - self.__yLabels['top'] .SetLabel(zlo) - self.__yLabels['bottom'].SetLabel(zhi) + self.__yLabels['bottom'].SetLabel(zlo) + self.__yLabels['top'] .SetLabel(zhi) self.__zLabels['left'] .SetLabel(xlo) self.__zLabels['right'] .SetLabel(xhi) - self.__zLabels['top'] .SetLabel(ylo) - self.__zLabels['bottom'].SetLabel(yhi) + self.__zLabels['bottom'].SetLabel(ylo) + self.__zLabels['top'] .SetLabel(yhi) self.PostSizeEvent() @@ -539,7 +531,7 @@ class OrthoPanel(canvaspanel.CanvasPanel): opts = self.getSceneOptions() layout = opts.layout - width, height = self.getCanvasPanel().GetClientSize().Get() + width, height = self.getContentPanel().GetClientSize().Get() show = [opts.showXCanvas, opts.showYCanvas, opts.showZCanvas] canvases = [self.__xcanvas, self.__ycanvas, self.__zcanvas] @@ -765,7 +757,7 @@ class OrthoPanel(canvaspanel.CanvasPanel): for w in widgets: self.__canvasSizer.Add(w, flag=flag) - self.getCanvasPanel().SetSizer(self.__canvasSizer) + self.getContentPanel().SetSizer(self.__canvasSizer) # Calculate/ adjust the appropriate sizes # for each canvas, such that they are scaled @@ -775,7 +767,7 @@ class OrthoPanel(canvaspanel.CanvasPanel): self.__calcCanvasSizes() self.Layout() - self.getCanvasPanel().Layout() + self.getContentPanel().Layout() self.Refresh() diff --git a/fsl/fsleyes/views/plotpanel.py b/fsl/fsleyes/views/plotpanel.py index 0a64d0e59dd5dcafbdf053cb7f0783f3806eb696..e80aa4b782ebd24f5dc04a026b658effb3eac796 100644 --- a/fsl/fsleyes/views/plotpanel.py +++ b/fsl/fsleyes/views/plotpanel.py @@ -355,13 +355,16 @@ class PlotPanel(viewpanel.ViewPanel): self.Refresh() - def drawDataSeries(self, extraSeries=None, **plotArgs): + def drawDataSeries(self, extraSeries=None, preproc=None, **plotArgs): """Plots all of the :class:`.DataSeries` instances in the :attr:`dataSeries` list :arg extraSeries: A sequence of additional ``DataSeries`` to be plotted. + :arg preproc: An optional preprocessing function - passed to the + :meth:`__drawOneDataSeries` method. + :arg plotArgs: Passed through to the :meth:`__drawOneDataSeries` method. """ @@ -397,7 +400,7 @@ class PlotPanel(viewpanel.ViewPanel): ylims = [] for ds in toPlot: - xlim, ylim = self.__drawOneDataSeries(ds, **plotArgs) + xlim, ylim = self.__drawOneDataSeries(ds, preproc, **plotArgs) xlims.append(xlim) ylims.append(ylim) @@ -474,16 +477,26 @@ class PlotPanel(viewpanel.ViewPanel): self.Refresh() - def __drawOneDataSeries(self, ds, **plotArgs): + def __drawOneDataSeries(self, ds, preproc=None, **plotArgs): """Plots a single :class:`.DataSeries` instance. This method is called by the :meth:`drawDataSeries` method. :arg ds: The ``DataSeries`` instance. + :arg preproc: An optional preprocessing function which must accept + the ``DataSeries`` instance as its sole argument, and + must return the ``(xdata, ydata)`` with any required + processing applied. The default preprocessing function + returns the result of a call to + :meth:`.DataSeries.getData`. + :arg plotArgs: May be used to customise the plot - these arguments are all passed through to the ``Axis.plot`` function. """ + + if preproc is None: + preproc = lambda s: s.getData() if ds.alpha == 0: return (0, 0), (0, 0) @@ -491,7 +504,7 @@ class PlotPanel(viewpanel.ViewPanel): log.debug('Drawing {} for {}'.format(type(ds).__name__, ds.overlay)) - xdata, ydata = ds.getData() + xdata, ydata = preproc(ds) if len(xdata) != len(ydata) or len(xdata) == 0: return (0, 0), (0, 0) diff --git a/fsl/fsleyes/views/timeseriespanel.py b/fsl/fsleyes/views/timeseriespanel.py index 5a879e526528b542ece4f59efe961faa81221d1b..d83221c9a1eee94064fc5a21b1cc3f4fa1f6351a 100644 --- a/fsl/fsleyes/views/timeseriespanel.py +++ b/fsl/fsleyes/views/timeseriespanel.py @@ -20,7 +20,7 @@ import fsl.data.featimage as fslfeatimage import fsl.data.melodicimage as fslmelimage import fsl.data.image as fslimage import fsl.fsleyes.colourmaps as fslcmaps -import fsl.fsleyes.plotting.timeseries as timeseries +import fsl.fsleyes.plotting as plotting import fsl.fsleyes.controls.timeseriescontrolpanel as timeseriescontrolpanel import fsl.fsleyes.controls.timeserieslistpanel as timeserieslistpanel @@ -281,14 +281,15 @@ class TimeSeriesPanel(plotpanel.PlotPanel): tss = [ts for ts in tss if ts is not None] for i, ts in enumerate(list(tss)): - if isinstance(ts, timeseries.FEATTimeSeries): + if isinstance(ts, plotting.FEATTimeSeries): tss.pop(i) tss = tss[:i] + ts.getModelTimeSeries() + tss[i:] for ts in tss: ts.label = ts.makeLabel() - self.drawDataSeries(extraSeries=tss) + self.drawDataSeries(extraSeries=tss, + preproc=self.__prepareTimeSeriesData) def getTimeSeries(self, overlay): @@ -298,6 +299,39 @@ class TimeSeriesPanel(plotpanel.PlotPanel): return self.__currentTss.get(overlay) + def __prepareTimeSeriesData(self, ts): + """Given a :class:`.TimeSeries` instance, scales and normalises + the x and y data according to the current values of the + :attr:`usePixdim` and :attr:`plotMode` properties. + + This method is used as a preprocessing function for all + :class:`.TimeSeries` instances that are plotted - see the + :meth:`.PlotPanel.drawDataSeries` method. + """ + + xdata, ydata = ts.getData() + + if self.usePixdim: + if isinstance(ts.overlay, fslmelimage.MelodicImage): + xdata *= ts.overlay.tr + else: + xdata *= ts.overlay.pixdim[3] + + if self.plotMode == 'demean': + ydata = ydata - ydata.mean() + + elif self.plotMode == 'normalise': + ymin = ydata.min() + ymax = ydata.max() + ydata = 2 * (ydata - ymin) / (ymax - ymin) - 1 + + elif self.plotMode == 'percentChange': + mean = ydata.mean() + ydata = 100 * (ydata / mean) - 100 + + return xdata, ydata + + def __overlayListChanged(self, *a): """Called when the :class:`.OverlayList` changes. Makes sure that there are no :class:`.TimeSeries` instances in the @@ -402,18 +436,18 @@ class TimeSeriesPanel(plotpanel.PlotPanel): return None, None, None if isinstance(overlay, fslfeatimage.FEATImage): - ts = timeseries.FEATTimeSeries(self, overlay, self._displayCtx) + ts = plotting.FEATTimeSeries(self, overlay, self._displayCtx) targets = [self._displayCtx] propNames = ['location'] elif isinstance(overlay, fslmelimage.MelodicImage) and \ self.plotMelodicICs: - ts = timeseries.MelodicTimeSeries(self, overlay, self._displayCtx) + ts = plotting.MelodicTimeSeries(self, overlay, self._displayCtx) targets = [self._displayCtx.getOpts(overlay)] propNames = ['volume'] else: - ts = timeseries.VoxelTimeSeries(self, overlay, self._displayCtx) + ts = plotting.VoxelTimeSeries(self, overlay, self._displayCtx) targets = [self._displayCtx] propNames = ['location'] diff --git a/fsl/tools/render.py b/fsl/tools/render.py index e346114e1992714d70b9885373b980a8a68c20fb..f586153e2bc5b3bd8d5f948a815e5afdcb51fdd8 100644 --- a/fsl/tools/render.py +++ b/fsl/tools/render.py @@ -77,22 +77,10 @@ def buildLabelBitmaps(overlayList, 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) + xform = opts.getTransform('world', 'display') + xorient = overlay.getOrientation(0, xform) + yorient = overlay.getOrientation(1, xform) + zorient = overlay.getOrientation(2, xform) if constants.ORIENT_UNKNOWN in [xorient, yorient, zorient]: fgColour = 'red' diff --git a/fsl/utils/dialog.py b/fsl/utils/dialog.py index ec53c566a417f0247b094f7cdd24e0e30867d8bd..8cc91ac0b27cf11adc5f5a99277eb58e7940214f 100644 --- a/fsl/utils/dialog.py +++ b/fsl/utils/dialog.py @@ -132,12 +132,19 @@ class SimpleMessageDialog(wx.Dialog): self.SetClientSize(( width, height)) self.Layout() + self.__message.Layout() if self.__style & SMD_KEEP_CENTERED: self.CentreOnParent() + # This ridiculousness seems to be + # necessary to force a repaint on + # all platforms (OSX, GTK, GTK/SSH) + wx.Yield() self.Refresh() self.Update() + self.__message.Refresh() + self.__message.Update() wx.Yield()