diff options
Diffstat (limited to 'silx/gui/plot/stats')
-rw-r--r-- | silx/gui/plot/stats/stats.py | 400 | ||||
-rw-r--r-- | silx/gui/plot/stats/statshandler.py | 124 |
2 files changed, 308 insertions, 216 deletions
diff --git a/silx/gui/plot/stats/stats.py b/silx/gui/plot/stats/stats.py index a753989..ad61536 100644 --- a/silx/gui/plot/stats/stats.py +++ b/silx/gui/plot/stats/stats.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# Copyright (c) 2017-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -30,15 +30,15 @@ __license__ = "MIT" __date__ = "06/06/2018" -import numpy -from silx.gui.plot.items.curve import Curve as CurveItem -from silx.gui.plot.items.image import ImageBase as ImageItem -from silx.gui.plot.items.scatter import Scatter as ScatterItem -from silx.gui.plot.items.histogram import Histogram as HistogramItem -from silx.math.combo import min_max from collections import OrderedDict import logging +import numpy + +from .. import items +from ....math.combo import min_max + + logger = logging.getLogger(__name__) @@ -62,7 +62,7 @@ class Stats(OrderedDict): def calculate(self, item, plot, onlimits): """ - Call all :class:`Stat` object registred and return the result of the + Call all :class:`Stat` object registered and return the result of the computation. :param item: the item for which we want statistics @@ -72,17 +72,29 @@ class Stats(OrderedDict): :return dict: dictionary with :class:`Stat` name as ket and result of the calculation as value """ - res = {} - if isinstance(item, CurveItem): + context = None + # Check for PlotWidget items + if isinstance(item, items.Curve): context = _CurveContext(item, plot, onlimits) - elif isinstance(item, ImageItem): + elif isinstance(item, items.ImageData): context = _ImageContext(item, plot, onlimits) - elif isinstance(item, ScatterItem): + elif isinstance(item, items.Scatter): context = _ScatterContext(item, plot, onlimits) - elif isinstance(item, HistogramItem): + elif isinstance(item, items.Histogram): context = _HistogramContext(item, plot, onlimits) else: - raise ValueError('Item type not managed') + # Check for SceneWidget items + from ...plot3d import items as items3d # Lazy import + + if isinstance(item, (items3d.Scatter2D, items3d.Scatter3D)): + context = _plot3DScatterContext(item, plot, onlimits) + elif isinstance(item, (items3d.ImageData, items3d.ScalarField3D)): + context = _plot3DArrayContext(item, plot, onlimits) + + if context is None: + raise ValueError('Item type not managed') + + res = {} for statName, stat in list(self.items()): if context.kind not in stat.compatibleKinds: logger.debug('kind %s not managed by statistic %s' @@ -124,12 +136,54 @@ class _StatsContext(object): self.min = None self.max = None self.data = None + self.values = None + """The array of data""" + + self.axes = None + """A list of array of position on each axis. + + If the signal is an array, + then each axis has the length of that dimension, + and the order is (z, y, x) (i.e., as the array shape). + If the signal is not an array, + then each axis has the same length as the signal, + and the order is (x, y, z). + """ + self.createContext(item, plot, onlimits) def createContext(self, item, plot, onlimits): raise NotImplementedError("Base class") + def isStructuredData(self): + """Returns True if data as an array-like structure. + + :rtype: bool + """ + if self.values is None or self.axes is None: + return False + + if numpy.prod([len(axis) for axis in self.axes]) == self.values.size: + return True + else: + # Make sure there is the right number of value in axes + for axis in self.axes: + assert len(axis) == self.values.size + return False + + def isScalarData(self): + """Returns True if data is a scalar. + + :rtype: bool + """ + if self.values is None or self.axes is None: + return False + if self.isStructuredData(): + return len(self.axes) == self.values.ndim + else: + return self.values.ndim == 1 + class _CurveContext(_StatsContext): """ @@ -149,8 +203,9 @@ class _CurveContext(_StatsContext): if onlimits: minX, maxX = plot.getXAxis().getLimits() - yData = yData[(minX <= xData) & (xData <= maxX)] - xData = xData[(minX <= xData) & (xData <= maxX)] + mask = (minX <= xData) & (xData <= maxX) + yData = yData[mask] + xData = xData[mask] self.xData = xData self.yData = yData @@ -160,11 +215,12 @@ class _CurveContext(_StatsContext): self.min, self.max = None, None self.data = (xData, yData) self.values = yData + self.axes = (xData,) class _HistogramContext(_StatsContext): """ - StatsContext for :class:`Curve` + StatsContext for :class:`Histogram` :param item: the item for which we want to compute the context :param plot: the plot containing the item @@ -176,12 +232,13 @@ class _HistogramContext(_StatsContext): plot=plot, onlimits=onlimits) def createContext(self, item, plot, onlimits): - xData, edges = item.getData(copy=True)[0:2] - yData = item._revertComputeEdges(x=edges, histogramType=item.getAlignment()) + yData, edges = item.getData(copy=True)[0:2] + xData = item._revertComputeEdges(x=edges, histogramType=item.getAlignment()) if onlimits: minX, maxX = plot.getXAxis().getLimits() - yData = yData[(minX <= xData) & (xData <= maxX)] - xData = xData[(minX <= xData) & (xData <= maxX)] + mask = (minX <= xData) & (xData <= maxX) + yData = yData[mask] + xData = xData[mask] self.xData = xData self.yData = yData @@ -191,11 +248,13 @@ class _HistogramContext(_StatsContext): self.min, self.max = None, None self.data = (xData, yData) self.values = yData + self.axes = (xData,) class _ScatterContext(_StatsContext): - """ - StatsContext for :class:`Scatter` + """StatsContext scatter plots. + + It supports :class:`~silx.gui.plot.items.Scatter`. :param item: the item for which we want to compute the context :param plot: the plot containing the item @@ -207,11 +266,14 @@ class _ScatterContext(_StatsContext): onlimits=onlimits) def createContext(self, item, plot, onlimits): - xData, yData, valueData, xerror, yerror = item.getData(copy=True) - assert plot + valueData = item.getValueData(copy=True) + xData = item.getXData(copy=True) + yData = item.getYData(copy=True) + if onlimits: minX, maxX = plot.getXAxis().getLimits() minY, maxY = plot.getYAxis().getLimits() + # filter on X axis valueData = valueData[(minX <= xData) & (xData <= maxX)] yData = yData[(minX <= xData) & (xData <= maxX)] @@ -220,17 +282,20 @@ class _ScatterContext(_StatsContext): valueData = valueData[(minY <= yData) & (yData <= maxY)] xData = xData[(minY <= yData) & (yData <= maxY)] yData = yData[(minY <= yData) & (yData <= maxY)] + if len(valueData) > 0: self.min, self.max = min_max(valueData) else: self.min, self.max = None, None self.data = (xData, yData, valueData) self.values = valueData + self.axes = (xData, yData) class _ImageContext(_StatsContext): - """ - StatsContext for :class:`ImageBase` + """StatsContext for images. + + It supports :class:`~silx.gui.plot.items.ImageData`. :param item: the item for which we want to compute the context :param plot: the plot containing the item @@ -244,7 +309,8 @@ class _ImageContext(_StatsContext): def createContext(self, item, plot, onlimits): self.origin = item.getOrigin() self.scale = item.getScale() - self.data = item.getData() + + self.data = item.getData(copy=True) if onlimits: minX, maxX = plot.getXAxis().getLimits() @@ -259,25 +325,88 @@ class _ImageContext(_StatsContext): YMinBound = max(YMinBound, 0) if XMaxBound <= XMinBound or YMaxBound <= YMinBound: - return self.noDataSelected() - data = item.getData() - self.data = data[YMinBound:YMaxBound + 1, XMinBound:XMaxBound + 1] - else: - self.data = item.getData() - + self.data = None + else: + self.data = self.data[YMinBound:YMaxBound + 1, + XMinBound:XMaxBound + 1] if self.data.size > 0: self.min, self.max = min_max(self.data) else: self.min, self.max = None, None self.values = self.data + if self.values is not None: + self.axes = (self.origin[1] + self.scale[1] * numpy.arange(self.data.shape[0]), + self.origin[0] + self.scale[0] * numpy.arange(self.data.shape[1])) + + +class _plot3DScatterContext(_StatsContext): + """StatsContext for 3D scatter plots. + + It supports :class:`~silx.gui.plot3d.items.Scatter2D` and + :class:`~silx.gui.plot3d.items.Scatter3D`. + + :param item: the item for which we want to compute the context + :param plot: the plot containing the item + :param bool onlimits: True if we want to apply statistic only on + visible data. + """ + def __init__(self, item, plot, onlimits): + _StatsContext.__init__(self, kind='scatter', item=item, plot=plot, + onlimits=onlimits) + + def createContext(self, item, plot, onlimits): + if onlimits: + raise RuntimeError("Unsupported plot %s" % str(plot)) + + values = item.getValueData(copy=False) + + if values is not None and len(values) > 0: + self.values = values + axes = [item.getXData(copy=False), item.getYData(copy=False)] + if self.values.ndim == 3: + axes.append(item.getZData(copy=False)) + self.axes = tuple(axes) + + self.min, self.max = min_max(self.values) + else: + self.values = None + self.axes = None + self.min, self.max = None, None + + +class _plot3DArrayContext(_StatsContext): + """StatsContext for 3D scalar field and data image. + + It supports :class:`~silx.gui.plot3d.items.ScalarField3D` and + :class:`~silx.gui.plot3d.items.ImageData`. + + :param item: the item for which we want to compute the context + :param plot: the plot containing the item + :param bool onlimits: True if we want to apply statistic only on + visible data. + """ + def __init__(self, item, plot, onlimits): + _StatsContext.__init__(self, kind='image', item=item, plot=plot, + onlimits=onlimits) + + def createContext(self, item, plot, onlimits): + if onlimits: + raise RuntimeError("Unsupported plot %s" % str(plot)) + + values = item.getData(copy=False) + + if values is not None and len(values) > 0: + self.values = values + self.axes = tuple([numpy.arange(size) for size in self.values.shape]) + self.min, self.max = min_max(self.values) + else: + self.values = None + self.axes = None + self.min, self.max = None, None + -BASIC_COMPATIBLE_KINDS = { - 'curve': CurveItem, - 'image': ImageItem, - 'scatter': ScatterItem, - 'histogram': HistogramItem, -} +BASIC_COMPATIBLE_KINDS = 'curve', 'image', 'scatter', 'histogram' class StatBase(object): @@ -285,9 +414,8 @@ class StatBase(object): Base class for defining a statistic. :param str name: the name of the statistic. Must be unique. - :param compatibleKinds: the kind of items (curve, scatter...) for which - the statistic apply. - :rtype: List or tuple + :param List[str] compatibleKinds: + The kind of items (curve, scatter...) for which the statistic apply. """ def __init__(self, name, compatibleKinds=BASIC_COMPATIBLE_KINDS, description=None): self.name = name @@ -298,7 +426,7 @@ class StatBase(object): """ compute the statistic for the given :class:`StatsContext` - :param context: + :param _StatsContext context: :return dict: key is stat name, statistic computed is the dict value """ raise NotImplementedError('Base class') @@ -307,7 +435,7 @@ class StatBase(object): """ If necessary add a tooltip for a stat kind - :param str kinf: the kind of item the statistic is compute for. + :param str kind: the kind of item the statistic is compute for. :return: tooltip or None if no tooltip """ return None @@ -329,17 +457,18 @@ class Stat(StatBase): self._fct = fct def calculate(self, context): - if context.kind in self.compatibleKinds: - return self._fct(context.values) + if context.values is not None: + if context.kind in self.compatibleKinds: + return self._fct(context.values) + else: + raise ValueError('Kind %s not managed by %s' + '' % (context.kind, self.name)) else: - raise ValueError('Kind %s not managed by %s' - '' % (context.kind, self.name)) + return None class StatMin(StatBase): - """ - Compute the minimal value on data - """ + """Compute the minimal value on data""" def __init__(self): StatBase.__init__(self, name='min') @@ -348,9 +477,7 @@ class StatMin(StatBase): class StatMax(StatBase): - """ - Compute the maximal value on data - """ + """Compute the maximal value on data""" def __init__(self): StatBase.__init__(self, name='max') @@ -359,9 +486,7 @@ class StatMax(StatBase): class StatDelta(StatBase): - """ - Compute the delta between minimal and maximal on data - """ + """Compute the delta between minimal and maximal on data""" def __init__(self): StatBase.__init__(self, name='delta') @@ -369,123 +494,84 @@ class StatDelta(StatBase): return context.max - context.min -class StatCoordMin(StatBase): - """ - Compute the first coordinates of the data minimal value - """ +class _StatCoord(StatBase): + """Base class for argmin and argmax stats""" + + def _indexToCoordinates(self, context, index): + """Returns the coordinates of data point at given index + + If data is an array, coordinates are in reverse order from data shape. + + :param _StatsContext context: + :param int index: Index in the flattened data array + :rtype: List[int] + """ + if context.isStructuredData(): + coordinates = [] + for axis in reversed(context.axes): + coordinates.append(axis[index % len(axis)]) + index = index // len(axis) + return tuple(coordinates) + else: + return tuple(axis[index] for axis in context.axes) + + +class StatCoordMin(_StatCoord): + """Compute the coordinates of the first minimum value of the data""" def __init__(self): - StatBase.__init__(self, name='coords min') + _StatCoord.__init__(self, name='coords min') def calculate(self, context): - if context.kind in ('curve', 'histogram'): - return context.xData[numpy.argmin(context.yData)] - elif context.kind == 'scatter': - xData, yData, valueData = context.data - return (xData[numpy.argmin(valueData)], - yData[numpy.argmin(valueData)]) - elif context.kind == 'image': - scaleX, scaleY = context.scale - originX, originY = context.origin - index1D = numpy.argmin(context.data) - ySize = (context.data.shape[1]) - x = index1D % context.data.shape[1] - y = (index1D - x) / ySize - x = x * scaleX + originX - y = y * scaleY + originY - return (x, y) - else: - raise ValueError('kind not managed') + if context.values is None or not context.isScalarData(): + return None + + index = numpy.argmin(context.values) + return self._indexToCoordinates(context, index) def getToolTip(self, kind): - if kind in ('scatter', 'image'): - return '(x, y)' - else: - return None + return "Coordinates of the first minimum value of the data" -class StatCoordMax(StatBase): - """ - Compute the first coordinates of the data minimal value - """ + +class StatCoordMax(_StatCoord): + """Compute the coordinates of the first maximum value of the data""" def __init__(self): - StatBase.__init__(self, name='coords max') + _StatCoord.__init__(self, name='coords max') def calculate(self, context): - if context.kind in ('curve', 'histogram'): - return context.xData[numpy.argmax(context.yData)] - elif context.kind == 'scatter': - xData, yData, valueData = context.data - return (xData[numpy.argmax(valueData)], - yData[numpy.argmax(valueData)]) - elif context.kind == 'image': - scaleX, scaleY = context.scale - originX, originY = context.origin - index1D = numpy.argmax(context.data) - ySize = (context.data.shape[1]) - x = index1D % context.data.shape[1] - y = (index1D - x) / ySize - x = x * scaleX + originX - y = y * scaleY + originY - return (x, y) - else: - raise ValueError('kind not managed') + if context.values is None or not context.isScalarData(): + return None + + index = numpy.argmax(context.values) + return self._indexToCoordinates(context, index) def getToolTip(self, kind): - if kind in ('scatter', 'image'): - return '(x, y)' - else: - return None + return "Coordinates of the first maximum value of the data" + class StatCOM(StatBase): - """ - Compute data center of mass - """ + """Compute data center of mass""" def __init__(self): StatBase.__init__(self, name='COM', description='Center of mass') def calculate(self, context): - if context.kind in ('curve', 'histogram'): - xData, yData = context.data - deno = numpy.sum(yData).astype(numpy.float32) - if deno == 0.: - return numpy.nan - else: - return numpy.sum(xData * yData).astype(numpy.float32) / deno - elif context.kind == 'scatter': - xData, yData, values = context.data - deno = numpy.sum(values).astype(numpy.float32) - if deno == 0.: - return numpy.nan, numpy.nan - else: - xcom = numpy.sum(xData * values).astype(numpy.float32) / deno - ycom = numpy.sum(yData * values).astype(numpy.float32) / deno - return (xcom, ycom) - elif context.kind == 'image': - yData = numpy.sum(context.data, axis=1) - xData = numpy.sum(context.data, axis=0) - dataXRange = range(context.data.shape[1]) - dataYRange = range(context.data.shape[0]) - xScale, yScale = context.scale - xOrigin, yOrigin = context.origin - - denoY = numpy.sum(yData) - if denoY == 0.: - ycom = numpy.nan - else: - ycom = numpy.sum(yData * dataYRange) / denoY - ycom = ycom * yScale + yOrigin + if context.values is None or not context.isScalarData(): + return None - denoX = numpy.sum(xData) - if denoX == 0.: - xcom = numpy.nan - else: - xcom = numpy.sum(xData * dataXRange) / denoX - xcom = xcom * xScale + xOrigin - return (xcom, ycom) + values = numpy.array(context.values, dtype=numpy.float64) + sum_ = numpy.sum(values) + if sum_ == 0.: + return (numpy.nan,) * len(context.axes) + + if context.isStructuredData(): + centerofmass = [] + for index, axis in enumerate(context.axes): + axes = tuple([i for i in range(len(context.axes)) if i != index]) + centerofmass.append( + numpy.sum(axis * numpy.sum(values, axis=axes)) / sum_) + return tuple(reversed(centerofmass)) else: - raise ValueError('kind not managed') + return tuple( + numpy.sum(axis * values) / sum_ for axis in context.axes) def getToolTip(self, kind): - if kind in ('scatter', 'image'): - return '(x, y)' - else: - return None + return "Compute the center of mass of the dataset" diff --git a/silx/gui/plot/stats/statshandler.py b/silx/gui/plot/stats/statshandler.py index 0a62b31..f69daff 100644 --- a/silx/gui/plot/stats/statshandler.py +++ b/silx/gui/plot/stats/statshandler.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# Copyright (c) 2017-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -45,7 +45,14 @@ class _FloatItem(qt.QTableWidgetItem): qt.QTableWidgetItem.__init__(self, type=type) def __lt__(self, other): - return float(self.text()) < float(other.text()) + self_values = self.text().lstrip('(').rstrip(')').split(',') + other_values = other.text().lstrip('(').rstrip(')').split(',') + for self_value, other_value in zip(self_values, other_values): + f_self_value = float(self_value) + f_other_value = float(other_value) + if f_self_value != f_other_value: + return f_self_value < f_other_value + return False class StatFormatter(object): @@ -89,10 +96,60 @@ class StatsHandler(object): self.stats = statsmdl.Stats() self.formatters = {} for elmt in statFormatters: - helper = _StatHelper(elmt) - self.add(stat=helper.stat, formatter=helper.statFormatter) + stat, formatter = self._processStatArgument(elmt) + self.add(stat=stat, formatter=formatter) + + @staticmethod + def _processStatArgument(arg): + """Process an element of the init arguments + + :param arg: The argument to process + :return: Corresponding (StatBase, StatFormatter) + """ + stat, formatter = None, None + + if isinstance(arg, statsmdl.StatBase): + stat = arg + else: + assert len(arg) > 0 + if isinstance(arg[0], statsmdl.StatBase): + stat = arg[0] + if len(arg) > 2: + raise ValueError('To many argument with %s. At most one ' + 'argument can be associated with the ' + 'BaseStat (the `StatFormatter`') + if len(arg) == 2: + assert arg[1] is None or isinstance(arg[1], (StatFormatter, str)) + formatter = arg[1] + else: + if isinstance(arg[0], tuple): + if len(arg) > 1: + formatter = arg[1] + arg = arg[0] + + if type(arg[0]) is not str: + raise ValueError('first element of the tuple should be a string' + ' or a StatBase instance') + if len(arg) == 1: + raise ValueError('A function should be associated with the' + 'stat name') + if len(arg) > 3: + raise ValueError('Two much argument given for defining statistic.' + 'Take at most three arguments (name, function, ' + 'kinds)') + if len(arg) == 2: + stat = statsmdl.Stat(name=arg[0], fct=arg[1]) + else: + stat = statsmdl.Stat(name=arg[0], fct=arg[1], kinds=arg[2]) + + return stat, formatter def add(self, stat, formatter=None): + """Add a stat to the list. + + :param StatBase stat: + :param Union[None,StatFormatter] formatter: + """ assert isinstance(stat, statsmdl.StatBase) self.stats.add(stat) _formatter = formatter @@ -101,9 +158,9 @@ class StatsHandler(object): self.formatters[stat.name] = _formatter def format(self, name, val): - """ - Apply the format for the `name` statistic and the given value - :param name: the name of the associated statistic + """Apply the format for the `name` statistic and the given value + + :param str name: the name of the associated statistic :param val: value before formatting :return: formatted value """ @@ -123,7 +180,7 @@ class StatsHandler(object): def calculate(self, item, plot, onlimits): """ - compute all statistic registred and return the list of formatted + compute all statistic registered and return the list of formatted statistics result. :param item: item for which we want to compute statistics @@ -137,54 +194,3 @@ class StatsHandler(object): for resName, resValue in list(res.items()): res[resName] = self.format(resName, res[resName]) return res - - -class _StatHelper(object): - """ - Helper class to generated the requested StatBase instance and the - associated StatFormatter - """ - def __init__(self, arg): - self.statFormatter = None - self.stat = None - - if isinstance(arg, statsmdl.StatBase): - self.stat = arg - else: - assert len(arg) > 0 - if isinstance(arg[0], statsmdl.StatBase): - self.dealWithStatAndFormatter(arg) - else: - _arg = arg - if isinstance(arg[0], tuple): - _arg = arg[0] - if len(arg) > 1: - self.statFormatter = arg[1] - self.createStatInstanceAndFormatter(_arg) - - def dealWithStatAndFormatter(self, arg): - assert isinstance(arg[0], statsmdl.StatBase) - self.stat = arg[0] - if len(arg) > 2: - raise ValueError('To many argument with %s. At most one ' - 'argument can be associated with the ' - 'BaseStat (the `StatFormatter`') - if len(arg) is 2: - assert isinstance(arg[1], (StatFormatter, type(None), str)) - self.statFormatter = arg[1] - - def createStatInstanceAndFormatter(self, arg): - if type(arg[0]) is not str: - raise ValueError('first element of the tuple should be a string' - ' or a StatBase instance') - if len(arg) is 1: - raise ValueError('A function should be associated with the' - 'stat name') - if len(arg) > 3: - raise ValueError('Two much argument given for defining statistic.' - 'Take at most three arguments (name, function, ' - 'kinds)') - if len(arg) is 2: - self.stat = statsmdl.Stat(name=arg[0], fct=arg[1]) - else: - self.stat = statsmdl.Stat(name=arg[0], fct=arg[1], kinds=arg[2]) |