#
# 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 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