Source code for fsleyes.views.timeseriespanel

#
# timeseriespanel.py - The TimeSeriesPanel class.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`TimeSeriesPanel`, which is a *FSLeyes
view* for displaying time series data from :class:`.Image` overlays.
"""


import logging
import itertools as it

import numpy as np

import wx

import fsl.data.featimage                      as fslfeatimage
import fsl.data.melodicimage                   as fslmelimage
import fsl.data.image                          as fslimage
import fsl.data.mesh                           as fslmesh
import fsleyes_props                           as props

import fsleyes.overlay                         as fsloverlay
import fsleyes.actions                         as actions
import fsleyes.actions.addmaskdataseries       as addmaskdataseries
import fsleyes.strings                         as strings
import fsleyes.plotting.timeseries             as timeseries
import fsleyes.controls.timeseriescontrolpanel as timeseriescontrolpanel
import fsleyes.controls.timeseriestoolbar      as timeseriestoolbar
from . import                                     plotpanel


log = logging.getLogger(__name__)


[docs]class TimeSeriesPanel(plotpanel.OverlayPlotPanel): """The ``TimeSeriesPanel`` is an :class:`.OverlayPlotPanel` which plots time series data from overlays. A ``TimeSeriesPanel`` looks something like the following: .. image:: images/timeseriespanel.png :scale: 50% :align: center A ``TimeSeriesPanel`` plots one or more :class:`.DataSeries` instances, which encapsulate time series data from an overlay. All time series related ``DataSeries`` classes are defined in the :mod:`.plotting.timeseries` module; these are all sub-classes of the :class:`.DataSeries` class - see the :class:`.PlotPanel` and :class:`.OverlayPlotPanel` documentation for more details: .. autosummary:: :nosignatures: .timeseries.VoxelTimeSeries .timeseries.ComplexTimeSeries .timeseries.FEATTimeSeries .timeseries.MelodicTimeSeries .timeseries.MeshTimeSeries **Control panels** Some *FSLeyes control* panels are associated with the :class:`.TimeSeriesPanel`: .. autosummary:: :nosignatures: ~fsleyes.controls.plotlistpanel.PlotListPanel ~fsleyes.controls.timeseriescontrolpanel.TimeSeriesControlPanel The ``TimeSeriesPanel`` defines some :mod:`.actions`, allowing the user to show/hide these control panels: .. autosummary:: :nosignatures: toggleTimeSeriesToolBar toggleTimeSeriesControl Some tools are also available, to do various things: .. autosummary:: :nosignatures: addMaskDataSeries **FEATures** The ``TimeSeriesPanel`` has some extra functionality for :class:`.FEATImage` overlays. For these overlays, a :class:`.FEATTimeSeries` instance is plotted, instead of a regular :class:`.TimeSeries` instance. The ``FEATTimeSeries`` class, in turn, has the ability to generate more ``TimeSeries`` instances which represent various aspects of the FEAT model fit. See the :class:`.FEATTimeSeries` and the :class:`.TimeSeriesControlPanel` classes for more details. **Melodic features** The ``TimeSeriesPanel`` also has some functionality for :class:`.MelodicImage` overlays - a :class:`.MelodicTimeSeries` instance is used to plot the component time courses for the current component (as defined by the :attr:`.NiftiOpts.volume` property). """ usePixdim = props.Boolean(default=False) """If ``True``, the X axis data is scaled by the pixdim value of the selected overlay (which, for FMRI time series data is typically set to the TR time). """ plotMode = props.Choice(('normal', 'demean', 'normalise', 'percentChange')) """Options to scale/offset the plotted time courses. ================= ======================================================= ``normal`` The data is plotted with no modifications ``demean`` The data is demeaned (i.e. plotted with a mean of 0) ``normalise`` The data is normalised to lie in the range ``[-1, 1]``. ``percentChange`` The data is scaled to percent changed ================= ======================================================= """ plotMelodicICs = props.Boolean(default=True) """If ``True``, the component time courses are plotted for :class:`.MelodicImage` overlays (using a :class:`.MelodicTimeSeries` instance). Otherwise, ``MelodicImage`` overlays are treated as regular 4D :class:`.Image` overlays (a :class:`.VoxelTimeSeries` instance is used). """
[docs] def __init__(self, parent, overlayList, displayCtx, frame): """Create a ``TimeSeriesPanel``. :arg parent: A :mod:`wx` parent object. :arg overlayList: An :class:`.OverlayList` instance. :arg displayCtx: A :class:`.DisplayContext` instance. :arg frame: The :class:`.FSLeyesFrame` instance. """ # If the currently selected image is from # a FEAT analysis, and the corresponding # filtered_func_data is loaded, enable the # initial state of the time course for # that filtered_func_data featImage = fsloverlay.findFEATImage( overlayList, displayCtx.getSelectedOverlay()) if featImage is None: initialState = None else: initialState = {featImage : True} plotpanel.OverlayPlotPanel.__init__( self, parent, overlayList, displayCtx, frame, initialState=initialState) self.addListener('plotMode', self.name, self.draw) self.addListener('usePixdim', self.name, self.draw) self.addListener('plotMelodicICs', self.name, self.__plotMelodicICsChanged) self.__addMaskAction = addmaskdataseries.AddMaskDataSeriesAction( overlayList, displayCtx, self) self.addMaskDataSeries.bindProps('enabled', self.__addMaskAction) self.initProfile()
[docs] def destroy(self): """Removes some listeners, and calls the :meth:`.PlotPanel.destroy` method. """ self.removeListener('plotMode', self.name) self.removeListener('usePixdim', self.name) self.removeListener('plotMelodicICs', self.name) self.__addMaskAction.destroy() self.__addMaskAction = None plotpanel.OverlayPlotPanel.destroy(self)
[docs] @actions.toggleControlAction(timeseriescontrolpanel.TimeSeriesControlPanel) def toggleTimeSeriesControl(self, floatPane=False): """Shows/hides a :class:`.TimeSeriesControlPanel`. See :meth:`.ViewPanel.togglePanel`. """ self.togglePanel(timeseriescontrolpanel.TimeSeriesControlPanel, self, location=wx.RIGHT, floatPane=floatPane)
[docs] @actions.toggleControlAction(timeseriestoolbar.TimeSeriesToolBar) def toggleTimeSeriesToolBar(self): """Shows/hides a :class:`.TimeSeriesToolBar`. See :meth:`.ViewPanel.togglePanel`. """ self.togglePanel(timeseriestoolbar.TimeSeriesToolBar, tsPanel=self)
[docs] @actions.action def addMaskDataSeries(self): """Executes the :class:`AddMaskDataSeriesAction`. """ self.__addMaskAction()
[docs] def getActions(self): """Overrides :meth:`.ActionProvider.getActions`. Returns all of the :mod:`.actions` that are defined on this ``TimeSeriesPanel``. """ actionz = [self.screenshot, self.importDataSeries, self.exportDataSeries, None, self.toggleOverlayList, self.togglePlotList, self.toggleTimeSeriesToolBar, self.toggleTimeSeriesControl] names = [a.__name__ if a is not None else None for a in actionz] return list(zip(names, actionz))
[docs] def getTools(self): """Returns a list of tools to be added to the ``FSLeyesFrame`` for ``TimeSeriesPanel`` views. """ return [self.addMaskDataSeries]
[docs] def draw(self, *a): """Overrides :meth:`.PlotPanel.draw`. Passes some :class:`.DataSeries` instances to the :meth:`.PlotPanel.drawDataSeries` method. """ if not self or self.destroyed: return tss = self.getDataSeriesToPlot() for ts in tss: # Changing the label might trigger # another call to this method, as # the PlotPanel might have a listener # registered on it. Hence the suppress with props.suppress(ts, 'label'): ts.label = ts.makeLabel() xlabel, ylabel = self.__generateDefaultLabels(tss) self.drawDataSeries(extraSeries=tss, xlabel=xlabel, ylabel=ylabel) self.drawArtists()
[docs] def createDataSeries(self, overlay): """Overrides :meth:`.OverlayPlotPanel.createDataSeries`. Creates and returns a :class:`.TimeSeries` instance (or an instance of one of the :class:`.TimeSeries` sub-classes) for the specified overlay. Returns a tuple containing the following: - A :class:`.DataSeries` instance for the given overlay - A list of *targets* - objects which have properties that influence the state of the ``TimeSeries`` instance. - A list of *property names*, one for each target. If the given overlay is not compatible (i.e. it has no time series data to be plotted), a tuple of ``None`` values is returned. """ displayCtx = self.displayCtx overlayList = self.overlayList tsargs = (overlay, overlayList, displayCtx, self) # Is this a mesh? if isinstance(overlay, fslmesh.Mesh): ts = timeseries.MeshTimeSeries(*tsargs) opts = displayCtx.getOpts(overlay) targets = [displayCtx, opts] propNames = ['location', 'vertexData'] # Is this a FEAT filtered_func_data image? elif isinstance(overlay, fslfeatimage.FEATImage): # If the filtered_func for this FEAT analysis # has been loaded, we show its time series. ts = timeseries.FEATTimeSeries(*tsargs) targets = [displayCtx] propNames = ['location'] # If this is a melodic IC image, and we are # currently configured to plot component ICs, # we use a MelodicTimeSeries object. elif isinstance(overlay, fslmelimage.MelodicImage) and \ self.plotMelodicICs: ts = timeseries.MelodicTimeSeries(*tsargs) targets = [displayCtx.getOpts(overlay)] propNames = ['volume'] # Otherwise it's a normal image elif isinstance(overlay, fslimage.Image) and overlay.ndim > 3: # Is it a complex data image? if overlay.iscomplex: ts = timeseries.ComplexTimeSeries(*tsargs) # Or just a bog-standard 4D image? else: ts = timeseries.VoxelTimeSeries(*tsargs) # listen to volumeDim for # images with >4 dimensions opts = displayCtx.getOpts(overlay) targets = [displayCtx, opts] propNames = ['location', 'volumeDim'] else: return None, None, None ts.colour = self.getOverlayPlotColour(overlay) ts.alpha = 1 ts.lineWidth = 1 ts.lineStyle = '-' return ts, targets, propNames
[docs] def prepareDataSeries(self, ts): """Overrides :class:`.PlotPanel.prepareDataSeries`. Given a :class:`.DataSeries` instance, scales and normalises the x and y data according to the current values of the :attr:`usePixdim` and :attr:`plotMode` properties. """ xdata, ydata = ts.getData() if (xdata is None) or (ydata is None) or (len(xdata) == 0): return xdata, ydata if self.usePixdim and isinstance(ts.overlay, fslimage.Image): if isinstance(ts.overlay, fslmelimage.MelodicImage): xdata = xdata * ts.overlay.tr else: xdata = xdata * ts.overlay.pixdim[3] toffset = ts.overlay.nibImage.header.get('toffset', 0) xdata += toffset if self.plotMode == 'demean': ydata = ydata - ydata.mean() elif self.plotMode == 'normalise': ymin = ydata.min() ymax = ydata.max() if not np.isclose(ymin, ymax): ydata = 2 * (ydata - ymin) / (ymax - ymin) - 1 else: ydata = np.zeros(len(ydata)) elif self.plotMode == 'percentChange': mean = ydata.mean() ydata = 100 * (ydata / mean) - 100 return xdata, ydata
def __plotMelodicICsChanged(self, *a): """Called when the :attr:`plotMelodicICs` property changes. Re-creates the internally cached :class:`.TimeSeries` instances for all :class:`.MelodicImage` overlays in the :class:`.OverlayList`. """ for overlay in self.overlayList: if isinstance(overlay, fslmelimage.MelodicImage): self.clearDataSeries(overlay) self.updateDataSeries() self.draw() def __generateDefaultLabels(self, timeSeries): """Called by :meth:`draw`. If the :attr:`.PlotPanel.xlabel` or :attr:`.PlotPanel.ylabel` properties are unset, an attempt is made to generate default labels. """ xlabel = self.xlabel ylabel = self.ylabel if xlabel is not None: return xlabel, ylabel if not self.usePixdim: xlabel = strings.nifti['t_unit', -1] return xlabel, ylabel # If all of the overlays related to the data series being # plotted: # # - are Images # - have the same time unit (as specified in the nifti header) # # Then a default label is specified # # n.b. this is not foolproof, as many # non-time 4D images will still set # the time units to seconds. # # # TODO Non-Image overlays with associated # time series (e.g. MeshOpts) # Get all the unique overlays overlays = [ts.overlay for ts in it.chain(timeSeries, self.dataSeries)] overlays = set(overlays) if not all([isinstance(o, fslimage.Image) for o in overlays]): return xlabel, ylabel # And all of their time units units = [o.timeUnits for o in overlays] if len(set(units)) == 1: xlabel = strings.nifti.get(('t_unit', units[0]), 'INVALID TIME UNITS') return xlabel, ylabel