From 4507aaa78509da3b198e1f80c2c19d03c56d478f Mon Sep 17 00:00:00 2001 From: Paul McCarthy <pauld.mccarthy@gmail.com> Date: Mon, 2 Nov 2015 13:19:15 +0000 Subject: [PATCH] TimeSeriesPanel and PowerSpectrogramPanel now share a common base class, the OverlayPlotPanel, which derives from PlotPanel, and contains all that DataSeries caching logic. HistogramPanel will follow. --- fsl/fsleyes/views/plotpanel.py | 183 ++++++++++++++--- fsl/fsleyes/views/powerspectrumpanel.py | 145 +++---------- fsl/fsleyes/views/timeseriespanel.py | 262 ++++++------------------ 3 files changed, 242 insertions(+), 348 deletions(-) diff --git a/fsl/fsleyes/views/plotpanel.py b/fsl/fsleyes/views/plotpanel.py index 6e04dd227..3ce9df49d 100644 --- a/fsl/fsleyes/views/plotpanel.py +++ b/fsl/fsleyes/views/plotpanel.py @@ -58,10 +58,7 @@ class PlotPanel(viewpanel.ViewPanel): 3. Override the :meth:`draw` method, so it calls the :meth:`drawDataSeries` method. - 4. Override the :meth:`getDataSeries` method, so it returns a - :class:`.DataSeries` instance associated with a given overlay. - - 5. If necessary, override the :meth:`destroy` method, but make + 4. If necessary, override the :meth:`destroy` method, but make sure that the base-class implementation is called. @@ -202,13 +199,17 @@ class PlotPanel(viewpanel.ViewPanel): figure = plt.Figure() axis = figure.add_subplot(111) - canvas = Canvas(self, -1, figure) + canvas = Canvas(self, -1, figure) + figure.subplots_adjust(top=1.0, bottom=0.0, left=0.0, right=1.0) + figure.patch.set_visible(False) + self.setCentrePanel(canvas) self.__figure = figure self.__axis = axis self.__canvas = canvas + self.__name = 'OverlayPlotPanel_{}'.format(self._name) if interactive: @@ -237,10 +238,9 @@ class PlotPanel(viewpanel.ViewPanel): 'smooth', 'xlabel', 'ylabel']: - self.addListener(propName, self._name, self.draw) + 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) @@ -248,7 +248,7 @@ class PlotPanel(viewpanel.ViewPanel): self.__name, self.__limitsChanged) - self.Bind(wx.EVT_SIZE, lambda *a: self.draw()) + self.Bind(wx.EVT_SIZE, self.draw) def getFigure(self): @@ -278,20 +278,6 @@ class PlotPanel(viewpanel.ViewPanel): raise NotImplementedError('The draw method must be ' 'implemented by PlotPanel subclasses') - - def getDataSeries(self, overlay): - """This method must be overridden by ``PlotPanel`` sub-classes. - - It may be called by the :class:`.PlotControlPanel` and - :class:`.PlotListPanel` to display controls allowing the user - to change :class:`.DataSeries` display properties. - - It should return the :class:`.DataSeries` instance associated with - the given overlay, or ``None`` if there is no ``DataSeries`` instance. - """ - raise NotImplementedError('The getDataSeries method must be ' - 'implemented by PlotPanel subclasses') - def destroy(self): """Removes some property listeners, and then calls @@ -310,7 +296,7 @@ class PlotPanel(viewpanel.ViewPanel): 'smooth', 'xlabel', 'ylabel']: - self.removeListener(propName, self._name) + self.removeListener(propName, self.__name) viewpanel.ViewPanel.destroy(self) @@ -696,3 +682,154 @@ class PlotPanel(viewpanel.ViewPanel): self.enableListener('limits', self.__name) return (xmin, xmax), (ymin, ymax) + + +class OverlayPlotPanel(PlotPanel): + + + showMode = props.Choice(('current', 'all', 'none')) + """Defines which data series to plot. + + =========== ===================================================== + ``current`` The time course for the currently selected overlay is + plotted. + ``all`` The time courses for all compatible overlays in the + :class:`.OverlayList` are plotted. + ``none`` Only the ``TimeSeries`` that are in the + :attr:`.PlotPanel.dataSeries` list will be plotted. + =========== ===================================================== + """ + + def __init__(self, *args, **kwargs): + + PlotPanel.__init__(self, *args, **kwargs) + + self.__name = 'OverlayPlotPanel_{}'.format(self._name) + + # The dataSeries attribute is a dictionary of + # + # {overlay : DataSeries} + # + # mappings, containing a DataSeries instance for + # each compatible overlay in the overlay list. + # + # Different DataSeries types need to be re-drawn + # when different properties change. For example, + # a TimeSeries instance needs to be redrawn when + # the DisplayContext.location property changes, + # whereas a MelodicTimeSeries instance needs to + # be redrawn when the VolumeOpts.volume property + # changes. + # + # Therefore, the refreshProps dictionary contains + # a set of + # + # {overlay : ([targets], [propNames])} + # + # mappings - for each overlay, a list of + # target objects (e.g. DisplayContext, VolumeOpts, + # etc), and a list of property names on each, + # defining the properties that need to trigger a + # redraw. + self.__dataSeries = {} + self.__refreshProps = {} + + self .addListener('showMode', + self.__name, + self.draw) + self._displayCtx .addListener('selectedOverlay', + self.__name, + self.draw) + self._overlayList.addListener('overlays', + self.__name, + self.__overlayListChanged) + + self.updateDataSeries() + + + def destroy(self): + self .removeListener('showMode', self.__name) + self._overlayList.removeListener('overlays', self.__name) + self._displayCtx .removeListener('selectedOverlay', self.__name) + + + def getDataSeries(self, overlay): + """ + + It may be called by the :class:`.PlotControlPanel` and + :class:`.PlotListPanel` to display controls allowing the user + to change :class:`.DataSeries` display properties. + + It should return the :class:`.DataSeries` instance associated with + the given overlay, or ``None`` if there is no ``DataSeries`` instance. + """ + return self.__dataSeries.get(overlay) + + + def createDataSeries(self, overlay): + """ + """ + raise NotImplementedError('createDataSeries must be ' + 'implemented by sub-classes') + + + def clearDataSeries(self, overlay): + """Destroys the internally cached :class:`.DataSeries` for the given + overlay. + """ + + ts = self.__dataSeries .pop(overlay, None) + targets, propNames = self.__refreshProps.pop(overlay, ([], [])) + + if ts is not None: + ts.destroy() + + for t, p in zip(targets, propNames): + t.removeListener(p, self.__name) + + + def updateDataSeries(self): + + for ovl in self._overlayList: + if ovl not in self.__dataSeries: + + ds, refreshTargets, refreshProps = self.createDataSeries(ovl) + + if ds is None: + continue + + self.__dataSeries[ ovl] = ds + self.__refreshProps[ovl] = (refreshTargets, refreshProps) + + ds.addGlobalListener(self.__name, self.draw, overwrite=True) + + for targets, propNames in self.__refreshProps.values(): + for target, propName in zip(targets, propNames): + target.addListener(propName, + self.__name, + self.draw, + overwrite=True) + + + def __overlayListChanged(self, *a): + """Called when the :class:`.OverlayList` changes. Makes sure that + there are no :class:`.TimeSeries` instances in the + :attr:`.PlotPanel.dataSeries` list, or in the internal cache, which + refer to overlays that no longer exist. + + Also calls :meth:`__updateCurrentTimeSeries`, whic ensures that a + :class:`.TimeSeries` instance for every compatiblew overlay is + cached internally. + """ + + for ds in list(self.dataSeries): + if ds.overlay not in self._overlayList: + self.dataSeries.remove(ds) + ds.destroy() + + for overlay in list(self.__dataSeries.keys()): + if overlay not in self._overlayList: + self.clearDataSeries(overlay) + + self.updateDataSeries() + self.draw() diff --git a/fsl/fsleyes/views/powerspectrumpanel.py b/fsl/fsleyes/views/powerspectrumpanel.py index 1022394f5..becef85ec 100644 --- a/fsl/fsleyes/views/powerspectrumpanel.py +++ b/fsl/fsleyes/views/powerspectrumpanel.py @@ -28,10 +28,10 @@ import fsl.data.melodicimage as fslmelimage log = logging.getLogger(__name__) -class PowerSpectrumPanel(plotpanel.PlotPanel): - """The ``PowerSpectrumPanel`` class is a :class:`.PlotPanel` which plots - power spectra of overlay data. The ``PowerSpectrumPanel`` shares much of - its design with the :class:`.TimeSeriesPanel`. +class PowerSpectrumPanel(plotpanel.OverlayPlotPanel): + """The ``PowerSpectrumPanel`` class is an :class:`.OverlayPlotPanel` which + plots power spectra of overlay data. The ``PowerSpectrumPanel`` shares + much of its design with the :class:`.TimeSeriesPanel`. The ``PowerSpectrumPanel`` uses :class:`.PowerSpectrumSeries` to plot @@ -51,20 +51,6 @@ class PowerSpectrumPanel(plotpanel.PlotPanel): """If ``True``, the x axis is scaled so that it represents frequency. """ - - showMode = props.Choice(('current', 'all', 'none')) - """Defines which power spectra to plot. - - =========== ======================================================== - ``current`` The power spectrum for the currently selected overlay is - plotted. - ``all`` The power spectra for all compatible overlays in the - :class:`.OverlayList` are plotted. - ``none`` Only the ``PowerSpectrumSeries`` that are in the - :attr:`.PlotPanel.dataSeries` list will be plotted. - =========== ======================================================== - """ - def __init__(self, parent, overlayList, displayCtx): """ @@ -77,53 +63,27 @@ class PowerSpectrumPanel(plotpanel.PlotPanel): location=wx.TOP) } - plotpanel.PlotPanel.__init__(self, - parent, - overlayList, - displayCtx, - actionz=actionz) - - figure = self.getFigure() - - figure.subplots_adjust( - top=1.0, bottom=0.0, left=0.0, right=1.0) - - figure.patch.set_visible(False) - - # A dictionary of - # - # {overlay : PowerSpectrumSeries} - # - # instances, one for each (compatible) - # overlay in the overlay list - self.__spectra = {} - self.__refreshProps = {} - - self .addListener('plotFrequencies', self._name, self.draw) - self .addListener('showMode', self._name, self.draw) - self .addListener('plotMelodicICs', + plotpanel.OverlayPlotPanel.__init__(self, + parent, + overlayList, + displayCtx, + actionz=actionz) + + + self.addListener('plotFrequencies', self._name, self.draw) + self.addListener('plotMelodicICs', self._name, self.__plotMelodicICsChanged) - overlayList.addListener('overlays', - self._name, - self.__overlayListChanged) - - displayCtx .addListener('selectedOverlay', - self._name, - self.__selectedOverlayChanged) - self.__overlayListChanged() + self.draw() def destroy(self): - self._overlayList.removeListener('overlays', self._name) - self._displayCtx .removeListener('selectedOverlay', self._name) - - plotpanel.PlotPanel.destroy(self) - + + self.removeListener('plotFrequencies', self._name) + self.removeListener('plotMelodicICs', self._name) + plotpanel.OverlayPlotPanel.destroy(self) - def getDataSeries(self, overlay): - return self.__spectra.get(overlay) def draw(self, *a): @@ -135,32 +95,12 @@ class PowerSpectrumPanel(plotpanel.PlotPanel): else: overlays = [] - pss = [self.__spectra.get(o) for o in overlays] + pss = [self.getDataSeries(o) for o in overlays] pss = [ps for ps in pss if ps is not None] self.drawDataSeries(extraSeries=pss, preproc=self.__prepareSpectrumData) - - def __overlayListChanged(self, *a): - - # Destroy any spectrum series for overlays - # that have been removed from the list - for ds in list(self.dataSeries): - if ds.overlay not in self._overlayList: - self.dataSeries.remove(ds) - ds.destroy() - - for overlay, ds in list(self.__spectra.items()): - if overlay not in self._overlayList: - self.__clearCacheForOverlay(overlay) - - self.__updateCachedSpectra() - self.draw() - - - def __selectedOverlayChanged(self, *a): - self.draw() def __plotMelodicICsChanged(self, *a): @@ -168,55 +108,16 @@ class PowerSpectrumPanel(plotpanel.PlotPanel): 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.__clearCacheForOverlay(overlay) + self.clearDataSeries(overlay) - self.__updateCachedSpectra() + self.updateDataSeries() self.draw() - def __clearCacheForOverlay(self, overlay): - """Destroys the internally cached :class:`.TimeSeries` for the given - overlay. - """ - - ts = self.__spectra .pop(overlay, None) - targets, propNames = self.__refreshProps.pop(overlay, ([], [])) - - if ts is not None: - ts.destroy() - - for t, p in zip(targets, propNames): - t.removeListener(p, self._name) - - - def __updateCachedSpectra(self): - # Create a new spectrum series for overlays - # which have been added to the list - for overlay in self._overlayList: - - ss = self.__spectra.get(overlay) - - if ss is None: - - ss, targets, propNames = self.__createSpectrumSeries(overlay) - - if ss is None: - continue - - self.__spectra[ overlay] = ss - self.__refreshProps[overlay] = targets, propNames - - ss.addGlobalListener(self._name, self.draw, overwrite=True) - - for targets, propNames in self.__refreshProps.values(): - for t, p in zip(targets, propNames): - t.addListener(p, self._name, self.draw, overwrite=True) - - - def __createSpectrumSeries(self, overlay): + def createDataSeries(self, overlay): if self.plotMelodicICs and \ isinstance(overlay, fslmelimage.MelodicImage): diff --git a/fsl/fsleyes/views/timeseriespanel.py b/fsl/fsleyes/views/timeseriespanel.py index 8e7767246..3fcc26336 100644 --- a/fsl/fsleyes/views/timeseriespanel.py +++ b/fsl/fsleyes/views/timeseriespanel.py @@ -28,7 +28,7 @@ import fsl.fsleyes.controls.timeserieslistpanel as timeserieslistpanel log = logging.getLogger(__name__) -class TimeSeriesPanel(plotpanel.PlotPanel): +class TimeSeriesPanel(plotpanel.OverlayPlotPanel): """The ``TimeSeriesPanel`` is a :class:`.PlotPanel` which plots time series data from :class:`.Image` overlays. A ``TimeSeriesPanel`` looks something like the following: @@ -124,20 +124,6 @@ class TimeSeriesPanel(plotpanel.PlotPanel): to the TR time). """ - - showMode = props.Choice(('current', 'all', 'none')) - """Defines which time series to plot. - - =========== ===================================================== - ``current`` The time course for the currently selected overlay is - plotted. - ``all`` The time courses for all compatible overlays in the - :class:`.OverlayList` are plotted. - ``none`` Only the ``TimeSeries`` that are in the - :attr:`.PlotPanel.dataSeries` list will be plotted. - =========== ===================================================== - """ - plotMode = props.Choice(('normal', 'demean', 'normalise', 'percentChange')) """Options to scale/offset the plotted time courses. @@ -178,87 +164,33 @@ class TimeSeriesPanel(plotpanel.PlotPanel): location=wx.TOP) } - plotpanel.PlotPanel.__init__( + plotpanel.OverlayPlotPanel.__init__( self, parent, overlayList, displayCtx, actionz=actionz) - figure = self.getFigure() - - figure.subplots_adjust( - top=1.0, bottom=0.0, left=0.0, right=1.0) - - figure.patch.set_visible(False) - - self .addListener('plotMode', self._name, self.draw) - self .addListener('usePixdim', self._name, self.draw) - self .addListener('showMode', self._name, self.draw) - displayCtx .addListener('selectedOverlay', self._name, self.draw) - self .addListener('plotMelodicICs', - self._name, - self.__plotMelodicICsChanged) - overlayList.addListener('overlays', - self._name, - self.__overlayListChanged) - - # The currentTss attribute is a dictionary of - # - # {overlay : TimeSeries} - # - # mappings, containing a TimeSeries instance for - # each compatible overlay in the overlay list. - # - # Different TimeSeries types need to be re-drawn - # when different properties change. For example, - # a TimeSeries instance needs to be redrawn when - # the DisplayContext.location property changes, - # whereas a MelodicTimeSeries instance needs to - # be redrawn when the VolumeOpts.volume property - # changes. - # - # Therefore, the refreshProps dictionary contains - # a set of - # - # {overlay : ([targets], [propNames])} - # - # mappings - for each overlay, a list of - # target objects (e.g. DisplayContext, VolumeOpts, - # etc), and a list of property names on each, - # defining the properties that need to trigger a - # redraw. - self.__currentTss = {} - self.__refreshProps = {} + self.addListener('plotMode', self._name, self.draw) + self.addListener('usePixdim', self._name, self.draw) + self.addListener('plotMelodicICs', + self._name, + self.__plotMelodicICsChanged) def addPanels(): self.run('toggleTimeSeriesControl') self.run('toggleTimeSeriesList') wx.CallAfter(addPanels) - - self.__overlayListChanged() - + self.draw() + 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('showMode', self._name) + self.removeListener('plotMode', self._name) + self.removeListener('usePixdim', self._name) + self.removeListener('plotMelodicICs', self._name) - self._overlayList.removeListener('overlays', self._name) - self._displayCtx .removeListener('selectedOverlay', self._name) - - for (targets, propNames) in self.__refreshProps.values(): - for target, propName in zip(targets, propNames): - target.removeListener(propName, self._name) - - for ts in self.__currentTss.values(): - ts.removeGlobalListener(self) - - self.__currentTss = None - self.__refreshProps = None - - plotpanel.PlotPanel.destroy(self) + plotpanel.OverlayPlotPanel.destroy(self) def draw(self, *a): @@ -275,7 +207,7 @@ class TimeSeriesPanel(plotpanel.PlotPanel): else: overlays = [] - tss = [self.__currentTss.get(o) for o in overlays] + tss = [self.getDataSeries(o) for o in overlays] tss = [ts for ts in tss if ts is not None] for i, ts in enumerate(list(tss)): @@ -290,12 +222,50 @@ class TimeSeriesPanel(plotpanel.PlotPanel): preproc=self.__prepareTimeSeriesData) - def getDataSeries(self, overlay): - """Overrides :meth:`.PlotPanel.getDataSeries`. Returns the - :class:`.TimeSeries` instance for the specified overlay, or ``None`` - if there is none. + def createDataSeries(self, overlay): + """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:`.TimeSeries` 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. """ - return self.__currentTss.get(overlay) + + if not (isinstance(overlay, fslimage.Image) and overlay.is4DImage()): + return None, None, None + + if isinstance(overlay, fslfeatimage.FEATImage): + ts = plotting.FEATTimeSeries(self, overlay, self._displayCtx) + targets = [self._displayCtx] + propNames = ['location'] + + elif isinstance(overlay, fslmelimage.MelodicImage) and \ + self.plotMelodicICs: + ts = plotting.MelodicTimeSeries(self, overlay, self._displayCtx) + targets = [self._displayCtx.getOpts(overlay)] + propNames = ['volume'] + + else: + ts = plotting.VoxelTimeSeries(self, overlay, self._displayCtx) + targets = [self._displayCtx] + propNames = ['location'] + + ts.colour = fslcmaps.randomDarkColour() + ts.alpha = 1 + ts.lineWidth = 1 + ts.lineStyle = '-' + ts.label = ts.makeLabel() + + return ts, targets, propNames def __prepareTimeSeriesData(self, ts): @@ -331,45 +301,6 @@ class TimeSeriesPanel(plotpanel.PlotPanel): return xdata, ydata - def __overlayListChanged(self, *a): - """Called when the :class:`.OverlayList` changes. Makes sure that - there are no :class:`.TimeSeries` instances in the - :attr:`.PlotPanel.dataSeries` list, or in the internal cache, which - refer to overlays that no longer exist. - - Also calls :meth:`__updateCurrentTimeSeries`, whic ensures that a - :class:`.TimeSeries` instance for every compatiblew overlay is - cached internally. - """ - - for ds in list(self.dataSeries): - if ds.overlay not in self._overlayList: - self.dataSeries.remove(ds) - ds.destroy() - - for overlay in list(self.__currentTss.keys()): - if overlay not in self._overlayList: - self.__clearCacheForOverlay(overlay) - - self.__updateCurrentTimeSeries() - self.draw() - - - def __clearCacheForOverlay(self, overlay): - """Destroys the internally cached :class:`.TimeSeries` for the given - overlay. - """ - - ts = self.__currentTss .pop(overlay, None) - targets, propNames = self.__refreshProps.pop(overlay, ([], [])) - - if ts is not None: - ts.destroy() - - for t, p in zip(targets, propNames): - t.removeListener(p, self._name) - - def __plotMelodicICsChanged(self, *a): """Called when the :attr:`plotMelodicICs` property changes. Re-creates the internally cached :class:`.TimeSeries` instances for all @@ -378,82 +309,7 @@ class TimeSeriesPanel(plotpanel.PlotPanel): for overlay in self._overlayList: if isinstance(overlay, fslmelimage.MelodicImage): - self.__clearCacheForOverlay(overlay) + self.clearDataSeries(overlay) - self.__updateCurrentTimeSeries() + self.updateDataSeries() self.draw() - - - def __updateCurrentTimeSeries(self, *a): - """Makes sure that a :class:`.TimeSeries` instance exists for every - compatible overlay in the :class:`.OverlayList`, and that - relevant property listeners are registered so they are redrawn as - needed. - """ - - for ovl in self._overlayList: - if ovl not in self.__currentTss: - - ts, refreshTargets, refreshProps = self.__genOneTimeSeries(ovl) - - if ts is None: - continue - - self.__currentTss[ ovl] = ts - self.__refreshProps[ovl] = (refreshTargets, refreshProps) - - ts.addGlobalListener(self._name, self.draw, overwrite=True) - - for targets, propNames in self.__refreshProps.values(): - for target, propName in zip(targets, propNames): - target.addListener(propName, - self._name, - self.draw, - overwrite=True) - - - - def __genOneTimeSeries(self, overlay): - """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:`.TimeSeries` 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. - """ - - if not (isinstance(overlay, fslimage.Image) and overlay.is4DImage()): - return None, None, None - - if isinstance(overlay, fslfeatimage.FEATImage): - ts = plotting.FEATTimeSeries(self, overlay, self._displayCtx) - targets = [self._displayCtx] - propNames = ['location'] - - elif isinstance(overlay, fslmelimage.MelodicImage) and \ - self.plotMelodicICs: - ts = plotting.MelodicTimeSeries(self, overlay, self._displayCtx) - targets = [self._displayCtx.getOpts(overlay)] - propNames = ['volume'] - - else: - ts = plotting.VoxelTimeSeries(self, overlay, self._displayCtx) - targets = [self._displayCtx] - propNames = ['location'] - - ts.colour = fslcmaps.randomDarkColour() - ts.alpha = 1 - ts.lineWidth = 1 - ts.lineStyle = '-' - ts.label = ts.makeLabel() - - return ts, targets, propNames -- GitLab