From e106f94166c90583d61855b3513a239b28f2b131 Mon Sep 17 00:00:00 2001 From: Paul McCarthy <pauld.mccarthy@gmail.com> Date: Wed, 9 Sep 2015 17:28:48 +0100 Subject: [PATCH] Half documented HistogramPanel. More work to be done. --- fsl/fsleyes/views/histogrampanel.py | 587 ++++++++++++++++------------ 1 file changed, 340 insertions(+), 247 deletions(-) diff --git a/fsl/fsleyes/views/histogrampanel.py b/fsl/fsleyes/views/histogrampanel.py index 29436a43c..4e41a4b96 100644 --- a/fsl/fsleyes/views/histogrampanel.py +++ b/fsl/fsleyes/views/histogrampanel.py @@ -1,10 +1,22 @@ #!/usr/bin/env python # -# histogrampanel.py - A panel which plots a histogram for the data from the -# currently selected overlay. +# histogrampanel.py - The HistogramPanel class. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # +"""This module provides the :class:`HistogramPanel`, which is a *FSLeyes view* +that plots the histogram of data from :class:`.Image` overlays. A +``HistogramPanel`` looks something like this: + +.. image:: images/histogrampanel.png + :scale: 50% + :align: center + + +``HistogramPanel`` instances use the :class:`HistogramSeries` class (a +:class:`.DataSeries` sub-class) to encapsulate histogram data. +""" + import logging @@ -23,32 +35,312 @@ import plotpanel log = logging.getLogger(__name__) - -def autoBin(data, dataRange): - # Automatic histogram bin calculation - # as implemented in the original FSLView +class HistogramPanel(plotpanel.PlotPanel): + """A :class:`.PlotPanel` which plots histograms from :class:`.Image` + overlay data. + + A ``HistogramPanel`` plots one or more :class:`HistogramSeries` instances, + each of which encapsulate histogram data from an :class:`.Image` overlay. + + By default, a ``HistogramPanel`` plots a histogram from the currently + selected overlay (dictated by the :attr:`.DisplayContext.selectedOverlay` + property), if it is an :class:`.Image` instance. In a similar manner to + the :class:`.TimeSeriesPanel`, this histogram is referred to as the + *current* histogram, and it can be enabled/disabled with the + :attr:`showCurrent` setting. - dMin, dMax = dataRange - dRange = dMax - dMin - binSize = np.power(10, np.ceil(np.log10(dRange) - 1) - 1) + A couple of control panels may be shown on a ``HistogramPanel``:: + + .. autosummary:: + :nosignatures: + + ~fsl.fsleyes.controls.histogramlistpanel.HistogramListPanel + ~fsl.fsleyes.controls.histogramcontrolpanel.HistogramControlPanel + + The following actions are provided, in addition to those already provided + by the :class:`.PlotPanel: + + ========================== =========================================== + ``toggleHistogramList`` Show/hide a :cass:`.HistogramListPanel`. + ``toggleHistogramControl`` Show/hide a :cass:`.HistogramControlPanel`. + ========================== =========================================== + """ + + + autoBin = props.Boolean(default=True) + """If ``True``, the number of bins used for each :class:`HistogramSeries` + is calculated automatically. Otherwise, :attr:`HistogramSeries.nbins` bins + are used. + """ - nbins = dRange / binSize - while nbins < 100: - binSize /= 2 - nbins = dRange / binSize + showCurrent = props.Boolean(default=True) + """If ``True``, a histogram for the currently selected overlay (if it is + an :class:`.Image` instance) is always plotted. + """ + + + histType = props.Choice(('probability', 'count')) + """The histogram type: + + =============== ========================================================== + ``count`` The y axis represents the absolute number of values within + each bin + ``probability`` The y axis represents the nuymber of values within each + bin, divided by the total number of values. + =============== ========================================================== + """ + + + selectedSeries = props.Int(minval=0, clamped=True) + """The currently selected :class:`HistogramSeries` - an index into the + :attr:`.PlotPanel.dataSeries` list. + + This property is used by the :class:`.HistogramListPanel` and the + :class:`.HistogramControlPanel`, to allow the user to change the settings + of individual :class:`HistogramSeries` instances. + """ + + + def __init__(self, parent, overlayList, displayCtx): + """Create a ``HistogramPanel``. + + :arg parent: The :mod:`wx` parent. + :arg overlayList: The :class:`.OverlayList` instance. + :arg displayCtx: The :class:`.DisplayContext` instance. + """ + + actionz = { + 'toggleHistogramList' : self.toggleHistogramList, + 'toggleHistogramControl' : lambda *a: self.togglePanel( + fslcontrols.HistogramControlPanel, self, location=wx.TOP) + } + + plotpanel.PlotPanel.__init__( + self, parent, overlayList, displayCtx, 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._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) + + # Creating a HistogramSeries is a bit expensive + # as it needs to, well, create a histogram. So + # we only create one HistogramSeries per overlay, + # and we cache them here so that the user only + # has to wait the first time they select an + # overlay for its histogram to be calculated. + # + # When a HistogramSeries is added to the dataSeries + # list, it is copied from the cached one so, again, + # the histogram calculation doesn't need to be done. + self.__histCache = {} + self.__current = None + self.__updateCurrent() + + self.Layout() + + + def destroy(self): + """Removes some property listeners, destroys all existing + :class:`HistogramSeries` instances, and calls + :meth:`.PlotPanel.destroy`. + """ + + self.removeListener('showCurrent', self._name) + self.removeListener('histType', self._name) + self.removeListener('autoBin', self._name) + self.removeListener('dataSeries', self._name) + + self._overlayList.removeListener('overlays', self._name) + self._displayCtx .removeListener('selectedOverlay', self._name) + + for hs in set(self.dataSeries[:] + self.__histCache.values()): + hs.destroy() + + plotpanel.PlotPanel.destroy(self) + + + def getCurrent(self): + """Return the :class:`HistogramSeries` instance for the currently + selected overlay. Returns ``None`` if :attr:``showCurrent` is + ``False``, or the current overlay is not an :class:`.Image`. + """ + if self.__current is None: + self.__updateCurrent() + + if self.__current is None: + return None + + return HistogramSeries(self.__current.overlay, + self, + self._displayCtx, + self._overlayList, + baseHs=self.__current) + + + def draw(self, *a): + """Draws the current :class:`HistogramSeries` if there is one,, and + any ``HistogramSeries`` that are in the :attr:`.PlotPanel.dataSeries` + list, via a call to :meth:`.PlotPanel.drawDataSeries`. + """ + + extra = None + + if self.showCurrent: + + if self.__current is not None: + extra = [self.__current] + + if self.smooth: self.drawDataSeries(extra) + else: self.drawDataSeries(extra, drawstyle='steps-pre') + + + def toggleHistogramList(self, *a): + """Shows/hides a :class:`.HistogramListPanel`. See the + :meth:`.ViewPanel.togglePanel` method. + """ + self.togglePanel(fslcontrols.HistogramListPanel, self, location=wx.TOP) + + + def __dataSeriesChanged(self, *a): + """Called when the :attr:`.PlotPanel.dataSeries` property changes. + """ + self.setConstraint('selectedSeries', + 'maxval', + len(self.dataSeries) - 1) + + listPanel = self.getPanel(fslcontrols.HistogramListPanel) + + if listPanel is None: + self.selectedSeries = 0 + else: + self.selectedSeries = listPanel.getListBox().GetSelection() + + + def __overlaysChanged(self, *a): + + 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) + + # 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() - if issubclass(data.dtype.type, np.integer): - binSize = max(1, np.ceil(binSize)) + + def __selectedOverlayChanged(self, *a): - adjMin = np.floor(dMin / binSize) * binSize - adjMax = np.ceil( dMax / binSize) * binSize + self.__updateCurrent() + self.draw() + + + 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. + """ - nbins = int((adjMax - adjMin) / binSize) + 1 + for ds in self.dataSeries: + ds.histPropsChanged() + + if self.__current is not None: + self.__current.histPropsChanged() + + self.draw() + + + def __updateCurrent(self): + + # 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 len(self._overlayList) == 0 or \ + not isinstance(overlay, fslimage.Image): + return + + # 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( + None, + 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() + + hs.colour = [0, 0, 0] + hs.alpha = 1 + hs.lineWidth = 1 + hs.lineStyle = '-' + hs.label = None - return nbins + self.__current = hs class HistogramSeries(plotpanel.DataSeries): @@ -139,7 +431,7 @@ class HistogramSeries(plotpanel.DataSeries): self.dataRange.xlo = nzmin self.dataRange.xhi = nzmax + dist - self.nbins = autoBin(nzData, self.dataRange.x) + self.nbins = self.autoBin(nzData, self.dataRange.x) if not self.overlay.is4DImage(): self.finiteData = finData @@ -228,7 +520,7 @@ class HistogramSeries(plotpanel.DataSeries): else: data = self.clippedFiniteData if self.hsPanel.autoBin: - nbins = autoBin(data, self.dataRange.x) + nbins = self.autoBin(data, self.dataRange.x) self.disableListener('nbins', self.name) self.nbins = nbins @@ -304,6 +596,33 @@ class HistogramSeries(plotpanel.DataSeries): self.showOverlayChanged() self.enableListener('showOverlay', self.name) + + def autoBin(self, 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 + + 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 + def getData(self): @@ -337,229 +656,3 @@ class HistogramSeries(plotpanel.DataSeries): if histType == 'count': return xdata, ydata elif histType == 'probability': return xdata, ydata / nvals - - -class HistogramPanel(plotpanel.PlotPanel): - - - autoBin = props.Boolean(default=True) - showCurrent = props.Boolean(default=True) - histType = props.Choice(('probability', 'count')) - - selectedSeries = props.Int(minval=0, clamped=True) - - - def __init__(self, parent, overlayList, displayCtx): - - actionz = { - 'toggleHistogramList' : self.toggleHistogramList, - 'toggleHistogramControl' : lambda *a: self.togglePanel( - fslcontrols.HistogramControlPanel, self, location=wx.TOP) - } - - plotpanel.PlotPanel.__init__( - self, parent, overlayList, displayCtx, 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._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, self, location=wx.TOP) - - 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. """ - - self.removeListener('showCurrent', self._name) - self.removeListener('histType', self._name) - self.removeListener('autoBin', self._name) - self.removeListener('dataSeries', self._name) - - self._overlayList.removeListener('overlays', self._name) - self._displayCtx .removeListener('selectedOverlay', self._name) - - for hs in set(self.dataSeries[:] + self.__histCache.values()): - hs.destroy() - - plotpanel.PlotPanel.destroy(self) - - - def __dataSeriesChanged(self, *a): - self.setConstraint('selectedSeries', - 'maxval', - len(self.dataSeries) - 1) - - listPanel = self.getPanel(fslcontrols.HistogramListPanel) - - if listPanel is None: - self.selectedSeries = 0 - else: - self.selectedSeries = listPanel.getListBox().GetSelection() - - - def __overlaysChanged(self, *a): - - 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) - - # 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() - - - def __selectedOverlayChanged(self, *a): - - self.__updateCurrent() - self.draw() - - - 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. - """ - - for ds in self.dataSeries: - ds.histPropsChanged() - - if self.__current is not None: - self.__current.histPropsChanged() - - self.draw() - - - def __updateCurrent(self): - - # 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 len(self._overlayList) == 0 or \ - not isinstance(overlay, fslimage.Image): - return - - # 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( - None, - 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() - - hs.colour = [0, 0, 0] - hs.alpha = 1 - hs.lineWidth = 1 - hs.lineStyle = '-' - hs.label = None - - self.__current = hs - - - def getCurrent(self): - if self.__current is None: - self.__updateCurrent() - - if self.__current is None: - return None - - return HistogramSeries(self.__current.overlay, - self, - self._displayCtx, - self._overlayList, - baseHs=self.__current) - - - def draw(self, *a): - - extra = None - - if self.showCurrent: - - if self.__current is not None: - extra = [self.__current] - - if self.smooth: self.drawDataSeries(extra) - else: self.drawDataSeries(extra, drawstyle='steps-pre') -- GitLab