diff options
Diffstat (limited to 'silx/gui/plot/items')
-rw-r--r-- | silx/gui/plot/items/__init__.py | 13 | ||||
-rw-r--r-- | silx/gui/plot/items/axis.py | 477 | ||||
-rw-r--r-- | silx/gui/plot/items/core.py | 182 | ||||
-rw-r--r-- | silx/gui/plot/items/curve.py | 18 | ||||
-rw-r--r-- | silx/gui/plot/items/histogram.py | 36 | ||||
-rw-r--r-- | silx/gui/plot/items/image.py | 75 | ||||
-rw-r--r-- | silx/gui/plot/items/marker.py | 11 | ||||
-rw-r--r-- | silx/gui/plot/items/scatter.py | 10 | ||||
-rw-r--r-- | silx/gui/plot/items/shape.py | 14 |
9 files changed, 725 insertions, 111 deletions
diff --git a/silx/gui/plot/items/__init__.py b/silx/gui/plot/items/__init__.py index b16fe40..bf39c87 100644 --- a/silx/gui/plot/items/__init__.py +++ b/silx/gui/plot/items/__init__.py @@ -22,22 +22,23 @@ # THE SOFTWARE. # # ###########################################################################*/ -"""This package provides classes that describes :class:`.Plot` content. +"""This package provides classes that describes :class:`.PlotWidget` 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`. +Instances of those classes are returned by :class:`.PlotWidget` methods that give +access to its content such as :meth:`.PlotWidget.getCurve`, :meth:`.PlotWidget.getImage`. """ __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "06/03/2017" +__date__ = "22/06/2017" from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, # noqa SymbolMixIn, ColorMixIn, YAxisMixIn, FillMixIn, # noqa - AlphaMixIn, LineMixIn) # noqa + AlphaMixIn, LineMixIn, ItemChangedType) # noqa from .curve import Curve # noqa from .histogram import Histogram # noqa -from .image import ImageBase, ImageData, ImageRgba # noqa +from .image import ImageBase, ImageData, ImageRgba, MaskImageData # noqa from .shape import Shape # noqa from .scatter import Scatter # noqa from .marker import Marker, XMarker, YMarker # noqa +from .axis import Axis, XAxis, YAxis, YRightAxis diff --git a/silx/gui/plot/items/axis.py b/silx/gui/plot/items/axis.py new file mode 100644 index 0000000..56fd762 --- /dev/null +++ b/silx/gui/plot/items/axis.py @@ -0,0 +1,477 @@ +# 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 for axes of the :class:`PlotWidget`. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "30/08/2017" + +import logging +from ... import qt + +_logger = logging.getLogger(__name__) + + +class Axis(qt.QObject): + """This class describes and controls a plot axis. + + Note: This is an abstract class. + """ + # States are half-stored on the backend of the plot, and half-stored on this + # object. + # TODO It would be good to store all the states of an axis in this object. + # i.e. vmin and vmax + + LINEAR = "linear" + """Constant defining a linear scale""" + + LOGARITHMIC = "log" + """Constant defining a logarithmic scale""" + + _SCALES = set([LINEAR, LOGARITHMIC]) + + sigInvertedChanged = qt.Signal(bool) + """Signal emitted when axis orientation has changed""" + + sigScaleChanged = qt.Signal(str) + """Signal emitted when axis scale has changed""" + + _sigLogarithmicChanged = qt.Signal(bool) + """Signal emitted when axis scale has changed to or from logarithmic""" + + sigAutoScaleChanged = qt.Signal(bool) + """Signal emitted when axis autoscale has changed""" + + sigLimitsChanged = qt.Signal(float, float) + """Signal emitted when axis autoscale has changed""" + + def __init__(self, plot): + """Constructor + + :param silx.gui.plot.PlotWidget.PlotWidget plot: Parent plot of this + axis + """ + qt.QObject.__init__(self, parent=plot) + self._scale = self.LINEAR + self._isAutoScale = True + # Store default labels provided to setGraph[X|Y]Label + self._defaultLabel = '' + # Store currently displayed labels + # Current label can differ from input one with active curve handling + self._currentLabel = '' + self._plot = plot + + def getLimits(self): + """Get the limits of this axis. + + :return: Minimum and maximum values of this axis as tuple + """ + return self._internalGetLimits() + + def setLimits(self, vmin, vmax): + """Set this axis limits. + + :param float vmin: minimum axis value + :param float vmax: maximum axis value + """ + vmin, vmax = self._checkLimits(vmin, vmax) + if self.getLimits() == (vmin, vmax): + return + + self._internalSetLimits(vmin, vmax) + self._plot._setDirtyPlot() + + self._emitLimitsChanged() + + def _emitLimitsChanged(self): + """Emit axis sigLimitsChanged and PlotWidget limitsChanged event""" + vmin, vmax = self.getLimits() + self.sigLimitsChanged.emit(vmin, vmax) + self._plot._notifyLimitsChanged(emitSignal=False) + + def _checkLimits(self, vmin, vmax): + """Makes sure axis range is not empty + + :param float vmin: Min axis value + :param float vmax: Max axis value + :return: (min, max) making sure min < max + :rtype: 2-tuple of float + """ + if vmax < vmin: + _logger.debug('%s axis: max < min, inverting limits.', self._defaultLabel) + vmin, vmax = vmax, vmin + elif vmax == vmin: + _logger.debug('%s axis: max == min, expanding limits.', self._defaultLabel) + if vmin == 0.: + vmin, vmax = -0.1, 0.1 + elif vmin < 0: + vmin, vmax = vmin * 1.1, vmin * 0.9 + else: # xmin > 0 + vmin, vmax = vmin * 0.9, vmin * 1.1 + + return vmin, vmax + + def isInverted(self): + """Return True if the axis is inverted (top to bottom for the y-axis), + False otherwise. It is always False for the X axis. + + :rtype: bool + """ + return False + + def setInverted(self, isInverted): + """Set the axis orientation. + + This is only available for the Y axis. + + :param bool flag: True for Y axis going from top to bottom, + False for Y axis going from bottom to top + """ + if isInverted == self.isInverted(): + return + raise NotImplementedError() + + def getLabel(self): + """Return the current displayed label of this axis. + + :param str axis: The Y axis for which to get the label (left or right) + :rtype: str + """ + return self._currentLabel + + def setLabel(self, label): + """Set the label displayed on the plot for this axis. + + The provided label can be temporarily replaced by the label of the + active curve if any. + + :param str label: The axis label + """ + self._defaultLabel = label + self._setCurrentLabel(label) + self._plot._setDirtyPlot() + + def _setCurrentLabel(self, label): + """Define the label currently displayed. + + If the label is None or empty the default label is used. + + :param str label: Currently displayed label + """ + if label is None or label == '': + label = self._defaultLabel + if label is None: + label = '' + self._currentLabel = label + self._internalSetCurrentLabel(label) + + def getScale(self): + """Return the name of the scale used by this axis. + + :rtype: str + """ + return self._scale + + def setScale(self, scale): + """Set the scale to be used by this axis. + + :param str scale: Name of the scale ("log", or "linear") + """ + assert(scale in self._SCALES) + if self._scale == scale: + return + + # For the backward compatibility signal + emitLog = self._scale == self.LOGARITHMIC or scale == self.LOGARITHMIC + + if scale == self.LOGARITHMIC: + self._internalSetLogarithmic(True) + elif scale == self.LINEAR: + self._internalSetLogarithmic(False) + else: + raise ValueError("Scale %s unsupported" % scale) + + self._scale = scale + + # TODO hackish way of forcing update of curves and images + for item in self._plot._getItems(withhidden=True): + item._updated() + self._plot._invalidateDataRange() + self._plot.resetZoom() + + self.sigScaleChanged.emit(self._scale) + if emitLog: + self._sigLogarithmicChanged.emit(self._scale == self.LOGARITHMIC) + + def _isLogarithmic(self): + """Return True if this axis scale is logarithmic, False if linear. + + :rtype: bool + """ + return self._scale == self.LOGARITHMIC + + def _setLogarithmic(self, flag): + """Set the scale of this axes (either linear or logarithmic). + + :param bool flag: True to use a logarithmic scale, False for linear. + """ + flag = bool(flag) + self.setScale(self.LOGARITHMIC if flag else self.LINEAR) + + def isAutoScale(self): + """Return True if axis is automatically adjusting its limits. + + :rtype: bool + """ + return self._isAutoScale + + def setAutoScale(self, flag=True): + """Set the axis limits adjusting behavior of :meth:`resetZoom`. + + :param bool flag: True to resize limits automatically, + False to disable it. + """ + self._isAutoScale = bool(flag) + self.sigAutoScaleChanged.emit(self._isAutoScale) + + def _setLimitsConstraints(self, minPos=None, maxPos=None): + raise NotImplementedError() + + def setLimitsConstraints(self, minPos=None, maxPos=None): + """ + Set a constaints on the position of the axes. + + :param float minPos: Minimum allowed axis value. + :param float maxPos: Maximum allowed axis value. + :return: True if the constaints was updated + :rtype: bool + """ + updated = self._setLimitsConstraints(minPos, maxPos) + if updated: + plot = self._plot + xMin, xMax = plot.getXAxis().getLimits() + yMin, yMax = plot.getYAxis().getLimits() + y2Min, y2Max = plot.getYAxis('right').getLimits() + plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max) + return updated + + def _setRangeConstraints(self, minRange=None, maxRange=None): + raise NotImplementedError() + + def setRangeConstraints(self, minRange=None, maxRange=None): + """ + Set a constaints on the position of the axes. + + :param float minRange: Minimum allowed left-to-right span across the + view + :param float maxRange: Maximum allowed left-to-right span across the + view + :return: True if the constaints was updated + :rtype: bool + """ + updated = self._setRangeConstraints(minRange, maxRange) + if updated: + plot = self._plot + xMin, xMax = plot.getXAxis().getLimits() + yMin, yMax = plot.getYAxis().getLimits() + y2Min, y2Max = plot.getYAxis('right').getLimits() + plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max) + return updated + + +class XAxis(Axis): + """Axis class defining primitives for the X axis""" + + # TODO With some changes on the backend, it will be able to remove all this + # specialised implementations (prefixel by '_internal') + + def _internalSetCurrentLabel(self, label): + self._plot._backend.setGraphXLabel(label) + + def _internalGetLimits(self): + return self._plot._backend.getGraphXLimits() + + def _internalSetLimits(self, xmin, xmax): + self._plot._backend.setGraphXLimits(xmin, xmax) + + def _internalSetLogarithmic(self, flag): + self._plot._backend.setXAxisLogarithmic(flag) + + def _setLimitsConstraints(self, minPos=None, maxPos=None): + constrains = self._plot._getViewConstraints() + updated = constrains.update(xMin=minPos, xMax=maxPos) + return updated + + def _setRangeConstraints(self, minRange=None, maxRange=None): + constrains = self._plot._getViewConstraints() + updated = constrains.update(minXRange=minRange, maxXRange=maxRange) + return updated + + +class YAxis(Axis): + """Axis class defining primitives for the Y axis""" + + # TODO With some changes on the backend, it will be able to remove all this + # specialised implementations (prefixel by '_internal') + + def _internalSetCurrentLabel(self, label): + self._plot._backend.setGraphYLabel(label, axis='left') + + def _internalGetLimits(self): + return self._plot._backend.getGraphYLimits(axis='left') + + def _internalSetLimits(self, ymin, ymax): + self._plot._backend.setGraphYLimits(ymin, ymax, axis='left') + + def _internalSetLogarithmic(self, flag): + self._plot._backend.setYAxisLogarithmic(flag) + + def setInverted(self, flag=True): + """Set the axis orientation. + + This is only available for the Y axis. + + :param bool flag: True for Y axis going from top to bottom, + False for Y axis going from bottom to top + """ + flag = bool(flag) + self._plot._backend.setYAxisInverted(flag) + self._plot._setDirtyPlot() + self.sigInvertedChanged.emit(flag) + + def isInverted(self): + """Return True if the axis is inverted (top to bottom for the y-axis), + False otherwise. It is always False for the X axis. + + :rtype: bool + """ + return self._plot._backend.isYAxisInverted() + + def _setLimitsConstraints(self, minPos=None, maxPos=None): + constrains = self._plot._getViewConstraints() + updated = constrains.update(yMin=minPos, yMax=maxPos) + return updated + + def _setRangeConstraints(self, minRange=None, maxRange=None): + constrains = self._plot._getViewConstraints() + updated = constrains.update(minYRange=minRange, maxYRange=maxRange) + return updated + + +class YRightAxis(Axis): + """Proxy axis for the secondary Y axes. It manages it own label and limit + but share the some state like scale and direction with the main axis.""" + + # TODO With some changes on the backend, it will be able to remove all this + # specialised implementations (prefixel by '_internal') + + def __init__(self, plot, mainAxis): + """Constructor + + :param silx.gui.plot.PlotWidget.PlotWidget plot: Parent plot of this + axis + :param Axis mainAxis: Axis which sharing state with this axis + """ + Axis.__init__(self, plot) + self.__mainAxis = mainAxis + + @property + def sigInvertedChanged(self): + """Signal emitted when axis orientation has changed""" + return self.__mainAxis.sigInvertedChanged + + @property + def sigScaleChanged(self): + """Signal emitted when axis scale has changed""" + return self.__mainAxis.sigScaleChanged + + @property + def _sigLogarithmicChanged(self): + """Signal emitted when axis scale has changed to or from logarithmic""" + return self.__mainAxis._sigLogarithmicChanged + + @property + def sigAutoScaleChanged(self): + """Signal emitted when axis autoscale has changed""" + return self.__mainAxis.sigAutoScaleChanged + + def _internalSetCurrentLabel(self, label): + self._plot._backend.setGraphYLabel(label, axis='right') + + def _internalGetLimits(self): + return self._plot._backend.getGraphYLimits(axis='right') + + def _internalSetLimits(self, ymin, ymax): + self._plot._backend.setGraphYLimits(ymin, ymax, axis='right') + + def setInverted(self, flag=True): + """Set the Y axis orientation. + + :param bool flag: True for Y axis going from top to bottom, + False for Y axis going from bottom to top + """ + return self.__mainAxis.setInverted(flag) + + def isInverted(self): + """Return True if Y axis goes from top to bottom, False otherwise.""" + return self.__mainAxis.isInverted() + + def getScale(self): + """Return the name of the scale used by this axis. + + :rtype: str + """ + return self.__mainAxis.getScale() + + def setScale(self, scale): + """Set the scale to be used by this axis. + + :param str scale: Name of the scale ("log", or "linear") + """ + self.__mainAxis.setScale(scale) + + def _isLogarithmic(self): + """Return True if Y axis scale is logarithmic, False if linear.""" + return self.__mainAxis._isLogarithmic() + + def _setLogarithmic(self, flag): + """Set the Y axes scale (either linear or logarithmic). + + :param bool flag: True to use a logarithmic scale, False for linear. + """ + return self.__mainAxis._setLogarithmic(flag) + + def isAutoScale(self): + """Return True if Y axes are automatically adjusting its limits.""" + return self.__mainAxis.isAutoScale() + + def setAutoScale(self, flag=True): + """Set the Y axis limits adjusting behavior of :meth:`PlotWidget.resetZoom`. + + :param bool flag: True to resize limits automatically, + False to disable it. + """ + return self.__mainAxis.setAutoScale(flag) diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py index 72bfd9a..0f4ffb9 100644 --- a/silx/gui/plot/items/core.py +++ b/silx/gui/plot/items/core.py @@ -27,22 +27,96 @@ __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "26/04/2017" +__date__ = "27/06/2017" +import collections from copy import deepcopy import logging import weakref import numpy -from silx.third_party import six +from silx.third_party import six, enum +from ... import qt from .. import Colors - +from ..Colormap import Colormap _logger = logging.getLogger(__name__) -class Item(object): +@enum.unique +class ItemChangedType(enum.Enum): + """Type of modification provided by :attr:`Item.sigItemChanged` signal.""" + # Private setters and setInfo are not emitting sigItemChanged signal. + # Signals to consider: + # COLORMAP_SET emitted when setColormap is called but not forward colormap object signal + # CURRENT_COLOR_CHANGED emitted current color changed because highlight changed, + # highlighted color changed or color changed depending on hightlight state. + + VISIBLE = 'visibleChanged' + """Item's visibility changed flag.""" + + ZVALUE = 'zValueChanged' + """Item's Z value changed flag.""" + + COLORMAP = 'colormapChanged' # Emitted when set + forward events from the colormap object + """Item's colormap changed flag. + + This is emitted both when setting a new colormap and + when the current colormap object is updated. + """ + + SYMBOL = 'symbolChanged' + """Item's symbol changed flag.""" + + SYMBOL_SIZE = 'symbolSizeChanged' + """Item's symbol size changed flag.""" + + LINE_WIDTH = 'lineWidthChanged' + """Item's line width changed flag.""" + + LINE_STYLE = 'lineStyleChanged' + """Item's line style changed flag.""" + + COLOR = 'colorChanged' + """Item's color changed flag.""" + + YAXIS = 'yAxisChanged' + """Item's Y axis binding changed flag.""" + + FILL = 'fillChanged' + """Item's fill changed flag.""" + + ALPHA = 'alphaChanged' + """Item's transparency alpha changed flag.""" + + DATA = 'dataChanged' + """Item's data changed flag""" + + HIGHLIGHTED = 'highlightedChanged' + """Item's highlight state changed flag.""" + + HIGHLIGHTED_COLOR = 'highlightedColorChanged' + """Item's highlighted color changed flag.""" + + SCALE = 'scaleChanged' + """Item's scale changed flag.""" + + TEXT = 'textChanged' + """Item's text changed flag.""" + + POSITION = 'positionChanged' + """Item's position changed flag. + + This is emitted when a marker position changed and + when an image origin changed. + """ + + OVERLAY = 'overlayChanged' + """Item's overlay state changed flag.""" + + +class Item(qt.QObject): """Description of an item of the plot""" _DEFAULT_Z_LAYER = 0 @@ -54,7 +128,15 @@ class Item(object): _DEFAULT_SELECTABLE = False """Default selectable state of items""" + sigItemChanged = qt.Signal(object) + """Signal emitted when the item has changed. + + It provides a flag describing which property of the item has changed. + See :class:`ItemChangedType` for flags description. + """ + def __init__(self): + super(Item, self).__init__() self._dirty = True self._plotRef = None self._visible = True @@ -114,7 +196,8 @@ class Item(object): if visible != self._visible: self._visible = visible # When visibility has changed, always mark as dirty - self._updated(checkVisibility=False) + self._updated(ItemChangedType.VISIBLE, + checkVisibility=False) def isOverlay(self): """Return true if item is drawn as an overlay. @@ -158,7 +241,7 @@ class Item(object): z = int(z) if z is not None else self._DEFAULT_Z_LAYER if z != self._z: self._z = z - self._updated() + self._updated(ItemChangedType.ZVALUE) def getInfo(self, copy=True): """Returns the info associated to this item @@ -172,11 +255,12 @@ class Item(object): info = deepcopy(info) self._info = info - def _updated(self, checkVisibility=True): + def _updated(self, event=None, checkVisibility=True): """Mark the item as dirty (i.e., needing update). This also triggers Plot.replot. + :param event: The event to send to :attr:`sigItemChanged` signal. :param bool checkVisibility: True to only mark as dirty if visible, False to always mark as dirty. """ @@ -187,6 +271,8 @@ class Item(object): plot = self.getPlot() if plot is not None: plot._itemRequiresUpdate(self) + if event is not None: + self.sigItemChanged.emit(event) def _update(self, backend): """Called by Plot to update the backend for this item. @@ -292,25 +378,32 @@ class DraggableMixIn(object): 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 + self._colormap = Colormap() + self._colormap.sigChanged.connect(self._colormapChanged) def getColormap(self): """Return the used colormap""" - return self._colormap.copy() + return self._colormap def setColormap(self, colormap): """Set the colormap of this image - :param dict colormap: colormap description + :param Colormap colormap: colormap description """ - self._colormap = colormap.copy() - # TODO colormap comparison + colormap object and events on modification - self._updated() + if isinstance(colormap, dict): + colormap = Colormap._fromDict(colormap) + + if self._colormap is not None: + self._colormap.sigChanged.disconnect(self._colormapChanged) + self._colormap = colormap + if self._colormap is not None: + self._colormap.sigChanged.connect(self._colormapChanged) + self._colormapChanged() + + def _colormapChanged(self): + """Handle updates of the colormap""" + self._updated(ItemChangedType.COLORMAP) class SymbolMixIn(object): @@ -355,7 +448,7 @@ class SymbolMixIn(object): symbol = self._DEFAULT_SYMBOL if symbol != self._symbol: self._symbol = symbol - self._updated() + self._updated(ItemChangedType.SYMBOL) def getSymbolSize(self): """Return the point marker size in points. @@ -375,7 +468,7 @@ class SymbolMixIn(object): size = self._DEFAULT_SYMBOL_SIZE if size != self._symbol_size: self._symbol_size = size - self._updated() + self._updated(ItemChangedType.SYMBOL_SIZE) class LineMixIn(object): @@ -405,7 +498,7 @@ class LineMixIn(object): width = float(width) if width != self._linewidth: self._linewidth = width - self._updated() + self._updated(ItemChangedType.LINE_WIDTH) def getLineStyle(self): """Return the type of the line @@ -435,7 +528,7 @@ class LineMixIn(object): style = self._DEFAULT_LINESTYLE if style != self._linestyle: self._linestyle = style - self._updated() + self._updated(ItemChangedType.LINE_STYLE) class ColorMixIn(object): @@ -473,8 +566,9 @@ class ColorMixIn(object): else: # Array of colors assert color.ndim == 2 - self._color = color - self._updated() + if self._color != color: + self._color = color + self._updated(ItemChangedType.COLOR) class YAxisMixIn(object): @@ -504,7 +598,7 @@ class YAxisMixIn(object): assert yaxis in ('left', 'right') if yaxis != self._yaxis: self._yaxis = yaxis - self._updated() + self._updated(ItemChangedType.YAXIS) class FillMixIn(object): @@ -528,7 +622,7 @@ class FillMixIn(object): fill = bool(fill) if fill != self._fill: self._fill = fill - self._updated() + self._updated(ItemChangedType.FILL) class AlphaMixIn(object): @@ -561,7 +655,7 @@ class AlphaMixIn(object): alpha = max(0., min(alpha, 1.)) # Clip alpha to [0., 1.] range if alpha != self._alpha: self._alpha = alpha - self._updated() + self._updated(ItemChangedType.ALPHA) class Points(Item, SymbolMixIn, AlphaMixIn): @@ -690,8 +784,8 @@ class Points(Item, SymbolMixIn, AlphaMixIn): plot = self.getPlot() if plot is not None: - xPositive = plot.isXAxisLogarithmic() - yPositive = plot.isYAxisLogarithmic() + xPositive = plot.getXAxis()._isLogarithmic() + yPositive = plot.getYAxis()._isLogarithmic() else: xPositive = False yPositive = False @@ -724,8 +818,8 @@ class Points(Item, SymbolMixIn, AlphaMixIn): Return None if caching is not applicable.""" plot = self.getPlot() if plot is not None: - xPositive = plot.isXAxisLogarithmic() - yPositive = plot.isYAxisLogarithmic() + xPositive = plot.getXAxis()._isLogarithmic() + yPositive = plot.getYAxis()._isLogarithmic() if xPositive or yPositive: # At least one axis has log scale, filter data if (xPositive, yPositive) not in self._filteredCache: @@ -779,24 +873,24 @@ class Points(Item, SymbolMixIn, AlphaMixIn): :param copy: True (Default) to get a copy, False to use internal representation (do not modify!) - :rtype: numpy.ndarray or None + :rtype: numpy.ndarray, float or None """ - if self._xerror is None: - return None - else: + if isinstance(self._xerror, numpy.ndarray): return numpy.array(self._xerror, copy=copy) + else: + return self._xerror # float or None 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 + :rtype: numpy.ndarray, float or None """ - if self._yerror is None: - return None - else: + if isinstance(self._yerror, numpy.ndarray): return numpy.array(self._yerror, copy=copy) + else: + return self._yerror # float or None def setData(self, x, y, xerror=None, yerror=None, copy=True): """Set the data of the curve. @@ -820,9 +914,15 @@ class Points(Item, SymbolMixIn, AlphaMixIn): assert x.ndim == y.ndim == 1 if xerror is not None: - xerror = numpy.array(xerror, copy=copy) + if isinstance(xerror, collections.Iterable): + xerror = numpy.array(xerror, copy=copy) + else: + xerror = float(xerror) if yerror is not None: - yerror = numpy.array(yerror, copy=copy) + if isinstance(yerror, collections.Iterable): + yerror = numpy.array(yerror, copy=copy) + else: + yerror = float(yerror) # TODO checks on xerror, yerror self._x, self._y = x, y self._xerror, self._yerror = xerror, yerror @@ -831,9 +931,9 @@ class Points(Item, SymbolMixIn, AlphaMixIn): 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() + self._updated(ItemChangedType.DATA) diff --git a/silx/gui/plot/items/curve.py b/silx/gui/plot/items/curve.py index d25ae00..ce7f03e 100644 --- a/silx/gui/plot/items/curve.py +++ b/silx/gui/plot/items/curve.py @@ -32,11 +32,9 @@ __date__ = "06/03/2017" import logging -import numpy - from .. import Colors -from .core import (Points, LabelsMixIn, SymbolMixIn, - ColorMixIn, YAxisMixIn, FillMixIn, LineMixIn) +from .core import (Points, LabelsMixIn, ColorMixIn, YAxisMixIn, + FillMixIn, LineMixIn, ItemChangedType) _logger = logging.getLogger(__name__) @@ -132,15 +130,15 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn): :param bool visible: True to display it, False otherwise """ - visibleChanged = self.isVisible() != bool(visible) - super(Curve, self).setVisible(visible) - + visible = bool(visible) # TODO hackish data range implementation - if visibleChanged: + if self.isVisible() != visible: plot = self.getPlot() if plot is not None: plot._invalidateDataRange() + super(Curve, self).setVisible(visible) + def isHighlighted(self): """Returns True if curve is highlighted. @@ -157,7 +155,7 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn): if highlighted != self._highlighted: self._highlighted = highlighted # TODO inefficient: better to use backend's setCurveColor - self._updated() + self._updated(ItemChangedType.HIGHLIGHTED) def getHighlightedColor(self): """Returns the RGBA highlight color of the item @@ -176,7 +174,7 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn): color = Colors.rgba(color) if color != self._highlightColor: self._highlightColor = color - self._updated() + self._updated(ItemChangedType.HIGHLIGHTED_COLOR) def getCurrentColor(self): """Returns the current color of the curve. diff --git a/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py index c3821bc..ad89677 100644 --- a/silx/gui/plot/items/histogram.py +++ b/silx/gui/plot/items/histogram.py @@ -27,7 +27,7 @@ __authors__ = ["H. Payno", "T. Vincent"] __license__ = "MIT" -__date__ = "02/05/2017" +__date__ = "27/06/2017" import logging @@ -35,7 +35,7 @@ import logging import numpy from .core import (Item, AlphaMixIn, ColorMixIn, FillMixIn, - LineMixIn, YAxisMixIn) + LineMixIn, YAxisMixIn, ItemChangedType) _logger = logging.getLogger(__name__) @@ -139,8 +139,8 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, # Filter-out values <= 0 plot = self.getPlot() if plot is not None: - xPositive = plot.isXAxisLogarithmic() - yPositive = plot.isYAxisLogarithmic() + xPositive = plot.getXAxis()._isLogarithmic() + yPositive = plot.getYAxis()._isLogarithmic() else: xPositive = False yPositive = False @@ -174,8 +174,8 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, plot = self.getPlot() if plot is not None: - xPositive = plot.isXAxisLogarithmic() - yPositive = plot.isYAxisLogarithmic() + xPositive = plot.getXAxis()._isLogarithmic() + yPositive = plot.getYAxis()._isLogarithmic() else: xPositive = False yPositive = False @@ -185,14 +185,19 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, if xPositive: # Replace edges <= 0 by NaN and corresponding values by NaN - clipped = (edges <= 0) + clipped_edges = (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 + edges[clipped_edges] = numpy.nan + clipped_values = numpy.logical_or(clipped_edges[:-1], + clipped_edges[1:]) + else: + clipped_values = numpy.zeros_like(values, dtype=numpy.bool) if yPositive: # Replace values <= 0 by NaN, do not modify edges - values[values <= 0] = numpy.nan + clipped_values = numpy.logical_or(clipped_values, values <= 0) + + values[clipped_values] = numpy.nan if xPositive or yPositive: return (numpy.nanmin(edges), @@ -211,14 +216,13 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, :param bool visible: True to display it, False otherwise """ - visibleChanged = self.isVisible() != bool(visible) - super(Histogram, self).setVisible(visible) - + visible = bool(visible) # TODO hackish data range implementation - if visibleChanged: + if self.isVisible() != visible: plot = self.getPlot() if plot is not None: plot._invalidateDataRange() + super(Histogram, self).setVisible(visible) def getValueData(self, copy=True): """The values of the histogram @@ -248,7 +252,7 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, :returns: (N histogram value, N+1 bin edges) :rtype: 2-tuple of numpy.nadarray """ - return (self.getValueData(copy), self.getBinEdgesData(copy)) + return self.getValueData(copy), self.getBinEdgesData(copy) def setData(self, histogram, edges, align='center', copy=True): """Set the histogram values and bin edges. @@ -286,3 +290,5 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, self._histogram = histogram self._edges = edges + + self._updated(ItemChangedType.DATA) diff --git a/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py index 7e1dd8b..acf7bf6 100644 --- a/silx/gui/plot/items/image.py +++ b/silx/gui/plot/items/image.py @@ -28,7 +28,7 @@ of the :class:`Plot`. __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "06/03/2017" +__date__ = "27/06/2017" from collections import Sequence @@ -36,7 +36,8 @@ import logging import numpy -from .core import Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, AlphaMixIn +from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, + AlphaMixIn, ItemChangedType) from ..Colors import applyColormapToData @@ -130,14 +131,23 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn): :param bool visible: True to display it, False otherwise """ - visibleChanged = self.isVisible() != bool(visible) - super(ImageBase, self).setVisible(visible) - + visible = bool(visible) # TODO hackish data range implementation - if visibleChanged: + if self.isVisible() != visible: plot = self.getPlot() if plot is not None: plot._invalidateDataRange() + super(ImageBase, self).setVisible(visible) + + def _isPlotLinear(self, plot): + """Return True if plot only uses linear scale for both of x and y + axes.""" + linear = plot.getXAxis().LINEAR + if plot.getXAxis().getScale() != linear: + return False + if plot.getYAxis().getScale() != linear: + return False + return True def _getBounds(self): if self.getData(copy=False).size == 0: # Empty data @@ -156,8 +166,7 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn): ymin, ymax = ymax, ymin plot = self.getPlot() - if (plot is not None and - plot.isXAxisLogarithmic() or plot.isYAxisLogarithmic()): + if plot is not None and not self._isPlotLinear(plot): return None else: return xmin, xmax, ymin, ymax @@ -197,7 +206,6 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn): origin = float(origin), float(origin) if origin != self._origin: self._origin = origin - self._updated() # TODO hackish data range implementation if self.isVisible(): @@ -205,6 +213,8 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn): if plot is not None: plot._invalidateDataRange() + self._updated(ItemChangedType.POSITION) + def getScale(self): """Returns the scale of the image in data coordinates. @@ -222,9 +232,17 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn): 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() + + # TODO hackish data range implementation + if self.isVisible(): + plot = self.getPlot() + if plot is not None: + plot._invalidateDataRange() + + self._updated(ItemChangedType.SCALE) class ImageData(ImageBase, ColormapMixIn): @@ -240,8 +258,9 @@ class ImageData(ImageBase, ColormapMixIn): """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 not self._isPlotLinear(plot): + # Do not render with non linear scales + return None if self.getAlternativeImageData(copy=False) is not None: dataToUse = self.getAlternativeImageData(copy=False) @@ -283,8 +302,7 @@ class ImageData(ImageBase, ColormapMixIn): else: # Apply colormap, in this case an new array is always returned colormap = self.getColormap() - image = applyColormapToData(self.getData(copy=False), - **colormap) + image = colormap.applyToData(self.getData(copy=False)) return image def getAlternativeImageData(self, copy=True): @@ -312,6 +330,14 @@ class ImageData(ImageBase, ColormapMixIn): """ data = numpy.array(data, copy=copy) assert data.ndim == 2 + if data.dtype.kind == 'b': + _logger.warning( + 'Converting boolean image to int8 to plot it.') + data = numpy.array(data, copy=False, dtype=numpy.int8) + elif numpy.issubdtype(data.dtype, numpy.complex): + _logger.warning( + 'Converting complex image to absolute value to plot it.') + data = numpy.absolute(data) self._data = data if alternative is not None: @@ -320,7 +346,6 @@ class ImageData(ImageBase, ColormapMixIn): 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(): @@ -328,6 +353,8 @@ class ImageData(ImageBase, ColormapMixIn): if plot is not None: plot._invalidateDataRange() + self._updated(ItemChangedType.DATA) + class ImageRgba(ImageBase): """Description of an RGB(A) image""" @@ -339,8 +366,9 @@ class ImageRgba(ImageBase): """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 not self._isPlotLinear(plot): + # Do not render with non linear scales + return None data = self.getData(copy=False) @@ -376,10 +404,19 @@ class ImageRgba(ImageBase): 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() + + self._updated(ItemChangedType.DATA) + + +class MaskImageData(ImageData): + """Description of an image used as a mask. + + This class is used to flag mask items. This information is used to improve + internal silx widgets. + """ + pass diff --git a/silx/gui/plot/items/marker.py b/silx/gui/plot/items/marker.py index c05558b..5f930b7 100644 --- a/silx/gui/plot/items/marker.py +++ b/silx/gui/plot/items/marker.py @@ -32,7 +32,8 @@ __date__ = "06/03/2017" import logging -from .core import Item, DraggableMixIn, ColorMixIn, SymbolMixIn +from .core import (Item, DraggableMixIn, ColorMixIn, SymbolMixIn, + ItemChangedType) _logger = logging.getLogger(__name__) @@ -95,7 +96,7 @@ class _BaseMarker(Item, DraggableMixIn, ColorMixIn): text = str(text) if text != self._text: self._text = text - self._updated() + self._updated(ItemChangedType.TEXT) def getXPosition(self): """Returns the X position of the marker line in data coordinates @@ -130,7 +131,7 @@ class _BaseMarker(Item, DraggableMixIn, ColorMixIn): x, y = float(x), float(y) if x != self._x or y != self._y: self._x, self._y = x, y - self._updated() + self._updated(ItemChangedType.POSITION) def getConstraint(self): """Returns the dragging constraint of this item""" @@ -216,7 +217,7 @@ class XMarker(_BaseMarker): x = float(x) if x != self._x: self._x = x - self._updated() + self._updated(ItemChangedType.POSITION) class YMarker(_BaseMarker): @@ -238,4 +239,4 @@ class YMarker(_BaseMarker): y = float(y) if y != self._y: self._y = y - self._updated() + self._updated(ItemChangedType.POSITION) diff --git a/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py index 3897dc1..98ed473 100644 --- a/silx/gui/plot/items/scatter.py +++ b/silx/gui/plot/items/scatter.py @@ -35,7 +35,7 @@ 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__) @@ -60,13 +60,7 @@ class Scatter(Points, ColormapMixIn): 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")) + rgbacolors = cmap.applyToData(self._value) return backend.addCurve(xFiltered, yFiltered, self.getLegend(), color=rgbacolors, diff --git a/silx/gui/plot/items/shape.py b/silx/gui/plot/items/shape.py index b663989..65b26a1 100644 --- a/silx/gui/plot/items/shape.py +++ b/silx/gui/plot/items/shape.py @@ -27,14 +27,14 @@ __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "06/03/2017" +__date__ = "17/05/2017" import logging import numpy -from .core import Item, ColorMixIn, FillMixIn +from .core import (Item, ColorMixIn, FillMixIn, ItemChangedType) _logger = logging.getLogger(__name__) @@ -46,7 +46,7 @@ class Shape(Item, ColorMixIn, FillMixIn): """Description of a shape item :param str type_: The type of shape in: - 'hline', 'polygon', 'rectangle', 'vline', 'polyline' + 'hline', 'polygon', 'rectangle', 'vline', 'polylines' """ def __init__(self, type_): @@ -54,7 +54,7 @@ class Shape(Item, ColorMixIn, FillMixIn): ColorMixIn.__init__(self) FillMixIn.__init__(self) self._overlay = False - assert type_ in ('hline', 'polygon', 'rectangle', 'vline', 'polyline') + assert type_ in ('hline', 'polygon', 'rectangle', 'vline', 'polylines') self._type = type_ self._points = () @@ -88,12 +88,12 @@ class Shape(Item, ColorMixIn, FillMixIn): overlay = bool(overlay) if overlay != self._overlay: self._overlay = overlay - self._updated() + self._updated(ItemChangedType.OVERLAY) def getType(self): """Returns the type of shape to draw. - One of: 'hline', 'polygon', 'rectangle', 'vline', 'polyline' + One of: 'hline', 'polygon', 'rectangle', 'vline', 'polylines' :rtype: str """ @@ -118,4 +118,4 @@ class Shape(Item, ColorMixIn, FillMixIn): :return: """ self._points = numpy.array(points, copy=copy) - self._updated() + self._updated(ItemChangedType.DATA) |