diff options
Diffstat (limited to 'silx/gui/plot/items')
-rw-r--r-- | silx/gui/plot/items/__init__.py | 4 | ||||
-rw-r--r-- | silx/gui/plot/items/_pick.py | 70 | ||||
-rw-r--r-- | silx/gui/plot/items/complex.py | 13 | ||||
-rw-r--r-- | silx/gui/plot/items/core.py | 162 | ||||
-rw-r--r-- | silx/gui/plot/items/curve.py | 40 | ||||
-rw-r--r-- | silx/gui/plot/items/histogram.py | 44 | ||||
-rw-r--r-- | silx/gui/plot/items/image.py | 26 | ||||
-rwxr-xr-x[-rw-r--r--] | silx/gui/plot/items/marker.py | 17 | ||||
-rw-r--r-- | silx/gui/plot/items/roi.py | 200 | ||||
-rw-r--r-- | silx/gui/plot/items/scatter.py | 429 | ||||
-rw-r--r-- | silx/gui/plot/items/shape.py | 64 |
11 files changed, 932 insertions, 137 deletions
diff --git a/silx/gui/plot/items/__init__.py b/silx/gui/plot/items/__init__.py index f3a36db..7eff1d0 100644 --- a/silx/gui/plot/items/__init__.py +++ b/silx/gui/plot/items/__init__.py @@ -40,11 +40,11 @@ from .complex import ImageComplexData # noqa from .curve import Curve, CurveStyle # noqa from .histogram import Histogram # noqa from .image import ImageBase, ImageData, ImageRgba, MaskImageData # noqa -from .shape import Shape # noqa +from .shape import Shape, BoundingRect # noqa from .scatter import Scatter # noqa from .marker import MarkerBase, Marker, XMarker, YMarker # noqa from .axis import Axis, XAxis, YAxis, YRightAxis -DATA_ITEMS = ImageComplexData, Curve, Histogram, ImageBase, Scatter +DATA_ITEMS = ImageComplexData, Curve, Histogram, ImageBase, Scatter, BoundingRect """Classes of items representing data and to consider to compute data bounds. """ diff --git a/silx/gui/plot/items/_pick.py b/silx/gui/plot/items/_pick.py new file mode 100644 index 0000000..14078fd --- /dev/null +++ b/silx/gui/plot/items/_pick.py @@ -0,0 +1,70 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# 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 classes supporting item picking.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "04/06/2019" + +import numpy + + +class PickingResult(object): + """Class to access picking information in a :class:`PlotWidget`""" + + def __init__(self, item, indices=None): + """Init + + :param item: The picked item + :param numpy.ndarray indices: Array-like of indices of picked data. + Either 1D or 2D with dim0: data dimension and dim1: indices. + No copy is made. + """ + self._item = item + + if indices is None or len(indices) == 0: + self._indices = None + else: + self._indices = numpy.array(indices, copy=False, dtype=numpy.int) + + def getItem(self): + """Returns the item this results corresponds to.""" + return self._item + + def getIndices(self, copy=True): + """Returns indices of picked data. + + If data is 1D, it returns a numpy.ndarray, otherwise + it returns a tuple with as many numpy.ndarray as there are + dimensions in the data. + + :param bool copy: True (default) to get a copy, + False to return internal arrays + :rtype: Union[None,numpy.ndarray,List[numpy.ndarray]] + """ + if self._indices is None: + return None + indices = numpy.array(self._indices, copy=copy) + return indices if indices.ndim == 1 else tuple(indices) diff --git a/silx/gui/plot/items/complex.py b/silx/gui/plot/items/complex.py index 3869a05..988022a 100644 --- a/silx/gui/plot/items/complex.py +++ b/silx/gui/plot/items/complex.py @@ -113,6 +113,16 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): colored phase + amplitude. """ + _SUPPORTED_COMPLEX_MODES = ( + ComplexMixIn.ComplexMode.ABSOLUTE, + ComplexMixIn.ComplexMode.PHASE, + ComplexMixIn.ComplexMode.REAL, + ComplexMixIn.ComplexMode.IMAGINARY, + ComplexMixIn.ComplexMode.AMPLITUDE_PHASE, + ComplexMixIn.ComplexMode.LOG10_AMPLITUDE_PHASE, + ComplexMixIn.ComplexMode.SQUARE_AMPLITUDE) + """Overrides supported ComplexMode""" + def __init__(self): ImageBase.__init__(self) ColormapMixIn.__init__(self) @@ -161,12 +171,9 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): 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=colormap, alpha=self.getAlpha()) diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py index e7342b0..6d6575b 100644 --- a/silx/gui/plot/items/core.py +++ b/silx/gui/plot/items/core.py @@ -47,6 +47,7 @@ from ....utils.enum import Enum as _Enum from ... import qt from ... import colors from ...colors import Colormap +from ._pick import PickingResult from silx import config @@ -136,6 +137,12 @@ class ItemChangedType(enum.Enum): COMPLEX_MODE = 'complexModeChanged' """Item's complex data visualization mode changed flag.""" + NAME = 'nameChanged' + """Item's name changed flag.""" + + EDITABLE = 'editableChanged' + """Item's editable state changed flags.""" + class Item(qt.QObject): """Description of an item of the plot""" @@ -330,6 +337,26 @@ class Item(qt.QObject): backend.remove(self._backendRenderer) self._backendRenderer = None + def pick(self, x, y): + """Run picking test on this item + + :param float x: The x pixel coord where to pick. + :param float y: The y pixel coord where to pick. + :return: None if not picked, else the picked position information + :rtype: Union[None,PickingResult] + """ + if not self.isVisible() or self._backendRenderer is None: + return None + plot = self.getPlot() + if plot is None: + return None + + indices = plot._backend.pickItem(x, y, self._backendRenderer) + if indices is None: + return None + else: + return PickingResult(self, indices if len(indices) != 0 else None) + # Mix-in classes ############################################################## @@ -471,6 +498,17 @@ class SymbolMixIn(ItemMixInBase): ('x', 'Cross'), ('.', 'Point'), (',', 'Pixel'), + ('|', 'Vertical line'), + ('_', 'Horizontal line'), + ('tickleft', 'Tick left'), + ('tickright', 'Tick right'), + ('tickup', 'Tick up'), + ('tickdown', 'Tick down'), + ('caretleft', 'Caret left'), + ('caretright', 'Caret right'), + ('caretup', 'Caret up'), + ('caretdown', 'Caret down'), + (u'\u2665', 'Heart'), ('', 'None'))) """Dict of supported symbols""" @@ -781,6 +819,7 @@ class ComplexMixIn(ItemMixInBase): class ComplexMode(_Enum): """Identify available display mode for complex""" + NONE = 'none' ABSOLUTE = 'amplitude' PHASE = 'phase' REAL = 'real' @@ -884,8 +923,54 @@ class ScatterVisualizationMixIn(ItemMixInBase): This is based on Delaunay triangulation """ + REGULAR_GRID = 'regular_grid' + """Display scatter plot as an image. + + It expects the points to be the intersection of a regular grid, + and the order of points following that of an image. + First line, then second one, and always in the same direction + (either all lines from left to right or all from right to left). + """ + + IRREGULAR_GRID = 'irregular_grid' + """Display scatter plot as contiguous quadrilaterals. + + It expects the points to be the intersection of an irregular grid, + and the order of points following that of an image. + First line, then second one, and always in the same direction + (either all lines from left to right or all from right to left). + """ + + @enum.unique + class VisualizationParameter(_Enum): + """Different parameter names for scatter plot visualizations""" + + GRID_MAJOR_ORDER = 'grid_major_order' + """The major order of points in the regular grid. + + Either 'row' (row-major, fast X) or 'column' (column-major, fast Y). + """ + + GRID_BOUNDS = 'grid_bounds' + """The expected range in data coordinates of the regular grid. + + A 2-tuple of 2-tuple: (begin (x, y), end (x, y)). + This provides the data coordinates of the first point and the expected + last on. + As for `GRID_SHAPE`, this can be wider than the current data. + """ + + GRID_SHAPE = 'grid_shape' + """The expected size of the regular grid (height, width). + + The given shape can be wider than the number of points, + in which case the grid is not fully filled. + """ + def __init__(self): self.__visualization = self.Visualization.POINTS + self.__parameters = dict( # Init parameters to None + (parameter, None) for parameter in self.VisualizationParameter) @classmethod def supportedVisualizations(cls): @@ -929,6 +1014,54 @@ class ScatterVisualizationMixIn(ItemMixInBase): """ return self.__visualization + def setVisualizationParameter(self, parameter, value=None): + """Set the given visualization parameter. + + :param Union[str,VisualizationParameter] parameter: + The name of the parameter to set + :param value: The value to use for this parameter + Set to None to automatically set the parameter + :raises ValueError: If parameter is not supported + :return: True if parameter was set, False if is was already set + :rtype: bool + """ + parameter = self.VisualizationParameter.from_value(parameter) + + if self.__parameters[parameter] != value: + self.__parameters[parameter] = value + self._updated(ItemChangedType.VISUALIZATION_MODE) + return True + return False + + def getVisualizationParameter(self, parameter): + """Returns the value of the given visualization parameter. + + This method returns the parameter as set by + :meth:`setVisualizationParameter`. + + :param parameter: The name of the parameter to retrieve + :returns: The value previously set or None if automatically set + :raises ValueError: If parameter is not supported + """ + if parameter not in self.VisualizationParameter: + raise ValueError("parameter not supported: %s", parameter) + + return self.__parameters[parameter] + + def getCurrentVisualizationParameter(self, parameter): + """Returns the current value of the given visualization parameter. + + If the parameter was set by :meth:`setVisualizationParameter` to + a value that is not None, this value is returned; + else the current value that is automatically computed is returned. + + :param parameter: The name of the parameter to retrieve + :returns: The current value (either set or automatically computed) + :raises ValueError: If parameter is not supported + """ + # Override in subclass to provide automatically computed parameters + return self.getVisualizationParameter(parameter) + class PointsBase(Item, SymbolMixIn, AlphaMixIn): """Base class for :class:`Curve` and :class:`Scatter`""" @@ -1224,3 +1357,32 @@ class PointsBase(Item, SymbolMixIn, AlphaMixIn): if plot is not None: plot._invalidateDataRange() self._updated(ItemChangedType.DATA) + + +class BaselineMixIn(object): + """Base class for Baseline mix-in""" + def __init__(self, baseline=None): + self._baseline = baseline + + def _setBaseline(self, baseline): + """ + Set baseline value + + :param baseline: baseline value(s) + :type: Union[None,float,numpy.ndarray] + """ + if (isinstance(baseline, abc.Iterable)): + baseline = numpy.array(baseline) + self._baseline = baseline + + def getBaseline(self, copy=True): + """ + + :param bool copy: + :return: histogram baseline + :rtype: Union[None,float,numpy.ndarray] + """ + if isinstance(self._baseline, numpy.ndarray): + return numpy.array(self._baseline, copy=True) + else: + return self._baseline diff --git a/silx/gui/plot/items/curve.py b/silx/gui/plot/items/curve.py index 439af33..5853ef5 100644 --- a/silx/gui/plot/items/curve.py +++ b/silx/gui/plot/items/curve.py @@ -38,7 +38,8 @@ import six from ....utils.deprecation import deprecated from ... import colors from .core import (PointsBase, LabelsMixIn, ColorMixIn, YAxisMixIn, - FillMixIn, LineMixIn, SymbolMixIn, ItemChangedType) + FillMixIn, LineMixIn, SymbolMixIn, ItemChangedType, + BaselineMixIn) _logger = logging.getLogger(__name__) @@ -151,7 +152,8 @@ class CurveStyle(object): return False -class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn): +class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, + LineMixIn, BaselineMixIn): """Description of a curve""" _DEFAULT_Z_LAYER = 1 @@ -169,6 +171,8 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixI _DEFAULT_HIGHLIGHT_STYLE = CurveStyle(color='black') """Default highlight style of the item""" + _DEFAULT_BASELINE = None + def __init__(self): PointsBase.__init__(self) ColorMixIn.__init__(self) @@ -176,9 +180,11 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixI FillMixIn.__init__(self) LabelsMixIn.__init__(self) LineMixIn.__init__(self) + BaselineMixIn.__init__(self) self._highlightStyle = self._DEFAULT_HIGHLIGHT_STYLE self._highlighted = False + self._setBaseline(Curve._DEFAULT_BASELINE) self.sigItemChanged.connect(self.__itemChanged) @@ -200,7 +206,7 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixI style = self.getCurrentStyle() - return backend.addCurve(xFiltered, yFiltered, self.getLegend(), + return backend.addCurve(xFiltered, yFiltered, color=style.getColor(), symbol=style.getSymbol(), linestyle=style.getLineStyle(), @@ -209,10 +215,10 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixI xerror=xerror, yerror=yerror, z=self.getZValue(), - selectable=self.isSelectable(), fill=self.isFill(), alpha=self.getAlpha(), - symbolsize=style.getSymbolSize()) + symbolsize=style.getSymbolSize(), + baseline=self.getBaseline(copy=False)) def __getitem__(self, item): """Compatibility with PyMca and silx <= 0.4.0""" @@ -241,7 +247,7 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixI 'yerror': self.getYErrorData(copy=False), 'z': self.getZValue(), 'selectable': self.isSelectable(), - 'fill': self.isFill() + 'fill': self.isFill(), } return params else: @@ -361,3 +367,25 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixI :rtype: 4-tuple of float in [0, 1] """ return self.getCurrentStyle().getColor() + + def setData(self, x, y, xerror=None, yerror=None, baseline=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 baseline: curve baseline + :type baseline: Union[None,float,numpy.ndarray] + :param bool copy: True make a copy of the data (default), + False to use provided arrays. + """ + PointsBase.setData(self, x=x, y=y, xerror=xerror, yerror=yerror, + copy=copy) + self._setBaseline(baseline=baseline) diff --git a/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py index a1d6586..993c0f0 100644 --- a/silx/gui/plot/items/histogram.py +++ b/silx/gui/plot/items/histogram.py @@ -8,7 +8,7 @@ # 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: +# furnished to do so, subject to the following conditions::t # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. @@ -32,8 +32,13 @@ __date__ = "28/08/2018" import logging import numpy +from collections import OrderedDict, namedtuple +try: + from collections import abc +except ImportError: # Python2 support + import collections as abc -from .core import (Item, AlphaMixIn, ColorMixIn, FillMixIn, +from .core import (Item, AlphaMixIn, BaselineMixIn, ColorMixIn, FillMixIn, LineMixIn, YAxisMixIn, ItemChangedType) _logger = logging.getLogger(__name__) @@ -96,7 +101,7 @@ def _getHistogramCurve(histogram, edges): # TODO: Yerror, test log scale class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, - LineMixIn, YAxisMixIn): + LineMixIn, YAxisMixIn, BaselineMixIn): """Description of an histogram""" _DEFAULT_Z_LAYER = 1 @@ -111,9 +116,12 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, _DEFAULT_LINESTYLE = '-' """Default line style of the histogram""" + _DEFAULT_BASELINE = None + def __init__(self): Item.__init__(self) AlphaMixIn.__init__(self) + BaselineMixIn.__init__(self) ColorMixIn.__init__(self) FillMixIn.__init__(self) LineMixIn.__init__(self) @@ -121,10 +129,11 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, self._histogram = () self._edges = () + self._setBaseline(Histogram._DEFAULT_BASELINE) def _addBackendRenderer(self, backend): """Update backend renderer""" - values, edges = self.getData(copy=False) + values, edges, baseline = self.getData(copy=False) if values.size == 0: return None # No data to display, do not add renderer @@ -153,7 +162,7 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, x[clipped] = numpy.nan y[clipped] = numpy.nan - return backend.addCurve(x, y, self.getLegend(), + return backend.addCurve(x, y, color=self.getColor(), symbol='', linestyle=self.getLineStyle(), @@ -162,13 +171,13 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, xerror=None, yerror=None, z=self.getZValue(), - selectable=self.isSelectable(), fill=self.isFill(), alpha=self.getAlpha(), + baseline=baseline, symbolsize=1) def _getBounds(self): - values, edges = self.getData(copy=False) + values, edges, baseline = self.getData(copy=False) plot = self.getPlot() if plot is not None: @@ -243,16 +252,19 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, return numpy.array(self._edges, copy=copy) def getData(self, copy=True): - """Return the histogram values and the bin edges + """Return the histogram values, bin edges and baseline :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) + return (self.getValueData(copy), + self.getBinEdgesData(copy), + self.getBaseline(copy)) - def setData(self, histogram, edges, align='center', copy=True): + def setData(self, histogram, edges, align='center', baseline=None, + copy=True): """Set the histogram values and bin edges. :param numpy.ndarray histogram: The values of the histogram. @@ -264,6 +276,8 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, 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 baseline: histogram baseline + :type baseline: Union[None,float,numpy.ndarray] :param bool copy: True make a copy of the data (default), False to use provided arrays. """ @@ -285,10 +299,18 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, # Check that bin edges are monotonic edgesDiff = numpy.diff(edges) assert numpy.all(edgesDiff >= 0) or numpy.all(edgesDiff <= 0) - + # manage baseline + if (isinstance(baseline, abc.Iterable)): + baseline = numpy.array(baseline) + if baseline.size == histogram.size: + new_baseline = numpy.empty(baseline.shape[0] * 2) + for i_value, value in enumerate(baseline): + new_baseline[i_value*2:i_value*2+2] = value + baseline = new_baseline self._histogram = histogram self._edges = edges self._alignement = align + self._setBaseline(baseline) if self.isVisible(): plot = self.getPlot() diff --git a/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py index d74f4d3..44cb70f 100644 --- a/silx/gui/plot/items/image.py +++ b/silx/gui/plot/items/image.py @@ -42,6 +42,7 @@ import numpy from ....utils.proxy import docstring from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, AlphaMixIn, ItemChangedType) +from ._pick import PickingResult _logger = logging.getLogger(__name__) @@ -142,6 +143,25 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn): plot._invalidateDataRange() super(ImageBase, self).setVisible(visible) + @docstring(Item) + def pick(self, x, y): + if super(ImageBase, self).pick(x, y) is not None: + plot = self.getPlot() + if plot is None: + return None + + dataPos = plot.pixelToData(x, y) + if dataPos is None: + return None + + origin = self.getOrigin() + scale = self.getScale() + column = int((dataPos[0] - origin[0]) / float(scale[0])) + row = int((dataPos[1] - origin[1]) / float(scale[1])) + return PickingResult(self, ([row], [column])) + + return None + def _isPlotLinear(self, plot): """Return True if plot only uses linear scale for both of x and y axes.""" @@ -282,12 +302,9 @@ class ImageData(ImageBase, ColormapMixIn): 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()) @@ -415,12 +432,9 @@ class ImageRgba(ImageBase): 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()) diff --git a/silx/gui/plot/items/marker.py b/silx/gui/plot/items/marker.py index 80ca0b6..f5a1689 100644..100755 --- a/silx/gui/plot/items/marker.py +++ b/silx/gui/plot/items/marker.py @@ -34,13 +34,13 @@ import logging from ....utils.proxy import docstring from .core import (Item, DraggableMixIn, ColorMixIn, LineMixIn, SymbolMixIn, - ItemChangedType) + ItemChangedType, YAxisMixIn) _logger = logging.getLogger(__name__) -class MarkerBase(Item, DraggableMixIn, ColorMixIn): +class MarkerBase(Item, DraggableMixIn, ColorMixIn, YAxisMixIn): """Base class for markers""" _DEFAULT_COLOR = (0., 0., 0., 1.) @@ -50,6 +50,7 @@ class MarkerBase(Item, DraggableMixIn, ColorMixIn): Item.__init__(self) DraggableMixIn.__init__(self) ColorMixIn.__init__(self) + YAxisMixIn.__init__(self) self._text = '' self._x = None @@ -62,15 +63,13 @@ class MarkerBase(Item, DraggableMixIn, ColorMixIn): 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, linestyle=linestyle, linewidth=linewidth, - constraint=self.getConstraint()) + constraint=self.getConstraint(), + yaxis=self.getYAxis()) def _addBackendRenderer(self, backend): """Update backend renderer""" @@ -81,13 +80,11 @@ class MarkerBase(Item, DraggableMixIn, ColorMixIn): self.setPosition(to[0], to[1]) def isOverlay(self): - """Return true if marker is drawn as an overlay. - - A marker is an overlay if it is draggable. + """Returns True: A marker is always rendered as an overlay. :rtype: bool """ - return self.isDraggable() + return True def getText(self): """Returns marker text. diff --git a/silx/gui/plot/items/roi.py b/silx/gui/plot/items/roi.py index 65831be..dcad943 100644 --- a/silx/gui/plot/items/roi.py +++ b/silx/gui/plot/items/roi.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2018 European Synchrotron Radiation Facility +# Copyright (c) 2018-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -40,12 +40,51 @@ from ....utils.weakref import WeakList from ... import qt from .. import items from ...colors import rgba +import silx.utils.deprecation +from silx.utils.proxy import docstring logger = logging.getLogger(__name__) -class RegionOfInterest(qt.QObject): +class _RegionOfInterestBase(qt.QObject): + """Base class of 1D and 2D region of interest + + :param QObject parent: See QObject + :param str name: The name of the ROI + """ + + sigItemChanged = qt.Signal(object) + """Signal emitted when item has changed. + + It provides a flag describing which property of the item has changed. + See :class:`ItemChangedType` for flags description. + """ + + def __init__(self, parent=None, name=''): + qt.QObject.__init__(self) + self.__name = str(name) + + def getName(self): + """Returns the name of the ROI + + :return: name of the region of interest + :rtype: str + """ + return self.__name + + def setName(self, name): + """Set the name of the ROI + + :param str name: name of the region of interest + """ + name = str(name) + if self.__name != name: + self.__name = name + self.sigItemChanged.emit(items.ItemChangedType.NAME) + + +class RegionOfInterest(_RegionOfInterestBase): """Object describing a region of interest in a plot. :param QObject parent: @@ -55,7 +94,7 @@ class RegionOfInterest(qt.QObject): _kind = None """Label for this kind of ROI. - Should be setted by inherited classes to custom the ROI manager widget. + Should be set by inherited classes to custom the ROI manager widget. """ sigRegionChanged = qt.Signal() @@ -65,15 +104,20 @@ class RegionOfInterest(qt.QObject): # Avoid circular dependancy from ..tools import roi as roi_tools assert parent is None or isinstance(parent, roi_tools.RegionOfInterestManager) - qt.QObject.__init__(self, parent) + _RegionOfInterestBase.__init__(self, parent, '') self._color = rgba('red') self._items = WeakList() self._editAnchors = WeakList() self._points = None - self._label = '' self._labelItem = None self._editable = False self._visible = True + self.sigItemChanged.connect(self.__itemChanged) + + def __itemChanged(self, event): + """Handle name change""" + if event == items.ItemChangedType.NAME: + self._updateLabelItem(self.getName()) def __del__(self): # Clean-up plot items @@ -140,22 +184,27 @@ class RegionOfInterest(qt.QObject): if isinstance(item, items.ColorMixIn): item.setColor(rgbaColor) + self.sigItemChanged.emit(items.ItemChangedType.COLOR) + + @silx.utils.deprecation.deprecated(reason='API modification', + replacement='getName()', + since_version=0.12) def getLabel(self): """Returns the label displayed for this ROI. :rtype: str """ - return self._label + return self.getName() + @silx.utils.deprecation.deprecated(reason='API modification', + replacement='setName(name)', + since_version=0.12) def setLabel(self, label): """Set the label displayed with this ROI. :param str label: The text label to display """ - label = str(label) - if label != self._label: - self._label = label - self._updateLabelItem(label) + self.setName(name=label) def isEditable(self): """Returns whether the ROI is editable by the user or not. @@ -176,6 +225,7 @@ class RegionOfInterest(qt.QObject): # Recreate plot items # This can be avoided once marker.setDraggable is public self._createPlotItems() + self.sigItemChanged.emit(items.ItemChangedType.EDITABLE) def isVisible(self): """Returns whether the ROI is visible in the plot. @@ -197,13 +247,13 @@ class RegionOfInterest(qt.QObject): hide it. """ visible = bool(visible) - if self._visible == visible: - return - self._visible = visible - if self._labelItem is not None: - self._labelItem.setVisible(visible) - for item in self._items + self._editAnchors: - item.setVisible(visible) + if self._visible != visible: + self._visible = visible + if self._labelItem is not None: + self._labelItem.setVisible(visible) + for item in self._items + self._editAnchors: + item.setVisible(visible) + self.sigItemChanged.emit(items.ItemChangedType.VISIBLE) def _getControlPoints(self): """Returns the current ROI control points. @@ -371,7 +421,7 @@ class RegionOfInterest(qt.QObject): markerPos = self._getLabelPosition() marker = items.Marker() marker.setPosition(*markerPos) - marker.setText(self.getLabel()) + marker.setText(self.getName()) marker.setColor(rgba(self.getColor())) marker.setSymbol('') marker._setDraggable(False) @@ -465,6 +515,12 @@ class PointROI(RegionOfInterest, items.SymbolMixIn): _plotShape = "point" """Plot shape which is used for the first interaction""" + _DEFAULT_SYMBOL = '+' + """Default symbol of the PointROI + + It overwrite the `SymbolMixIn` class attribte. + """ + def __init__(self, parent=None): items.SymbolMixIn.__init__(self) RegionOfInterest.__init__(self, parent=parent) @@ -488,31 +544,31 @@ class PointROI(RegionOfInterest, items.SymbolMixIn): return None def _updateLabelItem(self, label): - if self.isEditable(): - item = self._editAnchors[0] - else: + self._items[0].setText(label) + + def _updateShape(self): + if len(self._items) > 0: + controlPoints = self._getControlPoints() item = self._items[0] - item.setText(label) + item.setPosition(*controlPoints[0]) + + def __positionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + marker = self.sender() + if isinstance(marker, items.Marker): + self.setPosition(marker.getPosition()) def _createShapeItems(self, points): - if self.isEditable(): - return [] marker = items.Marker() marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getLabel()) - marker.setColor(rgba(self.getColor())) + marker.setText(self.getName()) marker.setSymbol(self.getSymbol()) marker.setSymbolSize(self.getSymbolSize()) - marker._setDraggable(False) - return [marker] - - def _createAnchorItems(self, points): - marker = items.Marker() - marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getLabel()) + marker.setColor(rgba(self.getColor())) marker._setDraggable(self.isEditable()) - marker.setSymbol(self.getSymbol()) - marker.setSymbolSize(self.getSymbolSize()) + if self.isEditable(): + marker.sigItemChanged.connect(self.__positionChanged) return [marker] def __str__(self): @@ -672,38 +728,31 @@ class HorizontalLineROI(RegionOfInterest, items.LineMixIn): return None def _updateLabelItem(self, label): - if self.isEditable(): - item = self._editAnchors[0] - else: - item = self._items[0] - item.setText(label) + self._items[0].setText(label) def _updateShape(self): - if not self.isEditable(): - if len(self._items) > 0: - controlPoints = self._getControlPoints() - item = self._items[0] - item.setPosition(*controlPoints[0]) + if len(self._items) > 0: + controlPoints = self._getControlPoints() + item = self._items[0] + item.setPosition(*controlPoints[0]) + + def __positionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + marker = self.sender() + if isinstance(marker, items.YMarker): + self.setPosition(marker.getYPosition()) def _createShapeItems(self, points): - if self.isEditable(): - return [] marker = items.YMarker() marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getLabel()) + marker.setText(self.getName()) marker.setColor(rgba(self.getColor())) - marker._setDraggable(False) marker.setLineWidth(self.getLineWidth()) marker.setLineStyle(self.getLineStyle()) - return [marker] - - def _createAnchorItems(self, points): - marker = items.YMarker() - marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getLabel()) marker._setDraggable(self.isEditable()) - marker.setLineWidth(self.getLineWidth()) - marker.setLineStyle(self.getLineStyle()) + if self.isEditable(): + marker.sigItemChanged.connect(self.__positionChanged) return [marker] def __str__(self): @@ -749,38 +798,31 @@ class VerticalLineROI(RegionOfInterest, items.LineMixIn): return None def _updateLabelItem(self, label): - if self.isEditable(): - item = self._editAnchors[0] - else: - item = self._items[0] - item.setText(label) + self._items[0].setText(label) def _updateShape(self): - if not self.isEditable(): - if len(self._items) > 0: - controlPoints = self._getControlPoints() - item = self._items[0] - item.setPosition(*controlPoints[0]) + if len(self._items) > 0: + controlPoints = self._getControlPoints() + item = self._items[0] + item.setPosition(*controlPoints[0]) + + def __positionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + marker = self.sender() + if isinstance(marker, items.XMarker): + self.setPosition(marker.getXPosition()) def _createShapeItems(self, points): - if self.isEditable(): - return [] marker = items.XMarker() marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getLabel()) + marker.setText(self.getName()) marker.setColor(rgba(self.getColor())) - marker._setDraggable(False) marker.setLineWidth(self.getLineWidth()) marker.setLineStyle(self.getLineStyle()) - return [marker] - - def _createAnchorItems(self, points): - marker = items.XMarker() - marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getLabel()) marker._setDraggable(self.isEditable()) - marker.setLineWidth(self.getLineWidth()) - marker.setLineStyle(self.getLineStyle()) + if self.isEditable(): + marker.sigItemChanged.connect(self.__positionChanged) return [marker] def __str__(self): diff --git a/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py index b2f087b..50cc694 100644 --- a/silx/gui/plot/items/scatter.py +++ b/silx/gui/plot/items/scatter.py @@ -25,11 +25,15 @@ """This module provides the :class:`Scatter` item of the :class:`Plot`. """ +from __future__ import division + + __authors__ = ["T. Vincent", "P. Knobel"] __license__ = "MIT" __date__ = "29/03/2017" +from collections import namedtuple import logging import threading import numpy @@ -37,10 +41,13 @@ import numpy from collections import defaultdict from concurrent.futures import ThreadPoolExecutor, CancelledError +from ....utils.proxy import docstring +from ....math.combo import min_max from ....utils.weakref import WeakList from .._utils.delaunay import delaunay from .core import PointsBase, ColormapMixIn, ScatterVisualizationMixIn from .axis import Axis +from ._pick import PickingResult _logger = logging.getLogger(__name__) @@ -79,6 +86,184 @@ class _GreedyThreadPoolExecutor(ThreadPoolExecutor): return future +# Functions to guess grid shape from coordinates + +def _get_z_line_length(array): + """Return length of line if array is a Z-like 2D regular grid. + + :param numpy.ndarray array: The 1D array of coordinates to check + :return: 0 if no line length could be found, + else the number of element per line. + :rtype: int + """ + sign = numpy.sign(numpy.diff(array)) + if len(sign) == 0 or sign[0] == 0: # We don't handle that + return 0 + # Check this way to account for 0 sign (i.e., diff == 0) + beginnings = numpy.where(sign == - sign[0])[0] + 1 + if len(beginnings) == 0: + return 0 + length = beginnings[0] + if numpy.all(numpy.equal(numpy.diff(beginnings), length)): + return length + return 0 + + +def _guess_z_grid_shape(x, y): + """Guess the shape of a grid from (x, y) coordinates. + + The grid might contain more elements than x and y, + as the last line might be partly filled. + + :param numpy.ndarray x: + :paran numpy.ndarray y: + :returns: (order, (height, width)) of the regular grid, + or None if could not guess one. + 'order' is 'row' if X (i.e., column) is the fast dimension, else 'column'. + :rtype: Union[List(str,int),None] + """ + width = _get_z_line_length(x) + if width != 0: + return 'row', (int(numpy.ceil(len(x) / width)), width) + else: + height = _get_z_line_length(y) + if height != 0: + return 'column', (height, int(numpy.ceil(len(y) / height))) + return None + + +def is_monotonic(array): + """Returns whether array is monotonic (increasing or decreasing). + + :param numpy.ndarray array: 1D array-like container. + :returns: 1 if array is monotonically increasing, + -1 if array is monotonically decreasing, + 0 if array is not monotonic + :rtype: int + """ + diff = numpy.diff(numpy.ravel(array)) + if numpy.all(diff >= 0): + return 1 + elif numpy.all(diff <= 0): + return -1 + else: + return 0 + + +def _guess_grid(x, y): + """Guess a regular grid from the points. + + Result convention is (x, y) + + :param numpy.ndarray x: X coordinates of the points + :param numpy.ndarray y: Y coordinates of the points + :returns: (order, (height, width) + order is 'row' or 'column' + :rtype: Union[List[str,List[int]],None] + """ + x, y = numpy.ravel(x), numpy.ravel(y) + + guess = _guess_z_grid_shape(x, y) + if guess is not None: + return guess + + else: + # Cannot guess a regular grid + # Let's assume it's a single line + order = 'row' # or 'column' doesn't matter for a single line + y_monotonic = is_monotonic(y) + if is_monotonic(x) or y_monotonic: # we can guess a line + x_min, x_max = min_max(x) + y_min, y_max = min_max(y) + + if not y_monotonic or x_max - x_min >= y_max - y_min: + # x only is monotonic or both are and X varies more + # line along X + shape = 1, len(x) + else: + # y only is monotonic or both are and Y varies more + # line along Y + shape = len(y), 1 + + else: # Cannot guess a line from the points + return None + + return order, shape + + +def _quadrilateral_grid_coords(points): + """Compute an irregular grid of quadrilaterals from a set of points + + The input points are expected to lie on a grid. + + :param numpy.ndarray points: + 3D data set of 2D input coordinates (height, width, 2) + height and width must be at least 2. + :return: 3D dataset of 2D coordinates of the grid (height+1, width+1, 2) + """ + assert points.ndim == 3 + assert points.shape[0] >= 2 + assert points.shape[1] >= 2 + assert points.shape[2] == 2 + + dim0, dim1 = points.shape[:2] + grid_points = numpy.zeros((dim0 + 1, dim1 + 1, 2), dtype=numpy.float64) + + # Compute inner points as mean of 4 neighbours + neighbour_view = numpy.lib.stride_tricks.as_strided( + points, + shape=(dim0 - 1, dim1 - 1, 2, 2, points.shape[2]), + strides=points.strides[:2] + points.strides[:2] + points.strides[-1:], writeable=False) + inner_points = numpy.mean(neighbour_view, axis=(2, 3)) + grid_points[1:-1, 1:-1] = inner_points + + # Compute 'vertical' sides + # Alternative: grid_points[1:-1, [0, -1]] = points[:-1, [0, -1]] + points[1:, [0, -1]] - inner_points[:, [0, -1]] + grid_points[1:-1, [0, -1], 0] = points[:-1, [0, -1], 0] + points[1:, [0, -1], 0] - inner_points[:, [0, -1], 0] + grid_points[1:-1, [0, -1], 1] = inner_points[:, [0, -1], 1] + + # Compute 'horizontal' sides + grid_points[[0, -1], 1:-1, 0] = inner_points[[0, -1], :, 0] + grid_points[[0, -1], 1:-1, 1] = points[[0, -1], :-1, 1] + points[[0, -1], 1:, 1] - inner_points[[0, -1], :, 1] + + # Compute corners + d0, d1 = [0, 0, -1, -1], [0, -1, -1, 0] + grid_points[d0, d1] = 2 * points[d0, d1] - inner_points[d0, d1] + return grid_points + + +def _quadrilateral_grid_as_triangles(points): + """Returns the points and indices to make a grid of quadirlaterals + + :param numpy.ndarray points: + 3D array of points (height, width, 2) + :return: triangle corners (4 * N, 2), triangle indices (2 * N, 3) + With N = height * width, the number of input points + """ + nbpoints = numpy.prod(points.shape[:2]) + + grid = _quadrilateral_grid_coords(points) + coords = numpy.empty((4 * nbpoints, 2), dtype=grid.dtype) + coords[::4] = grid[:-1, :-1].reshape(-1, 2) + coords[1::4] = grid[1:, :-1].reshape(-1, 2) + coords[2::4] = grid[:-1, 1:].reshape(-1, 2) + coords[3::4] = grid[1:, 1:].reshape(-1, 2) + + indices = numpy.empty((2 * nbpoints, 3), dtype=numpy.uint32) + indices[::2, 0] = numpy.arange(0, 4 * nbpoints, 4) + indices[::2, 1] = numpy.arange(1, 4 * nbpoints, 4) + indices[::2, 2] = numpy.arange(2, 4 * nbpoints, 4) + indices[1::2, 0] = indices[::2, 1] + indices[1::2, 1] = indices[::2, 2] + indices[1::2, 2] = numpy.arange(3, 4 * nbpoints, 4) + + return coords, indices + + +_RegularGridInfo = namedtuple( + '_RegularGridInfo', ['bounds', 'origin', 'scale', 'shape', 'order']) + + class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): """Description of a scatter""" @@ -87,7 +272,10 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): _SUPPORTED_SCATTER_VISUALIZATION = ( ScatterVisualizationMixIn.Visualization.POINTS, - ScatterVisualizationMixIn.Visualization.SOLID) + ScatterVisualizationMixIn.Visualization.SOLID, + ScatterVisualizationMixIn.Visualization.REGULAR_GRID, + ScatterVisualizationMixIn.Visualization.IRREGULAR_GRID, + ) """Overrides supported Visualizations""" def __init__(self): @@ -104,7 +292,86 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): # Cache triangles: x, y, indices self.__cacheTriangles = None, None, None + + # Cache regular grid info + self.__cacheRegularGridInfo = None + + @docstring(ScatterVisualizationMixIn) + def setVisualizationParameter(self, parameter, value): + changed = super(Scatter, self).setVisualizationParameter(parameter, value) + if changed and parameter in (self.VisualizationParameter.GRID_BOUNDS, + self.VisualizationParameter.GRID_MAJOR_ORDER, + self.VisualizationParameter.GRID_SHAPE): + self.__cacheRegularGridInfo = None + return changed + + @docstring(ScatterVisualizationMixIn) + def getCurrentVisualizationParameter(self, parameter): + value = self.getVisualizationParameter(parameter) + if value is not None: + return value # Value has been set, return it + + elif parameter is self.VisualizationParameter.GRID_BOUNDS: + grid = self.__getRegularGridInfo() + return None if grid is None else grid.bounds + elif parameter is self.VisualizationParameter.GRID_MAJOR_ORDER: + grid = self.__getRegularGridInfo() + return None if grid is None else grid.order + + elif parameter is self.VisualizationParameter.GRID_SHAPE: + grid = self.__getRegularGridInfo() + return None if grid is None else grid.shape + + else: + raise NotImplementedError() + + def __getRegularGridInfo(self): + """Get grid info""" + if self.__cacheRegularGridInfo is None: + shape = self.getVisualizationParameter( + self.VisualizationParameter.GRID_SHAPE) + order = self.getVisualizationParameter( + self.VisualizationParameter.GRID_MAJOR_ORDER) + if shape is None or order is None: + guess = _guess_grid(self.getXData(copy=False), + self.getYData(copy=False)) + if guess is None: + _logger.warning( + 'Cannot guess a grid: Cannot display as regular grid image') + return None + if shape is None: + shape = guess[1] + if order is None: + order = guess[0] + + bounds = self.getVisualizationParameter( + self.VisualizationParameter.GRID_BOUNDS) + if bounds is None: + x, y = self.getXData(copy=False), self.getYData(copy=False) + min_, max_ = min_max(x) + xRange = (min_, max_) if (x[0] - min_) < (max_ - x[0]) else (max_, min_) + min_, max_ = min_max(y) + yRange = (min_, max_) if (y[0] - min_) < (max_ - y[0]) else (max_, min_) + bounds = (xRange[0], yRange[0]), (xRange[1], yRange[1]) + + begin, end = bounds + scale = ((end[0] - begin[0]) / max(1, shape[1] - 1), + (end[1] - begin[1]) / max(1, shape[0] - 1)) + if scale[0] == 0 and scale[1] == 0: + scale = 1., 1. + elif scale[0] == 0: + scale = scale[1], scale[1] + elif scale[1] == 0: + scale = scale[0], scale[0] + + origin = begin[0] - 0.5 * scale[0], begin[1] - 0.5 * scale[1] + + self.__cacheRegularGridInfo = _RegularGridInfo( + bounds=bounds, origin=origin, scale=scale, shape=shape, order=order) + + return self.__cacheRegularGridInfo + def _addBackendRenderer(self, backend): """Update backend renderer""" # Filter-out values <= 0 @@ -129,8 +396,10 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): # Apply mask to colors rgbacolors = rgbacolors[mask] - if self.getVisualization() is self.Visualization.POINTS: - return backend.addCurve(xFiltered, yFiltered, self.getLegend(), + visualization = self.getVisualization() + + if visualization is self.Visualization.POINTS: + return backend.addCurve(xFiltered, yFiltered, color=rgbacolors, symbol=self.getSymbol(), linewidth=0, @@ -139,32 +408,153 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): xerror=xerror, yerror=yerror, z=self.getZValue(), - selectable=self.isSelectable(), fill=False, alpha=self.getAlpha(), - symbolsize=self.getSymbolSize()) + symbolsize=self.getSymbolSize(), + baseline=None) - else: # 'solid' + else: plot = self.getPlot() if (plot is None or plot.getXAxis().getScale() != Axis.LINEAR or plot.getYAxis().getScale() != Axis.LINEAR): - # Solid visualization is not available with log scaled axes + # Those visualizations are not available with log scaled axes return None - triangulation = self._getDelaunay().result() - if triangulation is None: - return None - else: - triangles = triangulation.simplices.astype(numpy.int32) - return backend.addTriangles(xFiltered, - yFiltered, - triangles, - legend=self.getLegend(), - color=rgbacolors, + if visualization is self.Visualization.SOLID: + triangulation = self._getDelaunay().result() + if triangulation is None: + _logger.warning( + 'Cannot get a triangulation: Cannot display as solid surface') + return None + else: + triangles = triangulation.simplices.astype(numpy.int32) + return backend.addTriangles(xFiltered, + yFiltered, + triangles, + color=rgbacolors, + z=self.getZValue(), + alpha=self.getAlpha()) + + elif visualization is self.Visualization.REGULAR_GRID: + gridInfo = self.__getRegularGridInfo() + if gridInfo is None: + return None + + dim0, dim1 = gridInfo.shape + if gridInfo.order == 'column': # transposition needed + dim0, dim1 = dim1, dim0 + + if len(rgbacolors) == dim0 * dim1: + image = rgbacolors.reshape(dim0, dim1, -1) + else: + # The points do not fill the whole image + image = numpy.empty((dim0 * dim1, 4), dtype=rgbacolors.dtype) + image[:len(rgbacolors)] = rgbacolors + image[len(rgbacolors):] = 0, 0, 0, 0 # Transparent pixels + image.shape = dim0, dim1, -1 + + if gridInfo.order == 'column': + image = numpy.transpose(image, axes=(1, 0, 2)) + + return backend.addImage( + data=image, + origin=gridInfo.origin, + scale=gridInfo.scale, + z=self.getZValue(), + colormap=None, + alpha=self.getAlpha()) + + elif visualization is self.Visualization.IRREGULAR_GRID: + gridInfo = self.__getRegularGridInfo() + if gridInfo is None: + return None + + shape = gridInfo.shape + if shape is None: # No shape, no display + return None + + # clip shape to fully filled lines + if len(xFiltered) != numpy.prod(shape): + if gridInfo.order == 'row': + shape = len(xFiltered) // shape[1], shape[1] + else: # column-major order + shape = shape[0], len(xFiltered) // shape[0] + if shape[0] < 2 or shape[1] < 2: # Not enough points + return None + + nbpoints = numpy.prod(shape) + if gridInfo.order == 'row': + points = numpy.transpose((xFiltered[:nbpoints], yFiltered[:nbpoints])) + points = points.reshape(shape[0], shape[1], 2) + + else: # column-major order + points = numpy.transpose((yFiltered[:nbpoints], xFiltered[:nbpoints])) + points = points.reshape(shape[1], shape[0], 2) + + coords, indices = _quadrilateral_grid_as_triangles(points) + + if gridInfo.order == 'row': + x, y = coords[:, 0], coords[:, 1] + else: # column-major order + y, x = coords[:, 0], coords[:, 1] + + gridcolors = numpy.empty( + (4 * nbpoints, rgbacolors.shape[-1]), dtype=rgbacolors.dtype) + for first in range(4): + gridcolors[first::4] = rgbacolors[:nbpoints] + + return backend.addTriangles(x, + y, + indices, + color=gridcolors, z=self.getZValue(), - selectable=self.isSelectable(), alpha=self.getAlpha()) + else: + _logger.error("Unhandled visualization %s", visualization) + return None + + @docstring(PointsBase) + def pick(self, x, y): + result = super(Scatter, self).pick(x, y) + + if result is not None: + visualization = self.getVisualization() + + if visualization is self.Visualization.IRREGULAR_GRID: + # Specific handling of picking for the irregular grid mode + index = result.getIndices(copy=False)[0] // 4 + result = PickingResult(self, (index,)) + + elif visualization is self.Visualization.REGULAR_GRID: + # Specific handling of picking for the regular grid mode + plot = self.getPlot() + if plot is None: + return None + + dataPos = plot.pixelToData(x, y) + if dataPos is None: + return None + + gridInfo = self.__getRegularGridInfo() + if gridInfo is None: + return None + + origin = gridInfo.origin + scale = gridInfo.scale + column = int((dataPos[0] - origin[0]) / scale[0]) + row = int((dataPos[1] - origin[1]) / scale[1]) + + if gridInfo.order == 'row': + index = row * gridInfo.shape[1] + column + else: + index = row + column * gridInfo.shape[0] + if index >= len(self.getXData(copy=False)): # OK as long as not log scale + return None # Image can be larger than scatter + + result = PickingResult(self, (index,)) + + return result def __getExecutor(self): """Returns async greedy executor @@ -358,6 +748,9 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): self.__interpolatorFuture.cancel() self.__interpolatorFuture = None + # Data changed, this needs update + self.__cacheRegularGridInfo = None + self._value = value if alpha is not None: diff --git a/silx/gui/plot/items/shape.py b/silx/gui/plot/items/shape.py index 9fc1306..e6dc529 100644 --- a/silx/gui/plot/items/shape.py +++ b/silx/gui/plot/items/shape.py @@ -36,7 +36,7 @@ import numpy import six from ... import colors -from .core import Item, ColorMixIn, FillMixIn, ItemChangedType, LineMixIn +from .core import Item, ColorMixIn, FillMixIn, ItemChangedType, LineMixIn, YAxisMixIn _logger = logging.getLogger(__name__) @@ -70,7 +70,6 @@ class Shape(Item, ColorMixIn, FillMixIn, LineMixIn): 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(), @@ -154,3 +153,64 @@ class Shape(Item, ColorMixIn, FillMixIn, LineMixIn): self._lineBgColor = color self._updated(ItemChangedType.LINE_BG_COLOR) + + +class BoundingRect(Item, YAxisMixIn): + """An invisible shape which enforce the plot view to display the defined + space on autoscale. + + This item do not display anything. But if the visible property is true, + this bounding box is used by the plot, if not, the bounding box is + ignored. That's the default behaviour for plot items. + + It can be applied on the "left" or "right" axes. Not both at the same time. + """ + + def __init__(self): + Item.__init__(self) + YAxisMixIn.__init__(self) + self.__bounds = None + + def _updated(self, event=None, checkVisibility=True): + if event in (ItemChangedType.YAXIS, + ItemChangedType.VISIBLE, + ItemChangedType.DATA): + # TODO hackish data range implementation + plot = self.getPlot() + if plot is not None: + plot._invalidateDataRange() + + super(BoundingRect, self)._updated(event, checkVisibility) + + def setBounds(self, rect): + """Set the bounding box of this item in data coordinates + + :param Union[None,List[float]] rect: (xmin, xmax, ymin, ymax) or None + """ + if rect is not None: + rect = float(rect[0]), float(rect[1]), float(rect[2]), float(rect[3]) + assert rect[0] <= rect[1] + assert rect[2] <= rect[3] + + if rect != self.__bounds: + self.__bounds = rect + self._updated(ItemChangedType.DATA) + + def _getBounds(self): + plot = self.getPlot() + if plot is not None: + xPositive = plot.getXAxis()._isLogarithmic() + yPositive = plot.getYAxis()._isLogarithmic() + if xPositive or yPositive: + bounds = list(self.__bounds) + if xPositive and bounds[1] <= 0: + return None + if xPositive and bounds[0] <= 0: + bounds[0] = bounds[1] + if yPositive and bounds[3] <= 0: + return None + if yPositive and bounds[2] <= 0: + bounds[2] = bounds[3] + return tuple(bounds) + + return self.__bounds |