Source code for fsleyes.plotting.histogramseries

#
# histogramseries.py - The HistogramSeries class.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`HistogramSeries`,
 :class:`ImageHistogramSeries`, :class:`ComplexHistogramSeries`, and
 :class:`MeshHistogramSeries` classes, used by the :class:`.HistogramPanel`
 for plotting histogram data.

Two standalone functions are also defined in this module:

  .. autosummary::
     :nosignatures:

     histogram
     autoBin
"""

import logging

import numpy as np

import fsl.utils.cache              as cache
import fsleyes_widgets.utils.status as status
import fsleyes_props                as props
import fsleyes.colourmaps           as fslcm
from . import                          dataseries


log = logging.getLogger(__name__)


[docs]class HistogramSeries(dataseries.DataSeries): """A ``HistogramSeries`` generates histogram data from an overlay. It is the base class for the :class:`ImageHistogramSeriess` and :class:`MeshHistogramSeries` classes. """ nbins = props.Int(minval=10, maxval=1000, default=100, clamped=False) """Number of bins to use in the histogram. This value is overridden by the :attr:`autoBin` setting. """ 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. """ ignoreZeros = props.Boolean(default=True) """If ``True``, zeros are excluded from the calculated histogram. """ includeOutliers = props.Boolean(default=False) """If ``True``, values which are outside of the :attr:`dataRange` are included in the histogram end bins. """ dataRange = props.Bounds(ndims=1, clamped=False) """Specifies the range of data which should be included in the histogram. See the :attr:`includeOutliers` property. """
[docs] def __init__(self, overlay, overlayList, displayCtx, plotPanel): """Create a ``HistogramSeries``. :arg overlay: The overlay from which the data to be plotted is retrieved. :arg overlayList: The :class:`.OverlayList` instance. :arg displayCtx: The :class:`.DisplayContext` instance. :arg plotPanel: The :class:`.HistogramPanel` that owns this ``HistogramSeries``. """ log.debug('New HistogramSeries instance for {} '.format(overlay.name)) dataseries.DataSeries.__init__( self, overlay, overlayList, displayCtx, plotPanel) self.__nvals = 0 self.__dataKey = None self.__xdata = np.array([]) self.__ydata = np.array([]) self.__finiteData = np.array([]) self.__nonZeroData = np.array([]) self.__clippedFiniteData = np.array([]) self.__clippedNonZeroData = np.array([]) self.__dataCache = cache.Cache(maxsize=10) self.__histCache = cache.Cache(maxsize=100) self.addListener('dataRange', self.name, self.__dataRangeChanged) self.addListener('nbins', self.name, self.__histPropsChanged) self.addListener('autoBin', self.name, self.__histPropsChanged) self.addListener('ignoreZeros', self.name, self.__histPropsChanged) self.addListener('includeOutliers', self.name, self.__histPropsChanged)
[docs] def destroy(self): """This needs to be called when this ``HistogramSeries`` instance is no longer being used. """ self.removeListener('nbins', self.name) self.removeListener('ignoreZeros', self.name) self.removeListener('includeOutliers', self.name) self.removeListener('dataRange', self.name) self.removeListener('nbins', self.name) self.__dataCache.clear() self.__histCache.clear() self.__dataCache = None self.__histCache = None self.__nvals = 0 self.__dataKey = None self.__xdata = None self.__ydata = None self.__finiteData = None self.__nonZeroData = None self.__clippedFiniteData = None self.__clippedNonZeroData = None dataseries.DataSeries.destroy(self)
[docs] def setHistogramData(self, data, key): """Must be called by sub-classes whenever the underlying histogram data changes. :arg data: A ``numpy`` array containing the data that the histogram is to be calculated on. Pass in ``None`` to indicate that there is currently no histogram data. :arg key: Something which identifies the ``data``, and can be used as a ``dict`` key. """ if data is None: self.__nvals = 0 self.__dataKey = None self.__xdata = np.array([]) self.__ydata = np.array([]) self.__finiteData = np.array([]) self.__nonZeroData = np.array([]) self.__clippedFiniteData = np.array([]) self.__clippedNonZeroData = np.array([]) # force the panel to refresh with props.skip(self, 'dataRange', self.name): self.propNotify('dataRange') return # We cache the following data, based # on the provided key, so they don't # need to be recalculated: # - finite data # - non-zero data # - finite minimum # - finite maximum # # The cache size is restricted (see its # creation in __init__) so we don't blow # out RAM cached = self.__dataCache.get(key, None) if cached is None: log.debug('New histogram data {} - extracting ' 'finite/non-zero data'.format(key)) finData = data[np.isfinite(data)] nzData = finData[finData != 0] dmin = finData.min() dmax = finData.max() self.__dataCache.put(key, (finData, nzData, dmin, dmax)) else: log.debug('Got histogram data {} from cache'.format(key)) finData, nzData, dmin, dmax = cached # The upper bound on the dataRange # is exclusive, so we initialise it # to a bit more than the data max. dist = (dmax - dmin) / 10000.0 with props.suppressAll(self): self.dataRange.xmin = dmin self.dataRange.xmax = dmax + dist self.dataRange.xlo = dmin self.dataRange.xhi = dmax + dist self.nbins = autoBin(nzData, self.dataRange.x) self.__dataKey = key self.__finiteData = finData self.__nonZeroData = nzData self.__dataRangeChanged() with props.skip(self, 'dataRange', self.name): self.propNotify('dataRange')
[docs] def onDataRangeChange(self): """May be implemented by sub-classes. Is called when the :attr:`dataRange` changes. """ pass
[docs] def getData(self): """Overrides :meth:`.DataSeries.getData`. Returns a tuple containing the ``(x, y)`` histogram data. """ return self.__xdata, self.__ydata
@property def binWidth(self): """Returns the width of one bin for this :class:`HistogramSeries`. """ lo, hi = self.dataRange.x return (hi - lo) / self.nbins
[docs] def getVertexData(self): """Returns a ``numpy`` array of shape ``(N, 2)``, which contains a set of "vertices" which can be used to display the histogram data as a filled polygon. """ x, y = self.getData() if x is None or y is None: return None verts = np.zeros((len(x) * 2, 2), dtype=x.dtype) verts[ :, 0] = x.repeat(2) verts[ 1:-1, 1] = y.repeat(2) return verts
@property def numHistogramValues(self): """Returns the number of values which were used in calculating the histogram. """ return self.__nvals def __dataRangeChanged(self, *args, **kwargs): """Called when the :attr:`dataRange` property changes, and also by the :meth:`__initProperties` and :meth:`__volumeChanged` methods. """ finData = self.__finiteData nzData = self.__nonZeroData self.__clippedFiniteData = finData[(finData >= self.dataRange.xlo) & (finData < self.dataRange.xhi)] self.__clippedNonZeroData = nzData[ (nzData >= self.dataRange.xlo) & (nzData < self.dataRange.xhi)] self.onDataRangeChange() self.__histPropsChanged() def __histPropsChanged(self, *a): """Called internally, and when any histogram settings change. Re-calculates the histogram data. """ log.debug('Calculating histogram for ' 'overlay {}'.format(self.overlay.name)) status.update('Calculating histogram for ' 'overlay {}'.format(self.overlay.name)) if np.isclose(self.dataRange.xhi, self.dataRange.xlo): self.__xdata = np.array([]) self.__ydata = np.array([]) self.__nvals = 0 return if self.ignoreZeros: if self.includeOutliers: data = self.__nonZeroData else: data = self.__clippedNonZeroData else: if self.includeOutliers: data = self.__finiteData else: data = self.__clippedFiniteData # Figure out the number of bins to use if self.autoBin: nbins = autoBin(data, self.dataRange.x) else: nbins = self.nbins # nbins is unclamped, but # we don't allow < 10 if nbins < 10: nbins = 10 # Update the nbins property with props.skip(self, 'nbins', self.name): self.nbins = nbins # We cache calculated bins and counts # for each combination of parameters, # as histogram calculation can take # time. hrange = (self.dataRange.xlo, self.dataRange.xhi) drange = (self.dataRange.xmin, self.dataRange.xmax) histkey = (self.__dataKey, self.includeOutliers, self.ignoreZeros, hrange, drange, self.nbins) cached = self.__histCache.get(histkey, None) if cached is not None: histX, histY, nvals = cached else: histX, histY, nvals = histogram(data, self.nbins, hrange, drange, self.includeOutliers, True) self.__histCache.put(histkey, (histX, histY, nvals)) self.__xdata = histX self.__ydata = histY self.__nvals = nvals status.update('Histogram for {} calculated.'.format( self.overlay.name)) log.debug('Calculated histogram for overlay ' '{} (number of values: {}, number ' 'of bins: {})'.format( self.overlay.name, self.__nvals, self.nbins))
[docs]class ImageHistogramSeries(HistogramSeries): """An ``ImageHistogramSeries`` instance manages generation of histogram data for an :class:`.Image` overlay. """ showOverlay = props.Boolean(default=False) """If ``True``, a mask :class:`.ProxyImage` overlay is added to the :class:`.OverlayList`, which highlights the voxels that have been included in the histogram. The mask image is managed by the :class:`.HistogramProfile` instance, which manages histogram plot interaction. """ showOverlayRange = props.Bounds(ndims=1) """Data range to display with the :attr:`.showOverlay` mask. """
[docs] def __init__(self, *args, **kwargs): """Create an ``ImageHistogramSeries``. All arguments are passed through to :meth:`HistogramSeries.__init__`. """ HistogramSeries.__init__(self, *args, **kwargs) self.__display = self.displayCtx.getDisplay(self.overlay) self.__opts = self.displayCtx.getOpts( self.overlay) self.__display.addListener('overlayType', self.name, self.__overlayTypeChanged) self.__opts .addListener('volume', self.name, self.__volumeChanged) self.__opts .addListener('volumeDim', self.name, self.__volumeChanged) self.__volumeChanged()
[docs] def destroy(self): """Must be called when this ``ImageHistogramSeries`` is no longer needed. Removes some property listeners, and calls :meth:`HistogramSeries.destroy`. """ HistogramSeries.destroy(self) self.__display.removeListener('overlayType', self.name) self.__opts .removeListener('volume', self.name) self.__opts .removeListener('volumeDim', self.name)
[docs] def redrawProperties(self): """Overrides :meth:`.DataSeries.redrawProperties`. The ``HistogramSeries`` data does not need to be re-plotted when the :attr:`showOverlay` or :attr:`showOverlayRange` properties change. """ propNames = dataseries.DataSeries.redrawProperties(self) propNames.remove('showOverlay') propNames.remove('showOverlayRange') return propNames
[docs] def onDataRangeChange(self): """Overrides :meth:`HistogramSeries.onDataRangeChange`. Makes sure that the :attr:`showOverlayRange` limits are synced to the :attr:`HistogramSeries.dataRange`. """ with props.suppress(self, 'showOverlayRange', notify=True): dlo, dhi = self.dataRange.x dist = (dhi - dlo) / 10000.0 needsInit = np.all(np.isclose(self.showOverlayRange.x, [0, 0])) self.showOverlayRange.xmin = dlo - dist self.showOverlayRange.xmax = dhi + dist if needsInit or not self.showOverlay: self.showOverlayRange.xlo = dlo self.showOverlayRange.xhi = dhi else: self.showOverlayRange.xlo = max(dlo, self.showOverlayRange.xlo) self.showOverlayRange.xhi = min(dhi, self.showOverlayRange.xhi)
def __volumeChanged(self, *args, **kwargs): """Called when the :attr:`volume` property changes, and also by the :meth:`__init__` method. Passes the data to the :meth:`HistogramSeries.setHistogramData` method. """ opts = self.__opts overlay = self.overlay volkey = (opts.volumeDim, opts.volume) self.setHistogramData(overlay[opts.index()], volkey) def __overlayTypeChanged(self, *a): """Called when the :attr:`.Display.overlayType` changes. When this happens, the :class:`.DisplayOpts` instance associated with the overlay gets destroyed and recreated. This method de-registers and re-registers property listeners as needed. """ oldOpts = self.__opts newOpts = self.displayCtx.getOpts(self.overlay) self.__opts = newOpts oldOpts.removeListener('volume', self.name) oldOpts.removeListener('volumeDim', self.name) newOpts.addListener( 'volume', self.name, self.__volumeChanged) newOpts.addListener( 'volumeDim', self.name, self.__volumeChanged)
[docs]class ComplexHistogramSeries(ImageHistogramSeries): """Thre ``ComplexHistogramSeries`` class is a specialisation of the :class:`ImageHistogramSeries` for images with a complex data type. See also the :class:`.ComplexTimeSeries` and :class:`.ComplexPowerSpectrumSeries` classes. """ plotReal = props.Boolean(default=True) plotImaginary = props.Boolean(default=False) plotMagnitude = props.Boolean(default=False) plotPhase = props.Boolean(default=False)
[docs] def __init__(self, *args, **kwargs): """Create a ``ComplexHistogramSeries``. All arguments are passed through to the ``ImageHistogramSeries`` constructor. """ ImageHistogramSeries.__init__(self, *args, **kwargs) self.__imaghs = ImaginaryHistogramSeries(*args, **kwargs) self.__maghs = MagnitudeHistogramSeries(*args, **kwargs) self.__phasehs = PhaseHistogramSeries( *args, **kwargs) for hs in (self.__imaghs, self.__maghs, self.__phasehs): hs.colour = fslcm.randomDarkColour() hs.bindProps('alpha', self) hs.bindProps('lineWidth', self) hs.bindProps('lineStyle', self) hs.bindProps('autoBin', self) hs.bindProps('ignoreZeros', self) hs.bindProps('includeOutliers', self)
[docs] def extraSeries(self): """Returns a list containing an :class:`ImaginaryHistogramSeries`, :class:`MagnitudeHistogramSeries`, and/or :class:`PhaseHistogramSeries`, depending on the values of the :attr:`plotImaginary`, :attr:`plotMagnitude`, and :attr:`plotPhase` properties. """ extras = [] if self.plotImaginary: extras.append(self.__imaghs) if self.plotMagnitude: extras.append(self.__maghs) if self.plotPhase: extras.append(self.__phasehs) return extras
[docs] def getData(self): """Overrides :meth:`HistogramSeries.setHistogramData`. If :attr:`plotReal` is ``False``, returns ``(None, None)``. Otherwise returns the parent class implementation. """ if self.plotReal: return ImageHistogramSeries.getData(self) else: return None, None
[docs] def setHistogramData(self, data, key): """Overrides :meth:`HistogramSeries.setHistogramData`. The real component of the data is passed to the parent class implementation. """ data = data.real ImageHistogramSeries.setHistogramData(self, data, key)
[docs]class ImaginaryHistogramSeries(ImageHistogramSeries): """Class which plots the histogram of the imaginary component of a complex-valued image. """
[docs] def setHistogramData(self, data, key): data = data.imag ImageHistogramSeries.setHistogramData(self, data, key)
[docs]class MagnitudeHistogramSeries(ImageHistogramSeries): """Class which plots the histogram of the magnitude of a complex-valued image. """
[docs] def setHistogramData(self, data, key): data = (data.real ** 2 + data.imag ** 2) ** 0.5 ImageHistogramSeries.setHistogramData(self, data, key)
[docs]class PhaseHistogramSeries(ImageHistogramSeries): """Class which plots the histogram of the phase of a complex-valued image. """
[docs] def setHistogramData(self, data, key): data = np.arctan2(data.imag, data.real) ImageHistogramSeries.setHistogramData(self, data, key)
[docs]class MeshHistogramSeries(HistogramSeries): """A ``MeshHistogramSeries`` instance manages generation of histogram data for a :class:`.Mesh` overlay. """
[docs] def __init__(self, *args, **kwargs): """Create a ``MeshHistogramSeries``. All arguments are passed through to :meth:`HistogramSeries.__init__`. """ HistogramSeries.__init__(self, *args, **kwargs) self.__opts = self.displayCtx.getOpts(self.overlay) self.__opts.addListener('vertexData', self.name, self.__vertexDataChanged) self.__opts.addListener('vertexDataIndex', self.name, self.__vertexDataChanged) self.__vertexDataChanged()
[docs] def destroy(self): """Must be called when this ``MeshHistogramSeries`` is no longer needed. Calls :meth:`HistogramSeries.destroy` and removes some property listeners. """ HistogramSeries.destroy(self) self.__opts.removeListener('vertexData', self.name) self.__opts.removeListener('vertexDataIndex', self.name) self.__opts = None
def __vertexDataChanged(self, *a): """Called when the :attr:`.MeshOpts.vertexData` or :attr:`.MeshOpts.vertexDataIndex` properties change. Updates the histogram data via :meth:`HistogramSeries.setHistogramData`. """ vdname = self.__opts.vertexData vdi = self.__opts.vertexDataIndex vd = self.__opts.getVertexData() if vd is None: self.setHistogramData(None, None) else: self.setHistogramData(vd[:, vdi], (vdname, vdi))
[docs]def histogram(data, nbins, histRange, dataRange, includeOutliers=False, count=True): """Calculates a histogram of the given ``data``. :arg data: The data to calculate a histogram foe :arg nbins: Number of bins to use :arg histRange: Tuple containing the ``(low, high)`` data range that the histogram is to be calculated on. :arg dataRange: Tuple containing the ``(min, max)`` range of values in the data :arg includeOutliers: If ``True``, the outermost bins will contain counts for values which are outside the ``histRange``. Defaults to ``False``. :arg count: If ``True`` (the default), the raw histogram counts are returned. Otherwise they are converted into probabilities. :returns: A tuple containing: - The ``x`` histogram data (bin edges) - The ``y`` histogram data - The total number of values that were used in the histogram calculation """ hlo, hhi = histRange dlo, dhi = dataRange # Calculate bin edges bins = np.linspace(hlo, hhi, nbins + 1) if includeOutliers: bins[ 0] = dlo bins[-1] = dhi # Calculate the histogram histX = bins histY, _ = np.histogram(data.flat, bins=bins) nvals = histY.sum() if not count: histY = histY / nvals return histX, histY, nvals
[docs]def autoBin(data, dataRange): """Calculates the number of bins which should be used for a histogram of the given data. The calculation is identical to that implemented in the original FSLView. :arg data: The data that the histogram is to be calculated on. :arg dataRange: A tuple containing the ``(min, max)`` histogram range. """ dMin, dMax = dataRange dRange = dMax - dMin if np.isclose(dRange, 0): return 1 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