diff options
author | Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> | 2017-08-18 14:48:52 +0200 |
---|---|---|
committer | Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> | 2017-08-18 14:48:52 +0200 |
commit | f7bdc2acff3c13a6d632c28c4569690ab106eed7 (patch) | |
tree | 9d67cdb7152ee4e711379e03fe0546c7c3b97303 /silx/gui/plot/items |
Import Upstream version 0.5.0+dfsg
Diffstat (limited to 'silx/gui/plot/items')
-rw-r--r-- | silx/gui/plot/items/__init__.py | 43 | ||||
-rw-r--r-- | silx/gui/plot/items/core.py | 839 | ||||
-rw-r--r-- | silx/gui/plot/items/curve.py | 192 | ||||
-rw-r--r-- | silx/gui/plot/items/histogram.py | 288 | ||||
-rw-r--r-- | silx/gui/plot/items/image.py | 385 | ||||
-rw-r--r-- | silx/gui/plot/items/marker.py | 241 | ||||
-rw-r--r-- | silx/gui/plot/items/scatter.py | 169 | ||||
-rw-r--r-- | silx/gui/plot/items/shape.py | 121 |
8 files changed, 2278 insertions, 0 deletions
diff --git a/silx/gui/plot/items/__init__.py b/silx/gui/plot/items/__init__.py new file mode 100644 index 0000000..b16fe40 --- /dev/null +++ b/silx/gui/plot/items/__init__.py @@ -0,0 +1,43 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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 +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This package provides classes that describes :class:`.Plot` content. + +Instances of those classes are returned by :class:`.Plot` methods that give +access to its content such as :meth:`.Plot.getCurve`, :meth:`.Plot.getImage`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/03/2017" + +from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, # noqa + SymbolMixIn, ColorMixIn, YAxisMixIn, FillMixIn, # noqa + AlphaMixIn, LineMixIn) # noqa +from .curve import Curve # noqa +from .histogram import Histogram # noqa +from .image import ImageBase, ImageData, ImageRgba # noqa +from .shape import Shape # noqa +from .scatter import Scatter # noqa +from .marker import Marker, XMarker, YMarker # noqa diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py new file mode 100644 index 0000000..72bfd9a --- /dev/null +++ b/silx/gui/plot/items/core.py @@ -0,0 +1,839 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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 +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides the base class for items of the :class:`Plot`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "26/04/2017" + +from copy import deepcopy +import logging +import weakref +import numpy +from silx.third_party import six + +from .. import Colors + + + +_logger = logging.getLogger(__name__) + + +class Item(object): + """Description of an item of the plot""" + + _DEFAULT_Z_LAYER = 0 + """Default layer for overlay rendering""" + + _DEFAULT_LEGEND = '' + """Default legend of items""" + + _DEFAULT_SELECTABLE = False + """Default selectable state of items""" + + def __init__(self): + self._dirty = True + self._plotRef = None + self._visible = True + self._legend = self._DEFAULT_LEGEND + self._selectable = self._DEFAULT_SELECTABLE + self._z = self._DEFAULT_Z_LAYER + self._info = None + self._xlabel = None + self._ylabel = None + + self._backendRenderer = None + + def getPlot(self): + """Returns Plot this item belongs to. + + :rtype: Plot or None + """ + return None if self._plotRef is None else self._plotRef() + + def _setPlot(self, plot): + """Set the plot this item belongs to. + + WARNING: This should only be called from the Plot. + + :param Plot plot: The Plot instance. + """ + if plot is not None and self._plotRef is not None: + raise RuntimeError('Trying to add a node at two places.') + self._plotRef = None if plot is None else weakref.ref(plot) + self._updated() + + def getBounds(self): # TODO return a Bounds object rather than a tuple + """Returns the bounding box of this item in data coordinates + + :returns: (xmin, xmax, ymin, ymax) or None + :rtype: 4-tuple of float or None + """ + return self._getBounds() + + def _getBounds(self): + """:meth:`getBounds` implementation to override by sub-class""" + return None + + def isVisible(self): + """True if item is visible, False otherwise + + :rtype: bool + """ + return self._visible + + def setVisible(self, visible): + """Set visibility of item. + + :param bool visible: True to display it, False otherwise + """ + visible = bool(visible) + if visible != self._visible: + self._visible = visible + # When visibility has changed, always mark as dirty + self._updated(checkVisibility=False) + + def isOverlay(self): + """Return true if item is drawn as an overlay. + + :rtype: bool + """ + return False + + def getLegend(self): + """Returns the legend of this item (str)""" + return self._legend + + def _setLegend(self, legend): + """Set the legend. + + This is private as it is used by the plot as an identifier + + :param str legend: Item legend + """ + legend = str(legend) if legend is not None else self._DEFAULT_LEGEND + self._legend = legend + + def isSelectable(self): + """Returns true if item is selectable (bool)""" + return self._selectable + + def _setSelectable(self, selectable): # TODO support update + """Set whether item is selectable or not. + + This is private for now as change is not handled. + + :param bool selectable: True to make item selectable + """ + self._selectable = bool(selectable) + + def getZValue(self): + """Returns the layer on which to draw this item (int)""" + return self._z + + def setZValue(self, z): + z = int(z) if z is not None else self._DEFAULT_Z_LAYER + if z != self._z: + self._z = z + self._updated() + + def getInfo(self, copy=True): + """Returns the info associated to this item + + :param bool copy: True to get a deepcopy, False otherwise. + """ + return deepcopy(self._info) if copy else self._info + + def setInfo(self, info, copy=True): + if copy: + info = deepcopy(info) + self._info = info + + def _updated(self, checkVisibility=True): + """Mark the item as dirty (i.e., needing update). + + This also triggers Plot.replot. + + :param bool checkVisibility: True to only mark as dirty if visible, + False to always mark as dirty. + """ + if not checkVisibility or self.isVisible(): + if not self._dirty: + self._dirty = True + # TODO: send event instead of explicit call + plot = self.getPlot() + if plot is not None: + plot._itemRequiresUpdate(self) + + def _update(self, backend): + """Called by Plot to update the backend for this item. + + This is meant to be called asynchronously from _updated. + This optimizes the number of call to _update. + + :param backend: The backend to update + """ + if self._dirty: + # Remove previous renderer from backend if any + self._removeBackendRenderer(backend) + + # If not visible, do not add renderer to backend + if self.isVisible(): + self._backendRenderer = self._addBackendRenderer(backend) + + self._dirty = False + + def _addBackendRenderer(self, backend): + """Override in subclass to add specific backend renderer. + + :param BackendBase backend: The backend to update + :return: The renderer handle to store or None if no renderer in backend + """ + return None + + def _removeBackendRenderer(self, backend): + """Override in subclass to remove specific backend renderer. + + :param BackendBase backend: The backend to update + """ + if self._backendRenderer is not None: + backend.remove(self._backendRenderer) + self._backendRenderer = None + + +# Mix-in classes ############################################################## + +class LabelsMixIn(object): + """Mix-in class for items with x and y labels + + Setters are private, otherwise it needs to check the plot + current active curve and access the internal current labels. + """ + + def __init__(self): + self._xlabel = None + self._ylabel = None + + def getXLabel(self): + """Return the X axis label associated to this curve + + :rtype: str or None + """ + return self._xlabel + + def _setXLabel(self, label): + """Set the X axis label associated with this curve + + :param str label: The X axis label + """ + self._xlabel = str(label) + + def getYLabel(self): + """Return the Y axis label associated to this curve + + :rtype: str or None + """ + return self._ylabel + + def _setYLabel(self, label): + """Set the Y axis label associated with this curve + + :param str label: The Y axis label + """ + self._ylabel = str(label) + + +class DraggableMixIn(object): + """Mix-in class for draggable items""" + + def __init__(self): + self._draggable = False + + def isDraggable(self): + """Returns true if image is draggable + + :rtype: bool + """ + return self._draggable + + def _setDraggable(self, draggable): # TODO support update + """Set if image is draggable or not. + + This is private for not as it does not support update. + + :param bool draggable: + """ + self._draggable = bool(draggable) + + +class ColormapMixIn(object): + """Mix-in class for items with colormap""" + + _DEFAULT_COLORMAP = {'name': 'gray', 'normalization': 'linear', + 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0} + """Default colormap of the item""" + + def __init__(self): + self._colormap = self._DEFAULT_COLORMAP + + def getColormap(self): + """Return the used colormap""" + return self._colormap.copy() + + def setColormap(self, colormap): + """Set the colormap of this image + + :param dict colormap: colormap description + """ + self._colormap = colormap.copy() + # TODO colormap comparison + colormap object and events on modification + self._updated() + + +class SymbolMixIn(object): + """Mix-in class for items with symbol type""" + + _DEFAULT_SYMBOL = '' + """Default marker of the item""" + + _DEFAULT_SYMBOL_SIZE = 6.0 + """Default marker size of the item""" + + def __init__(self): + self._symbol = self._DEFAULT_SYMBOL + self._symbol_size = self._DEFAULT_SYMBOL_SIZE + + def getSymbol(self): + """Return the point marker type. + + Marker type:: + + - 'o' circle + - '.' point + - ',' pixel + - '+' cross + - 'x' x-cross + - 'd' diamond + - 's' square + + :rtype: str + """ + return self._symbol + + def setSymbol(self, symbol): + """Set the marker type + + See :meth:`getSymbol`. + + :param str symbol: Marker type + """ + assert symbol in ('o', '.', ',', '+', 'x', 'd', 's', '', None) + if symbol is None: + symbol = self._DEFAULT_SYMBOL + if symbol != self._symbol: + self._symbol = symbol + self._updated() + + def getSymbolSize(self): + """Return the point marker size in points. + + :rtype: float + """ + return self._symbol_size + + def setSymbolSize(self, size): + """Set the point marker size in points. + + See :meth:`getSymbolSize`. + + :param str symbol: Marker type + """ + if size is None: + size = self._DEFAULT_SYMBOL_SIZE + if size != self._symbol_size: + self._symbol_size = size + self._updated() + + +class LineMixIn(object): + """Mix-in class for item with line""" + + _DEFAULT_LINEWIDTH = 1. + """Default line width""" + + _DEFAULT_LINESTYLE = '-' + """Default line style""" + + def __init__(self): + self._linewidth = self._DEFAULT_LINEWIDTH + self._linestyle = self._DEFAULT_LINESTYLE + + def getLineWidth(self): + """Return the curve line width in pixels (int)""" + return self._linewidth + + def setLineWidth(self, width): + """Set the width in pixel of the curve line + + See :meth:`getLineWidth`. + + :param float width: Width in pixels + """ + width = float(width) + if width != self._linewidth: + self._linewidth = width + self._updated() + + def getLineStyle(self): + """Return the type of the line + + Type of line:: + + - ' ' no line + - '-' solid line + - '--' dashed line + - '-.' dash-dot line + - ':' dotted line + + :rtype: str + """ + return self._linestyle + + def setLineStyle(self, style): + """Set the style of the curve line. + + See :meth:`getLineStyle`. + + :param str style: Line style + """ + style = str(style) + assert style in ('', ' ', '-', '--', '-.', ':', None) + if style is None: + style = self._DEFAULT_LINESTYLE + if style != self._linestyle: + self._linestyle = style + self._updated() + + +class ColorMixIn(object): + """Mix-in class for item with color""" + + _DEFAULT_COLOR = (0., 0., 0., 1.) + """Default color of the item""" + + def __init__(self): + self._color = self._DEFAULT_COLOR + + def getColor(self): + """Returns the RGBA color of the item + + :rtype: 4-tuple of float in [0, 1] + """ + return self._color + + def setColor(self, color, copy=True): + """Set item color + + :param color: color(s) to be used + :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or + one of the predefined color names defined in Colors.py + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + """ + if isinstance(color, six.string_types): + color = Colors.rgba(color) + else: + color = numpy.array(color, copy=copy) + # TODO more checks + improve color array support + if color.ndim == 1: # Single RGBA color + color = Colors.rgba(color) + else: # Array of colors + assert color.ndim == 2 + + self._color = color + self._updated() + + +class YAxisMixIn(object): + """Mix-in class for item with yaxis""" + + _DEFAULT_YAXIS = 'left' + """Default Y axis the item belongs to""" + + def __init__(self): + self._yaxis = self._DEFAULT_YAXIS + + def getYAxis(self): + """Returns the Y axis this curve belongs to. + + Either 'left' or 'right'. + + :rtype: str + """ + return self._yaxis + + def setYAxis(self, yaxis): + """Set the Y axis this curve belongs to. + + :param str yaxis: 'left' or 'right' + """ + yaxis = str(yaxis) + assert yaxis in ('left', 'right') + if yaxis != self._yaxis: + self._yaxis = yaxis + self._updated() + + +class FillMixIn(object): + """Mix-in class for item with fill""" + + def __init__(self): + self._fill = False + + def isFill(self): + """Returns whether the item is filled or not. + + :rtype: bool + """ + return self._fill + + def setFill(self, fill): + """Set whether to fill the item or not. + + :param bool fill: + """ + fill = bool(fill) + if fill != self._fill: + self._fill = fill + self._updated() + + +class AlphaMixIn(object): + """Mix-in class for item with opacity""" + + def __init__(self): + self._alpha = 1. + + def getAlpha(self): + """Returns the opacity of the item + + :rtype: float in [0, 1.] + """ + return self._alpha + + def setAlpha(self, alpha): + """Set the opacity of the item + + .. note:: + + If the colormap already has some transparency, this alpha + adds additional transparency. The alpha channel of the colormap + is multiplied by this value. + + :param alpha: Opacity of the item, between 0 (full transparency) + and 1. (full opacity) + :type alpha: float + """ + alpha = float(alpha) + alpha = max(0., min(alpha, 1.)) # Clip alpha to [0., 1.] range + if alpha != self._alpha: + self._alpha = alpha + self._updated() + + +class Points(Item, SymbolMixIn, AlphaMixIn): + """Base class for :class:`Curve` and :class:`Scatter`""" + # note: _logFilterData must be overloaded if you overload + # getData to change its signature + + _DEFAULT_Z_LAYER = 1 + """Default overlay layer for points, + on top of images.""" + + def __init__(self): + Item.__init__(self) + SymbolMixIn.__init__(self) + AlphaMixIn.__init__(self) + self._x = () + self._y = () + self._xerror = None + self._yerror = None + + # Store filtered data for x > 0 and/or y > 0 + self._filteredCache = {} + self._clippedCache = {} + + # Store bounds depending on axes filtering >0: + # key is (isXPositiveFilter, isYPositiveFilter) + self._boundsCache = {} + + @staticmethod + def _logFilterError(value, error): + """Filter/convert error values if they go <= 0. + + Replace error leading to negative values by nan + + :param numpy.ndarray value: 1D array of values + :param numpy.ndarray error: + Array of errors: scalar, N, Nx1 or 2xN or None. + :return: Filtered error so error bars are never negative + """ + if error is not None: + # Convert Nx1 to N + if error.ndim == 2 and error.shape[1] == 1 and len(value) != 1: + error = numpy.ravel(error) + + # Supports error being scalar, N or 2xN array + errorClipped = (value - numpy.atleast_2d(error)[0]) <= 0 + + if numpy.any(errorClipped): # Need filtering + + # expand errorbars to 2xN + if error.size == 1: # Scalar + error = numpy.full( + (2, len(value)), error, dtype=numpy.float) + + elif error.ndim == 1: # N array + newError = numpy.empty((2, len(value)), + dtype=numpy.float) + newError[0, :] = error + newError[1, :] = error + error = newError + + elif error.size == 2 * len(value): # 2xN array + error = numpy.array( + error, copy=True, dtype=numpy.float) + + else: + _logger.error("Unhandled error array") + return error + + error[0, errorClipped] = numpy.nan + + return error + + def _getClippingBoolArray(self, xPositive, yPositive): + """Compute a boolean array to filter out points with negative + coordinates on log axes. + + :param bool xPositive: True to filter arrays according to X coords. + :param bool yPositive: True to filter arrays according to Y coords. + :rtype: boolean numpy.ndarray + """ + assert xPositive or yPositive + if (xPositive, yPositive) not in self._clippedCache: + x = self.getXData(copy=False) + y = self.getYData(copy=False) + xclipped = (x <= 0) if xPositive else False + yclipped = (y <= 0) if yPositive else False + self._clippedCache[(xPositive, yPositive)] = \ + numpy.logical_or(xclipped, yclipped) + return self._clippedCache[(xPositive, yPositive)] + + def _logFilterData(self, xPositive, yPositive): + """Filter out values with x or y <= 0 on log axes + + :param bool xPositive: True to filter arrays according to X coords. + :param bool yPositive: True to filter arrays according to Y coords. + :return: The filter arrays or unchanged object if filtering not needed + :rtype: (x, y, xerror, yerror) + """ + x = self.getXData(copy=False) + y = self.getYData(copy=False) + xerror = self.getXErrorData(copy=False) + yerror = self.getYErrorData(copy=False) + + if xPositive or yPositive: + clipped = self._getClippingBoolArray(xPositive, yPositive) + + if numpy.any(clipped): + # copy to keep original array and convert to float + x = numpy.array(x, copy=True, dtype=numpy.float) + x[clipped] = numpy.nan + y = numpy.array(y, copy=True, dtype=numpy.float) + y[clipped] = numpy.nan + + if xPositive and xerror is not None: + xerror = self._logFilterError(x, xerror) + + if yPositive and yerror is not None: + yerror = self._logFilterError(y, yerror) + + return x, y, xerror, yerror + + def _getBounds(self): + if self.getXData(copy=False).size == 0: # Empty data + return None + + plot = self.getPlot() + if plot is not None: + xPositive = plot.isXAxisLogarithmic() + yPositive = plot.isYAxisLogarithmic() + else: + xPositive = False + yPositive = False + + # TODO bounds do not take error bars into account + if (xPositive, yPositive) not in self._boundsCache: + # use the getData class method because instance method can be + # overloaded to return additional arrays + data = Points.getData(self, copy=False, + displayed=True) + if len(data) == 5: + # hack to avoid duplicating caching mechanism in Scatter + # (happens when cached data is used, caching done using + # Scatter._logFilterData) + x, y, xerror, yerror = data[0], data[1], data[3], data[4] + else: + x, y, xerror, yerror = data + + self._boundsCache[(xPositive, yPositive)] = ( + numpy.nanmin(x), + numpy.nanmax(x), + numpy.nanmin(y), + numpy.nanmax(y) + ) + return self._boundsCache[(xPositive, yPositive)] + + def _getCachedData(self): + """Return cached filtered data if applicable, + i.e. if any axis is in log scale. + Return None if caching is not applicable.""" + plot = self.getPlot() + if plot is not None: + xPositive = plot.isXAxisLogarithmic() + yPositive = plot.isYAxisLogarithmic() + if xPositive or yPositive: + # At least one axis has log scale, filter data + if (xPositive, yPositive) not in self._filteredCache: + self._filteredCache[(xPositive, yPositive)] = \ + self._logFilterData(xPositive, yPositive) + return self._filteredCache[(xPositive, yPositive)] + return None + + def getData(self, copy=True, displayed=False): + """Returns the x, y values of the curve points and xerror, yerror + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :param bool displayed: True to only get curve points that are displayed + in the plot. Default: False + Note: If plot has log scale, negative points + are not displayed. + :returns: (x, y, xerror, yerror) + :rtype: 4-tuple of numpy.ndarray + """ + if displayed: # filter data according to plot state + cached_data = self._getCachedData() + if cached_data is not None: + return cached_data + + return (self.getXData(copy), + self.getYData(copy), + self.getXErrorData(copy), + self.getYErrorData(copy)) + + def getXData(self, copy=True): + """Returns the x coordinates of the data points + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: numpy.ndarray + """ + return numpy.array(self._x, copy=copy) + + def getYData(self, copy=True): + """Returns the y coordinates of the data points + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: numpy.ndarray + """ + return numpy.array(self._y, copy=copy) + + def getXErrorData(self, copy=True): + """Returns the x error of the points + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: numpy.ndarray or None + """ + if self._xerror is None: + return None + else: + return numpy.array(self._xerror, copy=copy) + + def getYErrorData(self, copy=True): + """Returns the y error of the points + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: numpy.ndarray or None + """ + if self._yerror is None: + return None + else: + return numpy.array(self._yerror, copy=copy) + + def setData(self, x, y, xerror=None, yerror=None, copy=True): + """Set the data of the curve. + + :param numpy.ndarray x: The data corresponding to the x coordinates. + :param numpy.ndarray y: The data corresponding to the y coordinates. + :param xerror: Values with the uncertainties on the x values + :type xerror: A float, or a numpy.ndarray of float32. + If it is an array, it can either be a 1D array of + same length as the data or a 2D array with 2 rows + of same length as the data: row 0 for positive errors, + row 1 for negative errors. + :param yerror: Values with the uncertainties on the y values. + :type yerror: A float, or a numpy.ndarray of float32. See xerror. + :param bool copy: True make a copy of the data (default), + False to use provided arrays. + """ + x = numpy.array(x, copy=copy) + y = numpy.array(y, copy=copy) + assert len(x) == len(y) + assert x.ndim == y.ndim == 1 + + if xerror is not None: + xerror = numpy.array(xerror, copy=copy) + if yerror is not None: + yerror = numpy.array(yerror, copy=copy) + # TODO checks on xerror, yerror + self._x, self._y = x, y + self._xerror, self._yerror = xerror, yerror + + self._boundsCache = {} # Reset cached bounds + self._filteredCache = {} # Reset cached filtered data + self._clippedCache = {} # Reset cached clipped bool array + + self._updated() + # TODO hackish data range implementation + if self.isVisible(): + plot = self.getPlot() + if plot is not None: + plot._invalidateDataRange() diff --git a/silx/gui/plot/items/curve.py b/silx/gui/plot/items/curve.py new file mode 100644 index 0000000..d25ae00 --- /dev/null +++ b/silx/gui/plot/items/curve.py @@ -0,0 +1,192 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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 +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides the :class:`Curve` item of the :class:`Plot`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/03/2017" + + +import logging + +import numpy + +from .. import Colors +from .core import (Points, LabelsMixIn, SymbolMixIn, + ColorMixIn, YAxisMixIn, FillMixIn, LineMixIn) + + +_logger = logging.getLogger(__name__) + + +class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn): + """Description of a curve""" + + _DEFAULT_Z_LAYER = 1 + """Default overlay layer for curves""" + + _DEFAULT_SELECTABLE = True + """Default selectable state for curves""" + + _DEFAULT_LINEWIDTH = 1. + """Default line width of the curve""" + + _DEFAULT_LINESTYLE = '-' + """Default line style of the curve""" + + _DEFAULT_HIGHLIGHT_COLOR = (0, 0, 0, 255) + """Default highlight color of the item""" + + def __init__(self): + Points.__init__(self) + ColorMixIn.__init__(self) + YAxisMixIn.__init__(self) + FillMixIn.__init__(self) + LabelsMixIn.__init__(self) + LineMixIn.__init__(self) + + self._highlightColor = self._DEFAULT_HIGHLIGHT_COLOR + self._highlighted = False + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + # Filter-out values <= 0 + xFiltered, yFiltered, xerror, yerror = self.getData( + copy=False, displayed=True) + + if len(xFiltered) == 0: + return None # No data to display, do not add renderer to backend + + return backend.addCurve(xFiltered, yFiltered, self.getLegend(), + color=self.getCurrentColor(), + symbol=self.getSymbol(), + linestyle=self.getLineStyle(), + linewidth=self.getLineWidth(), + yaxis=self.getYAxis(), + xerror=xerror, + yerror=yerror, + z=self.getZValue(), + selectable=self.isSelectable(), + fill=self.isFill(), + alpha=self.getAlpha(), + symbolsize=self.getSymbolSize()) + + def __getitem__(self, item): + """Compatibility with PyMca and silx <= 0.4.0""" + if isinstance(item, slice): + return [self[index] for index in range(*item.indices(5))] + elif item == 0: + return self.getXData(copy=False) + elif item == 1: + return self.getYData(copy=False) + elif item == 2: + return self.getLegend() + elif item == 3: + info = self.getInfo(copy=False) + return {} if info is None else info + elif item == 4: + params = { + 'info': self.getInfo(), + 'color': self.getColor(), + 'symbol': self.getSymbol(), + 'linewidth': self.getLineWidth(), + 'linestyle': self.getLineStyle(), + 'xlabel': self.getXLabel(), + 'ylabel': self.getYLabel(), + 'yaxis': self.getYAxis(), + 'xerror': self.getXErrorData(copy=False), + 'yerror': self.getYErrorData(copy=False), + 'z': self.getZValue(), + 'selectable': self.isSelectable(), + 'fill': self.isFill() + } + return params + else: + raise IndexError("Index out of range: %s", str(item)) + + def setVisible(self, visible): + """Set visibility of item. + + :param bool visible: True to display it, False otherwise + """ + visibleChanged = self.isVisible() != bool(visible) + super(Curve, self).setVisible(visible) + + # TODO hackish data range implementation + if visibleChanged: + plot = self.getPlot() + if plot is not None: + plot._invalidateDataRange() + + def isHighlighted(self): + """Returns True if curve is highlighted. + + :rtype: bool + """ + return self._highlighted + + def setHighlighted(self, highlighted): + """Set the highlight state of the curve + + :param bool highlighted: + """ + highlighted = bool(highlighted) + if highlighted != self._highlighted: + self._highlighted = highlighted + # TODO inefficient: better to use backend's setCurveColor + self._updated() + + def getHighlightedColor(self): + """Returns the RGBA highlight color of the item + + :rtype: 4-tuple of int in [0, 255] + """ + return self._highlightColor + + def setHighlightedColor(self, color): + """Set the color to use when highlighted + + :param color: color(s) to be used for highlight + :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or + one of the predefined color names defined in Colors.py + """ + color = Colors.rgba(color) + if color != self._highlightColor: + self._highlightColor = color + self._updated() + + def getCurrentColor(self): + """Returns the current color of the curve. + + This color is either the color of the curve or the highlighted color, + depending on the highlight state. + + :rtype: 4-tuple of int in [0, 255] + """ + if self.isHighlighted(): + return self.getHighlightedColor() + else: + return self.getColor() diff --git a/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py new file mode 100644 index 0000000..c3821bc --- /dev/null +++ b/silx/gui/plot/items/histogram.py @@ -0,0 +1,288 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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 +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides the :class:`Histogram` item of the :class:`Plot`. +""" + +__authors__ = ["H. Payno", "T. Vincent"] +__license__ = "MIT" +__date__ = "02/05/2017" + + +import logging + +import numpy + +from .core import (Item, AlphaMixIn, ColorMixIn, FillMixIn, + LineMixIn, YAxisMixIn) + + +_logger = logging.getLogger(__name__) + + +def _computeEdges(x, histogramType): + """Compute the edges from a set of xs and a rule to generate the edges + + :param x: the x value of the curve to transform into an histogram + :param histogramType: the type of histogram we wan't to generate. + This define the way to center the histogram values compared to the + curve value. Possible values can be:: + + - 'left' + - 'right' + - 'center' + + :return: the edges for the given x and the histogramType + """ + # for now we consider that the spaces between xs are constant + edges = x.copy() + if histogramType is 'left': + width = 1 + if len(x) > 1: + width = x[1] - x[0] + edges = numpy.append(x[0] - width, edges) + if histogramType is 'center': + edges = _computeEdges(edges, 'right') + widths = (edges[1:] - edges[0:-1]) / 2.0 + widths = numpy.append(widths, widths[-1]) + edges = edges - widths + if histogramType is 'right': + width = 1 + if len(x) > 1: + width = x[-1] - x[-2] + edges = numpy.append(edges, x[-1] + width) + + return edges + + +def _getHistogramCurve(histogram, edges): + """Returns the x and y value of a curve corresponding to the histogram + + :param numpy.ndarray histogram: The values of the histogram + :param numpy.ndarray edges: The bin edges of the histogram + :return: a tuple(x, y) which contains the value of the curve to use + to display the histogram + """ + assert len(histogram) + 1 == len(edges) + x = numpy.empty(len(histogram) * 2, dtype=edges.dtype) + y = numpy.empty(len(histogram) * 2, dtype=histogram.dtype) + # Make a curve with stairs + x[:-1:2] = edges[:-1] + x[1::2] = edges[1:] + y[:-1:2] = histogram + y[1::2] = histogram + + return x, y + + +# TODO: Yerror, test log scale +class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, + LineMixIn, YAxisMixIn): + """Description of an histogram""" + + _DEFAULT_Z_LAYER = 1 + """Default overlay layer for histograms""" + + _DEFAULT_SELECTABLE = False + """Default selectable state for histograms""" + + _DEFAULT_LINEWIDTH = 1. + """Default line width of the histogram""" + + _DEFAULT_LINESTYLE = '-' + """Default line style of the histogram""" + + def __init__(self): + Item.__init__(self) + AlphaMixIn.__init__(self) + ColorMixIn.__init__(self) + FillMixIn.__init__(self) + LineMixIn.__init__(self) + YAxisMixIn.__init__(self) + + self._histogram = () + self._edges = () + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + values, edges = self.getData(copy=False) + + if values.size == 0: + return None # No data to display, do not add renderer + + if values.size == 0: + return None # No data to display, do not add renderer to backend + + x, y = _getHistogramCurve(values, edges) + + # Filter-out values <= 0 + plot = self.getPlot() + if plot is not None: + xPositive = plot.isXAxisLogarithmic() + yPositive = plot.isYAxisLogarithmic() + else: + xPositive = False + yPositive = False + + if xPositive or yPositive: + clipped = numpy.logical_or( + (x <= 0) if xPositive else False, + (y <= 0) if yPositive else False) + # Make a copy and replace negative points by NaN + x = numpy.array(x, dtype=numpy.float) + y = numpy.array(y, dtype=numpy.float) + x[clipped] = numpy.nan + y[clipped] = numpy.nan + + return backend.addCurve(x, y, self.getLegend(), + color=self.getColor(), + symbol='', + linestyle=self.getLineStyle(), + linewidth=self.getLineWidth(), + yaxis=self.getYAxis(), + xerror=None, + yerror=None, + z=self.getZValue(), + selectable=self.isSelectable(), + fill=self.isFill(), + alpha=self.getAlpha(), + symbolsize=1) + + def _getBounds(self): + values, edges = self.getData(copy=False) + + plot = self.getPlot() + if plot is not None: + xPositive = plot.isXAxisLogarithmic() + yPositive = plot.isYAxisLogarithmic() + else: + xPositive = False + yPositive = False + + if xPositive or yPositive: + values = numpy.array(values, copy=True, dtype=numpy.float) + + if xPositive: + # Replace edges <= 0 by NaN and corresponding values by NaN + clipped = (edges <= 0) + edges = numpy.array(edges, copy=True, dtype=numpy.float) + edges[clipped] = numpy.nan + values[numpy.logical_or(clipped[:-1], clipped[1:])] = numpy.nan + + if yPositive: + # Replace values <= 0 by NaN, do not modify edges + values[values <= 0] = numpy.nan + + if xPositive or yPositive: + return (numpy.nanmin(edges), + numpy.nanmax(edges), + numpy.nanmin(values), + numpy.nanmax(values)) + + else: # No log scale, include 0 in bounds + return (numpy.nanmin(edges), + numpy.nanmax(edges), + min(0, numpy.nanmin(values)), + max(0, numpy.nanmax(values))) + + def setVisible(self, visible): + """Set visibility of item. + + :param bool visible: True to display it, False otherwise + """ + visibleChanged = self.isVisible() != bool(visible) + super(Histogram, self).setVisible(visible) + + # TODO hackish data range implementation + if visibleChanged: + plot = self.getPlot() + if plot is not None: + plot._invalidateDataRange() + + def getValueData(self, copy=True): + """The values of the histogram + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :returns: The bin edges of the histogram + :rtype: numpy.ndarray + """ + return numpy.array(self._histogram, copy=copy) + + def getBinEdgesData(self, copy=True): + """The bin edges of the histogram (number of histogram values + 1) + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :returns: The bin edges of the histogram + :rtype: numpy.ndarray + """ + return numpy.array(self._edges, copy=copy) + + def getData(self, copy=True): + """Return the histogram values and the bin edges + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :returns: (N histogram value, N+1 bin edges) + :rtype: 2-tuple of numpy.nadarray + """ + return (self.getValueData(copy), self.getBinEdgesData(copy)) + + def setData(self, histogram, edges, align='center', copy=True): + """Set the histogram values and bin edges. + + :param numpy.ndarray histogram: The values of the histogram. + :param numpy.ndarray edges: + The bin edges of the histogram. + If histogram and edges have the same length, the bin edges + are computed according to the align parameter. + :param str align: + In case histogram values and edges have the same length N, + the N+1 bin edges are computed according to the alignment in: + 'center' (default), 'left', 'right'. + :param bool copy: True make a copy of the data (default), + False to use provided arrays. + """ + histogram = numpy.array(histogram, copy=copy) + edges = numpy.array(edges, copy=copy) + + assert histogram.ndim == 1 + assert edges.ndim == 1 + assert edges.size in (histogram.size, histogram.size + 1) + assert align in ('center', 'left', 'right') + + if histogram.size == 0: # No data + self._histogram = () + self._edges = () + else: + if edges.size == histogram.size: # Compute true bin edges + edges = _computeEdges(edges, align) + + # Check that bin edges are monotonic + edgesDiff = numpy.diff(edges) + assert numpy.all(edgesDiff >= 0) or numpy.all(edgesDiff <= 0) + + self._histogram = histogram + self._edges = edges diff --git a/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py new file mode 100644 index 0000000..7e1dd8b --- /dev/null +++ b/silx/gui/plot/items/image.py @@ -0,0 +1,385 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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 +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides the :class:`ImageData` and :class:`ImageRgba` items +of the :class:`Plot`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/03/2017" + + +from collections import Sequence +import logging + +import numpy + +from .core import Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, AlphaMixIn +from ..Colors import applyColormapToData + + +_logger = logging.getLogger(__name__) + + +def _convertImageToRgba32(image, copy=True): + """Convert an RGB or RGBA image to RGBA32. + + It converts from floats in [0, 1], bool, integer and uint in [0, 255] + + If the input image is already an RGBA32 image, + the returned image shares the same data. + + :param image: Image to convert to + :type image: numpy.ndarray with 3 dimensions: height, width, color channels + :param bool copy: True (Default) to get a copy, False, avoid copy if possible + :return: The image converted to RGBA32 with dimension: (height, width, 4) + :rtype: numpy.ndarray of uint8 + """ + assert image.ndim == 3 + assert image.shape[-1] in (3, 4) + + # Convert type to uint8 + if image.dtype.name != 'uin8': + if image.dtype.kind == 'f': # Float in [0, 1] + image = (numpy.clip(image, 0., 1.) * 255).astype(numpy.uint8) + elif image.dtype.kind == 'b': # boolean + image = image.astype(numpy.uint8) * 255 + elif image.dtype.kind in ('i', 'u'): # int, uint + image = numpy.clip(image, 0, 255).astype(numpy.uint8) + else: + raise ValueError('Unsupported image dtype: %s', image.dtype.name) + copy = False # A copy as already been done, avoid next one + + # Convert RGB to RGBA + if image.shape[-1] == 3: + new_image = numpy.empty((image.shape[0], image.shape[1], 4), + dtype=numpy.uint8) + new_image[:, :, :3] = image + new_image[:, :, 3] = 255 + return new_image # This is a copy anyway + else: + return numpy.array(image, copy=copy) + + +class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn): + """Description of an image""" + + def __init__(self): + Item.__init__(self) + LabelsMixIn.__init__(self) + DraggableMixIn.__init__(self) + AlphaMixIn.__init__(self) + self._data = numpy.zeros((0, 0, 4), dtype=numpy.uint8) + + self._origin = (0., 0.) + self._scale = (1., 1.) + + def __getitem__(self, item): + """Compatibility with PyMca and silx <= 0.4.0""" + if isinstance(item, slice): + return [self[index] for index in range(*item.indices(5))] + elif item == 0: + return self.getData(copy=False) + elif item == 1: + return self.getLegend() + elif item == 2: + info = self.getInfo(copy=False) + return {} if info is None else info + elif item == 3: + return None + elif item == 4: + params = { + 'info': self.getInfo(), + 'origin': self.getOrigin(), + 'scale': self.getScale(), + 'z': self.getZValue(), + 'selectable': self.isSelectable(), + 'draggable': self.isDraggable(), + 'colormap': None, + 'xlabel': self.getXLabel(), + 'ylabel': self.getYLabel(), + } + return params + else: + raise IndexError("Index out of range: %s" % str(item)) + + def setVisible(self, visible): + """Set visibility of item. + + :param bool visible: True to display it, False otherwise + """ + visibleChanged = self.isVisible() != bool(visible) + super(ImageBase, self).setVisible(visible) + + # TODO hackish data range implementation + if visibleChanged: + plot = self.getPlot() + if plot is not None: + plot._invalidateDataRange() + + def _getBounds(self): + if self.getData(copy=False).size == 0: # Empty data + return None + + height, width = self.getData(copy=False).shape[:2] + origin = self.getOrigin() + scale = self.getScale() + # Taking care of scale might be < 0 + xmin, xmax = origin[0], origin[0] + width * scale[0] + if xmin > xmax: + xmin, xmax = xmax, xmin + # Taking care of scale might be < 0 + ymin, ymax = origin[1], origin[1] + height * scale[1] + if ymin > ymax: + ymin, ymax = ymax, ymin + + plot = self.getPlot() + if (plot is not None and + plot.isXAxisLogarithmic() or plot.isYAxisLogarithmic()): + return None + else: + return xmin, xmax, ymin, ymax + + def getData(self, copy=True): + """Returns the image data + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: numpy.ndarray + """ + return numpy.array(self._data, copy=copy) + + def getRgbaImageData(self, copy=True): + """Get the displayed RGB(A) image + + :returns: numpy.ndarray of uint8 of shape (height, width, 4) + """ + raise NotImplementedError('This MUST be implemented in sub-class') + + def getOrigin(self): + """Returns the offset from origin at which to display the image. + + :rtype: 2-tuple of float + """ + return self._origin + + def setOrigin(self, origin): + """Set the offset from origin at which to display the image. + + :param origin: (ox, oy) Offset from origin + :type origin: float or 2-tuple of float + """ + if isinstance(origin, Sequence): + origin = float(origin[0]), float(origin[1]) + else: # single value origin + origin = float(origin), float(origin) + if origin != self._origin: + self._origin = origin + self._updated() + + # TODO hackish data range implementation + if self.isVisible(): + plot = self.getPlot() + if plot is not None: + plot._invalidateDataRange() + + def getScale(self): + """Returns the scale of the image in data coordinates. + + :rtype: 2-tuple of float + """ + return self._scale + + def setScale(self, scale): + """Set the scale of the image + + :param scale: (sx, sy) Scale of the image + :type scale: float or 2-tuple of float + """ + if isinstance(scale, Sequence): + scale = float(scale[0]), float(scale[1]) + else: # single value scale + scale = float(scale), float(scale) + if scale != self._scale: + self._scale = scale + self._updated() + + +class ImageData(ImageBase, ColormapMixIn): + """Description of a data image with a colormap""" + + def __init__(self): + ImageBase.__init__(self) + ColormapMixIn.__init__(self) + self._data = numpy.zeros((0, 0), dtype=numpy.float32) + self._alternativeImage = None + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + plot = self.getPlot() + assert plot is not None + if plot.isXAxisLogarithmic() or plot.isYAxisLogarithmic(): + return None # Do not render with log scales + + if self.getAlternativeImageData(copy=False) is not None: + dataToUse = self.getAlternativeImageData(copy=False) + else: + dataToUse = self.getData(copy=False) + + if dataToUse.size == 0: + return None # No data to display + + return backend.addImage(dataToUse, + legend=self.getLegend(), + origin=self.getOrigin(), + scale=self.getScale(), + z=self.getZValue(), + selectable=self.isSelectable(), + draggable=self.isDraggable(), + colormap=self.getColormap(), + alpha=self.getAlpha()) + + def __getitem__(self, item): + """Compatibility with PyMca and silx <= 0.4.0""" + if item == 3: + return self.getAlternativeImageData(copy=False) + + params = ImageBase.__getitem__(self, item) + if item == 4: + params['colormap'] = self.getColormap() + + return params + + def getRgbaImageData(self, copy=True): + """Get the displayed RGB(A) image + + :returns: numpy.ndarray of uint8 of shape (height, width, 4) + """ + if self._alternativeImage is not None: + return _convertImageToRgba32( + self.getAlternativeImageData(copy=False), copy=copy) + else: + # Apply colormap, in this case an new array is always returned + colormap = self.getColormap() + image = applyColormapToData(self.getData(copy=False), + **colormap) + return image + + def getAlternativeImageData(self, copy=True): + """Get the optional RGBA image that is displayed instead of the data + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :returns: None or numpy.ndarray + :rtype: numpy.ndarray or None + """ + if self._alternativeImage is None: + return None + else: + return numpy.array(self._alternativeImage, copy=copy) + + def setData(self, data, alternative=None, copy=True): + """"Set the image data and optionally an alternative RGB(A) representation + + :param numpy.ndarray data: Data array with 2 dimensions (h, w) + :param alternative: RGB(A) image to display instead of data, + shape: (h, w, 3 or 4) + :type alternative: None or numpy.ndarray + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + """ + data = numpy.array(data, copy=copy) + assert data.ndim == 2 + self._data = data + + if alternative is not None: + alternative = numpy.array(alternative, copy=copy) + assert alternative.ndim == 3 + assert alternative.shape[2] in (3, 4) + assert alternative.shape[:2] == data.shape[:2] + self._alternativeImage = alternative + self._updated() + + # TODO hackish data range implementation + if self.isVisible(): + plot = self.getPlot() + if plot is not None: + plot._invalidateDataRange() + + +class ImageRgba(ImageBase): + """Description of an RGB(A) image""" + + def __init__(self): + ImageBase.__init__(self) + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + plot = self.getPlot() + assert plot is not None + if plot.isXAxisLogarithmic() or plot.isYAxisLogarithmic(): + return None # Do not render with log scales + + data = self.getData(copy=False) + + if data.size == 0: + return None # No data to display + + return backend.addImage(data, + legend=self.getLegend(), + origin=self.getOrigin(), + scale=self.getScale(), + z=self.getZValue(), + selectable=self.isSelectable(), + draggable=self.isDraggable(), + colormap=None, + alpha=self.getAlpha()) + + def getRgbaImageData(self, copy=True): + """Get the displayed RGB(A) image + + :returns: numpy.ndarray of uint8 of shape (height, width, 4) + """ + return _convertImageToRgba32(self.getData(copy=False), copy=copy) + + def setData(self, data, copy=True): + """Set the image data + + :param data: RGB(A) image data to set + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + """ + data = numpy.array(data, copy=copy) + assert data.ndim == 3 + assert data.shape[-1] in (3, 4) + self._data = data + + self._updated() + + # TODO hackish data range implementation + if self.isVisible(): + plot = self.getPlot() + if plot is not None: + plot._invalidateDataRange() diff --git a/silx/gui/plot/items/marker.py b/silx/gui/plot/items/marker.py new file mode 100644 index 0000000..c05558b --- /dev/null +++ b/silx/gui/plot/items/marker.py @@ -0,0 +1,241 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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 +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides markers item of the :class:`Plot`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/03/2017" + + +import logging + +from .core import Item, DraggableMixIn, ColorMixIn, SymbolMixIn + + +_logger = logging.getLogger(__name__) + + +class _BaseMarker(Item, DraggableMixIn, ColorMixIn): + """Base class for markers""" + + _DEFAULT_COLOR = (0., 0., 0., 1.) + """Default color of the markers""" + + def __init__(self): + Item.__init__(self) + DraggableMixIn.__init__(self) + ColorMixIn.__init__(self) + + self._text = '' + self._x = None + self._y = None + self._constraint = self._defaultConstraint + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + # TODO not very nice way to do it, but simple + symbol = self.getSymbol() if isinstance(self, Marker) else None + + return backend.addMarker( + x=self.getXPosition(), + y=self.getYPosition(), + legend=self.getLegend(), + text=self.getText(), + color=self.getColor(), + selectable=self.isSelectable(), + draggable=self.isDraggable(), + symbol=symbol, + constraint=self.getConstraint(), + overlay=self.isOverlay()) + + def isOverlay(self): + """Return true if marker is drawn as an overlay. + + A marker is an overlay if it is draggable. + + :rtype: bool + """ + return self.isDraggable() + + def getText(self): + """Returns marker text. + + :rtype: str + """ + return self._text + + def setText(self, text): + """Set the text of the marker. + + :param str text: The text to use + """ + text = str(text) + if text != self._text: + self._text = text + self._updated() + + def getXPosition(self): + """Returns the X position of the marker line in data coordinates + + :rtype: float or None + """ + return self._x + + def getYPosition(self): + """Returns the Y position of the marker line in data coordinates + + :rtype: float or None + """ + return self._y + + def getPosition(self): + """Returns the (x, y) position of the marker in data coordinates + + :rtype: 2-tuple of float or None + """ + return self._x, self._y + + def setPosition(self, x, y): + """Set marker position in data coordinates + + Constraint are applied if any. + + :param float x: X coordinates in data frame + :param float y: Y coordinates in data frame + """ + x, y = self.getConstraint()(x, y) + x, y = float(x), float(y) + if x != self._x or y != self._y: + self._x, self._y = x, y + self._updated() + + def getConstraint(self): + """Returns the dragging constraint of this item""" + return self._constraint + + def _setConstraint(self, constraint): # TODO support update + """Set the constraint. + + This is private for now as update is not handled. + + :param callable constraint: + :param constraint: A function filtering item displacement by + dragging operations or None for no filter. + This function is called each time the item is + moved. + This is only used if isDraggable returns True. + :type constraint: None or a callable that takes the coordinates of + the current cursor position in the plot as input + and that returns the filtered coordinates. + """ + if constraint is None: + constraint = self._defaultConstraint + assert callable(constraint) + self._constraint = constraint + + @staticmethod + def _defaultConstraint(*args): + """Default constraint not doing anything""" + return args + + +class Marker(_BaseMarker, SymbolMixIn): + """Description of a marker""" + + _DEFAULT_SYMBOL = '+' + """Default symbol of the marker""" + + def __init__(self): + _BaseMarker.__init__(self) + SymbolMixIn.__init__(self) + + self._x = 0. + self._y = 0. + + def _setConstraint(self, constraint): + """Set the constraint function of the marker drag. + + It also supports 'horizontal' and 'vertical' str as constraint. + + :param constraint: The constraint of the dragging of this marker + :type: constraint: callable or str + """ + if constraint == 'horizontal': + constraint = self._horizontalConstraint + elif constraint == 'vertical': + constraint = self._verticalConstraint + + super(Marker, self)._setConstraint(constraint) + + def _horizontalConstraint(self, _, y): + return self.getXPosition(), y + + def _verticalConstraint(self, x, _): + return x, self.getYPosition() + + +class XMarker(_BaseMarker): + """Description of a marker""" + + def __init__(self): + _BaseMarker.__init__(self) + self._x = 0. + + def setPosition(self, x, y): + """Set marker line position in data coordinates + + Constraint are applied if any. + + :param float x: X coordinates in data frame + :param float y: Y coordinates in data frame + """ + x, _ = self.getConstraint()(x, y) + x = float(x) + if x != self._x: + self._x = x + self._updated() + + +class YMarker(_BaseMarker): + """Description of a marker""" + + def __init__(self): + _BaseMarker.__init__(self) + self._y = 0. + + def setPosition(self, x, y): + """Set marker line position in data coordinates + + Constraint are applied if any. + + :param float x: X coordinates in data frame + :param float y: Y coordinates in data frame + """ + _, y = self.getConstraint()(x, y) + y = float(y) + if y != self._y: + self._y = y + self._updated() diff --git a/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py new file mode 100644 index 0000000..3897dc1 --- /dev/null +++ b/silx/gui/plot/items/scatter.py @@ -0,0 +1,169 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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 +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides the :class:`Scatter` item of the :class:`Plot`. +""" + +__authors__ = ["T. Vincent", "P. Knobel"] +__license__ = "MIT" +__date__ = "29/03/2017" + + +import logging + +import numpy + +from .core import Points, ColormapMixIn +from silx.gui.plot.Colors import applyColormapToData # TODO: cherry-pick commit or wait for PR merge + +_logger = logging.getLogger(__name__) + + +class Scatter(Points, ColormapMixIn): + """Description of a scatter""" + _DEFAULT_SYMBOL = 'o' + """Default symbol of the scatter plots""" + + def __init__(self): + Points.__init__(self) + ColormapMixIn.__init__(self) + self._value = () + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + # Filter-out values <= 0 + xFiltered, yFiltered, valueFiltered, xerror, yerror = self.getData( + copy=False, displayed=True) + + if len(xFiltered) == 0: + return None # No data to display, do not add renderer to backend + + cmap = self.getColormap() + rgbacolors = applyColormapToData(self._value, + cmap["name"], + cmap["normalization"], + cmap["autoscale"], + cmap["vmin"], + cmap["vmax"], + cmap.get("colors")) + + return backend.addCurve(xFiltered, yFiltered, self.getLegend(), + color=rgbacolors, + symbol=self.getSymbol(), + linewidth=0, + linestyle="", + yaxis='left', + xerror=xerror, + yerror=yerror, + z=self.getZValue(), + selectable=self.isSelectable(), + fill=False, + alpha=self.getAlpha(), + symbolsize=self.getSymbolSize()) + + def _logFilterData(self, xPositive, yPositive): + """Filter out values with x or y <= 0 on log axes + + :param bool xPositive: True to filter arrays according to X coords. + :param bool yPositive: True to filter arrays according to Y coords. + :return: The filtered arrays or unchanged object if not filtering needed + :rtype: (x, y, value, xerror, yerror) + """ + # overloaded from Points to filter also value. + value = self.getValueData(copy=False) + + if xPositive or yPositive: + clipped = self._getClippingBoolArray(xPositive, yPositive) + + if numpy.any(clipped): + # copy to keep original array and convert to float + value = numpy.array(value, copy=True, dtype=numpy.float) + value[clipped] = numpy.nan + + x, y, xerror, yerror = Points._logFilterData(self, xPositive, yPositive) + + return x, y, value, xerror, yerror + + def getValueData(self, copy=True): + """Returns the value assigned to the scatter data points. + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: numpy.ndarray + """ + return numpy.array(self._value, copy=copy) + + def getData(self, copy=True, displayed=False): + """Returns the x, y coordinates and the value of the data points + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :param bool displayed: True to only get curve points that are displayed + in the plot. Default: False. + Note: If plot has log scale, negative points + are not displayed. + :returns: (x, y, value, xerror, yerror) + :rtype: 5-tuple of numpy.ndarray + """ + if displayed: + data = self._getCachedData() + if data is not None: + assert len(data) == 5 + return data + + return (self.getXData(copy), + self.getYData(copy), + self.getValueData(copy), + self.getXErrorData(copy), + self.getYErrorData(copy)) + + # reimplemented from Points to handle `value` + def setData(self, x, y, value, xerror=None, yerror=None, copy=True): + """Set the data of the scatter. + + :param numpy.ndarray x: The data corresponding to the x coordinates. + :param numpy.ndarray y: The data corresponding to the y coordinates. + :param numpy.ndarray value: The data corresponding to the value of + the data points. + :param xerror: Values with the uncertainties on the x values + :type xerror: A float, or a numpy.ndarray of float32. + If it is an array, it can either be a 1D array of + same length as the data or a 2D array with 2 rows + of same length as the data: row 0 for positive errors, + row 1 for negative errors. + :param yerror: Values with the uncertainties on the y values + :type yerror: A float, or a numpy.ndarray of float32. See xerror. + :param bool copy: True make a copy of the data (default), + False to use provided arrays. + """ + value = numpy.array(value, copy=copy) + assert value.ndim == 1 + assert len(x) == len(value) + + self._value = value + + # set x, y, xerror, yerror + + # call self._updated + plot._invalidateDataRange() + Points.setData(self, x, y, xerror, yerror, copy) diff --git a/silx/gui/plot/items/shape.py b/silx/gui/plot/items/shape.py new file mode 100644 index 0000000..b663989 --- /dev/null +++ b/silx/gui/plot/items/shape.py @@ -0,0 +1,121 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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 +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides the :class:`Shape` item of the :class:`Plot`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/03/2017" + + +import logging + +import numpy + +from .core import Item, ColorMixIn, FillMixIn + + +_logger = logging.getLogger(__name__) + + +# TODO probably make one class for each kind of shape +# TODO check fill:polygon/polyline + fill = duplicated +class Shape(Item, ColorMixIn, FillMixIn): + """Description of a shape item + + :param str type_: The type of shape in: + 'hline', 'polygon', 'rectangle', 'vline', 'polyline' + """ + + def __init__(self, type_): + Item.__init__(self) + ColorMixIn.__init__(self) + FillMixIn.__init__(self) + self._overlay = False + assert type_ in ('hline', 'polygon', 'rectangle', 'vline', 'polyline') + self._type = type_ + self._points = () + + self._handle = None + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + points = self.getPoints(copy=False) + x, y = points.T[0], points.T[1] + return backend.addItem(x, + y, + legend=self.getLegend(), + shape=self.getType(), + color=self.getColor(), + fill=self.isFill(), + overlay=self.isOverlay(), + z=self.getZValue()) + + def isOverlay(self): + """Return true if shape is drawn as an overlay + + :rtype: bool + """ + return self._overlay + + def setOverlay(self, overlay): + """Set the overlay state of the shape + + :param bool overlay: True to make it an overlay + """ + overlay = bool(overlay) + if overlay != self._overlay: + self._overlay = overlay + self._updated() + + def getType(self): + """Returns the type of shape to draw. + + One of: 'hline', 'polygon', 'rectangle', 'vline', 'polyline' + + :rtype: str + """ + return self._type + + def getPoints(self, copy=True): + """Get the control points of the shape. + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :return: Array of point coordinates + :rtype: numpy.ndarray with 2 dimensions + """ + return numpy.array(self._points, copy=copy) + + def setPoints(self, points, copy=True): + """Set the point coordinates + + :param numpy.ndarray points: Array of point coordinates + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :return: + """ + self._points = numpy.array(points, copy=copy) + self._updated() |