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

All DataSeries classes moved into a new package, fsl/fsleyes/plotting/.

parent 73143752
No related branches found
No related tags found
No related merge requests found
...@@ -11,12 +11,13 @@ control* which allows the user to configure a :class:`.TimeSeriesPanel`. ...@@ -11,12 +11,13 @@ control* which allows the user to configure a :class:`.TimeSeriesPanel`.
import wx import wx
import props import props
import pwidgets.widgetlist as widgetlist import pwidgets.widgetlist as widgetlist
import fsl.fsleyes.panel as fslpanel import fsl.fsleyes.panel as fslpanel
import fsl.fsleyes.tooltips as fsltooltips import fsl.fsleyes.plotting.timeseries as timeseries
import fsl.data.strings as strings import fsl.fsleyes.tooltips as fsltooltips
import fsl.data.strings as strings
class TimeSeriesControlPanel(fslpanel.FSLEyesPanel): class TimeSeriesControlPanel(fslpanel.FSLEyesPanel):
...@@ -262,8 +263,6 @@ class TimeSeriesControlPanel(fslpanel.FSLEyesPanel): ...@@ -262,8 +263,6 @@ class TimeSeriesControlPanel(fslpanel.FSLEyesPanel):
# We're assuminbg that the TimeSeriesPanel has # We're assuminbg that the TimeSeriesPanel has
# already updated its current TimeSeries for # already updated its current TimeSeries for
# the newly selected overlay. # the newly selected overlay.
import fsl.fsleyes.views.timeseriespanel as tsp
if self.__selectedOverlay is not None: if self.__selectedOverlay is not None:
display = self._displayCtx.getDisplay(self.__selectedOverlay) display = self._displayCtx.getDisplay(self.__selectedOverlay)
...@@ -275,7 +274,7 @@ class TimeSeriesControlPanel(fslpanel.FSLEyesPanel): ...@@ -275,7 +274,7 @@ class TimeSeriesControlPanel(fslpanel.FSLEyesPanel):
ts = self.__tsPanel.getCurrent() ts = self.__tsPanel.getCurrent()
if ts is None or not isinstance(ts, tsp.FEATTimeSeries): if ts is None or not isinstance(ts, timeseries.FEATTimeSeries):
return return
overlay = ts.overlay overlay = ts.overlay
......
...@@ -15,12 +15,13 @@ import copy ...@@ -15,12 +15,13 @@ import copy
import wx import wx
import numpy as np import numpy as np
import props import props
import pwidgets.elistbox as elistbox import fsl.fsleyes.plotting.timeseries as timeseries
import fsl.fsleyes.panel as fslpanel import pwidgets.elistbox as elistbox
import fsl.fsleyes.tooltips as fsltooltips import fsl.fsleyes.panel as fslpanel
import fsl.data.strings as strings import fsl.fsleyes.tooltips as fsltooltips
import fsl.fsleyes.colourmaps as fslcm import fsl.data.strings as strings
import fsl.fsleyes.colourmaps as fslcm
class TimeSeriesListPanel(fslpanel.FSLEyesPanel): class TimeSeriesListPanel(fslpanel.FSLEyesPanel):
...@@ -114,11 +115,9 @@ class TimeSeriesListPanel(fslpanel.FSLEyesPanel): ...@@ -114,11 +115,9 @@ class TimeSeriesListPanel(fslpanel.FSLEyesPanel):
"""Creates a label to use for the given :class:`.TimeSeries` instance. """Creates a label to use for the given :class:`.TimeSeries` instance.
""" """
import fsl.fsleyes.views.timeseriespanel as tsp
display = self._displayCtx.getDisplay(ts.overlay) display = self._displayCtx.getDisplay(ts.overlay)
if isinstance(ts, tsp.MelodicTimeSeries): if isinstance(ts, timeseries.MelodicTimeSeries):
return '{} [component {}]'.format(display.name, ts.coords) return '{} [component {}]'.format(display.name, ts.coords)
else: else:
return '{} [{} {} {}]'.format(display.name, return '{} [{} {} {}]'.format(display.name,
......
#!/usr/bin/env python
#
# dataseries.py -
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
import props
class DataSeries(props.HasProperties):
"""A ``DataSeries`` instance encapsulates some data to be plotted by
a :class:`PlotPanel`, with the data extracted from an overlay in the
:class:`.OverlayList`.
Sub-class implementations must accept an overlay object, pass this
overlay to the ``DataSeries`` constructor, and override the
:meth:`getData` method. The overlay is accessible as an instance
attribute, confusingly called ``overlay``.
Each``DataSeries`` instance is plotted as a line, with the line
style defined by properties on the ``DataSeries`` instance,
such as :attr:`colour`, :attr:`lineWidth` etc.
"""
colour = props.Colour()
"""Line colour. """
alpha = props.Real(minval=0.0, maxval=1.0, default=1.0, clamped=True)
"""Line transparency."""
label = props.String()
"""Line label (used in the plot legend)."""
lineWidth = props.Choice((0.5, 1, 2, 3, 4, 5))
"""Line width. """
lineStyle = props.Choice(('-', '--', '-.', ':'))
"""Line style. """
def __init__(self, overlay):
"""Create a ``DataSeries``.
:arg overlay: The overlay from which the data to be plotted is
derived.
"""
self.overlay = overlay
def __copy__(self):
"""``DataSeries`` copy operator. Sub-classes with constructors
that require more than just the overlay object will need to
implement their own copy operator.
"""
return type(self)(self.overlay)
def getData(self):
"""This method must be implemented by sub-classes. It must return
the data to be plotted, as a tuple of the form:
``(xdata, ydata)``
where ``xdata`` and ``ydata`` are sequences containing the x/y data
to be plotted.
"""
raise NotImplementedError('The getData method must be '
'implemented by subclasses')
#!/usr/bin/env python
#
# histogramseries.py -
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
import logging
import numpy as np
import props
import fsl.data.image as fslimage
import dataseries
log = logging.getLogger(__name__)
class HistogramSeries(dataseries.DataSeries):
"""A ``HistogramSeries`` generates histogram data from an :class:`.Image`
instance.
"""
nbins = props.Int(minval=10, maxval=500, default=100, clamped=True)
"""Number of bins to use in the histogram. This value is overridden
by the :attr:`HistogramPanel.autoBin` setting.
.. note:: I'm not sure why ``autoBin`` is a :class:`HistogramPanel`
setting, rather than a ``HistogramSeries`` setting. I might
change this some time.
"""
ignoreZeros = props.Boolean(default=True)
"""If ``True``, zeros are excluded from the calculated histogram. """
showOverlay = props.Boolean(default=False)
"""If ``True``, a 3D mask :class:`.Image` overlay is added to the
:class:`.OverlayList`, which highlights the voxels that have been included
in the histogram.
"""
includeOutliers = props.Boolean(default=False)
"""If ``True``, values which are outside of the :attr:`dataRange` are
included in the histogram end bins.
"""
volume = props.Int(minval=0, maxval=0, clamped=True)
"""If the :class:`.Image` overlay associated with this ``HistogramSeries``
is 4D, this settings specifies the index of the volume that the histogram
is calculated upon.
.. note:: Calculating the histogram over an entire 4D :class:`.Image` is
not yet supported.
"""
dataRange = props.Bounds(ndims=1)
"""Specifies the range of data which should be included in the histogram.
See the :attr:`includeOutliers` property.
"""
def __init__(self,
overlay,
hsPanel,
displayCtx,
overlayList,
volume=0,
baseHs=None):
"""Create a ``HistogramSeries``.
:arg overlay: The :class:`.Image` overlay to calculate a histogram
for.
:arg hsPanel: The :class:`HistogramPanel` that is displaying this
``HistogramSeries``.
:arg displayCtx: The :class:`.DisplayContext` instance.
:arg overlayList: The :class:`.OverlayList` instance.
:arg volume: If the ``overlay`` is 4D, the initial value for the
:attr:`volume` property.
:arg baseHs: If a ``HistogramSeries`` has already been created
for the ``overlay``, it may be passed in here, so
that the histogram data can be copied instead of
having to be re-calculated.
"""
log.debug('New HistogramSeries instance for {} '
'(based on existing instance: {})'.format(
overlay.name, baseHs is not None))
dataseries.DataSeries.__init__(self, overlay)
self.volume = volume
self.__hsPanel = hsPanel
self.__name = '{}_{}'.format(type(self).__name__, id(self))
self.__displayCtx = displayCtx
self.__overlayList = overlayList
self.__overlay3D = None
if overlay.is4DImage():
self.setConstraint('volume', 'maxval', overlay.shape[3] - 1)
# If we have a baseHS, we
# can copy all its data
if baseHs is not None:
self.dataRange.xmin = baseHs.dataRange.xmin
self.dataRange.xmax = baseHs.dataRange.xmax
self.dataRange.x = baseHs.dataRange.x
self.nbins = baseHs.nbins
self.volume = baseHs.volume
self.ignoreZeros = baseHs.ignoreZeros
self.includeOutliers = baseHs.includeOutliers
self.__nvals = baseHs.__nvals
self.__xdata = np.array(baseHs.__xdata)
self.__ydata = np.array(baseHs.__ydata)
self.__finiteData = np.array(baseHs.__finiteData)
self.__nonZeroData = np.array(baseHs.__nonZeroData)
self.__clippedFiniteData = np.array(baseHs.__finiteData)
self.__clippedNonZeroData = np.array(baseHs.__nonZeroData)
# Otherwise we need to calculate
# it all for ourselves
else:
self.__initProperties()
overlayList.addListener('overlays',
self.__name,
self.__overlayListChanged)
self .addListener('volume',
self.__name,
self.__volumeChanged)
self .addListener('dataRange',
self.__name,
self.__dataRangeChanged)
self .addListener('nbins',
self.__name,
self.__histPropsChanged)
self .addListener('ignoreZeros',
self.__name,
self.__histPropsChanged)
self .addListener('includeOutliers',
self.__name,
self.__histPropsChanged)
self .addListener('showOverlay',
self.__name,
self.__showOverlayChanged)
def update(self):
"""This method may be called to force re-calculation of the
histogram data.
"""
self.__histPropsChanged()
def destroy(self):
"""This needs to be called when this ``HistogramSeries`` instance
is no longer being used.
It removes several property listeners and, if the :attr:`overlay3D`
property is ``True``, removes the mask overlay from the
:class:`.OverlayList`.
"""
self .removeListener('nbins', self.__name)
self .removeListener('ignoreZeros', self.__name)
self .removeListener('includeOutliers', self.__name)
self .removeListener('volume', self.__name)
self .removeListener('dataRange', self.__name)
self .removeListener('nbins', self.__name)
self.__overlayList.removeListener('overlays', self.__name)
if self.__overlay3D is not None:
self.__overlayList.remove(self.__overlay3D)
self.__overlay3D = None
def __initProperties(self):
"""Called by :meth:`__init__`. Calculates and caches some things which
are needed for the histogram calculation.
.. note:: This method is never called if a ``baseHs`` is provided to
:meth:`__init__`.
"""
log.debug('Performining initial histogram '
'calculations for overlay {}'.format(
self.overlay.name))
data = self.overlay.data[:]
finData = data[np.isfinite(data)]
dmin = finData.min()
dmax = finData.max()
dist = (dmax - dmin) / 10000.0
nzData = finData[finData != 0]
nzmin = nzData.min()
nzmax = nzData.max()
self.dataRange.xmin = dmin
self.dataRange.xmax = dmax + dist
self.dataRange.xlo = nzmin
self.dataRange.xhi = nzmax + dist
self.nbins = self.__autoBin(nzData, self.dataRange.x)
if not self.overlay.is4DImage():
self.__finiteData = finData
self.__nonZeroData = nzData
self.__dataRangeChanged(callHistPropsChanged=False)
else:
self.__volumeChanged(callHistPropsChanged=False)
self.__histPropsChanged()
def __volumeChanged(
self,
ctx=None,
value=None,
valid=None,
name=None,
callHistPropsChanged=True):
"""Called when the :attr:`volume` property changes, and also by the
:meth:`__initProperties` method.
Re-calculates some things for the new overlay volume.
:arg callHistPropsChanged: If ``True`` (the default), the
:meth:`__histPropsChanged` method will be
called.
All other arguments are ignored, but are passed in when this method is
called due to a property change (see the
:meth:`.HasProperties.addListener` method).
"""
if self.overlay.is4DImage(): data = self.overlay.data[..., self.volume]
else: data = self.overlay.data[:]
data = data[np.isfinite(data)]
self.__finiteData = data
self.__nonZeroData = data[data != 0]
self.__dataRangeChanged(callHistPropsChanged=False)
if callHistPropsChanged:
self.__histPropsChanged()
def __dataRangeChanged(
self,
ctx=None,
value=None,
valid=None,
name=None,
callHistPropsChanged=True):
"""Called when the :attr:`dataRange` property changes, and also by the
:meth:`__initProperties` and :meth:`__volumeChanged` methods.
:arg callHistPropsChanged: If ``True`` (the default), the
:meth:`__histPropsChanged` method will be
called.
All other arguments are ignored, but are passed in when this method is
called due to a property change (see the
:meth:`.HasProperties.addListener` method).
"""
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)]
if callHistPropsChanged:
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))
if self.dataRange.xhi - self.dataRange.xlo < 0.00000001:
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
if self.__hsPanel.autoBin:
nbins = self.__autoBin(data, self.dataRange.x)
self.disableListener('nbins', self.__name)
self.nbins = nbins
self.enableListener('nbins', self.__name)
# Calculate bin edges
bins = np.linspace(self.dataRange.xlo,
self.dataRange.xhi,
self.nbins + 1)
if self.includeOutliers:
bins[ 0] = self.dataRange.xmin
bins[-1] = self.dataRange.xmax
# Calculate the histogram
histX = bins
histY, _ = np.histogram(data.flat, bins=bins)
self.__xdata = histX
self.__ydata = histY
self.__nvals = histY.sum()
log.debug('Calculated histogram for overlay '
'{} (number of values: {}, number '
'of bins: {})'.format(
self.overlay.name,
self.__nvals,
self.nbins))
def __showOverlayChanged(self, *a):
"""Called when the :attr:`showOverlay` property changes.
Adds/removes a 3D mask :class:`.Image` to the :class:`.OverlayList`,
which highlights the voxels that have been included in the histogram.
The :class:`.MaskOpts.threshold` property is bound to the
:attr:`dataRange` property, so the masked voxels are updated whenever
the histogram data range changes, and vice versa.
"""
if not self.showOverlay:
if self.__overlay3D is not None:
log.debug('Removing 3D histogram overlay mask for {}'.format(
self.overlay.name))
self.__overlayList.remove(self.__overlay3D)
self.__overlay3D = None
else:
log.debug('Creating 3D histogram overlay mask for {}'.format(
self.overlay.name))
self.__overlay3D = fslimage.Image(
self.overlay.data,
name='{}/histogram/mask'.format(self.overlay.name),
header=self.overlay.nibImage.get_header())
self.__overlayList.append(self.__overlay3D)
opts = self.__displayCtx.getOpts(self.__overlay3D,
overlayType='mask')
opts.bindProps('volume', self)
opts.bindProps('colour', self)
opts.bindProps('threshold', self, 'dataRange')
def __overlayListChanged(self, *a):
"""Called when the :class:`.OverlayList` changes.
If a 3D mask overlay was being shown, and it has been removed from the
``OverlayList``, the :attr:`showOverlay` property is updated
accordingly.
"""
if self.__overlay3D is None:
return
# If a 3D overlay was being shown, and it
# has been removed from the overlay list
# by the user, turn the showOverlay property
# off
if self.__overlay3D not in self.__overlayList:
self.disableListener('showOverlay', self.__name)
self.showOverlay = False
self.__showOverlayChanged()
self.enableListener('showOverlay', self.__name)
def __autoBin(self, 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
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):
"""Overrides :meth:`.DataSeries.getData`.
Returns a tuple containing the ``(x, y)`` histogram data.
"""
if len(self.__xdata) == 0 or \
len(self.__ydata) == 0:
return self.__xdata, self.__ydata
# If smoothing is not enabled, we'll
# munge the histogram data a bit so
# that plt.plot(drawstyle='steps-pre')
# plots it nicely.
if not self.__hsPanel.smooth:
xdata = np.zeros(len(self.__xdata) + 1, dtype=np.float32)
ydata = np.zeros(len(self.__ydata) + 2, dtype=np.float32)
xdata[ :-1] = self.__xdata
xdata[ -1] = self.__xdata[-1]
ydata[1:-1] = self.__ydata
# If smoothing is enabled, the above munge
# is not necessary, and will probably cause
# the spline interpolation (performed by
# the PlotPanel) to fail.
else:
xdata = np.array(self.__xdata[:-1], dtype=np.float32)
ydata = np.array(self.__ydata, dtype=np.float32)
nvals = self.__nvals
histType = self.__hsPanel.histType
if histType == 'count': return xdata, ydata
elif histType == 'probability': return xdata, ydata / nvals
This diff is collapsed.
...@@ -22,13 +22,13 @@ import logging ...@@ -22,13 +22,13 @@ import logging
import wx import wx
import numpy as np
import props import props
import fsl.data.image as fslimage import fsl.data.image as fslimage
import fsl.data.strings as strings import fsl.data.strings as strings
import fsl.utils.dialog as fsldlg import fsl.utils.dialog as fsldlg
import fsl.fsleyes.plotting.histogramseries as histogramseries
import fsl.fsleyes.controls.histogramcontrolpanel as histogramcontrolpanel import fsl.fsleyes.controls.histogramcontrolpanel as histogramcontrolpanel
import fsl.fsleyes.controls.histogramlistpanel as histogramlistpanel import fsl.fsleyes.controls.histogramlistpanel as histogramlistpanel
import plotpanel import plotpanel
...@@ -198,11 +198,11 @@ class HistogramPanel(plotpanel.PlotPanel): ...@@ -198,11 +198,11 @@ class HistogramPanel(plotpanel.PlotPanel):
if self.__current is None: if self.__current is None:
return None return None
return HistogramSeries(self.__current.overlay, return histogramseries.HistogramSeries(self.__current.overlay,
self, self,
self._displayCtx, self._displayCtx,
self._overlayList, self._overlayList,
baseHs=self.__current) baseHs=self.__current)
def draw(self, *a): def draw(self, *a):
...@@ -331,11 +331,11 @@ class HistogramPanel(plotpanel.PlotPanel): ...@@ -331,11 +331,11 @@ class HistogramPanel(plotpanel.PlotPanel):
baseHs = None baseHs = None
def loadHs(): def loadHs():
return HistogramSeries(overlay, return histogramseries.HistogramSeries(overlay,
self, self,
self._displayCtx, self._displayCtx,
self._overlayList, self._overlayList,
baseHs=baseHs) baseHs=baseHs)
# We are creating a new HS instance, so it # We are creating a new HS instance, so it
# needs to do some initla data range/histogram # needs to do some initla data range/histogram
...@@ -368,463 +368,3 @@ class HistogramPanel(plotpanel.PlotPanel): ...@@ -368,463 +368,3 @@ class HistogramPanel(plotpanel.PlotPanel):
hs.label = None hs.label = None
self.__current = hs self.__current = hs
class HistogramSeries(plotpanel.DataSeries):
"""A ``HistogramSeries`` generates histogram data from an :class:`.Image`
instance.
"""
nbins = props.Int(minval=10, maxval=500, default=100, clamped=True)
"""Number of bins to use in the histogram. This value is overridden
by the :attr:`HistogramPanel.autoBin` setting.
.. note:: I'm not sure why ``autoBin`` is a :class:`HistogramPanel`
setting, rather than a ``HistogramSeries`` setting. I might
change this some time.
"""
ignoreZeros = props.Boolean(default=True)
"""If ``True``, zeros are excluded from the calculated histogram. """
showOverlay = props.Boolean(default=False)
"""If ``True``, a 3D mask :class:`.Image` overlay is added to the
:class:`.OverlayList`, which highlights the voxels that have been included
in the histogram.
"""
includeOutliers = props.Boolean(default=False)
"""If ``True``, values which are outside of the :attr:`dataRange` are
included in the histogram end bins.
"""
volume = props.Int(minval=0, maxval=0, clamped=True)
"""If the :class:`.Image` overlay associated with this ``HistogramSeries``
is 4D, this settings specifies the index of the volume that the histogram
is calculated upon.
.. note:: Calculating the histogram over an entire 4D :class:`.Image` is
not yet supported.
"""
dataRange = props.Bounds(ndims=1)
"""Specifies the range of data which should be included in the histogram.
See the :attr:`includeOutliers` property.
"""
def __init__(self,
overlay,
hsPanel,
displayCtx,
overlayList,
volume=0,
baseHs=None):
"""Create a ``HistogramSeries``.
:arg overlay: The :class:`.Image` overlay to calculate a histogram
for.
:arg hsPanel: The :class:`HistogramPanel` that is displaying this
``HistogramSeries``.
:arg displayCtx: The :class:`.DisplayContext` instance.
:arg overlayList: The :class:`.OverlayList` instance.
:arg volume: If the ``overlay`` is 4D, the initial value for the
:attr:`volume` property.
:arg baseHs: If a ``HistogramSeries`` has already been created
for the ``overlay``, it may be passed in here, so
that the histogram data can be copied instead of
having to be re-calculated.
"""
log.debug('New HistogramSeries instance for {} '
'(based on existing instance: {})'.format(
overlay.name, baseHs is not None))
plotpanel.DataSeries.__init__(self, overlay)
self.volume = volume
self.__hsPanel = hsPanel
self.__name = '{}_{}'.format(type(self).__name__, id(self))
self.__displayCtx = displayCtx
self.__overlayList = overlayList
self.__overlay3D = None
if overlay.is4DImage():
self.setConstraint('volume', 'maxval', overlay.shape[3] - 1)
# If we have a baseHS, we
# can copy all its data
if baseHs is not None:
self.dataRange.xmin = baseHs.dataRange.xmin
self.dataRange.xmax = baseHs.dataRange.xmax
self.dataRange.x = baseHs.dataRange.x
self.nbins = baseHs.nbins
self.volume = baseHs.volume
self.ignoreZeros = baseHs.ignoreZeros
self.includeOutliers = baseHs.includeOutliers
self.__nvals = baseHs.__nvals
self.__xdata = np.array(baseHs.__xdata)
self.__ydata = np.array(baseHs.__ydata)
self.__finiteData = np.array(baseHs.__finiteData)
self.__nonZeroData = np.array(baseHs.__nonZeroData)
self.__clippedFiniteData = np.array(baseHs.__finiteData)
self.__clippedNonZeroData = np.array(baseHs.__nonZeroData)
# Otherwise we need to calculate
# it all for ourselves
else:
self.__initProperties()
overlayList.addListener('overlays',
self.__name,
self.__overlayListChanged)
self .addListener('volume',
self.__name,
self.__volumeChanged)
self .addListener('dataRange',
self.__name,
self.__dataRangeChanged)
self .addListener('nbins',
self.__name,
self.__histPropsChanged)
self .addListener('ignoreZeros',
self.__name,
self.__histPropsChanged)
self .addListener('includeOutliers',
self.__name,
self.__histPropsChanged)
self .addListener('showOverlay',
self.__name,
self.__showOverlayChanged)
def update(self):
"""This method may be called to force re-calculation of the
histogram data.
"""
self.__histPropsChanged()
def destroy(self):
"""This needs to be called when this ``HistogramSeries`` instance
is no longer being used.
It removes several property listeners and, if the :attr:`overlay3D`
property is ``True``, removes the mask overlay from the
:class:`.OverlayList`.
"""
self .removeListener('nbins', self.__name)
self .removeListener('ignoreZeros', self.__name)
self .removeListener('includeOutliers', self.__name)
self .removeListener('volume', self.__name)
self .removeListener('dataRange', self.__name)
self .removeListener('nbins', self.__name)
self.__overlayList.removeListener('overlays', self.__name)
if self.__overlay3D is not None:
self.__overlayList.remove(self.__overlay3D)
self.__overlay3D = None
def __initProperties(self):
"""Called by :meth:`__init__`. Calculates and caches some things which
are needed for the histogram calculation.
.. note:: This method is never called if a ``baseHs`` is provided to
:meth:`__init__`.
"""
log.debug('Performining initial histogram '
'calculations for overlay {}'.format(
self.overlay.name))
data = self.overlay.data[:]
finData = data[np.isfinite(data)]
dmin = finData.min()
dmax = finData.max()
dist = (dmax - dmin) / 10000.0
nzData = finData[finData != 0]
nzmin = nzData.min()
nzmax = nzData.max()
self.dataRange.xmin = dmin
self.dataRange.xmax = dmax + dist
self.dataRange.xlo = nzmin
self.dataRange.xhi = nzmax + dist
self.nbins = self.__autoBin(nzData, self.dataRange.x)
if not self.overlay.is4DImage():
self.__finiteData = finData
self.__nonZeroData = nzData
self.__dataRangeChanged(callHistPropsChanged=False)
else:
self.__volumeChanged(callHistPropsChanged=False)
self.__histPropsChanged()
def __volumeChanged(
self,
ctx=None,
value=None,
valid=None,
name=None,
callHistPropsChanged=True):
"""Called when the :attr:`volume` property changes, and also by the
:meth:`__initProperties` method.
Re-calculates some things for the new overlay volume.
:arg callHistPropsChanged: If ``True`` (the default), the
:meth:`__histPropsChanged` method will be
called.
All other arguments are ignored, but are passed in when this method is
called due to a property change (see the
:meth:`.HasProperties.addListener` method).
"""
if self.overlay.is4DImage(): data = self.overlay.data[..., self.volume]
else: data = self.overlay.data[:]
data = data[np.isfinite(data)]
self.__finiteData = data
self.__nonZeroData = data[data != 0]
self.__dataRangeChanged(callHistPropsChanged=False)
if callHistPropsChanged:
self.__histPropsChanged()
def __dataRangeChanged(
self,
ctx=None,
value=None,
valid=None,
name=None,
callHistPropsChanged=True):
"""Called when the :attr:`dataRange` property changes, and also by the
:meth:`__initProperties` and :meth:`__volumeChanged` methods.
:arg callHistPropsChanged: If ``True`` (the default), the
:meth:`__histPropsChanged` method will be
called.
All other arguments are ignored, but are passed in when this method is
called due to a property change (see the
:meth:`.HasProperties.addListener` method).
"""
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)]
if callHistPropsChanged:
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))
if self.dataRange.xhi - self.dataRange.xlo < 0.00000001:
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
if self.__hsPanel.autoBin:
nbins = self.__autoBin(data, self.dataRange.x)
self.disableListener('nbins', self.__name)
self.nbins = nbins
self.enableListener('nbins', self.__name)
# Calculate bin edges
bins = np.linspace(self.dataRange.xlo,
self.dataRange.xhi,
self.nbins + 1)
if self.includeOutliers:
bins[ 0] = self.dataRange.xmin
bins[-1] = self.dataRange.xmax
# Calculate the histogram
histX = bins
histY, _ = np.histogram(data.flat, bins=bins)
self.__xdata = histX
self.__ydata = histY
self.__nvals = histY.sum()
log.debug('Calculated histogram for overlay '
'{} (number of values: {}, number '
'of bins: {})'.format(
self.overlay.name,
self.__nvals,
self.nbins))
def __showOverlayChanged(self, *a):
"""Called when the :attr:`showOverlay` property changes.
Adds/removes a 3D mask :class:`.Image` to the :class:`.OverlayList`,
which highlights the voxels that have been included in the histogram.
The :class:`.MaskOpts.threshold` property is bound to the
:attr:`dataRange` property, so the masked voxels are updated whenever
the histogram data range changes, and vice versa.
"""
if not self.showOverlay:
if self.__overlay3D is not None:
log.debug('Removing 3D histogram overlay mask for {}'.format(
self.overlay.name))
self.__overlayList.remove(self.__overlay3D)
self.__overlay3D = None
else:
log.debug('Creating 3D histogram overlay mask for {}'.format(
self.overlay.name))
self.__overlay3D = fslimage.Image(
self.overlay.data,
name='{}/histogram/mask'.format(self.overlay.name),
header=self.overlay.nibImage.get_header())
self.__overlayList.append(self.__overlay3D)
opts = self.__displayCtx.getOpts(self.__overlay3D,
overlayType='mask')
opts.bindProps('volume', self)
opts.bindProps('colour', self)
opts.bindProps('threshold', self, 'dataRange')
def __overlayListChanged(self, *a):
"""Called when the :class:`.OverlayList` changes.
If a 3D mask overlay was being shown, and it has been removed from the
``OverlayList``, the :attr:`showOverlay` property is updated
accordingly.
"""
if self.__overlay3D is None:
return
# If a 3D overlay was being shown, and it
# has been removed from the overlay list
# by the user, turn the showOverlay property
# off
if self.__overlay3D not in self.__overlayList:
self.disableListener('showOverlay', self.__name)
self.showOverlay = False
self.__showOverlayChanged()
self.enableListener('showOverlay', self.__name)
def __autoBin(self, 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
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):
"""Overrides :meth:`.DataSeries.getData`.
Returns a tuple containing the ``(x, y)`` histogram data.
"""
if len(self.__xdata) == 0 or \
len(self.__ydata) == 0:
return self.__xdata, self.__ydata
# If smoothing is not enabled, we'll
# munge the histogram data a bit so
# that plt.plot(drawstyle='steps-pre')
# plots it nicely.
if not self.__hsPanel.smooth:
xdata = np.zeros(len(self.__xdata) + 1, dtype=np.float32)
ydata = np.zeros(len(self.__ydata) + 2, dtype=np.float32)
xdata[ :-1] = self.__xdata
xdata[ -1] = self.__xdata[-1]
ydata[1:-1] = self.__ydata
# If smoothing is enabled, the above munge
# is not necessary, and will probably cause
# the spline interpolation (performed by
# the PlotPanel) to fail.
else:
xdata = np.array(self.__xdata[:-1], dtype=np.float32)
ydata = np.array(self.__ydata, dtype=np.float32)
nvals = self.__nvals
histType = self.__hsPanel.histType
if histType == 'count': return xdata, ydata
elif histType == 'probability': return xdata, ydata / nvals
...@@ -4,10 +4,9 @@ ...@@ -4,10 +4,9 @@
# #
# Author: Paul McCarthy <pauldmccarthy@gmail.com> # Author: Paul McCarthy <pauldmccarthy@gmail.com>
# #
"""This module provides the :class:`PlotPanel` and :class:`DataSeries` """This module provides the :class:`PlotPanel` class. The ``PlotPanel`` class
classes. The ``PlotPanel`` class is the base class for all *FSLeyes views* is the base class for all *FSLeyes views* which display some sort of data
which display some sort of data plot. See the :mod:`~fsl.fsleyes` package plot. See the :mod:`~fsl.fsleyes` package documentation for more details..
documentation for more details..
""" """
...@@ -54,7 +53,7 @@ class PlotPanel(viewpanel.ViewPanel): ...@@ -54,7 +53,7 @@ class PlotPanel(viewpanel.ViewPanel):
1. Call the ``PlotPanel`` constructor. 1. Call the ``PlotPanel`` constructor.
2. Define a :class:`DataSeries` sub-class. 2. Define a :class:`.DataSeries` sub-class.
3. Override the :meth:`draw` method, so it calls the 3. Override the :meth:`draw` method, so it calls the
:meth:`drawDataSeries` method. :meth:`drawDataSeries` method.
...@@ -66,8 +65,9 @@ class PlotPanel(viewpanel.ViewPanel): ...@@ -66,8 +65,9 @@ class PlotPanel(viewpanel.ViewPanel):
**Data series** **Data series**
A ``PlotPanel`` instance plots data contained in one or more A ``PlotPanel`` instance plots data contained in one or more
:class:`DataSeries` instances. Therefore, ``PlotPanel`` sub-classes also :class:`.DataSeries` instances; all ``DataSeries`` classes are defined in
need to define a sub-class of the :class:`DataSeries` base class. the :mod:`.plotting` sub-package. Therefore, ``PlotPanel`` sub-classes
also need to define a sub-class of the :class:`.DataSeries` base class.
``DataSeries`` objects can be plotted by passing them to the ``DataSeries`` objects can be plotted by passing them to the
:meth:`drawDataSeries` method. :meth:`drawDataSeries` method.
...@@ -105,7 +105,7 @@ class PlotPanel(viewpanel.ViewPanel): ...@@ -105,7 +105,7 @@ class PlotPanel(viewpanel.ViewPanel):
dataSeries = props.List() dataSeries = props.List()
"""This list contains :class:`DataSeries` instances which are plotted """This list contains :class:`.DataSeries` instances which are plotted
on every call to :meth:`drawDataSeries`. ``DataSeries`` instances can on every call to :meth:`drawDataSeries`. ``DataSeries`` instances can
be added/removed directly to/from this list. be added/removed directly to/from this list.
""" """
...@@ -266,7 +266,7 @@ class PlotPanel(viewpanel.ViewPanel): ...@@ -266,7 +266,7 @@ class PlotPanel(viewpanel.ViewPanel):
def draw(self, *a): def draw(self, *a):
"""This method must be overridden by ``PlotPanel`` sub-classes. """This method must be overridden by ``PlotPanel`` sub-classes.
It is called whenever a :class:`DataSeries` is added to the It is called whenever a :class:`.DataSeries` is added to the
:attr:`dataSeries` list, or when any plot display properties change. :attr:`dataSeries` list, or when any plot display properties change.
Sub-class implementations should call the :meth:`drawDataSeries` Sub-class implementations should call the :meth:`drawDataSeries`
...@@ -356,7 +356,7 @@ class PlotPanel(viewpanel.ViewPanel): ...@@ -356,7 +356,7 @@ class PlotPanel(viewpanel.ViewPanel):
def drawDataSeries(self, extraSeries=None, **plotArgs): def drawDataSeries(self, extraSeries=None, **plotArgs):
"""Plots all of the :class:`DataSeries` instances in the """Plots all of the :class:`.DataSeries` instances in the
:attr:`dataSeries` list :attr:`dataSeries` list
:arg extraSeries: A sequence of additional ``DataSeries`` to be :arg extraSeries: A sequence of additional ``DataSeries`` to be
...@@ -475,7 +475,7 @@ class PlotPanel(viewpanel.ViewPanel): ...@@ -475,7 +475,7 @@ class PlotPanel(viewpanel.ViewPanel):
def __drawOneDataSeries(self, ds, **plotArgs): def __drawOneDataSeries(self, ds, **plotArgs):
"""Plots a single :class:`DataSeries` instance. This method is called """Plots a single :class:`.DataSeries` instance. This method is called
by the :meth:`drawDataSeries` method. by the :meth:`drawDataSeries` method.
:arg ds: The ``DataSeries`` instance. :arg ds: The ``DataSeries`` instance.
...@@ -586,7 +586,7 @@ class PlotPanel(viewpanel.ViewPanel): ...@@ -586,7 +586,7 @@ class PlotPanel(viewpanel.ViewPanel):
def __dataSeriesChanged(self, *a): def __dataSeriesChanged(self, *a):
"""Called when the :attr:`dataSeries` list changes. Adds listeners """Called when the :attr:`dataSeries` list changes. Adds listeners
to any new :class:`DataSeries` instances, and then calls :meth:`draw`. to any new :class:`.DataSeries` instances, and then calls :meth:`draw`.
""" """
for ds in self.dataSeries: for ds in self.dataSeries:
...@@ -666,69 +666,3 @@ class PlotPanel(viewpanel.ViewPanel): ...@@ -666,69 +666,3 @@ class PlotPanel(viewpanel.ViewPanel):
self.enableListener('limits', self.__name) self.enableListener('limits', self.__name)
return (xmin, xmax), (ymin, ymax) return (xmin, xmax), (ymin, ymax)
class DataSeries(props.HasProperties):
"""A ``DataSeries`` instance encapsulates some data to be plotted by
a :class:`PlotPanel`, with the data extracted from an overlay in the
:class:`.OverlayList`.
Sub-class implementations must accept an overlay object, pass this
overlay to the ``DataSeries`` constructor, and override the
:meth:`getData` method. The overlay is accessible as an instance
attribute, confusingly called ``overlay``.
Each``DataSeries`` instance is plotted as a line, with the line
style defined by properties on the ``DataSeries`` instance,
such as :attr:`colour`, :attr:`lineWidth` etc.
"""
colour = props.Colour()
"""Line colour. """
alpha = props.Real(minval=0.0, maxval=1.0, default=1.0, clamped=True)
"""Line transparency."""
label = props.String()
"""Line label (used in the plot legend)."""
lineWidth = props.Choice((0.5, 1, 2, 3, 4, 5))
"""Line width. """
lineStyle = props.Choice(('-', '--', '-.', ':'))
"""Line style. """
def __init__(self, overlay):
"""Create a ``DataSeries``.
:arg overlay: The overlay from which the data to be plotted is
derived.
"""
self.overlay = overlay
def __copy__(self):
"""``DataSeries`` copy operator. Sub-classes with constructors
that require more than just the overlay object will need to
implement their own copy operator.
"""
return type(self)(self.overlay)
def getData(self):
"""This method must be implemented by sub-classes. It must return
the data to be plotted, as a tuple of the form:
``(xdata, ydata)``
where ``xdata`` and ``ydata`` are sequences containing the x/y data
to be plotted.
"""
raise NotImplementedError('The getData method must be '
'implemented by subclasses')
This diff is collapsed.
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