Skip to content
Snippets Groups Projects
Commit e106f941 authored by Paul McCarthy's avatar Paul McCarthy
Browse files

Half documented HistogramPanel. More work to be done.

parent b4c967a8
No related branches found
No related tags found
No related merge requests found
#!/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')
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment