diff options
author | Alexandre Marie <alexandre.marie@synchrotron-soleil.fr> | 2020-07-21 14:45:14 +0200 |
---|---|---|
committer | Alexandre Marie <alexandre.marie@synchrotron-soleil.fr> | 2020-07-21 14:45:14 +0200 |
commit | 328032e2317e3ac4859196bbf12bdb71795302fe (patch) | |
tree | 8cd13462beab109e3cb53410c42335b6d1e00ee6 /silx/gui/plot/items | |
parent | 33ed2a64c92b0311ae35456c016eb284e426afc2 (diff) |
New upstream version 0.13.0+dfsg
Diffstat (limited to 'silx/gui/plot/items')
-rw-r--r-- | silx/gui/plot/items/__init__.py | 9 | ||||
-rw-r--r-- | silx/gui/plot/items/_pick.py | 6 | ||||
-rw-r--r-- | silx/gui/plot/items/axis.py | 4 | ||||
-rw-r--r-- | silx/gui/plot/items/complex.py | 11 | ||||
-rw-r--r-- | silx/gui/plot/items/core.py | 223 | ||||
-rw-r--r-- | silx/gui/plot/items/curve.py | 64 | ||||
-rw-r--r-- | silx/gui/plot/items/histogram.py | 20 | ||||
-rw-r--r-- | silx/gui/plot/items/image.py | 116 | ||||
-rwxr-xr-x | silx/gui/plot/items/marker.py | 22 | ||||
-rw-r--r-- | silx/gui/plot/items/roi.py | 3025 | ||||
-rw-r--r-- | silx/gui/plot/items/scatter.py | 286 | ||||
-rw-r--r-- | silx/gui/plot/items/shape.py | 109 |
12 files changed, 2868 insertions, 1027 deletions
diff --git a/silx/gui/plot/items/__init__.py b/silx/gui/plot/items/__init__.py index 7eff1d0..4d4eac0 100644 --- a/silx/gui/plot/items/__init__.py +++ b/silx/gui/plot/items/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2019 European Synchrotron Radiation Facility +# Copyright (c) 2017-2020 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 @@ -39,12 +39,13 @@ from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, # noqa 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, BoundingRect # noqa +from .image import ImageBase, ImageData, ImageRgba, ImageStack, MaskImageData # noqa +from .shape import Shape, BoundingRect, XAxisExtent, YAxisExtent # 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, BoundingRect +DATA_ITEMS = (ImageComplexData, Curve, Histogram, ImageBase, Scatter, + BoundingRect, XAxisExtent, YAxisExtent) """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 index 14078fd..4ddf4f6 100644 --- a/silx/gui/plot/items/_pick.py +++ b/silx/gui/plot/items/_pick.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2019 European Synchrotron Radiation Facility +# Copyright (c) 2019-2020 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 @@ -47,7 +47,9 @@ class PickingResult(object): if indices is None or len(indices) == 0: self._indices = None else: - self._indices = numpy.array(indices, copy=False, dtype=numpy.int) + # Indices is set to None if indices array is empty + indices = numpy.array(indices, copy=False, dtype=numpy.int) + self._indices = None if indices.size == 0 else indices def getItem(self): """Returns the item this results corresponds to.""" diff --git a/silx/gui/plot/items/axis.py b/silx/gui/plot/items/axis.py index 8ea5c7a..be85e6a 100644 --- a/silx/gui/plot/items/axis.py +++ b/silx/gui/plot/items/axis.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# Copyright (c) 2017-2020 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 @@ -239,7 +239,7 @@ class Axis(qt.QObject): # TODO hackish way of forcing update of curves and images plot = self._getPlot() - for item in plot._getItems(withhidden=True): + for item in plot.getItems(): item._updated() plot._invalidateDataRange() diff --git a/silx/gui/plot/items/complex.py b/silx/gui/plot/items/complex.py index 988022a..8f0694d 100644 --- a/silx/gui/plot/items/complex.py +++ b/silx/gui/plot/items/complex.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2019 European Synchrotron Radiation Facility +# Copyright (c) 2017-2020 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 @@ -165,6 +165,11 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): data = self.getRgbaImageData(copy=False) else: colormap = self.getColormap() + if colormap.isAutoscale(): + # Avoid backend to compute autoscale: use item cache + colormap = colormap.copy() + colormap.setVRange(*colormap.getColormapRange(self)) + data = self.getData(copy=False) if data.size == 0: @@ -173,7 +178,6 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): return backend.addImage(data, origin=self.getOrigin(), scale=self.getScale(), - z=self.getZValue(), colormap=colormap, alpha=self.getAlpha()) @@ -191,6 +195,8 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): colormap = self._colormaps[self.getComplexMode()] if colormap is not super(ImageComplexData, self).getColormap(): super(ImageComplexData, self).setColormap(colormap) + + self._setColormappedData(self.getData(copy=False), copy=False) return changed def _setAmplitudeRangeInfo(self, max_=None, delta=2): @@ -260,6 +266,7 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): self._data = data self._dataByModesCache = {} + self._setColormappedData(self.getData(copy=False), copy=False) # TODO hackish data range implementation if self.isVisible(): diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py index 6d6575b..9426a13 100644 --- a/silx/gui/plot/items/core.py +++ b/silx/gui/plot/items/core.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2019 European Synchrotron Radiation Facility +# Copyright (c) 2017-2020 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 @@ -43,6 +43,7 @@ import weakref import numpy import six +from ....utils.deprecation import deprecated from ....utils.enum import Enum as _Enum from ... import qt from ... import colors @@ -143,6 +144,9 @@ class ItemChangedType(enum.Enum): EDITABLE = 'editableChanged' """Item's editable state changed flags.""" + SELECTABLE = 'selectableChanged' + """Item's selectable state changed flags.""" + class Item(qt.QObject): """Description of an item of the plot""" @@ -150,9 +154,6 @@ class Item(qt.QObject): _DEFAULT_Z_LAYER = 0 """Default layer for overlay rendering""" - _DEFAULT_LEGEND = '' - """Default legend of items""" - _DEFAULT_SELECTABLE = False """Default selectable state of items""" @@ -168,19 +169,19 @@ class Item(qt.QObject): self._dirty = True self._plotRef = None self._visible = True - self._legend = self._DEFAULT_LEGEND self._selectable = self._DEFAULT_SELECTABLE self._z = self._DEFAULT_Z_LAYER self._info = None self._xlabel = None self._ylabel = None + self.__name = '' self._backendRenderer = None def getPlot(self): - """Returns Plot this item belongs to. + """Returns the ~silx.gui.plot.PlotWidget this item belongs to. - :rtype: Plot or None + :rtype: Union[~silx.gui.plot.PlotWidget,None] """ return None if self._plotRef is None else self._plotRef() @@ -189,7 +190,7 @@ class Item(qt.QObject): WARNING: This should only be called from the Plot. - :param Plot plot: The Plot instance. + :param Union[~silx.gui.plot.PlotWidget,None] plot: The Plot instance. """ if plot is not None and self._plotRef is not None: raise RuntimeError('Trying to add a node at two places.') @@ -234,19 +235,35 @@ class Item(qt.QObject): """ return False - def getLegend(self): - """Returns the legend of this item (str)""" - return self._legend + def getName(self): + """Returns the name of the item which is used as legend. - def _setLegend(self, legend): - """Set the legend. + :rtype: str + """ + return self.__name - This is private as it is used by the plot as an identifier + def setName(self, name): + """Set the name of the item which is used as legend. - :param str legend: Item legend + :param str name: New name of the item + :raises RuntimeError: If item belongs to a PlotWidget. """ - legend = str(legend) if legend is not None else self._DEFAULT_LEGEND - self._legend = legend + name = str(name) + if self.__name != name: + if self.getPlot() is not None: + raise RuntimeError( + "Cannot change name while item is in a PlotWidget") + + self.__name = name + self._updated(ItemChangedType.NAME) + + def getLegend(self): # Replaced by getName for API consistency + return self.getName() + + @deprecated(replacement='setName', since_version='0.13') + def _setLegend(self, legend): + legend = str(legend) if legend is not None else '' + self.setName(legend) def isSelectable(self): """Returns true if item is selectable (bool)""" @@ -355,12 +372,12 @@ class Item(qt.QObject): if indices is None: return None else: - return PickingResult(self, indices if len(indices) != 0 else None) + return PickingResult(self, indices) # Mix-in classes ############################################################## -class ItemMixInBase(qt.QObject): +class ItemMixInBase(object): """Base class for Item mix-in""" def _updated(self, event=None, checkVisibility=True): @@ -454,6 +471,8 @@ class ColormapMixIn(ItemMixInBase): def __init__(self): self._colormap = Colormap() self._colormap.sigChanged.connect(self._colormapChanged) + self.__data = None + self.__cacheColormapRange = {} # Store {normalization: range} def getColormap(self): """Return the used colormap""" @@ -480,6 +499,70 @@ class ColormapMixIn(ItemMixInBase): """Handle updates of the colormap""" self._updated(ItemChangedType.COLORMAP) + def _setColormappedData(self, data, copy=True, + min_=None, minPositive=None, max_=None): + """Set the data used to compute the colormapped display. + + It also resets the cache of data ranges. + + This method MUST be called by inheriting classes when data is updated. + + :param Union[None,numpy.ndarray] data: + :param Union[None,float] min_: Minimum value of the data + :param Union[None,float] minPositive: + Minimum of strictly positive values of the data + :param Union[None,float] max_: Maximum value of the data + """ + self.__data = None if data is None else numpy.array(data, copy=copy) + self.__cacheColormapRange = {} # Reset cache + + # Fill-up colormap range cache if values are provided + if max_ is not None and numpy.isfinite(max_): + if min_ is not None and numpy.isfinite(min_): + self.__cacheColormapRange[Colormap.LINEAR, Colormap.MINMAX] = min_, max_ + if minPositive is not None and numpy.isfinite(minPositive): + self.__cacheColormapRange[Colormap.LOGARITHM, Colormap.MINMAX] = minPositive, max_ + + colormap = self.getColormap() + if None in (colormap.getVMin(), colormap.getVMax()): + self._colormapChanged() + + def getColormappedData(self, copy=True): + """Returns the data used to compute the displayed colors + + :param bool copy: True to get a copy, + False to get internal data (do not modify!). + :rtype: Union[None,numpy.ndarray] + """ + if self.__data is None: + return None + else: + return numpy.array(self.__data, copy=copy) + + def _getColormapAutoscaleRange(self, colormap=None): + """Returns the autoscale range for current data and colormap. + + :param Union[None,~silx.gui.colors.Colormap] colormap: + The colormap for which to compute the autoscale range. + If None, the default, the colormap of the item is used + :return: (vmin, vmax) range (vmin and /or vmax might be `None`) + """ + if colormap is None: + colormap = self.getColormap() + + data = self.getColormappedData(copy=False) + if colormap is None or data is None: + return None, None + + normalization = colormap.getNormalization() + autoscaleMode = colormap.getAutoscaleMode() + key = normalization, autoscaleMode + vRange = self.__cacheColormapRange.get(key, None) + if vRange is None: + vRange = colormap._computeAutoscaleRange(data) + self.__cacheColormapRange[key] = vRange + return vRange + class SymbolMixIn(ItemMixInBase): """Mix-in class for items with symbol type""" @@ -712,6 +795,8 @@ class ColorMixIn(ItemMixInBase): """ if isinstance(color, six.string_types): color = colors.rgba(color) + elif isinstance(color, qt.QColor): + color = colors.rgba(color) else: color = numpy.array(color, copy=copy) # TODO more checks + improve color array support @@ -941,6 +1026,10 @@ class ScatterVisualizationMixIn(ItemMixInBase): (either all lines from left to right or all from right to left). """ + BINNED_STATISTIC = 'binned_statistic' + """Display scatter plot as 2D binned statistic (i.e., generalized histogram). + """ + @enum.unique class VisualizationParameter(_Enum): """Different parameter names for scatter plot visualizations""" @@ -967,10 +1056,30 @@ class ScatterVisualizationMixIn(ItemMixInBase): in which case the grid is not fully filled. """ + BINNED_STATISTIC_SHAPE = 'binned_statistic_shape' + """The number of bins in each dimension (height, width). + """ + + BINNED_STATISTIC_FUNCTION = 'binned_statistic_function' + """The reduction function to apply to each bin (str). + + Available reduction functions are: 'mean' (default), 'count', 'sum'. + """ + + _SUPPORTED_VISUALIZATION_PARAMETER_VALUES = { + VisualizationParameter.GRID_MAJOR_ORDER: ('row', 'column'), + VisualizationParameter.BINNED_STATISTIC_FUNCTION: ('mean', 'count', 'sum'), + } + """Supported visualization parameter values. + + Defined for parameters with a set of acceptable values. + """ + def __init__(self): self.__visualization = self.Visualization.POINTS self.__parameters = dict( # Init parameters to None (parameter, None) for parameter in self.VisualizationParameter) + self.__parameters[self.VisualizationParameter.BINNED_STATISTIC_FUNCTION] = 'mean' @classmethod def supportedVisualizations(cls): @@ -985,6 +1094,20 @@ class ScatterVisualizationMixIn(ItemMixInBase): else: return cls._SUPPORTED_SCATTER_VISUALIZATION + @classmethod + def supportedVisualizationParameterValues(cls, parameter): + """Returns the list of supported scatter visualization modes. + + See :meth:`VisualizationParameters` + + :param VisualizationParameter parameter: + This parameter for which to retrieve the supported values. + :returns: tuple of supported of values or None if not defined. + """ + parameter = cls.VisualizationParameter(parameter) + return cls._SUPPORTED_VISUALIZATION_PARAMETER_VALUES.get( + parameter, None) + def setVisualization(self, mode): """Set the scatter plot visualization mode to use. @@ -1024,10 +1147,15 @@ class ScatterVisualizationMixIn(ItemMixInBase): :raises ValueError: If parameter is not supported :return: True if parameter was set, False if is was already set :rtype: bool + :raise ValueError: If value is not supported """ parameter = self.VisualizationParameter.from_value(parameter) if self.__parameters[parameter] != value: + validValues = self.supportedVisualizationParameterValues(parameter) + if validValues is not None and value not in validValues: + raise ValueError("Unsupported parameter value: %s" % str(value)) + self.__parameters[parameter] = value self._updated(ItemChangedType.VISUALIZATION_MODE) return True @@ -1151,14 +1279,12 @@ class PointsBase(Item, SymbolMixIn, AlphaMixIn): if xPositive: x = self.getXData(copy=False) - with warnings.catch_warnings(): # Ignore NaN warnings - warnings.simplefilter('ignore', category=RuntimeWarning) + with numpy.errstate(invalid='ignore'): # Ignore NaN warnings xclipped = x <= 0 if yPositive: y = self.getYData(copy=False) - with warnings.catch_warnings(): # Ignore NaN warnings - warnings.simplefilter('ignore', category=RuntimeWarning) + with numpy.errstate(invalid='ignore'): # Ignore NaN warnings yclipped = y <= 0 self._clippedCache[(xPositive, yPositive)] = \ @@ -1386,3 +1512,54 @@ class BaselineMixIn(object): return numpy.array(self._baseline, copy=True) else: return self._baseline + + +class _Style: + """Object which store styles""" + + +class HighlightedMixIn(ItemMixInBase): + + def __init__(self): + self._highlightStyle = self._DEFAULT_HIGHLIGHT_STYLE + self._highlighted = False + + def isHighlighted(self): + """Returns True if curve is highlighted. + + :rtype: bool + """ + return self._highlighted + + def setHighlighted(self, highlighted): + """Set the highlight state of the curve + + :param bool highlighted: + """ + highlighted = bool(highlighted) + if highlighted != self._highlighted: + self._highlighted = highlighted + # TODO inefficient: better to use backend's setCurveColor + self._updated(ItemChangedType.HIGHLIGHTED) + + def getHighlightedStyle(self): + """Returns the highlighted style in use + + :rtype: CurveStyle + """ + return self._highlightStyle + + def setHighlightedStyle(self, style): + """Set the style to use for highlighting + + :param CurveStyle style: New style to use + """ + previous = self.getHighlightedStyle() + if style != previous: + assert isinstance(style, _Style) + self._highlightStyle = style + self._updated(ItemChangedType.HIGHLIGHTED_STYLE) + + # Backward compatibility event + if previous.getColor() != style.getColor(): + self._updated(ItemChangedType.HIGHLIGHTED_COLOR) diff --git a/silx/gui/plot/items/curve.py b/silx/gui/plot/items/curve.py index 5853ef5..7922fa1 100644 --- a/silx/gui/plot/items/curve.py +++ b/silx/gui/plot/items/curve.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2019 European Synchrotron Radiation Facility +# Copyright (c) 2017-2020 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 @@ -39,13 +39,13 @@ from ....utils.deprecation import deprecated from ... import colors from .core import (PointsBase, LabelsMixIn, ColorMixIn, YAxisMixIn, FillMixIn, LineMixIn, SymbolMixIn, ItemChangedType, - BaselineMixIn) + BaselineMixIn, HighlightedMixIn, _Style) _logger = logging.getLogger(__name__) -class CurveStyle(object): +class CurveStyle(_Style): """Object storing the style of a curve. Set a value to None to use the default @@ -153,7 +153,7 @@ class CurveStyle(object): class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, - LineMixIn, BaselineMixIn): + LineMixIn, BaselineMixIn, HighlightedMixIn): """Description of a curve""" _DEFAULT_Z_LAYER = 1 @@ -181,9 +181,8 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LabelsMixIn.__init__(self) LineMixIn.__init__(self) BaselineMixIn.__init__(self) + HighlightedMixIn.__init__(self) - self._highlightStyle = self._DEFAULT_HIGHLIGHT_STYLE - self._highlighted = False self._setBaseline(Curve._DEFAULT_BASELINE) self.sigItemChanged.connect(self.__itemChanged) @@ -214,7 +213,6 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, yaxis=self.getYAxis(), xerror=xerror, yerror=yerror, - z=self.getZValue(), fill=self.isFill(), alpha=self.getAlpha(), symbolsize=style.getSymbolSize(), @@ -229,7 +227,7 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, elif item == 1: return self.getYData(copy=False) elif item == 2: - return self.getLegend() + return self.getName() elif item == 3: info = self.getInfo(copy=False) return {} if info is None else info @@ -267,46 +265,6 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, super(Curve, self).setVisible(visible) - def isHighlighted(self): - """Returns True if curve is highlighted. - - :rtype: bool - """ - return self._highlighted - - def setHighlighted(self, highlighted): - """Set the highlight state of the curve - - :param bool highlighted: - """ - highlighted = bool(highlighted) - if highlighted != self._highlighted: - self._highlighted = highlighted - # TODO inefficient: better to use backend's setCurveColor - self._updated(ItemChangedType.HIGHLIGHTED) - - def getHighlightedStyle(self): - """Returns the highlighted style in use - - :rtype: CurveStyle - """ - return self._highlightStyle - - def setHighlightedStyle(self, style): - """Set the style to use for highlighting - - :param CurveStyle style: New style to use - """ - previous = self.getHighlightedStyle() - if style != previous: - assert isinstance(style, CurveStyle) - self._highlightStyle = style - self._updated(ItemChangedType.HIGHLIGHTED_STYLE) - - # Backward compatibility event - if previous.getColor() != style.getColor(): - self._updated(ItemChangedType.HIGHLIGHTED_COLOR) - @deprecated(replacement='Curve.getHighlightedStyle().getColor()', since_version='0.9.0') def getHighlightedColor(self): @@ -350,11 +308,11 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, symbolsize=self.getSymbolSize() if symbolsize is None else symbolsize) else: - return CurveStyle(color=self.getColor(), - linestyle=self.getLineStyle(), - linewidth=self.getLineWidth(), - symbol=self.getSymbol(), - symbolsize=self.getSymbolSize()) + return CurveStyle(color=self.getColor(), + linestyle=self.getLineStyle(), + linewidth=self.getLineWidth(), + symbol=self.getSymbol(), + symbolsize=self.getSymbolSize()) @deprecated(replacement='Curve.getCurrentStyle()', since_version='0.9.0') diff --git a/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py index 993c0f0..935f8d5 100644 --- a/silx/gui/plot/items/histogram.py +++ b/silx/gui/plot/items/histogram.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2019 European Synchrotron Radiation Facility +# Copyright (c) 2017-2020 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 @@ -60,17 +60,17 @@ def _computeEdges(x, histogramType): """ # for now we consider that the spaces between xs are constant edges = x.copy() - if histogramType is 'left': + if histogramType == 'left': width = 1 if len(x) > 1: width = x[1] - x[0] edges = numpy.append(x[0] - width, edges) - if histogramType is 'center': + if histogramType == 'center': edges = _computeEdges(edges, 'right') widths = (edges[1:] - edges[0:-1]) / 2.0 widths = numpy.append(widths, widths[-1]) edges = edges - widths - if histogramType is 'right': + if histogramType == 'right': width = 1 if len(x) > 1: width = x[-1] - x[-2] @@ -170,7 +170,6 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, yaxis=self.getYAxis(), xerror=None, yerror=None, - z=self.getZValue(), fill=self.isFill(), alpha=self.getAlpha(), baseline=baseline, @@ -213,6 +212,8 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, numpy.nanmax(values)) else: # No log scale on y axis, include 0 in bounds + if numpy.all(numpy.isnan(values)): + return None return (numpy.nanmin(edges), numpy.nanmax(edges), min(0, numpy.nanmin(values)), @@ -236,7 +237,7 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, :param copy: True (Default) to get a copy, False to use internal representation (do not modify!) - :returns: The bin edges of the histogram + :returns: The values of the histogram :rtype: numpy.ndarray """ return numpy.array(self._histogram, copy=copy) @@ -298,6 +299,7 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, # Check that bin edges are monotonic edgesDiff = numpy.diff(edges) + edgesDiff = edgesDiff[numpy.logical_not(numpy.isnan(edgesDiff))] assert numpy.all(edgesDiff >= 0) or numpy.all(edgesDiff <= 0) # manage baseline if (isinstance(baseline, abc.Iterable)): @@ -342,11 +344,11 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, """ # for now we consider that the spaces between xs are constant edges = x.copy() - if histogramType is 'left': + if histogramType == 'left': return edges[1:] - if histogramType is 'center': + if histogramType == 'center': edges = (edges[1:] + edges[:-1]) / 2.0 - if histogramType is 'right': + if histogramType == 'right': width = 1 if len(x) > 1: width = x[-1] + x[-2] diff --git a/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py index 44cb70f..91c051d 100644 --- a/silx/gui/plot/items/image.py +++ b/silx/gui/plot/items/image.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2019 European Synchrotron Radiation Facility +# Copyright (c) 2017-2020 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 @@ -42,7 +42,6 @@ import numpy from ....utils.proxy import docstring from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, AlphaMixIn, ItemChangedType) -from ._pick import PickingResult _logger = logging.getLogger(__name__) @@ -108,7 +107,7 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn): elif item == 0: return self.getData(copy=False) elif item == 1: - return self.getLegend() + return self.getName() elif item == 2: info = self.getInfo(copy=False) return {} if info is None else info @@ -143,25 +142,6 @@ 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.""" @@ -301,11 +281,16 @@ class ImageData(ImageBase, ColormapMixIn): if dataToUse.size == 0: return None # No data to display + colormap = self.getColormap() + if colormap.isAutoscale(): + # Avoid backend to compute autoscale: use item cache + colormap = colormap.copy() + colormap.setVRange(*colormap.getColormapRange(self)) + return backend.addImage(dataToUse, origin=self.getOrigin(), scale=self.getScale(), - z=self.getZValue(), - colormap=self.getColormap(), + colormap=colormap, alpha=self.getAlpha()) def __getitem__(self, item): @@ -331,7 +316,7 @@ class ImageData(ImageBase, ColormapMixIn): else: # Apply colormap, in this case an new array is always returned colormap = self.getColormap() - image = colormap.applyToData(self.getData(copy=False)) + image = colormap.applyToData(self) alphaImage = self.getAlphaData(copy=False) if alphaImage is not None: # Apply transparency @@ -386,6 +371,7 @@ class ImageData(ImageBase, ColormapMixIn): 'Converting complex image to absolute value to plot it.') data = numpy.absolute(data) self._data = data + self._setColormappedData(data, copy=False) if alternative is not None: alternative = numpy.array(alternative, copy=copy) @@ -434,7 +420,6 @@ class ImageRgba(ImageBase): return backend.addImage(data, origin=self.getOrigin(), scale=self.getScale(), - z=self.getZValue(), colormap=None, alpha=self.getAlpha()) @@ -473,3 +458,82 @@ class MaskImageData(ImageData): internal silx widgets. """ pass + + +class ImageStack(ImageData): + """Item to store a stack of images and to show it in the plot as one + of the images of the stack. + + The stack is a 3D array ordered this way: `frame id, y, x`. + So the first image of the stack can be reached this way: `stack[0, :, :]` + """ + + def __init__(self): + ImageData.__init__(self) + self.__stack = None + """A 3D numpy array (or a mimic one, see ListOfImages)""" + self.__stackPosition = None + """Displayed position in the cube""" + + def setStackData(self, stack, position=None, copy=True): + """Set the stack data + + :param stack: A 3D numpy array like + :param int position: The position of the displayed image in the stack + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + """ + if self.__stack is stack: + return + if copy: + stack = numpy.array(stack) + assert stack.ndim == 3 + self.__stack = stack + if position is not None: + self.__stackPosition = position + if self.__stackPosition is None: + self.__stackPosition = 0 + self.__updateDisplayedData() + + def getStackData(self, copy=True): + """Get the stored stack array. + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: A 3D numpy array, or numpy array like + """ + if copy: + return numpy.array(self.__stack) + else: + return self.__stack + + def setStackPosition(self, pos): + """Set the displayed position on the stack. + + This function will clamp the stack position according to + the real size of the first axis of the stack. + + :param int pos: A position on the first axis of the stack. + """ + if self.__stackPosition == pos: + return + self.__stackPosition = pos + self.__updateDisplayedData() + + def getStackPosition(self): + """Get the displayed position of the stack. + + :rtype: int + """ + return self.__stackPosition + + def __updateDisplayedData(self): + """Update the displayed frame whenever the stack or the stack + position are updated.""" + if self.__stack is None or self.__stackPosition is None: + empty = numpy.array([]).reshape(0, 0) + self.setData(empty, copy=False) + return + size = len(self.__stack) + self.__stackPosition = numpy.clip(self.__stackPosition, 0, size) + self.setData(self.__stack[self.__stackPosition], copy=False) diff --git a/silx/gui/plot/items/marker.py b/silx/gui/plot/items/marker.py index f5a1689..50d070c 100755 --- a/silx/gui/plot/items/marker.py +++ b/silx/gui/plot/items/marker.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2019 European Synchrotron Radiation Facility +# Copyright (c) 2017-2020 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 @@ -35,7 +35,7 @@ import logging from ....utils.proxy import docstring from .core import (Item, DraggableMixIn, ColorMixIn, LineMixIn, SymbolMixIn, ItemChangedType, YAxisMixIn) - +from silx.gui import qt _logger = logging.getLogger(__name__) @@ -43,6 +43,11 @@ _logger = logging.getLogger(__name__) class MarkerBase(Item, DraggableMixIn, ColorMixIn, YAxisMixIn): """Base class for markers""" + sigDragStarted = qt.Signal() + """Signal emitted when the marker is pressed""" + sigDragFinished = qt.Signal() + """Signal emitted when the marker is released""" + _DEFAULT_COLOR = (0., 0., 0., 1.) """Default color of the markers""" @@ -56,6 +61,7 @@ class MarkerBase(Item, DraggableMixIn, ColorMixIn, YAxisMixIn): self._x = None self._y = None self._constraint = self._defaultConstraint + self.__isBeingDragged = False def _addRendererCall(self, backend, symbol=None, linestyle='-', linewidth=1): @@ -167,6 +173,18 @@ class MarkerBase(Item, DraggableMixIn, ColorMixIn, YAxisMixIn): """Default constraint not doing anything""" return args + def _startDrag(self): + self.__isBeingDragged = True + self.sigDragStarted.emit() + + def _endDrag(self): + self.__isBeingDragged = False + self.sigDragFinished.emit() + + def isBeingDragged(self) -> bool: + """Returns whether the marker is currently dragged by the user.""" + return self.__isBeingDragged + class Marker(MarkerBase, SymbolMixIn): """Description of a marker""" diff --git a/silx/gui/plot/items/roi.py b/silx/gui/plot/items/roi.py index dcad943..ff73fe6 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-2019 European Synchrotron Radiation Facility +# Copyright (c) 2018-2020 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 @@ -23,6 +23,10 @@ # # ###########################################################################*/ """This module provides ROI item for the :class:`~silx.gui.plot.PlotWidget`. + +.. inheritance-diagram:: + silx.gui.plot.items.roi + :parts: 1 """ __authors__ = ["T. Vincent"] @@ -30,18 +34,21 @@ __license__ = "MIT" __date__ = "28/06/2018" -import functools -import itertools import logging -import collections import numpy +import weakref +from silx.image.shapes import Polygon from ....utils.weakref import WeakList from ... import qt +from ... import utils from .. import items +from ..items import core from ...colors import rgba import silx.utils.deprecation -from silx.utils.proxy import docstring +from silx.image._boundingbox import _BoundingBox +from ....utils.proxy import docstring +from ..utils.intersections import segments_intersection logger = logging.getLogger(__name__) @@ -54,6 +61,9 @@ class _RegionOfInterestBase(qt.QObject): :param str name: The name of the ROI """ + sigAboutToBeRemoved = qt.Signal() + """Signal emitted just before this ROI is removed from its manager.""" + sigItemChanged = qt.Signal(object) """Signal emitted when item has changed. @@ -61,9 +71,9 @@ class _RegionOfInterestBase(qt.QObject): See :class:`ItemChangedType` for flags description. """ - def __init__(self, parent=None, name=''): - qt.QObject.__init__(self) - self.__name = str(name) + def __init__(self, parent=None): + qt.QObject.__init__(self, parent=parent) + self.__name = '' def getName(self): """Returns the name of the ROI @@ -81,18 +91,44 @@ class _RegionOfInterestBase(qt.QObject): name = str(name) if self.__name != name: self.__name = name - self.sigItemChanged.emit(items.ItemChangedType.NAME) + self._updated(items.ItemChangedType.NAME) + + def _updated(self, event=None, checkVisibility=True): + """Implement Item mix-in update method by updating the plot items + + See :class:`~silx.gui.plot.items.Item._updated` + """ + self.sigItemChanged.emit(event) + def contains(self, position): + """Returns True if the `position` is in this ROI. -class RegionOfInterest(_RegionOfInterestBase): + :param tuple[float,float] position: position to check + :return: True if the value / point is consider to be in the region of + interest. + :rtype: bool + """ + raise NotImplementedError("Base class") + + +class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): """Object describing a region of interest in a plot. :param QObject parent: The RegionOfInterestManager that created this object """ - _kind = None - """Label for this kind of ROI. + _DEFAULT_LINEWIDTH = 1. + """Default line width of the curve""" + + _DEFAULT_LINESTYLE = '-' + """Default line style of the curve""" + + _DEFAULT_HIGHLIGHT_STYLE = items.CurveStyle(linewidth=2) + """Default highlight style of the item""" + + ICON, NAME, SHORT_NAME = None, None, None + """Metadata to describe the ROI in labels, tooltips and widgets Should be set by inherited classes to custom the ROI manager widget. """ @@ -100,50 +136,125 @@ class RegionOfInterest(_RegionOfInterestBase): sigRegionChanged = qt.Signal() """Signal emitted everytime the shape or position of the ROI changes""" + sigEditingStarted = qt.Signal() + """Signal emitted when the user start editing the roi""" + + sigEditingFinished = qt.Signal() + """Signal emitted when the region edition is finished. During edition + sigEditionChanged will be emitted several times and + sigRegionEditionFinished only at end""" + def __init__(self, parent=None): - # Avoid circular dependancy + # Avoid circular dependency from ..tools import roi as roi_tools assert parent is None or isinstance(parent, roi_tools.RegionOfInterestManager) - _RegionOfInterestBase.__init__(self, parent, '') + _RegionOfInterestBase.__init__(self, parent) + core.HighlightedMixIn.__init__(self) self._color = rgba('red') - self._items = WeakList() - self._editAnchors = WeakList() - self._points = None - self._labelItem = None self._editable = False + self._selectable = False + self._focusProxy = None 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 - self._removePlotItems() + self._child = WeakList() + + def _connectToPlot(self, plot): + """Called after connection to a plot""" + for item in self.getItems(): + # This hack is needed to avoid reentrant call from _disconnectFromPlot + # to the ROI manager. It also speed up the item tests in _itemRemoved + item._roiGroup = True + plot.addItem(item) + + def _disconnectFromPlot(self, plot): + """Called before disconnection from a plot""" + for item in self.getItems(): + # The item could be already be removed by the plot + if item.getPlot() is not None: + del item._roiGroup + plot.removeItem(item) + + def _setItemName(self, item): + """Helper to generate a unique id to a plot item""" + legend = "__ROI-%d__%d" % (id(self), id(item)) + item.setName(legend) def setParent(self, parent): """Set the parent of the RegionOfInterest - :param Union[None,RegionOfInterestManager] parent: + :param Union[None,RegionOfInterestManager] parent: The new parent """ - # Avoid circular dependancy + # Avoid circular dependency from ..tools import roi as roi_tools if (parent is not None and not isinstance(parent, roi_tools.RegionOfInterestManager)): raise ValueError('Unsupported parent') - self._removePlotItems() + previousParent = self.parent() + if previousParent is not None: + previousPlot = previousParent.parent() + if previousPlot is not None: + self._disconnectFromPlot(previousPlot) super(RegionOfInterest, self).setParent(parent) - self._createPlotItems() + if parent is not None: + plot = parent.parent() + if plot is not None: + self._connectToPlot(plot) + + def addItem(self, item): + """Add an item to the set of this ROI children. + + This item will be added and removed to the plot used by the ROI. + + If the ROI is already part of a plot, the item will also be added to + the plot. + + It the item do not have a name already, a unique one is generated to + avoid item collision in the plot. + + :param silx.gui.plot.items.Item item: A plot item + """ + assert item is not None + self._child.append(item) + if item.getName() == '': + self._setItemName(item) + manager = self.parent() + if manager is not None: + plot = manager.parent() + if plot is not None: + item._roiGroup = True + plot.addItem(item) + + def removeItem(self, item): + """Remove an item from this ROI children. + + If the item is part of a plot it will be removed too. + + :param silx.gui.plot.items.Item item: A plot item + """ + assert item is not None + self._child.remove(item) + plot = item.getPlot() + if plot is not None: + del item._roiGroup + plot.removeItem(item) + + def getItems(self): + """Returns the list of PlotWidget items of this RegionOfInterest. + + :rtype: List[~silx.gui.plot.items.Item] + """ + return tuple(self._child) @classmethod - def _getKind(cls): + def _getShortName(cls): """Return an human readable kind of ROI :rtype: str """ - return cls._kind + if hasattr(cls, "SHORT_NAME"): + name = cls.SHORT_NAME + if name is None: + name = cls.__name__ + return name def getColor(self): """Returns the color of this ROI @@ -152,14 +263,6 @@ class RegionOfInterest(_RegionOfInterestBase): """ return qt.QColor.fromRgbF(*self._color) - def _getAnchorColor(self, color): - """Returns the anchor color from the base ROI color - - :param Union[numpy.array,Tuple,List]: color - :rtype: Union[numpy.array,Tuple,List] - """ - return color[:3] + (0.5,) - def setColor(self, color): """Set the color used for this ROI. @@ -169,22 +272,7 @@ class RegionOfInterest(_RegionOfInterestBase): color = rgba(color) if color != self._color: self._color = color - - # Update color of shape items in the plot - rgbaColor = rgba(color) - for item in list(self._items): - if isinstance(item, items.ColorMixIn): - item.setColor(rgbaColor) - item = self._getLabelItem() - if isinstance(item, items.ColorMixIn): - item.setColor(rgbaColor) - - rgbaColor = self._getAnchorColor(rgbaColor) - for item in list(self._editAnchors): - if isinstance(item, items.ColorMixIn): - item.setColor(rgbaColor) - - self.sigItemChanged.emit(items.ItemChangedType.COLOR) + self._updated(items.ItemChangedType.COLOR) @silx.utils.deprecation.deprecated(reason='API modification', replacement='getName()', @@ -222,10 +310,50 @@ class RegionOfInterest(_RegionOfInterestBase): editable = bool(editable) if self._editable != editable: self._editable = editable - # Recreate plot items - # This can be avoided once marker.setDraggable is public - self._createPlotItems() - self.sigItemChanged.emit(items.ItemChangedType.EDITABLE) + self._updated(items.ItemChangedType.EDITABLE) + + def isSelectable(self): + """Returns whether the ROI is selectable by the user or not. + + :rtype: bool + """ + return self._selectable + + def setSelectable(self, selectable): + """Set whether the ROI can be selected interactively. + + :param bool selectable: True to allow selection by the user, + False to disable. + """ + selectable = bool(selectable) + if self._selectable != selectable: + self._selectable = selectable + self._updated(items.ItemChangedType.SELECTABLE) + + def getFocusProxy(self): + """Returns the ROI which have to be selected when this ROI is selected, + else None if no proxy specified. + + :rtype: RegionOfInterest + """ + proxy = self._focusProxy + if proxy is None: + return None + proxy = proxy() + if proxy is None: + self._focusProxy = None + return proxy + + def setFocusProxy(self, roi): + """Set the real ROI which will be selected when this ROI is selected, + else None to remove the proxy already specified. + + :param RegionOfInterest roi: A ROI + """ + if roi is not None: + self._focusProxy = weakref.ref(roi) + else: + self._focusProxy = None def isVisible(self): """Returns whether the ROI is visible in the plot. @@ -249,21 +377,7 @@ class RegionOfInterest(_RegionOfInterestBase): visible = bool(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. - - It returns an empty tuple if there is currently no ROI. - - :return: Array of (x, y) position in plot coordinates - :rtype: numpy.ndarray - """ - return None if self._points is None else numpy.array(self._points) + self._updated(items.ItemChangedType.VISIBLE) @classmethod def showFirstInteractionShape(cls): @@ -272,7 +386,7 @@ class RegionOfInterest(_RegionOfInterestBase): :rtype: bool """ - return True + return False @classmethod def getFirstInteractionShape(cls): @@ -291,226 +405,369 @@ class RegionOfInterest(_RegionOfInterestBase): This interaction is constrained by the plot API and only supports few shapes. """ - points = self._createControlPointsFromFirstShape(points) - self._setControlPoints(points) - - def _createControlPointsFromFirstShape(self, points): - """Returns the list of control points from the very first shape - provided. + raise NotImplementedError() - This shape is provided by the plot interaction and constained by the - class of the ROI itself. + def creationStarted(self): + """"Called when the ROI creation interaction was started. """ - return points + pass - def _setControlPoints(self, points): - """Set this ROI control points. + @docstring(_RegionOfInterestBase) + def contains(self, position): + raise NotImplementedError("Base class") - :param points: Iterable of (x, y) control points + def creationFinalized(self): + """"Called when the ROI creation interaction was finalized. """ - points = numpy.array(points) + pass - nbPointsChanged = (self._points is None or - points.shape != self._points.shape) + def _updateItemProperty(self, event, source, destination): + """Update the item property of a destination from an item source. - if nbPointsChanged or not numpy.all(numpy.equal(points, self._points)): - self._points = points - - self._updateShape() - if self._items and not nbPointsChanged: # Update plot items - item = self._getLabelItem() - if item is not None: - markerPos = self._getLabelPosition() - item.setPosition(*markerPos) - - if self._editAnchors: # Update anchors - for anchor, point in zip(self._editAnchors, points): - old = anchor.blockSignals(True) - anchor.setPosition(*point) - anchor.blockSignals(old) - - else: # No items or new point added - # re-create plot items - self._createPlotItems() - - self.sigRegionChanged.emit() - - def _updateShape(self): - """Called when shape must be updated. - - Must be reimplemented if a shape item have to be updated. + :param items.ItemChangedType event: Property type to update + :param silx.gui.plot.items.Item source: The reference for the data + :param event Union[Item,List[Item]] destination: The item(s) to update """ - return - - def _getLabelPosition(self): - """Compute position of the label + if not isinstance(destination, (list, tuple)): + destination = [destination] + if event == items.ItemChangedType.NAME: + value = source.getName() + for d in destination: + d.setName(value) + elif event == items.ItemChangedType.EDITABLE: + value = source.isEditable() + for d in destination: + d.setEditable(value) + elif event == items.ItemChangedType.SELECTABLE: + value = source.isSelectable() + for d in destination: + d._setSelectable(value) + elif event == items.ItemChangedType.COLOR: + value = rgba(source.getColor()) + for d in destination: + d.setColor(value) + elif event == items.ItemChangedType.LINE_STYLE: + value = self.getLineStyle() + for d in destination: + d.setLineStyle(value) + elif event == items.ItemChangedType.LINE_WIDTH: + value = self.getLineWidth() + for d in destination: + d.setLineWidth(value) + elif event == items.ItemChangedType.SYMBOL: + value = self.getSymbol() + for d in destination: + d.setSymbol(value) + elif event == items.ItemChangedType.SYMBOL_SIZE: + value = self.getSymbolSize() + for d in destination: + d.setSymbolSize(value) + elif event == items.ItemChangedType.VISIBLE: + value = self.isVisible() + for d in destination: + d.setVisible(value) + else: + assert False - :return: (x, y) position of the marker + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.HIGHLIGHTED: + style = self.getCurrentStyle() + self._updatedStyle(event, style) + else: + hilighted = self.isHighlighted() + if hilighted: + if event == items.ItemChangedType.HIGHLIGHTED_STYLE: + style = self.getCurrentStyle() + self._updatedStyle(event, style) + else: + if event in [items.ItemChangedType.COLOR, + items.ItemChangedType.LINE_STYLE, + items.ItemChangedType.LINE_WIDTH, + items.ItemChangedType.SYMBOL, + items.ItemChangedType.SYMBOL_SIZE]: + style = self.getCurrentStyle() + self._updatedStyle(event, style) + super(RegionOfInterest, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + """Called when the current displayed style of the ROI was changed. + + :param event: The event responsible of the change of the style + :param items.CurveStyle style: The current style """ - return None + pass + + def getCurrentStyle(self): + """Returns the current curve style. - def _createPlotItems(self): - """Create items displaying the ROI in the plot. + Curve style depends on curve highlighting - It first removes any existing plot items. + :rtype: CurveStyle """ - roiManager = self.parent() - if roiManager is None: - return - plot = roiManager.parent() + baseColor = rgba(self.getColor()) + if isinstance(self, core.LineMixIn): + baseLinestyle = self.getLineStyle() + baseLinewidth = self.getLineWidth() + else: + baseLinestyle = self._DEFAULT_LINESTYLE + baseLinewidth = self._DEFAULT_LINEWIDTH + if isinstance(self, core.SymbolMixIn): + baseSymbol = self.getSymbol() + baseSymbolsize = self.getSymbolSize() + else: + baseSymbol = 'o' + baseSymbolsize = 1 + + if self.isHighlighted(): + style = self.getHighlightedStyle() + color = style.getColor() + linestyle = style.getLineStyle() + linewidth = style.getLineWidth() + symbol = style.getSymbol() + symbolsize = style.getSymbolSize() + + return items.CurveStyle( + color=baseColor if color is None else color, + linestyle=baseLinestyle if linestyle is None else linestyle, + linewidth=baseLinewidth if linewidth is None else linewidth, + symbol=baseSymbol if symbol is None else symbol, + symbolsize=baseSymbolsize if symbolsize is None else symbolsize) + else: + return items.CurveStyle(color=baseColor, + linestyle=baseLinestyle, + linewidth=baseLinewidth, + symbol=baseSymbol, + symbolsize=baseSymbolsize) - self._removePlotItems() + def _editingStarted(self): + assert self._editable is True + self.sigEditingStarted.emit() - legendPrefix = "__RegionOfInterest-%d__" % id(self) - itemIndex = 0 + def _editingFinished(self): + self.sigEditingFinished.emit() - controlPoints = self._getControlPoints() - if self._labelItem is None: - self._labelItem = self._createLabelItem() - if self._labelItem is not None: - self._labelItem._setLegend(legendPrefix + "label") - plot._add(self._labelItem) - self._labelItem.setVisible(self.isVisible()) +class HandleBasedROI(RegionOfInterest): + """Manage a ROI based on a set of handles""" - self._items = WeakList() - plotItems = self._createShapeItems(controlPoints) - for item in plotItems: - item._setLegend(legendPrefix + str(itemIndex)) - plot._add(item) - item.setVisible(self.isVisible()) - self._items.append(item) - itemIndex += 1 + def __init__(self, parent=None): + RegionOfInterest.__init__(self, parent=parent) + self._handles = [] + self._posOrigin = None + self._posPrevious = None - self._editAnchors = WeakList() - if self.isEditable(): - plotItems = self._createAnchorItems(controlPoints) - color = rgba(self.getColor()) - color = self._getAnchorColor(color) - for index, item in enumerate(plotItems): - item._setLegend(legendPrefix + str(itemIndex)) - item.setColor(color) - item.setVisible(self.isVisible()) - plot._add(item) - item.sigItemChanged.connect(functools.partial( - self._controlPointAnchorChanged, index)) - self._editAnchors.append(item) - itemIndex += 1 + def addUserHandle(self, item=None): + """ + Add a new free handle to the ROI. - def _updateLabelItem(self, label): - """Update the marker displaying the label. + This handle do nothing. It have to be managed by the ROI + implementing this class. - Inherite this method to custom the way the ROI display the label. + :param Union[None,silx.gui.plot.items.Marker] item: The new marker to + add, else None to create a default marker. + :rtype: silx.gui.plot.items.Marker + """ + return self.addHandle(item, role="user") - :param str label: The new label to use + def addLabelHandle(self, item=None): """ - item = self._getLabelItem() - if item is not None: - item.setText(label) + Add a new label handle to the ROI. - def _createLabelItem(self): - """Returns a created marker which will be used to dipslay the label of - this ROI. + This handle is not draggable nor selectable. - Inherite this method to return nothing if no new items have to be - created, or your own marker. + It is displayed without symbol, but it is always visible anyway + the ROI is editable, in order to display text. - :rtype: Union[None,Marker] + :param Union[None,silx.gui.plot.items.Marker] item: The new marker to + add, else None to create a default marker. + :rtype: silx.gui.plot.items.Marker """ - # Add label marker - markerPos = self._getLabelPosition() - marker = items.Marker() - marker.setPosition(*markerPos) - marker.setText(self.getName()) - marker.setColor(rgba(self.getColor())) - marker.setSymbol('') - marker._setDraggable(False) - return marker + return self.addHandle(item, role="label") - def _getLabelItem(self): - """Returns the marker displaying the label of this ROI. - - Inherite this method to choose your own item. In case this item is also - a control point. + def addTranslateHandle(self, item=None): """ - return self._labelItem + Add a new translate handle to the ROI. - def _createShapeItems(self, points): - """Create shape items from the current control points. + Dragging translate handles affect the position position of the ROI + but not the shape itself. - :rtype: List[PlotItem] + :param Union[None,silx.gui.plot.items.Marker] item: The new marker to + add, else None to create a default marker. + :rtype: silx.gui.plot.items.Marker """ - return [] - - def _createAnchorItems(self, points): - """Create anchor items from the current control points. + return self.addHandle(item, role="translate") - :rtype: List[Marker] + def addHandle(self, item=None, role="default"): """ - return [] + Add a new handle to the ROI. - def _controlPointAnchorChanged(self, index, event): - """Handle update of position of an edition anchor + Dragging handles while affect the position or the shape of the + ROI. - :param int index: Index of the anchor - :param ItemChangedType event: Event type + :param Union[None,silx.gui.plot.items.Marker] item: The new marker to + add, else None to create a default marker. + :rtype: silx.gui.plot.items.Marker + """ + if item is None: + item = items.Marker() + color = rgba(self.getColor()) + color = self._computeHandleColor(color) + item.setColor(color) + if role == "default": + item.setSymbol("s") + elif role == "user": + pass + elif role == "translate": + item.setSymbol("+") + elif role == "label": + item.setSymbol("") + + if role == "user": + pass + elif role == "label": + item._setSelectable(False) + item._setDraggable(False) + item.setVisible(True) + else: + self.__updateEditable(item, self.isEditable(), remove=False) + item._setSelectable(False) + + self._handles.append((item, role)) + self.addItem(item) + return item + + def removeHandle(self, handle): + data = [d for d in self._handles if d[0] is handle][0] + self._handles.remove(data) + role = data[1] + if role not in ["user", "label"]: + if self.isEditable(): + self.__updateEditable(handle, False) + self.removeItem(handle) + + def getHandles(self): + """Returns the list of handles of this HandleBasedROI. + + :rtype: List[~silx.gui.plot.items.Marker] """ - if event == items.ItemChangedType.POSITION: - anchor = self._editAnchors[index] - previous = self._points[index].copy() - current = anchor.getPosition() - self._controlPointAnchorPositionChanged(index, current, previous) + return tuple(data[0] for data in self._handles) - def _controlPointAnchorPositionChanged(self, index, current, previous): - """Called when an anchor is manually edited. + def _updated(self, event=None, checkVisibility=True): + """Implement Item mix-in update method by updating the plot items - This function have to be inherited to change the behaviours of the - control points. This function have to call :meth:`_getControlPoints` to - reach the previous state of the control points. Updated the positions - of the changed control points. Then call :meth:`_setControlPoints` to - update the anchors and send signals. + See :class:`~silx.gui.plot.items.Item._updated` """ - points = self._getControlPoints() - points[index] = current - self._setControlPoints(points) + if event == items.ItemChangedType.NAME: + self._updateText(self.getName()) + elif event == items.ItemChangedType.VISIBLE: + for item, role in self._handles: + visible = self.isVisible() + editionVisible = visible and self.isEditable() + if role not in ["user", "label"]: + item.setVisible(editionVisible) + else: + item.setVisible(visible) + elif event == items.ItemChangedType.EDITABLE: + for item, role in self._handles: + editable = self.isEditable() + if role not in ["user", "label"]: + self.__updateEditable(item, editable) + super(HandleBasedROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + super(HandleBasedROI, self)._updatedStyle(event, style) + + # Update color of shape items in the plot + color = rgba(self.getColor()) + handleColor = self._computeHandleColor(color) + for item, role in self._handles: + if role == 'user': + pass + elif role == 'label': + item.setColor(color) + else: + item.setColor(handleColor) + + def __updateEditable(self, handle, editable, remove=True): + # NOTE: visibility change emit a position update event + handle.setVisible(editable and self.isVisible()) + handle._setDraggable(editable) + if editable: + handle.sigDragStarted.connect(self._handleEditingStarted) + handle.sigItemChanged.connect(self._handleEditingUpdated) + handle.sigDragFinished.connect(self._handleEditingFinished) + else: + if remove: + handle.sigDragStarted.disconnect(self._handleEditingStarted) + handle.sigItemChanged.disconnect(self._handleEditingUpdated) + handle.sigDragFinished.disconnect(self._handleEditingFinished) + + def _handleEditingStarted(self): + super(HandleBasedROI, self)._editingStarted() + handle = self.sender() + self._posOrigin = numpy.array(handle.getPosition()) + self._posPrevious = numpy.array(self._posOrigin) + self.handleDragStarted(handle, self._posOrigin) + + def _handleEditingUpdated(self): + if self._posOrigin is None: + # Avoid to handle events when visibility change + return + handle = self.sender() + current = numpy.array(handle.getPosition()) + self.handleDragUpdated(handle, self._posOrigin, self._posPrevious, current) + self._posPrevious = current + + def _handleEditingFinished(self): + handle = self.sender() + current = numpy.array(handle.getPosition()) + self.handleDragFinished(handle, self._posOrigin, current) + self._posPrevious = None + self._posOrigin = None + super(HandleBasedROI, self)._editingFinished() + + def isHandleBeingDragged(self): + """Returns True if one of the handles is currently being dragged. - def _removePlotItems(self): - """Remove items from their plot.""" - for item in itertools.chain(list(self._items), - list(self._editAnchors)): + :rtype: bool + """ + return self._posOrigin is not None - plot = item.getPlot() - if plot is not None: - plot._remove(item) - self._items = WeakList() - self._editAnchors = WeakList() + def handleDragStarted(self, handle, origin): + """Called when an handler drag started""" + pass - if self._labelItem is not None: - item = self._labelItem - plot = item.getPlot() - if plot is not None: - plot._remove(item) - self._labelItem = None + def handleDragUpdated(self, handle, origin, previous, current): + """Called when an handle drag position changed""" + pass - def _updated(self, event=None, checkVisibility=True): - """Implement Item mix-in update method by updating the plot items + def handleDragFinished(self, handle, origin, current): + """Called when an handle drag finished""" + pass - See :class:`~silx.gui.plot.items.Item._updated` + def _computeHandleColor(self, color): + """Returns the anchor color from the base ROI color + + :param Union[numpy.array,Tuple,List]: color + :rtype: Union[numpy.array,Tuple,List] """ - self._createPlotItems() + return color[:3] + (0.5,) - def __str__(self): - """Returns parameters of the ROI as a string.""" - points = self._getControlPoints() - params = '; '.join('(%f; %f)' % (pt[0], pt[1]) for pt in points) - return "%s(%s)" % (self.__class__.__name__, params) + def _updateText(self, text): + """Update the text displayed by this ROI + + :param str text: A text + """ + pass class PointROI(RegionOfInterest, items.SymbolMixIn): """A ROI identifying a point in a 2D plot.""" - _kind = "Point" - """Label for this kind of ROI""" + ICON = 'add-shape-point' + NAME = 'point markers' + SHORT_NAME = "point" + """Metadata for this kind of ROI""" _plotShape = "point" """Plot shape which is used for the first interaction""" @@ -522,82 +779,186 @@ class PointROI(RegionOfInterest, items.SymbolMixIn): """ def __init__(self, parent=None): - items.SymbolMixIn.__init__(self) RegionOfInterest.__init__(self, parent=parent) + items.SymbolMixIn.__init__(self) + self._marker = items.Marker() + self._marker.sigItemChanged.connect(self._pointPositionChanged) + self._marker.setSymbol(self._DEFAULT_SYMBOL) + self._marker.sigDragStarted.connect(self._editingStarted) + self._marker.sigDragFinished.connect(self._editingFinished) + self.addItem(self._marker) + + def setFirstShapePoints(self, points): + self.setPosition(points[0]) + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.NAME: + label = self.getName() + self._marker.setText(label) + elif event == items.ItemChangedType.EDITABLE: + self._marker._setDraggable(self.isEditable()) + elif event in [items.ItemChangedType.VISIBLE, + items.ItemChangedType.SELECTABLE]: + self._updateItemProperty(event, self, self._marker) + super(PointROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + self._marker.setColor(style.getColor()) def getPosition(self): """Returns the position of this ROI :rtype: numpy.ndarray """ - return self._points[0].copy() + return self._marker.getPosition() def setPosition(self, pos): """Set the position of this ROI :param numpy.ndarray pos: 2d-coordinate of this point """ - controlPoints = numpy.array([pos]) - self._setControlPoints(controlPoints) - - def _createLabelItem(self): - return None + self._marker.setPosition(*pos) - def _updateLabelItem(self, label): - self._items[0].setText(label) - - def _updateShape(self): - if len(self._items) > 0: - controlPoints = self._getControlPoints() - item = self._items[0] - item.setPosition(*controlPoints[0]) + @docstring(_RegionOfInterestBase) + def contains(self, position): + raise NotImplementedError('Base class') - def __positionChanged(self, event): + def _pointPositionChanged(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): - marker = items.Marker() - marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getName()) - marker.setSymbol(self.getSymbol()) - marker.setSymbolSize(self.getSymbolSize()) - marker.setColor(rgba(self.getColor())) - marker._setDraggable(self.isEditable()) - if self.isEditable(): - marker.sigItemChanged.connect(self.__positionChanged) - return [marker] + self.sigRegionChanged.emit() def __str__(self): - points = self._getControlPoints() - params = '%f %f' % (points[0, 0], points[0, 1]) + params = '%f %f' % self.getPosition() return "%s(%s)" % (self.__class__.__name__, params) -class LineROI(RegionOfInterest, items.LineMixIn): +class CrossROI(HandleBasedROI, items.LineMixIn): + """A ROI identifying a point in a 2D plot and displayed as a cross + """ + + ICON = 'add-shape-cross' + NAME = 'cross marker' + SHORT_NAME = "cross" + """Metadata for this kind of ROI""" + + _plotShape = "point" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + HandleBasedROI.__init__(self, parent=parent) + items.LineMixIn.__init__(self) + self._handle = self.addHandle() + self._handle.sigItemChanged.connect(self._handlePositionChanged) + self._handleLabel = self.addLabelHandle() + self._vmarker = self.addUserHandle(items.YMarker()) + self._vmarker._setSelectable(False) + self._vmarker._setDraggable(False) + self._vmarker.setPosition(*self.getPosition()) + self._hmarker = self.addUserHandle(items.XMarker()) + self._hmarker._setSelectable(False) + self._hmarker._setDraggable(False) + self._hmarker.setPosition(*self.getPosition()) + + def _updated(self, event=None, checkVisibility=True): + if event in [items.ItemChangedType.VISIBLE]: + markers = (self._vmarker, self._hmarker) + self._updateItemProperty(event, self, markers) + super(CrossROI, self)._updated(event, checkVisibility) + + def _updateText(self, text): + self._handleLabel.setText(text) + + def _updatedStyle(self, event, style): + super(CrossROI, self)._updatedStyle(event, style) + for marker in [self._vmarker, self._hmarker]: + marker.setColor(style.getColor()) + marker.setLineStyle(style.getLineStyle()) + marker.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + pos = points[0] + self.setPosition(pos) + + def getPosition(self): + """Returns the position of this ROI + + :rtype: numpy.ndarray + """ + return self._handle.getPosition() + + def setPosition(self, pos): + """Set the position of this ROI + + :param numpy.ndarray pos: 2d-coordinate of this point + """ + self._handle.setPosition(*pos) + + def _handlePositionChanged(self, event): + """Handle center marker position updates""" + if event is items.ItemChangedType.POSITION: + position = self.getPosition() + self._handleLabel.setPosition(*position) + self._vmarker.setPosition(*position) + self._hmarker.setPosition(*position) + self.sigRegionChanged.emit() + + @docstring(HandleBasedROI) + def contains(self, position): + roiPos = self.getPosition() + return position[0] == roiPos[0] or position[1] == roiPos[1] + + +class LineROI(HandleBasedROI, items.LineMixIn): """A ROI identifying a line in a 2D plot. This ROI provides 1 anchor for each boundary of the line, plus an center in the center to translate the full ROI. """ - _kind = "Line" - """Label for this kind of ROI""" + ICON = 'add-shape-diagonal' + NAME = 'line ROI' + SHORT_NAME = "line" + """Metadata for this kind of ROI""" _plotShape = "line" """Plot shape which is used for the first interaction""" def __init__(self, parent=None): + HandleBasedROI.__init__(self, parent=parent) items.LineMixIn.__init__(self) - RegionOfInterest.__init__(self, parent=parent) + self._handleStart = self.addHandle() + self._handleEnd = self.addHandle() + self._handleCenter = self.addTranslateHandle() + self._handleLabel = self.addLabelHandle() + + shape = items.Shape("polylines") + shape.setPoints([[0, 0], [0, 0]]) + shape.setColor(rgba(self.getColor())) + shape.setFill(False) + shape.setOverlay(True) + shape.setLineStyle(self.getLineStyle()) + shape.setLineWidth(self.getLineWidth()) + self.__shape = shape + self.addItem(shape) + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.VISIBLE: + self._updateItemProperty(event, self, self.__shape) + super(LineROI, self)._updated(event, checkVisibility) - def _createControlPointsFromFirstShape(self, points): - center = numpy.mean(points, axis=0) - controlPoints = numpy.array([points[0], points[1], center]) - return controlPoints + def _updatedStyle(self, event, style): + super(LineROI, self)._updatedStyle(event, style) + self.__shape.setColor(style.getColor()) + self.__shape.setLineStyle(style.getLineStyle()) + self.__shape.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + assert len(points) == 2 + self.setEndPoints(points[0], points[1]) + + def _updateText(self, text): + self._handleLabel.setText(text) def setEndPoints(self, startPoint, endPoint): """Set this line location using the ending points @@ -605,88 +966,83 @@ class LineROI(RegionOfInterest, items.LineMixIn): :param numpy.ndarray startPoint: Staring bounding point of the line :param numpy.ndarray endPoint: Ending bounding point of the line """ - assert(startPoint.shape == (2,) and endPoint.shape == (2,)) - shapePoints = numpy.array([startPoint, endPoint]) - controlPoints = self._createControlPointsFromFirstShape(shapePoints) - self._setControlPoints(controlPoints) + if not numpy.array_equal((startPoint, endPoint), self.getEndPoints()): + self.__updateEndPoints(startPoint, endPoint) + + def __updateEndPoints(self, startPoint, endPoint): + """Update marker and shape to match given end points + + :param numpy.ndarray startPoint: Staring bounding point of the line + :param numpy.ndarray endPoint: Ending bounding point of the line + """ + startPoint = numpy.array(startPoint) + endPoint = numpy.array(endPoint) + center = (startPoint + endPoint) * 0.5 + + with utils.blockSignals(self._handleStart): + self._handleStart.setPosition(startPoint[0], startPoint[1]) + with utils.blockSignals(self._handleEnd): + self._handleEnd.setPosition(endPoint[0], endPoint[1]) + with utils.blockSignals(self._handleCenter): + self._handleCenter.setPosition(center[0], center[1]) + with utils.blockSignals(self._handleLabel): + self._handleLabel.setPosition(center[0], center[1]) + + line = numpy.array((startPoint, endPoint)) + self.__shape.setPoints(line) + self.sigRegionChanged.emit() def getEndPoints(self): """Returns bounding points of this ROI. :rtype: Tuple(numpy.ndarray,numpy.ndarray) """ - startPoint = self._points[0].copy() - endPoint = self._points[1].copy() + startPoint = numpy.array(self._handleStart.getPosition()) + endPoint = numpy.array(self._handleEnd.getPosition()) return (startPoint, endPoint) - def _getLabelPosition(self): - points = self._getControlPoints() - return points[-1] - - def _updateShape(self): - if len(self._items) == 0: - return - shape = self._items[0] - points = self._getControlPoints() - points = self._getShapeFromControlPoints(points) - shape.setPoints(points) - - def _getShapeFromControlPoints(self, points): - # Remove the center from the control points - return points[0:2] - - def _createShapeItems(self, points): - shapePoints = self._getShapeFromControlPoints(points) - item = items.Shape("polylines") - item.setPoints(shapePoints) - item.setColor(rgba(self.getColor())) - item.setFill(False) - item.setOverlay(True) - item.setLineStyle(self.getLineStyle()) - item.setLineWidth(self.getLineWidth()) - return [item] - - def _createAnchorItems(self, points): - anchors = [] - for point in points[0:-1]: - anchor = items.Marker() - anchor.setPosition(*point) - anchor.setText('') - anchor.setSymbol('s') - anchor._setDraggable(True) - anchors.append(anchor) - - # Add an anchor to the center of the rectangle - center = numpy.mean(points, axis=0) - anchor = items.Marker() - anchor.setPosition(*center) - anchor.setText('') - anchor.setSymbol('+') - anchor._setDraggable(True) - anchors.append(anchor) - - return anchors - - def _controlPointAnchorPositionChanged(self, index, current, previous): - if index == len(self._editAnchors) - 1: - # It is the center anchor - points = self._getControlPoints() - center = numpy.mean(points[0:-1], axis=0) - offset = current - previous - points[-1] = current - points[0:-1] = points[0:-1] + offset - self._setControlPoints(points) - else: - # Update the center - points = self._getControlPoints() - points[index] = current - center = numpy.mean(points[0:-1], axis=0) - points[-1] = center - self._setControlPoints(points) + def handleDragUpdated(self, handle, origin, previous, current): + if handle is self._handleStart: + _start, end = self.getEndPoints() + self.__updateEndPoints(current, end) + elif handle is self._handleEnd: + start, _end = self.getEndPoints() + self.__updateEndPoints(start, current) + elif handle is self._handleCenter: + start, end = self.getEndPoints() + delta = current - previous + start += delta + end += delta + self.setEndPoints(start, end) + + @docstring(_RegionOfInterestBase) + def contains(self, position): + bottom_left = position[0], position[1] + bottom_right = position[0] + 1, position[1] + top_left = position[0], position[1] + 1 + top_right = position[0] + 1, position[1] + 1 + + line_pt1 = self._points[0] + line_pt2 = self._points[1] + + bb1 = _BoundingBox.from_points(self._points) + if bb1.contains(position) is False: + return False + + return ( + segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2, + seg2_start_pt=bottom_left, seg2_end_pt=bottom_right) or + segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2, + seg2_start_pt=bottom_right, seg2_end_pt=top_right) or + segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2, + seg2_start_pt=top_right, seg2_end_pt=top_left) or + segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2, + seg2_start_pt=top_left, seg2_end_pt=bottom_left) + ) def __str__(self): - points = self._getControlPoints() - params = points[0][0], points[0][1], points[1][0], points[1][1] + start, end = self.getEndPoints() + params = start[0], start[1], end[0], end[1] params = 'start: %f %f; end: %f %f' % params return "%s(%s)" % (self.__class__.__name__, params) @@ -694,199 +1050,230 @@ class LineROI(RegionOfInterest, items.LineMixIn): class HorizontalLineROI(RegionOfInterest, items.LineMixIn): """A ROI identifying an horizontal line in a 2D plot.""" - _kind = "HLine" - """Label for this kind of ROI""" + ICON = 'add-shape-horizontal' + NAME = 'horizontal line ROI' + SHORT_NAME = "hline" + """Metadata for this kind of ROI""" _plotShape = "hline" """Plot shape which is used for the first interaction""" def __init__(self, parent=None): - items.LineMixIn.__init__(self) RegionOfInterest.__init__(self, parent=parent) + items.LineMixIn.__init__(self) + self._marker = items.YMarker() + self._marker.sigItemChanged.connect(self._linePositionChanged) + self._marker.sigDragStarted.connect(self._editingStarted) + self._marker.sigDragFinished.connect(self._editingFinished) + self.addItem(self._marker) - def _createControlPointsFromFirstShape(self, points): - points = numpy.array([(float('nan'), points[0, 1])], - dtype=numpy.float64) - return points + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.NAME: + label = self.getName() + self._marker.setText(label) + elif event == items.ItemChangedType.EDITABLE: + self._marker._setDraggable(self.isEditable()) + elif event in [items.ItemChangedType.VISIBLE, + items.ItemChangedType.SELECTABLE]: + self._updateItemProperty(event, self, self._marker) + super(HorizontalLineROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + self._marker.setColor(style.getColor()) + self._marker.setLineStyle(style.getLineStyle()) + self._marker.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + pos = points[0, 1] + if pos == self.getPosition(): + return + self.setPosition(pos) def getPosition(self): """Returns the position of this line if the horizontal axis :rtype: float """ - return self._points[0, 1] + pos = self._marker.getPosition() + return pos[1] def setPosition(self, pos): """Set the position of this ROI :param float pos: Horizontal position of this line """ - controlPoints = numpy.array([[float('nan'), pos]]) - self._setControlPoints(controlPoints) - - def _createLabelItem(self): - return None - - def _updateLabelItem(self, label): - self._items[0].setText(label) + self._marker.setPosition(0, pos) - def _updateShape(self): - if len(self._items) > 0: - controlPoints = self._getControlPoints() - item = self._items[0] - item.setPosition(*controlPoints[0]) + @docstring(_RegionOfInterestBase) + def contains(self, position): + return position[1] == self.getPosition()[1] - def __positionChanged(self, event): + def _linePositionChanged(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): - marker = items.YMarker() - marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getName()) - marker.setColor(rgba(self.getColor())) - marker.setLineWidth(self.getLineWidth()) - marker.setLineStyle(self.getLineStyle()) - marker._setDraggable(self.isEditable()) - if self.isEditable(): - marker.sigItemChanged.connect(self.__positionChanged) - return [marker] + self.sigRegionChanged.emit() def __str__(self): - points = self._getControlPoints() - params = 'y: %f' % points[0, 1] + params = 'y: %f' % self.getPosition() return "%s(%s)" % (self.__class__.__name__, params) class VerticalLineROI(RegionOfInterest, items.LineMixIn): """A ROI identifying a vertical line in a 2D plot.""" - _kind = "VLine" - """Label for this kind of ROI""" + ICON = 'add-shape-vertical' + NAME = 'vertical line ROI' + SHORT_NAME = "vline" + """Metadata for this kind of ROI""" _plotShape = "vline" """Plot shape which is used for the first interaction""" def __init__(self, parent=None): - items.LineMixIn.__init__(self) RegionOfInterest.__init__(self, parent=parent) + items.LineMixIn.__init__(self) + self._marker = items.XMarker() + self._marker.sigItemChanged.connect(self._linePositionChanged) + self._marker.sigDragStarted.connect(self._editingStarted) + self._marker.sigDragFinished.connect(self._editingFinished) + self.addItem(self._marker) - def _createControlPointsFromFirstShape(self, points): - points = numpy.array([(points[0, 0], float('nan'))], - dtype=numpy.float64) - return points + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.NAME: + label = self.getName() + self._marker.setText(label) + elif event == items.ItemChangedType.EDITABLE: + self._marker._setDraggable(self.isEditable()) + elif event in [items.ItemChangedType.VISIBLE, + items.ItemChangedType.SELECTABLE]: + self._updateItemProperty(event, self, self._marker) + super(VerticalLineROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + self._marker.setColor(style.getColor()) + self._marker.setLineStyle(style.getLineStyle()) + self._marker.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + pos = points[0, 0] + self.setPosition(pos) def getPosition(self): """Returns the position of this line if the horizontal axis :rtype: float """ - return self._points[0, 0] + pos = self._marker.getPosition() + return pos[0] def setPosition(self, pos): """Set the position of this ROI :param float pos: Horizontal position of this line """ - controlPoints = numpy.array([[pos, float('nan')]]) - self._setControlPoints(controlPoints) - - def _createLabelItem(self): - return None + self._marker.setPosition(pos, 0) - def _updateLabelItem(self, label): - self._items[0].setText(label) + @docstring(RegionOfInterest) + def contains(self, position): + return position[0] == self.getPosition()[0] - def _updateShape(self): - if len(self._items) > 0: - controlPoints = self._getControlPoints() - item = self._items[0] - item.setPosition(*controlPoints[0]) - - def __positionChanged(self, event): + def _linePositionChanged(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): - marker = items.XMarker() - marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getName()) - marker.setColor(rgba(self.getColor())) - marker.setLineWidth(self.getLineWidth()) - marker.setLineStyle(self.getLineStyle()) - marker._setDraggable(self.isEditable()) - if self.isEditable(): - marker.sigItemChanged.connect(self.__positionChanged) - return [marker] + self.sigRegionChanged.emit() def __str__(self): - points = self._getControlPoints() - params = 'x: %f' % points[0, 0] + params = 'x: %f' % self.getPosition() return "%s(%s)" % (self.__class__.__name__, params) -class RectangleROI(RegionOfInterest, items.LineMixIn): +class RectangleROI(HandleBasedROI, items.LineMixIn): """A ROI identifying a rectangle in a 2D plot. This ROI provides 1 anchor for each corner, plus an anchor in the center to translate the full ROI. """ - _kind = "Rectangle" - """Label for this kind of ROI""" + ICON = 'add-shape-rectangle' + NAME = 'rectangle ROI' + SHORT_NAME = "rectangle" + """Metadata for this kind of ROI""" _plotShape = "rectangle" """Plot shape which is used for the first interaction""" def __init__(self, parent=None): + HandleBasedROI.__init__(self, parent=parent) items.LineMixIn.__init__(self) - RegionOfInterest.__init__(self, parent=parent) + self._handleTopLeft = self.addHandle() + self._handleTopRight = self.addHandle() + self._handleBottomLeft = self.addHandle() + self._handleBottomRight = self.addHandle() + self._handleCenter = self.addTranslateHandle() + self._handleLabel = self.addLabelHandle() + + shape = items.Shape("rectangle") + shape.setPoints([[0, 0], [0, 0]]) + shape.setFill(False) + shape.setOverlay(True) + shape.setLineStyle(self.getLineStyle()) + shape.setLineWidth(self.getLineWidth()) + shape.setColor(rgba(self.getColor())) + self.__shape = shape + self.addItem(shape) - def _createControlPointsFromFirstShape(self, points): - point0 = points[0] - point1 = points[1] + def _updated(self, event=None, checkVisibility=True): + if event in [items.ItemChangedType.VISIBLE]: + self._updateItemProperty(event, self, self.__shape) + super(RectangleROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + super(RectangleROI, self)._updatedStyle(event, style) + self.__shape.setColor(style.getColor()) + self.__shape.setLineStyle(style.getLineStyle()) + self.__shape.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + assert len(points) == 2 + self._setBound(points) - # 4 corners - controlPoints = numpy.array([ - point0[0], point0[1], - point0[0], point1[1], - point1[0], point1[1], - point1[0], point0[1], - ]) - # Central - center = numpy.mean(points, axis=0) - controlPoints = numpy.append(controlPoints, center) - controlPoints.shape = -1, 2 - return controlPoints + def _setBound(self, points): + """Initialize the rectangle from a bunch of points""" + top = max(points[:, 1]) + bottom = min(points[:, 1]) + left = min(points[:, 0]) + right = max(points[:, 0]) + size = right - left, top - bottom + self._updateGeometry(origin=(left, bottom), size=size) + + def _updateText(self, text): + self._handleLabel.setText(text) def getCenter(self): """Returns the central point of this rectangle :rtype: numpy.ndarray([float,float]) """ - return numpy.mean(self._points, axis=0) + pos = self._handleCenter.getPosition() + return numpy.array(pos) def getOrigin(self): """Returns the corner point with the smaller coordinates :rtype: numpy.ndarray([float,float]) """ - return numpy.min(self._points, axis=0) + pos = self._handleBottomLeft.getPosition() + return numpy.array(pos) def getSize(self): """Returns the size of this rectangle :rtype: numpy.ndarray([float,float]) """ - minPoint = numpy.min(self._points, axis=0) - maxPoint = numpy.max(self._points, axis=0) - return maxPoint - minPoint + vmin = self._handleBottomLeft.getPosition() + vmax = self._handleTopRight.getPosition() + vmin, vmax = numpy.array(vmin), numpy.array(vmax) + return vmax - vmin def setOrigin(self, position): """Set the origin position of this ROI @@ -915,93 +1302,80 @@ class RectangleROI(RegionOfInterest, items.LineMixIn): def setGeometry(self, origin=None, size=None, center=None): """Set the geometry of the ROI """ + if ((origin is None or numpy.array_equal(origin, self.getOrigin())) and + (center is None or numpy.array_equal(center, self.getCenter())) and + numpy.array_equal(size, self.getSize())): + return # Nothing has changed + + self._updateGeometry(origin, size, center) + + def _updateGeometry(self, origin=None, size=None, center=None): + """Forced update of the geometry of the ROI""" if origin is not None: origin = numpy.array(origin) size = numpy.array(size) points = numpy.array([origin, origin + size]) - controlPoints = self._createControlPointsFromFirstShape(points) + center = origin + size * 0.5 elif center is not None: center = numpy.array(center) size = numpy.array(size) points = numpy.array([center - size * 0.5, center + size * 0.5]) - controlPoints = self._createControlPointsFromFirstShape(points) else: - raise ValueError("Origin or cengter expected") - self._setControlPoints(controlPoints) - - def _getLabelPosition(self): - points = self._getControlPoints() - return points.min(axis=0) - - def _updateShape(self): - if len(self._items) == 0: - return - shape = self._items[0] - points = self._getControlPoints() - points = self._getShapeFromControlPoints(points) - shape.setPoints(points) - - def _getShapeFromControlPoints(self, points): - minPoint = points.min(axis=0) - maxPoint = points.max(axis=0) - return numpy.array([minPoint, maxPoint]) - - def _createShapeItems(self, points): - shapePoints = self._getShapeFromControlPoints(points) - item = items.Shape("rectangle") - item.setPoints(shapePoints) - item.setColor(rgba(self.getColor())) - item.setFill(False) - item.setOverlay(True) - item.setLineStyle(self.getLineStyle()) - item.setLineWidth(self.getLineWidth()) - return [item] - - def _createAnchorItems(self, points): - # Remove the center control point - points = points[0:-1] - - anchors = [] - for point in points: - anchor = items.Marker() - anchor.setPosition(*point) - anchor.setText('') - anchor.setSymbol('s') - anchor._setDraggable(True) - anchors.append(anchor) - - # Add an anchor to the center of the rectangle - center = numpy.mean(points, axis=0) - anchor = items.Marker() - anchor.setPosition(*center) - anchor.setText('') - anchor.setSymbol('+') - anchor._setDraggable(True) - anchors.append(anchor) - - return anchors - - def _controlPointAnchorPositionChanged(self, index, current, previous): - if index == len(self._editAnchors) - 1: + raise ValueError("Origin or center expected") + + with utils.blockSignals(self._handleBottomLeft): + self._handleBottomLeft.setPosition(points[0, 0], points[0, 1]) + with utils.blockSignals(self._handleBottomRight): + self._handleBottomRight.setPosition(points[1, 0], points[0, 1]) + with utils.blockSignals(self._handleTopLeft): + self._handleTopLeft.setPosition(points[0, 0], points[1, 1]) + with utils.blockSignals(self._handleTopRight): + self._handleTopRight.setPosition(points[1, 0], points[1, 1]) + with utils.blockSignals(self._handleCenter): + self._handleCenter.setPosition(center[0], center[1]) + with utils.blockSignals(self._handleLabel): + self._handleLabel.setPosition(points[0, 0], points[0, 1]) + + self.__shape.setPoints(points) + self.sigRegionChanged.emit() + + @docstring(HandleBasedROI) + def contains(self, position): + assert isinstance(position, (tuple, list, numpy.array)) + points = self.__shape.getPoints() + bb1 = _BoundingBox.from_points(points) + return bb1.contains(position) + + def handleDragUpdated(self, handle, origin, previous, current): + if handle is self._handleCenter: # It is the center anchor - points = self._getControlPoints() - center = numpy.mean(points[0:-1], axis=0) - offset = current - previous - points[-1] = current - points[0:-1] = points[0:-1] + offset - self._setControlPoints(points) + size = self.getSize() + self._updateGeometry(center=current, size=size) else: - # Fix other corners - constrains = [(1, 3), (0, 2), (3, 1), (2, 0)] - constrains = constrains[index] - points = self._getControlPoints() - points[index] = current - points[constrains[0]][0] = current[0] - points[constrains[1]][1] = current[1] - # Update the center - center = numpy.mean(points[0:-1], axis=0) - points[-1] = center - self._setControlPoints(points) + opposed = { + self._handleBottomLeft: self._handleTopRight, + self._handleTopRight: self._handleBottomLeft, + self._handleBottomRight: self._handleTopLeft, + self._handleTopLeft: self._handleBottomRight, + } + handle2 = opposed[handle] + current2 = handle2.getPosition() + points = numpy.array([current, current2]) + + # Switch handles if they were crossed by interaction + if self._handleBottomLeft.getXPosition() > self._handleBottomRight.getXPosition(): + self._handleBottomLeft, self._handleBottomRight = self._handleBottomRight, self._handleBottomLeft + + if self._handleTopLeft.getXPosition() > self._handleTopRight.getXPosition(): + self._handleTopLeft, self._handleTopRight = self._handleTopRight, self._handleTopLeft + + if self._handleBottomLeft.getYPosition() > self._handleTopLeft.getYPosition(): + self._handleBottomLeft, self._handleTopLeft = self._handleTopLeft, self._handleBottomLeft + + if self._handleBottomRight.getYPosition() > self._handleTopRight.getYPosition(): + self._handleBottomRight, self._handleTopRight = self._handleTopRight, self._handleBottomRight + + self._setBound(points) def __str__(self): origin = self.getOrigin() @@ -1011,21 +1385,504 @@ class RectangleROI(RegionOfInterest, items.LineMixIn): return "%s(%s)" % (self.__class__.__name__, params) -class PolygonROI(RegionOfInterest, items.LineMixIn): +class CircleROI(HandleBasedROI, items.LineMixIn): + """A ROI identifying a circle in a 2D plot. + + This ROI provides 1 anchor at the center to translate the circle, + and one anchor on the perimeter to change the radius. + """ + + ICON = 'add-shape-circle' + NAME = 'circle ROI' + SHORT_NAME = "circle" + """Metadata for this kind of ROI""" + + _kind = "Circle" + """Label for this kind of ROI""" + + _plotShape = "line" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + items.LineMixIn.__init__(self) + HandleBasedROI.__init__(self, parent=parent) + self._handlePerimeter = self.addHandle() + self._handleCenter = self.addTranslateHandle() + self._handleCenter.sigItemChanged.connect(self._centerPositionChanged) + self._handleLabel = self.addLabelHandle() + + shape = items.Shape("polygon") + shape.setPoints([[0, 0], [0, 0]]) + shape.setColor(rgba(self.getColor())) + shape.setFill(False) + shape.setOverlay(True) + shape.setLineStyle(self.getLineStyle()) + shape.setLineWidth(self.getLineWidth()) + self.__shape = shape + self.addItem(shape) + + self.__radius = 0 + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.VISIBLE: + self._updateItemProperty(event, self, self.__shape) + super(CircleROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + super(CircleROI, self)._updatedStyle(event, style) + self.__shape.setColor(style.getColor()) + self.__shape.setLineStyle(style.getLineStyle()) + self.__shape.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + assert len(points) == 2 + self._setRay(points) + + def _setRay(self, points): + """Initialize the circle from the center point and a + perimeter point.""" + center = points[0] + radius = numpy.linalg.norm(points[0] - points[1]) + self.setGeometry(center=center, radius=radius) + + def _updateText(self, text): + self._handleLabel.setText(text) + + def getCenter(self): + """Returns the central point of this rectangle + + :rtype: numpy.ndarray([float,float]) + """ + pos = self._handleCenter.getPosition() + return numpy.array(pos) + + def getRadius(self): + """Returns the radius of this circle + + :rtype: float + """ + return self.__radius + + def setCenter(self, position): + """Set the center point of this ROI + + :param numpy.ndarray position: Location of the center of the circle + """ + self._handleCenter.setPosition(*position) + + def setRadius(self, radius): + """Set the size of this ROI + + :param float size: Radius of the circle + """ + radius = float(radius) + if radius != self.__radius: + self.__radius = radius + self._updateGeometry() + + def setGeometry(self, center, radius): + """Set the geometry of the ROI + """ + if numpy.array_equal(center, self.getCenter()): + self.setRadius(radius) + else: + self.__radius = float(radius) # Update radius directly + self.setCenter(center) # Calls _updateGeometry + + def _updateGeometry(self): + """Update the handles and shape according to given parameters""" + center = self.getCenter() + perimeter_point = numpy.array([center[0] + self.__radius, center[1]]) + + self._handlePerimeter.setPosition(perimeter_point[0], perimeter_point[1]) + self._handleLabel.setPosition(center[0], center[1]) + + nbpoints = 27 + angles = numpy.arange(nbpoints) * 2.0 * numpy.pi / nbpoints + circleShape = numpy.array((numpy.cos(angles) * self.__radius, + numpy.sin(angles) * self.__radius)).T + circleShape += center + self.__shape.setPoints(circleShape) + self.sigRegionChanged.emit() + + def _centerPositionChanged(self, event): + """Handle position changed events of the center marker""" + if event is items.ItemChangedType.POSITION: + self._updateGeometry() + + def handleDragUpdated(self, handle, origin, previous, current): + if handle is self._handlePerimeter: + center = self.getCenter() + self.setRadius(numpy.linalg.norm(center - current)) + + def __str__(self): + center = self.getCenter() + radius = self.getRadius() + params = center[0], center[1], radius + params = 'center: %f %f; radius: %f;' % params + return "%s(%s)" % (self.__class__.__name__, params) + + +class EllipseROI(HandleBasedROI, items.LineMixIn): + """A ROI identifying an oriented ellipse in a 2D plot. + + This ROI provides 1 anchor at the center to translate the circle, + and two anchors on the perimeter to modify the major-radius and + minor-radius. These two anchors also allow to change the orientation. + """ + + ICON = 'add-shape-ellipse' + NAME = 'ellipse ROI' + SHORT_NAME = "ellipse" + """Metadata for this kind of ROI""" + + _plotShape = "line" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + items.LineMixIn.__init__(self) + HandleBasedROI.__init__(self, parent=parent) + self._handleAxis0 = self.addHandle() + self._handleAxis1 = self.addHandle() + self._handleCenter = self.addTranslateHandle() + self._handleCenter.sigItemChanged.connect(self._centerPositionChanged) + self._handleLabel = self.addLabelHandle() + + shape = items.Shape("polygon") + shape.setPoints([[0, 0], [0, 0]]) + shape.setColor(rgba(self.getColor())) + shape.setFill(False) + shape.setOverlay(True) + shape.setLineStyle(self.getLineStyle()) + shape.setLineWidth(self.getLineWidth()) + self.__shape = shape + self.addItem(shape) + + self._radius = 0., 0. + self._orientation = 0. # angle in radians between the X-axis and the _handleAxis0 + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.VISIBLE: + self._updateItemProperty(event, self, self.__shape) + super(EllipseROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + super(EllipseROI, self)._updatedStyle(event, style) + self.__shape.setColor(style.getColor()) + self.__shape.setLineStyle(style.getLineStyle()) + self.__shape.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + assert len(points) == 2 + self._setRay(points) + + @staticmethod + def _calculateOrientation(p0, p1): + """return angle in radians between the vector p0-p1 + and the X axis + + :param p0: first point coordinates (x, y) + :param p1: second point coordinates + :return: + """ + vector = (p1[0] - p0[0], p1[1] - p0[1]) + x_unit_vector = (1, 0) + norm = numpy.linalg.norm(vector) + if norm != 0: + theta = numpy.arccos(numpy.dot(vector, x_unit_vector) / norm) + else: + theta = 0 + if vector[1] < 0: + # arccos always returns values in range [0, pi] + theta = 2 * numpy.pi - theta + return theta + + def _setRay(self, points): + """Initialize the circle from the center point and a + perimeter point.""" + center = points[0] + radius = numpy.linalg.norm(points[0] - points[1]) + orientation = self._calculateOrientation(points[0], points[1]) + self.setGeometry(center=center, + radius=(radius, radius), + orientation=orientation) + + def _updateText(self, text): + self._handleLabel.setText(text) + + def getCenter(self): + """Returns the central point of this rectangle + + :rtype: numpy.ndarray([float,float]) + """ + pos = self._handleCenter.getPosition() + return numpy.array(pos) + + def getMajorRadius(self): + """Returns the half-diameter of the major axis. + + :rtype: float + """ + return max(self._radius) + + def getMinorRadius(self): + """Returns the half-diameter of the minor axis. + + :rtype: float + """ + return min(self._radius) + + def getOrientation(self): + """Return angle in radians between the horizontal (X) axis + and the major axis of the ellipse in [0, 2*pi[ + + :rtype: float: + """ + return self._orientation + + def setCenter(self, center): + """Set the center point of this ROI + + :param numpy.ndarray position: Coordinates (X, Y) of the center + of the ellipse + """ + self._handleCenter.setPosition(*center) + + def setMajorRadius(self, radius): + """Set the half-diameter of the major axis of the ellipse. + + :param float radius: + Major radius of the ellipsis. Must be a positive value. + """ + if self._radius[0] > self._radius[1]: + newRadius = radius, self._radius[1] + else: + newRadius = self._radius[0], radius + self.setGeometry(radius=newRadius) + + def setMinorRadius(self, radius): + """Set the half-diameter of the minor axis of the ellipse. + + :param float radius: + Minor radius of the ellipsis. Must be a positive value. + """ + if self._radius[0] > self._radius[1]: + newRadius = self._radius[0], radius + else: + newRadius = radius, self._radius[1] + self.setGeometry(radius=newRadius) + + def setOrientation(self, orientation): + """Rotate the ellipse + + :param float orientation: Angle in radians between the horizontal and + the major axis. + :return: + """ + self.setGeometry(orientation=orientation) + + def setGeometry(self, center=None, radius=None, orientation=None): + """ + + :param center: (X, Y) coordinates + :param float majorRadius: + :param float minorRadius: + :param float orientation: angle in radians between the major axis and the + horizontal + :return: + """ + if center is None: + center = self.getCenter() + + if radius is None: + radius = self._radius + else: + radius = float(radius[0]), float(radius[1]) + + if orientation is None: + orientation = self._orientation + else: + # ensure that we store the orientation in range [0, 2*pi + orientation = numpy.mod(orientation, 2 * numpy.pi) + + if (numpy.array_equal(center, self.getCenter()) or + radius != self._radius or + orientation != self._orientation): + + # Update parameters directly + self._radius = radius + self._orientation = orientation + + if numpy.array_equal(center, self.getCenter()): + self._updateGeometry() + else: + # This will call _updateGeometry + self.setCenter(center) + + def _updateGeometry(self): + """Update shape and markers""" + center = self.getCenter() + + orientation = self.getOrientation() + if self._radius[1] > self._radius[0]: + # _handleAxis1 is the major axis + orientation -= numpy.pi/2 + + point0 = numpy.array([center[0] + self._radius[0] * numpy.cos(orientation), + center[1] + self._radius[0] * numpy.sin(orientation)]) + point1 = numpy.array([center[0] - self._radius[1] * numpy.sin(orientation), + center[1] + self._radius[1] * numpy.cos(orientation)]) + with utils.blockSignals(self._handleAxis0): + self._handleAxis0.setPosition(*point0) + with utils.blockSignals(self._handleAxis1): + self._handleAxis1.setPosition(*point1) + with utils.blockSignals(self._handleLabel): + self._handleLabel.setPosition(*center) + + nbpoints = 27 + angles = numpy.arange(nbpoints) * 2.0 * numpy.pi / nbpoints + X = (self._radius[0] * numpy.cos(angles) * numpy.cos(orientation) + - self._radius[1] * numpy.sin(angles) * numpy.sin(orientation)) + Y = (self._radius[0] * numpy.cos(angles) * numpy.sin(orientation) + + self._radius[1] * numpy.sin(angles) * numpy.cos(orientation)) + + ellipseShape = numpy.array((X, Y)).T + ellipseShape += center + self.__shape.setPoints(ellipseShape) + self.sigRegionChanged.emit() + + def handleDragUpdated(self, handle, origin, previous, current): + if handle in (self._handleAxis0, self._handleAxis1): + center = self.getCenter() + orientation = self._calculateOrientation(center, current) + distance = numpy.linalg.norm(center - current) + + if handle is self._handleAxis1: + if self._radius[0] > distance: + # _handleAxis1 is not the major axis, rotate -90 degrees + orientation -= numpy.pi/2 + radius = self._radius[0], distance + + else: # _handleAxis0 + if self._radius[1] > distance: + # _handleAxis0 is not the major axis, rotate +90 degrees + orientation += numpy.pi/2 + radius = distance, self._radius[1] + + self.setGeometry(radius=radius, orientation=orientation) + + def _centerPositionChanged(self, event): + """Handle position changed events of the center marker""" + if event is items.ItemChangedType.POSITION: + self._updateGeometry() + + def __str__(self): + center = self.getCenter() + major = self.getMajorRadius() + minor = self.getMinorRadius() + orientation = self.getOrientation() + params = center[0], center[1], major, minor, orientation + params = 'center: %f %f; major radius: %f: minor radius: %f; orientation: %f' % params + return "%s(%s)" % (self.__class__.__name__, params) + + +class PolygonROI(HandleBasedROI, items.LineMixIn): """A ROI identifying a closed polygon in a 2D plot. This ROI provides 1 anchor for each point of the polygon. """ - _kind = "Polygon" - """Label for this kind of ROI""" + ICON = 'add-shape-polygon' + NAME = 'polygon ROI' + SHORT_NAME = "polygon" + """Metadata for this kind of ROI""" _plotShape = "polygon" """Plot shape which is used for the first interaction""" def __init__(self, parent=None): + HandleBasedROI.__init__(self, parent=parent) items.LineMixIn.__init__(self) - RegionOfInterest.__init__(self, parent=parent) + self._handleLabel = self.addLabelHandle() + self._handleCenter = self.addTranslateHandle() + self._handlePoints = [] + self._points = numpy.empty((0, 2)) + self._handleClose = None + + self._polygon_shape = None + shape = self.__createShape() + self.__shape = shape + self.addItem(shape) + + def _updated(self, event=None, checkVisibility=True): + if event in [items.ItemChangedType.VISIBLE]: + self._updateItemProperty(event, self, self.__shape) + super(PolygonROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + super(PolygonROI, self)._updatedStyle(event, style) + self.__shape.setColor(style.getColor()) + self.__shape.setLineStyle(style.getLineStyle()) + self.__shape.setLineWidth(style.getLineWidth()) + if self._handleClose is not None: + color = self._computeHandleColor(style.getColor()) + self._handleClose.setColor(color) + + def __createShape(self, interaction=False): + kind = "polygon" if not interaction else "polylines" + shape = items.Shape(kind) + shape.setPoints([[0, 0], [0, 0]]) + shape.setFill(False) + shape.setOverlay(True) + style = self.getCurrentStyle() + shape.setLineStyle(style.getLineStyle()) + shape.setLineWidth(style.getLineWidth()) + shape.setColor(rgba(style.getColor())) + return shape + + def setFirstShapePoints(self, points): + if self._handleClose is not None: + self._handleClose.setPosition(*points[0]) + self.setPoints(points) + + def creationStarted(self): + """"Called when the ROI creation interaction was started. + """ + # Handle to see where to close the polygon + self._handleClose = self.addUserHandle() + self._handleClose.setSymbol("o") + color = self._computeHandleColor(rgba(self.getColor())) + self._handleClose.setColor(color) + + # Hide the center while creating the first shape + self._handleCenter.setSymbol("") + + # In interaction replace the polygon by a line, to display something unclosed + self.removeItem(self.__shape) + self.__shape = self.__createShape(interaction=True) + self.__shape.setPoints(self._points) + self.addItem(self.__shape) + + def isBeingCreated(self): + """Returns true if the ROI is in creation step""" + return self._handleClose is not None + + def creationFinalized(self): + """"Called when the ROI creation interaction was finalized. + """ + self.removeHandle(self._handleClose) + self._handleClose = None + self.removeItem(self.__shape) + self.__shape = self.__createShape() + self.__shape.setPoints(self._points) + self.addItem(self.__shape) + # Hide the center while creating the first shape + self._handleCenter.setSymbol("+") + for handle in self._handlePoints: + handle.setSymbol("s") + + def _updateText(self, text): + self._handleLabel.setText(text) def getPoints(self): """Returns the list of the points of this polygon. @@ -1040,213 +1897,484 @@ class PolygonROI(RegionOfInterest, items.LineMixIn): :param numpy.ndarray pos: 2d-coordinate of this point """ assert(len(points.shape) == 2 and points.shape[1] == 2) - if len(points) > 0: - controlPoints = numpy.array(points) - else: - controlPoints = numpy.empty((0, 2)) - self._setControlPoints(controlPoints) - def _getLabelPosition(self): - points = self._getControlPoints() - if len(points) == 0: - # FIXME: we should return none, this polygon have no location - return numpy.array([0, 0]) - return points[numpy.argmin(points[:, 1])] + if numpy.array_equal(points, self._points): + return # Nothing has changed - def _updateShape(self): - if len(self._items) == 0: - return - shape = self._items[0] - points = self._getControlPoints() - shape.setPoints(points) + self._polygon_shape = None + + # Update the needed handles + while len(self._handlePoints) != len(points): + if len(self._handlePoints) < len(points): + handle = self.addHandle() + self._handlePoints.append(handle) + if self.isBeingCreated(): + handle.setSymbol("") + else: + handle = self._handlePoints.pop(-1) + self.removeHandle(handle) + + for handle, position in zip(self._handlePoints, points): + with utils.blockSignals(handle): + handle.setPosition(position[0], position[1]) + + if len(points) > 0: + if not self.isHandleBeingDragged(): + vmin = numpy.min(points, axis=0) + vmax = numpy.max(points, axis=0) + center = (vmax + vmin) * 0.5 + with utils.blockSignals(self._handleCenter): + self._handleCenter.setPosition(center[0], center[1]) + + num = numpy.argmin(points[:, 1]) + pos = points[num] + with utils.blockSignals(self._handleLabel): + self._handleLabel.setPosition(pos[0], pos[1]) - def _createShapeItems(self, points): if len(points) == 0: - return [] + self._points = numpy.empty((0, 2)) + else: + self._points = points + self.__shape.setPoints(self._points) + self.sigRegionChanged.emit() + + def translate(self, x, y): + points = self.getPoints() + delta = numpy.array([x, y]) + self.setPoints(points) + self.setPoints(points + delta) + + def handleDragUpdated(self, handle, origin, previous, current): + if handle is self._handleCenter: + delta = current - previous + self.translate(delta[0], delta[1]) else: - item = items.Shape("polygon") - item.setPoints(points) - item.setColor(rgba(self.getColor())) - item.setFill(False) - item.setOverlay(True) - item.setLineStyle(self.getLineStyle()) - item.setLineWidth(self.getLineWidth()) - return [item] - - def _createAnchorItems(self, points): - anchors = [] - for point in points: - anchor = items.Marker() - anchor.setPosition(*point) - anchor.setText('') - anchor.setSymbol('s') - anchor._setDraggable(True) - anchors.append(anchor) - return anchors + points = self.getPoints() + num = self._handlePoints.index(handle) + points[num] = current + self.setPoints(points) + + def handleDragFinished(self, handle, origin, current): + points = self._points + if len(points) > 0: + # Only update the center at the end + # To avoid to disturb the interaction + vmin = numpy.min(points, axis=0) + vmax = numpy.max(points, axis=0) + center = (vmax + vmin) * 0.5 + with utils.blockSignals(self._handleCenter): + self._handleCenter.setPosition(center[0], center[1]) def __str__(self): - points = self._getControlPoints() + points = self._points params = '; '.join('%f %f' % (pt[0], pt[1]) for pt in points) return "%s(%s)" % (self.__class__.__name__, params) + @docstring(HandleBasedROI) + def contains(self, position): + bb1 = _BoundingBox.from_points(self.getPoints()) + if bb1.contains(position) is False: + return False + + if self._polygon_shape is None: + self._polygon_shape = Polygon(vertices=self.getPoints()) + + # warning: both the polygon and the value are inverted + return self._polygon_shape.is_inside(row=position[0], col=position[1]) + + def _setControlPoints(self, points): + RegionOfInterest._setControlPoints(self, points=points) + self._polygon_shape = None -class ArcROI(RegionOfInterest, items.LineMixIn): + +class ArcROI(HandleBasedROI, items.LineMixIn): """A ROI identifying an arc of a circle with a width. - This ROI provides 3 anchors to control the curvature, 1 anchor to control - the weigth, and 1 anchor to translate the shape. + This ROI provides + - 3 handle to control the curvature + - 1 handle to control the weight + - 1 anchor to translate the shape. """ - _kind = "Arc" - """Label for this kind of ROI""" + ICON = 'add-shape-arc' + NAME = 'arc ROI' + SHORT_NAME = "arc" + """Metadata for this kind of ROI""" _plotShape = "line" """Plot shape which is used for the first interaction""" - _ArcGeometry = collections.namedtuple('ArcGeometry', ['center', - 'startPoint', 'endPoint', - 'radius', 'weight', - 'startAngle', 'endAngle']) + class _Geometry: + def __init__(self): + self.center = None + self.startPoint = None + self.endPoint = None + self.radius = None + self.weight = None + self.startAngle = None + self.endAngle = None + self._closed = None + + @classmethod + def createEmpty(cls): + zero = numpy.array([0, 0]) + return cls.create(zero, zero.copy(), zero.copy(), 0, 0, 0, 0) + + @classmethod + def createRect(cls, startPoint, endPoint, weight): + return cls.create(None, startPoint, endPoint, None, weight, None, None, False) + + @classmethod + def createCircle(cls, center, startPoint, endPoint, radius, + weight, startAngle, endAngle): + return cls.create(center, startPoint, endPoint, radius, + weight, startAngle, endAngle, True) + + @classmethod + def create(cls, center, startPoint, endPoint, radius, + weight, startAngle, endAngle, closed=False): + g = cls() + g.center = center + g.startPoint = startPoint + g.endPoint = endPoint + g.radius = radius + g.weight = weight + g.startAngle = startAngle + g.endAngle = endAngle + g._closed = closed + return g + + def withWeight(self, weight): + """Create a new geometry with another weight + """ + return self.create(self.center, self.startPoint, self.endPoint, + self.radius, weight, + self.startAngle, self.endAngle, self._closed) + + def withRadius(self, radius): + """Create a new geometry with another radius. + + The weight and the center is conserved. + """ + startPoint = self.center + (self.startPoint - self.center) / self.radius * radius + endPoint = self.center + (self.endPoint - self.center) / self.radius * radius + return self.create(self.center, startPoint, endPoint, + radius, self.weight, + self.startAngle, self.endAngle, self._closed) + + def translated(self, x, y): + delta = numpy.array([x, y]) + center = None if self.center is None else self.center + delta + startPoint = None if self.startPoint is None else self.startPoint + delta + endPoint = None if self.endPoint is None else self.endPoint + delta + return self.create(center, startPoint, endPoint, + self.radius, self.weight, + self.startAngle, self.endAngle, self._closed) + + def getKind(self): + """Returns the kind of shape defined""" + if self.center is None: + return "rect" + elif numpy.isnan(self.startAngle): + return "point" + elif self.isClosed(): + if self.weight <= 0 or self.weight * 0.5 >= self.radius: + return "circle" + else: + return "donut" + else: + if self.weight * 0.5 < self.radius: + return "arc" + else: + return "camembert" + + def isClosed(self): + """Returns True if the geometry is a circle like""" + if self._closed is not None: + return self._closed + delta = numpy.abs(self.endAngle - self.startAngle) + self._closed = numpy.isclose(delta, numpy.pi * 2) + return self._closed + + def __str__(self): + return str((self.center, + self.startPoint, + self.endPoint, + self.radius, + self.weight, + self.startAngle, + self.endAngle, + self._closed)) def __init__(self, parent=None): + HandleBasedROI.__init__(self, parent=parent) items.LineMixIn.__init__(self) - RegionOfInterest.__init__(self, parent=parent) - self._geometry = None + self._geometry = self._Geometry.createEmpty() + self._handleLabel = self.addLabelHandle() + + self._handleStart = self.addHandle() + self._handleStart.setSymbol("o") + self._handleMid = self.addHandle() + self._handleMid.setSymbol("o") + self._handleEnd = self.addHandle() + self._handleEnd.setSymbol("o") + self._handleWeight = self.addHandle() + self._handleWeight._setConstraint(self._arcCurvatureMarkerConstraint) + self._handleMove = self.addTranslateHandle() + + shape = items.Shape("polygon") + shape.setPoints([[0, 0], [0, 0]]) + shape.setColor(rgba(self.getColor())) + shape.setFill(False) + shape.setOverlay(True) + shape.setLineStyle(self.getLineStyle()) + shape.setLineWidth(self.getLineWidth()) + self.__shape = shape + self.addItem(shape) + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.VISIBLE: + self._updateItemProperty(event, self, self.__shape) + super(ArcROI, self)._updated(event, checkVisibility) - def _getInternalGeometry(self): - """Returns the object storing the internal geometry of this ROI. + def _updatedStyle(self, event, style): + super(ArcROI, self)._updatedStyle(event, style) + self.__shape.setColor(style.getColor()) + self.__shape.setLineStyle(style.getLineStyle()) + self.__shape.setLineWidth(style.getLineWidth()) - This geometry is derived from the control points and cached for - efficiency. Calling :meth:`_setControlPoints` invalidate the cache. + def setFirstShapePoints(self, points): + """"Initialize the ROI using the points from the first interaction. + + This interaction is constrained by the plot API and only supports few + shapes. """ - if self._geometry is None: - controlPoints = self._getControlPoints() - self._geometry = self._createGeometryFromControlPoint(controlPoints) - return self._geometry + # The first shape is a line + point0 = points[0] + point1 = points[1] - @classmethod - def showFirstInteractionShape(cls): - return False + # Compute a non collinear point for the curvature + center = (point1 + point0) * 0.5 + normal = point1 - center + normal = numpy.array((normal[1], -normal[0])) + defaultCurvature = numpy.pi / 5.0 + weightCoef = 0.20 + mid = center - normal * defaultCurvature + distance = numpy.linalg.norm(point0 - point1) + weight = distance * weightCoef - def _getLabelPosition(self): - points = self._getControlPoints() - return points.min(axis=0) + geometry = self._createGeometryFromControlPoints(point0, mid, point1, weight) + self._geometry = geometry + self._updateHandles() - def _updateShape(self): - if len(self._items) == 0: - return - shape = self._items[0] - points = self._getControlPoints() - points = self._getShapeFromControlPoints(points) - shape.setPoints(points) - - def _controlPointAnchorPositionChanged(self, index, current, previous): - controlPoints = self._getControlPoints() - currentWeigth = numpy.linalg.norm(controlPoints[3] - controlPoints[1]) * 2 - - if index in [0, 2]: - # Moving start or end will maintain the same curvature - # Then we have to custom the curvature control point - startPoint = controlPoints[0] - endPoint = controlPoints[2] - center = (startPoint + endPoint) * 0.5 - normal = (endPoint - startPoint) - normal = numpy.array((normal[1], -normal[0])) - distance = numpy.linalg.norm(normal) - # Compute the coeficient which have to be constrained - if distance != 0: - normal /= distance - midVector = controlPoints[1] - center - constainedCoef = numpy.dot(midVector, normal) / distance + def _updateText(self, text): + self._handleLabel.setText(text) + + def _updateMidHandle(self): + """Keep the same geometry, but update the location of the control + points. + + So calling this function do not trigger sigRegionChanged. + """ + geometry = self._geometry + + if geometry.isClosed(): + start = numpy.array(self._handleStart.getPosition()) + geometry.endPoint = start + with utils.blockSignals(self._handleEnd): + self._handleEnd.setPosition(*start) + midPos = geometry.center + geometry.center - start + else: + if geometry.center is None: + midPos = geometry.startPoint * 0.66 + geometry.endPoint * 0.34 else: - constainedCoef = 1.0 - - # Compute the location of the curvature point - controlPoints[index] = current - startPoint = controlPoints[0] - endPoint = controlPoints[2] - center = (startPoint + endPoint) * 0.5 - normal = (endPoint - startPoint) + midAngle = geometry.startAngle * 0.66 + geometry.endAngle * 0.34 + vector = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)]) + midPos = geometry.center + geometry.radius * vector + + with utils.blockSignals(self._handleMid): + self._handleMid.setPosition(*midPos) + + def _updateWeightHandle(self): + geometry = self._geometry + if geometry.center is None: + # rectangle + center = (geometry.startPoint + geometry.endPoint) * 0.5 + normal = geometry.endPoint - geometry.startPoint normal = numpy.array((normal[1], -normal[0])) distance = numpy.linalg.norm(normal) if distance != 0: - # BTW we dont need to divide by the distance here - # Cause we compute normal * distance after all - normal /= distance - midPoint = center + normal * constainedCoef * distance - controlPoints[1] = midPoint - - # The weight have to be fixed - self._updateWeightControlPoint(controlPoints, currentWeigth) - self._setControlPoints(controlPoints) - - elif index == 1: - # The weight have to be fixed - controlPoints[index] = current - self._updateWeightControlPoint(controlPoints, currentWeigth) - self._setControlPoints(controlPoints) + normal = normal / distance + weightPos = center + normal * geometry.weight * 0.5 else: - super(ArcROI, self)._controlPointAnchorPositionChanged(index, current, previous) + if geometry.isClosed(): + midAngle = geometry.startAngle + numpy.pi * 0.5 + elif geometry.center is not None: + midAngle = (geometry.startAngle + geometry.endAngle) * 0.5 + vector = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)]) + weightPos = geometry.center + (geometry.radius + geometry.weight * 0.5) * vector + + with utils.blockSignals(self._handleWeight): + self._handleWeight.setPosition(*weightPos) + + def _getWeightFromHandle(self, weightPos): + geometry = self._geometry + if geometry.center is None: + # rectangle + center = (geometry.startPoint + geometry.endPoint) * 0.5 + return numpy.linalg.norm(center - weightPos) * 2 + else: + distance = numpy.linalg.norm(geometry.center - weightPos) + return abs(distance - geometry.radius) * 2 - def _updateWeightControlPoint(self, controlPoints, weigth): - startPoint = controlPoints[0] - midPoint = controlPoints[1] - endPoint = controlPoints[2] - normal = (endPoint - startPoint) - normal = numpy.array((normal[1], -normal[0])) - distance = numpy.linalg.norm(normal) - if distance != 0: - normal /= distance - controlPoints[3] = midPoint + normal * weigth * 0.5 + def _updateHandles(self): + geometry = self._geometry + with utils.blockSignals(self._handleStart): + self._handleStart.setPosition(*geometry.startPoint) + with utils.blockSignals(self._handleEnd): + self._handleEnd.setPosition(*geometry.endPoint) + + self._updateMidHandle() + self._updateWeightHandle() - def _createGeometryFromControlPoint(self, controlPoints): + self._updateShape() + + def _updateCurvature(self, start, mid, end, updateCurveHandles, checkClosed=False): + """Update the curvature using 3 control points in the curve + + :param bool updateCurveHandles: If False curve handles are already at + the right location + """ + if updateCurveHandles: + with utils.blockSignals(self._handleStart): + self._handleStart.setPosition(*start) + with utils.blockSignals(self._handleMid): + self._handleMid.setPosition(*mid) + with utils.blockSignals(self._handleEnd): + self._handleEnd.setPosition(*end) + + if checkClosed: + closed = self._isCloseInPixel(start, end) + else: + closed = self._geometry.isClosed() + + weight = self._geometry.weight + geometry = self._createGeometryFromControlPoints(start, mid, end, weight, closed=closed) + self._geometry = geometry + + self._updateWeightHandle() + self._updateShape() + + def handleDragUpdated(self, handle, origin, previous, current): + if handle is self._handleStart: + mid = numpy.array(self._handleMid.getPosition()) + end = numpy.array(self._handleEnd.getPosition()) + self._updateCurvature(current, mid, end, + checkClosed=True, updateCurveHandles=False) + elif handle is self._handleMid: + if self._geometry.isClosed(): + radius = numpy.linalg.norm(self._geometry.center - current) + self._geometry = self._geometry.withRadius(radius) + self._updateHandles() + else: + start = numpy.array(self._handleStart.getPosition()) + end = numpy.array(self._handleEnd.getPosition()) + self._updateCurvature(start, current, end, updateCurveHandles=False) + elif handle is self._handleEnd: + start = numpy.array(self._handleStart.getPosition()) + mid = numpy.array(self._handleMid.getPosition()) + self._updateCurvature(start, mid, current, + checkClosed=True, updateCurveHandles=False) + elif handle is self._handleWeight: + weight = self._getWeightFromHandle(current) + self._geometry = self._geometry.withWeight(weight) + self._updateShape() + elif handle is self._handleMove: + delta = current - previous + self.translate(*delta) + + def _isCloseInPixel(self, point1, point2): + manager = self.parent() + if manager is None: + return False + plot = manager.parent() + if plot is None: + return False + point1 = plot.dataToPixel(*point1) + if point1 is None: + return False + point2 = plot.dataToPixel(*point2) + if point2 is None: + return False + return abs(point1[0] - point2[0]) + abs(point1[1] - point2[1]) < 15 + + def _normalizeGeometry(self): + """Keep the same phisical geometry, but with normalized parameters. + """ + geometry = self._geometry + if geometry.weight * 0.5 >= geometry.radius: + radius = (geometry.weight * 0.5 + geometry.radius) * 0.5 + geometry = geometry.withRadius(radius) + geometry = geometry.withWeight(radius * 2) + self._geometry = geometry + return True + return False + + def handleDragFinished(self, handle, origin, current): + if handle in [self._handleStart, self._handleMid, self._handleEnd]: + if self._normalizeGeometry(): + self._updateHandles() + else: + self._updateMidHandle() + if self._geometry.isClosed(): + self._handleStart.setSymbol("x") + self._handleEnd.setSymbol("x") + else: + self._handleStart.setSymbol("o") + self._handleEnd.setSymbol("o") + + def _createGeometryFromControlPoints(self, start, mid, end, weight, closed=None): """Returns the geometry of the object""" - weigth = numpy.linalg.norm(controlPoints[3] - controlPoints[1]) * 2 - if numpy.allclose(controlPoints[0], controlPoints[2]): + if closed or (closed is None and numpy.allclose(start, end)): # Special arc: It's a closed circle - center = (controlPoints[0] + controlPoints[1]) * 0.5 - radius = numpy.linalg.norm(controlPoints[0] - center) - v = controlPoints[0] - center + center = (start + mid) * 0.5 + radius = numpy.linalg.norm(start - center) + v = start - center startAngle = numpy.angle(complex(v[0], v[1])) endAngle = startAngle + numpy.pi * 2.0 - return self._ArcGeometry(center, controlPoints[0], controlPoints[2], - radius, weigth, startAngle, endAngle) + return self._Geometry.createCircle(center, start, end, radius, + weight, startAngle, endAngle) - elif numpy.linalg.norm( - numpy.cross(controlPoints[1] - controlPoints[0], - controlPoints[2] - controlPoints[0])) < 1e-5: + elif numpy.linalg.norm(numpy.cross(mid - start, end - start)) < 1e-5: # Degenerated arc, it's a rectangle - return self._ArcGeometry(None, controlPoints[0], controlPoints[2], - None, weigth, None, None) + return self._Geometry.createRect(start, end, weight) else: - center, radius = self._circleEquation(*controlPoints[:3]) - v = controlPoints[0] - center + center, radius = self._circleEquation(start, mid, end) + v = start - center startAngle = numpy.angle(complex(v[0], v[1])) - v = controlPoints[1] - center + v = mid - center midAngle = numpy.angle(complex(v[0], v[1])) - v = controlPoints[2] - center + v = end - center endAngle = numpy.angle(complex(v[0], v[1])) + # Is it clockwise or anticlockwise - if (midAngle - startAngle + 2 * numpy.pi) % (2 * numpy.pi) <= numpy.pi: + relativeMid = (endAngle - midAngle + 2 * numpy.pi) % (2 * numpy.pi) + relativeEnd = (endAngle - startAngle + 2 * numpy.pi) % (2 * numpy.pi) + if relativeMid < relativeEnd: if endAngle < startAngle: endAngle += 2 * numpy.pi else: if endAngle > startAngle: endAngle -= 2 * numpy.pi - return self._ArcGeometry(center, controlPoints[0], controlPoints[2], - radius, weigth, startAngle, endAngle) + return self._Geometry.create(center, start, end, + radius, weight, startAngle, endAngle) - def _isCircle(self, geometry): - """Returns True if the geometry is a closed circle""" - delta = numpy.abs(geometry.endAngle - geometry.startAngle) - return numpy.isclose(delta, numpy.pi * 2) - - def _getShapeFromControlPoints(self, controlPoints): - geometry = self._createGeometryFromControlPoint(controlPoints) - if geometry.center is None: + def _createShapeFromGeometry(self, geometry): + kind = geometry.getKind() + if kind == "rect": # It is not an arc - # but we can display it as an the intermediat shape + # but we can display it as an intermediate shape normal = (geometry.endPoint - geometry.startPoint) normal = numpy.array((normal[1], -normal[0])) distance = numpy.linalg.norm(normal) @@ -1257,15 +2385,40 @@ class ArcROI(RegionOfInterest, items.LineMixIn): geometry.endPoint + normal * geometry.weight * 0.5, geometry.endPoint - normal * geometry.weight * 0.5, geometry.startPoint - normal * geometry.weight * 0.5]) + elif kind == "point": + # It is not an arc + # but we can display it as an intermediate shape + # NOTE: At least 2 points are expected + points = numpy.array([geometry.startPoint, geometry.startPoint]) + elif kind == "circle": + outerRadius = geometry.radius + geometry.weight * 0.5 + angles = numpy.arange(0, 2 * numpy.pi, 0.1) + # It's a circle + points = [] + numpy.append(angles, angles[-1]) + for angle in angles: + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + points.append(geometry.center + direction * outerRadius) + points = numpy.array(points) + elif kind == "donut": + innerRadius = geometry.radius - geometry.weight * 0.5 + outerRadius = geometry.radius + geometry.weight * 0.5 + angles = numpy.arange(0, 2 * numpy.pi, 0.1) + # It's a donut + points = [] + # NOTE: NaN value allow to create 2 separated circle shapes + # using a single plot item. It's a kind of cheat + points.append(numpy.array([float("nan"), float("nan")])) + for angle in angles: + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + points.insert(0, geometry.center + direction * innerRadius) + points.append(geometry.center + direction * outerRadius) + points.append(numpy.array([float("nan"), float("nan")])) + points = numpy.array(points) else: innerRadius = geometry.radius - geometry.weight * 0.5 outerRadius = geometry.radius + geometry.weight * 0.5 - if numpy.isnan(geometry.startAngle): - # Degenerated, it's a point - # At least 2 points are expected - return numpy.array([geometry.startPoint, geometry.startPoint]) - delta = 0.1 if geometry.endAngle >= geometry.startAngle else -0.1 if geometry.startAngle == geometry.endAngle: # Degenerated, it's a line (single radius) @@ -1280,57 +2433,58 @@ class ArcROI(RegionOfInterest, items.LineMixIn): if angles[-1] != geometry.endAngle: angles = numpy.append(angles, geometry.endAngle) - isCircle = self._isCircle(geometry) - - if isCircle: - if innerRadius <= 0: - # It's a circle - points = [] - numpy.append(angles, angles[-1]) - for angle in angles: - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - points.append(geometry.center + direction * outerRadius) - else: - # It's a donut - points = [] - # NOTE: NaN value allow to create 2 separated circle shapes - # using a single plot item. It's a kind of cheat - points.append(numpy.array([float("nan"), float("nan")])) - for angle in angles: - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - points.insert(0, geometry.center + direction * innerRadius) - points.append(geometry.center + direction * outerRadius) - points.append(numpy.array([float("nan"), float("nan")])) + if kind == "camembert": + # It's a part of camembert + points = [] + points.append(geometry.center) + points.append(geometry.startPoint) + delta = 0.1 if geometry.endAngle >= geometry.startAngle else -0.1 + for angle in angles: + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + points.append(geometry.center + direction * outerRadius) + points.append(geometry.endPoint) + points.append(geometry.center) + elif kind == "arc": + # It's a part of donut + points = [] + points.append(geometry.startPoint) + for angle in angles: + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + points.insert(0, geometry.center + direction * innerRadius) + points.append(geometry.center + direction * outerRadius) + points.insert(0, geometry.endPoint) + points.append(geometry.endPoint) else: - if innerRadius <= 0: - # It's a part of camembert - points = [] - points.append(geometry.center) - points.append(geometry.startPoint) - delta = 0.1 if geometry.endAngle >= geometry.startAngle else -0.1 - for angle in angles: - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - points.append(geometry.center + direction * outerRadius) - points.append(geometry.endPoint) - points.append(geometry.center) - else: - # It's a part of donut - points = [] - points.append(geometry.startPoint) - for angle in angles: - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - points.insert(0, geometry.center + direction * innerRadius) - points.append(geometry.center + direction * outerRadius) - points.insert(0, geometry.endPoint) - points.append(geometry.endPoint) + assert False + points = numpy.array(points) return points - def _setControlPoints(self, points): - # Invalidate the geometry - self._geometry = None - RegionOfInterest._setControlPoints(self, points) + def _updateShape(self): + geometry = self._geometry + points = self._createShapeFromGeometry(geometry) + self.__shape.setPoints(points) + + index = numpy.nanargmin(points[:, 1]) + pos = points[index] + with utils.blockSignals(self._handleLabel): + self._handleLabel.setPosition(pos[0], pos[1]) + + if geometry.center is None: + movePos = geometry.startPoint * 0.34 + geometry.endPoint * 0.66 + elif (geometry.isClosed() + or abs(geometry.endAngle - geometry.startAngle) > numpy.pi * 0.7): + movePos = geometry.center + else: + moveAngle = geometry.startAngle * 0.34 + geometry.endAngle * 0.66 + vector = numpy.array([numpy.cos(moveAngle), numpy.sin(moveAngle)]) + movePos = geometry.center + geometry.radius * vector + + with utils.blockSignals(self._handleMove): + self._handleMove.setPosition(*movePos) + + self.sigRegionChanged.emit() def getGeometry(self): """Returns a tuple containing the geometry of this ROI @@ -1344,7 +2498,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn): :raise ValueError: In case the ROI can't be represented as section of a circle """ - geometry = self._getInternalGeometry() + geometry = self._geometry if geometry.center is None: raise ValueError("This ROI can't be represented as a section of circle") return geometry.center, self.getInnerRadius(), self.getOuterRadius(), geometry.startAngle, geometry.endAngle @@ -1354,8 +2508,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn): :rtype: bool """ - geometry = self._getInternalGeometry() - return self._isCircle(geometry) + return self._geometry.isClosed() def getCenter(self): """Returns the center of the circle used to draw arcs of this ROI. @@ -1364,8 +2517,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn): :rtype: numpy.ndarray """ - geometry = self._getInternalGeometry() - return geometry.center + return self._geometry.center def getStartAngle(self): """Returns the angle of the start of the section of this ROI (in radian). @@ -1375,8 +2527,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn): :rtype: float """ - geometry = self._getInternalGeometry() - return geometry.startAngle + return self._geometry.startAngle def getEndAngle(self): """Returns the angle of the end of the section of this ROI (in radian). @@ -1386,15 +2537,14 @@ class ArcROI(RegionOfInterest, items.LineMixIn): :rtype: float """ - geometry = self._getInternalGeometry() - return geometry.endAngle + return self._geometry.endAngle def getInnerRadius(self): """Returns the radius of the smaller arc used to draw this ROI. :rtype: float """ - geometry = self._getInternalGeometry() + geometry = self._geometry radius = geometry.radius - geometry.weight * 0.5 if radius < 0: radius = 0 @@ -1405,7 +2555,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn): :rtype: float """ - geometry = self._getInternalGeometry() + geometry = self._geometry radius = geometry.radius + geometry.weight * 0.5 return radius @@ -1427,96 +2577,67 @@ class ArcROI(RegionOfInterest, items.LineMixIn): center = numpy.array(center) radius = (innerRadius + outerRadius) * 0.5 weight = outerRadius - innerRadius - geometry = self._ArcGeometry(center, None, None, radius, weight, startAngle, endAngle) - controlPoints = self._createControlPointsFromGeometry(geometry) - self._setControlPoints(controlPoints) - - def _createControlPointsFromGeometry(self, geometry): - if geometry.startPoint or geometry.endPoint: - # Duplication with the angles - raise NotImplementedError("This general case is not implemented") - - angle = geometry.startAngle - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - startPoint = geometry.center + direction * geometry.radius - angle = geometry.endAngle - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - endPoint = geometry.center + direction * geometry.radius - - angle = (geometry.startAngle + geometry.endAngle) * 0.5 - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - curvaturePoint = geometry.center + direction * geometry.radius - weightPoint = curvaturePoint + direction * geometry.weight * 0.5 - - return numpy.array([startPoint, curvaturePoint, endPoint, weightPoint]) - - def _createControlPointsFromFirstShape(self, points): - # The first shape is a line - point0 = points[0] - point1 = points[1] + vector = numpy.array([numpy.cos(startAngle), numpy.sin(startAngle)]) + startPoint = center + vector * radius + vector = numpy.array([numpy.cos(endAngle), numpy.sin(endAngle)]) + endPoint = center + vector * radius + + geometry = self._Geometry.create(center, startPoint, endPoint, + radius, weight, + startAngle, endAngle, closed=None) + self._geometry = geometry + self._updateHandles() + + @docstring(HandleBasedROI) + def contains(self, position): + # first check distance, fastest + center = self.getCenter() + distance = numpy.sqrt((position[1] - center[1]) ** 2 + ((position[0] - center[0])) ** 2) + is_in_distance = self.getInnerRadius() <= distance <= self.getOuterRadius() + if not is_in_distance: + return False + rel_pos = position[1] - center[1], position[0] - center[0] + angle = numpy.arctan2(*rel_pos) + start_angle = self.getStartAngle() + end_angle = self.getEndAngle() + + if start_angle < end_angle: + # I never succeed to find a condition where start_angle < end_angle + # so this is untested + is_in_angle = start_angle <= angle <= end_angle + else: + if end_angle < -numpy.pi and angle > 0: + angle = angle - (numpy.pi *2.0) + is_in_angle = end_angle <= angle <= start_angle + return is_in_angle - # Compute a non colineate point for the curvature - center = (point1 + point0) * 0.5 - normal = point1 - center - normal = numpy.array((normal[1], -normal[0])) - defaultCurvature = numpy.pi / 5.0 - defaultWeight = 0.20 # percentage - curvaturePoint = center - normal * defaultCurvature - weightPoint = center - normal * defaultCurvature * (1.0 + defaultWeight) - - # 3 corners - controlPoints = numpy.array([ - point0, - curvaturePoint, - point1, - weightPoint - ]) - return controlPoints - - def _createShapeItems(self, points): - shapePoints = self._getShapeFromControlPoints(points) - item = items.Shape("polygon") - item.setPoints(shapePoints) - item.setColor(rgba(self.getColor())) - item.setFill(False) - item.setOverlay(True) - item.setLineStyle(self.getLineStyle()) - item.setLineWidth(self.getLineWidth()) - return [item] - - def _createAnchorItems(self, points): - anchors = [] - symbols = ['o', 'o', 'o', 's'] - - for index, point in enumerate(points): - if index in [1, 3]: - constraint = self._arcCurvatureMarkerConstraint - else: - constraint = None - anchor = items.Marker() - anchor.setPosition(*point) - anchor.setText('') - anchor.setSymbol(symbols[index]) - anchor._setDraggable(True) - if constraint is not None: - anchor._setConstraint(constraint) - anchors.append(anchor) - - return anchors + def translate(self, x, y): + self._geometry = self._geometry.translated(x, y) + self._updateHandles() def _arcCurvatureMarkerConstraint(self, x, y): - """Curvature marker remains on "mediatrice" """ - start = self._points[0] - end = self._points[2] - midPoint = (start + end) / 2. - normal = (end - start) - normal = numpy.array((normal[1], -normal[0])) - distance = numpy.linalg.norm(normal) - if distance != 0: - normal /= distance - v = numpy.dot(normal, (numpy.array((x, y)) - midPoint)) - x, y = midPoint + v * normal + """Curvature marker remains on perpendicular bisector""" + geometry = self._geometry + if geometry.center is None: + center = (geometry.startPoint + geometry.endPoint) * 0.5 + vector = geometry.startPoint - geometry.endPoint + vector = numpy.array((vector[1], -vector[0])) + vdist = numpy.linalg.norm(vector) + if vdist != 0: + normal = numpy.array((vector[1], -vector[0])) / vdist + else: + normal = numpy.array((0, 0)) + else: + if geometry.isClosed(): + midAngle = geometry.startAngle + numpy.pi * 0.5 + else: + midAngle = (geometry.startAngle + geometry.endAngle) * 0.5 + normal = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)]) + center = geometry.center + dist = numpy.dot(normal, (numpy.array((x, y)) - center)) + dist = numpy.clip(dist, geometry.radius, geometry.radius * 2) + x, y = center + dist * normal return x, y @staticmethod @@ -1530,7 +2651,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn): w = z - x w /= y - x c = (x - y) * (w - abs(w) ** 2) / 2j / w.imag - x - return ((-c.real, -c.imag), abs(c + x)) + return numpy.array((-c.real, -c.imag)), abs(c + x) def __str__(self): try: @@ -1540,3 +2661,221 @@ class ArcROI(RegionOfInterest, items.LineMixIn): except ValueError: params = "invalid" return "%s(%s)" % (self.__class__.__name__, params) + + +class HorizontalRangeROI(RegionOfInterest, items.LineMixIn): + """A ROI identifying an horizontal range in a 1D plot.""" + + ICON = 'add-range-horizontal' + NAME = 'horizontal range ROI' + SHORT_NAME = "hrange" + + _plotShape = "line" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + RegionOfInterest.__init__(self, parent=parent) + items.LineMixIn.__init__(self) + self._markerMin = items.XMarker() + self._markerMax = items.XMarker() + self._markerCen = items.XMarker() + self._markerCen.setLineStyle(" ") + self._markerMin._setConstraint(self.__positionMinConstraint) + self._markerMax._setConstraint(self.__positionMaxConstraint) + self._markerMin.sigDragStarted.connect(self._editingStarted) + self._markerMin.sigDragFinished.connect(self._editingFinished) + self._markerMax.sigDragStarted.connect(self._editingStarted) + self._markerMax.sigDragFinished.connect(self._editingFinished) + self._markerCen.sigDragStarted.connect(self._editingStarted) + self._markerCen.sigDragFinished.connect(self._editingFinished) + self.addItem(self._markerCen) + self.addItem(self._markerMin) + self.addItem(self._markerMax) + self.__filterReentrant = utils.LockReentrant() + + def setFirstShapePoints(self, points): + vmin = min(points[:, 0]) + vmax = max(points[:, 0]) + self._updatePos(vmin, vmax) + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.NAME: + self._updateText() + elif event == items.ItemChangedType.EDITABLE: + self._updateEditable() + self._updateText() + elif event == items.ItemChangedType.LINE_STYLE: + markers = [self._markerMin, self._markerMax] + self._updateItemProperty(event, self, markers) + elif event in [items.ItemChangedType.VISIBLE, + items.ItemChangedType.SELECTABLE]: + markers = [self._markerMin, self._markerMax, self._markerCen] + self._updateItemProperty(event, self, markers) + super(HorizontalRangeROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + markers = [self._markerMin, self._markerMax, self._markerCen] + for m in markers: + m.setColor(style.getColor()) + m.setLineWidth(style.getLineWidth()) + + def _updateText(self): + text = self.getName() + if self.isEditable(): + self._markerMin.setText("") + self._markerCen.setText(text) + else: + self._markerMin.setText(text) + self._markerCen.setText("") + + def _updateEditable(self): + editable = self.isEditable() + self._markerMin._setDraggable(editable) + self._markerMax._setDraggable(editable) + self._markerCen._setDraggable(editable) + if self.isEditable(): + self._markerMin.sigItemChanged.connect(self._minPositionChanged) + self._markerMax.sigItemChanged.connect(self._maxPositionChanged) + self._markerCen.sigItemChanged.connect(self._cenPositionChanged) + self._markerCen.setLineStyle(":") + else: + self._markerMin.sigItemChanged.disconnect(self._minPositionChanged) + self._markerMax.sigItemChanged.disconnect(self._maxPositionChanged) + self._markerCen.sigItemChanged.disconnect(self._cenPositionChanged) + self._markerCen.setLineStyle(" ") + + def _updatePos(self, vmin, vmax, force=False): + """Update marker position and emit signal. + + :param float vmin: + :param float vmax: + :param bool force: + True to update even if already at the right position. + """ + if not force and numpy.array_equal((vmin, vmax), self.getRange()): + return # Nothing has changed + + center = (vmin + vmax) * 0.5 + with self.__filterReentrant: + with utils.blockSignals(self._markerMin): + self._markerMin.setPosition(vmin, 0) + with utils.blockSignals(self._markerCen): + self._markerCen.setPosition(center, 0) + with utils.blockSignals(self._markerMax): + self._markerMax.setPosition(vmax, 0) + self.sigRegionChanged.emit() + + def setRange(self, vmin, vmax): + """Set the range of this ROI. + + :param float vmin: Staring location of the range + :param float vmax: Ending location of the range + """ + if vmin is None or vmax is None: + err = "Can't set vmin or vmax to None" + raise ValueError(err) + if vmin > vmax: + err = "Can't set vmin and vmax because vmin >= vmax " \ + "vmin = %s, vmax = %s" % (vmin, vmax) + raise ValueError(err) + self._updatePos(vmin, vmax) + + def getRange(self): + """Returns the range of this ROI. + + :rtype: Tuple[float,float] + """ + vmin = self.getMin() + vmax = self.getMax() + return vmin, vmax + + def setMin(self, vmin): + """Set the min of this ROI. + + :param float vmin: New min + """ + vmax = self.getMax() + self._updatePos(vmin, vmax) + + def getMin(self): + """Returns the min value of this ROI. + + :rtype: float + """ + return self._markerMin.getPosition()[0] + + def setMax(self, vmax): + """Set the max of this ROI. + + :param float vmax: New max + """ + vmin = self.getMin() + self._updatePos(vmin, vmax) + + def getMax(self): + """Returns the max value of this ROI. + + :rtype: float + """ + return self._markerMax.getPosition()[0] + + def setCenter(self, center): + """Set the center of this ROI. + + :param float center: New center + """ + vmin, vmax = self.getRange() + previousCenter = (vmin + vmax) * 0.5 + delta = center - previousCenter + self._updatePos(vmin + delta, vmax + delta) + + def getCenter(self): + """Returns the center location of this ROI. + + :rtype: float + """ + vmin, vmax = self.getRange() + return (vmin + vmax) * 0.5 + + def __positionMinConstraint(self, x, y): + """Constraint of the min marker""" + if self.__filterReentrant.locked(): + # Ignore the constraint when we set an explicit value + return x, y + vmax = self.getMax() + if vmax is None: + return x, y + return min(x, vmax), y + + def __positionMaxConstraint(self, x, y): + """Constraint of the max marker""" + if self.__filterReentrant.locked(): + # Ignore the constraint when we set an explicit value + return x, y + vmin = self.getMin() + if vmin is None: + return x, y + return max(x, vmin), y + + def _minPositionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + marker = self.sender() + self._updatePos(marker.getXPosition(), self.getMax(), force=True) + + def _maxPositionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + marker = self.sender() + self._updatePos(self.getMin(), marker.getXPosition(), force=True) + + def _cenPositionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + marker = self.sender() + self.setCenter(marker.getXPosition()) + + def __str__(self): + vrange = self.getRange() + params = 'min: %f; max: %f' % vrange + return "%s(%s)" % (self.__class__.__name__, params) diff --git a/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py index 50cc694..5e7d65b 100644 --- a/silx/gui/plot/items/scatter.py +++ b/silx/gui/plot/items/scatter.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2019 European Synchrotron Radiation Facility +# Copyright (c) 2017-2020 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 @@ -43,6 +43,7 @@ from concurrent.futures import ThreadPoolExecutor, CancelledError from ....utils.proxy import docstring from ....math.combo import min_max +from ....math.histogram import Histogramnd from ....utils.weakref import WeakList from .._utils.delaunay import delaunay from .core import PointsBase, ColormapMixIn, ScatterVisualizationMixIn @@ -142,12 +143,13 @@ def is_monotonic(array): :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 + with numpy.errstate(invalid='ignore'): + if numpy.all(diff >= 0): + return 1 + elif numpy.all(diff <= 0): + return -1 + else: + return 0 def _guess_grid(x, y): @@ -264,6 +266,10 @@ _RegularGridInfo = namedtuple( '_RegularGridInfo', ['bounds', 'origin', 'scale', 'shape', 'order']) +_HistogramInfo = namedtuple( + '_HistogramInfo', ['mean', 'count', 'sum', 'origin', 'scale', 'shape']) + + class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): """Description of a scatter""" @@ -275,6 +281,7 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): ScatterVisualizationMixIn.Visualization.SOLID, ScatterVisualizationMixIn.Visualization.REGULAR_GRID, ScatterVisualizationMixIn.Visualization.IRREGULAR_GRID, + ScatterVisualizationMixIn.Visualization.BINNED_STATISTIC, ) """Overrides supported Visualizations""" @@ -293,17 +300,53 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): # Cache triangles: x, y, indices self.__cacheTriangles = None, None, None - # Cache regular grid info + # Cache regular grid and histogram info self.__cacheRegularGridInfo = None + self.__cacheHistogramInfo = None + + def _updateColormappedData(self): + """Update the colormapped data, to be called when changed""" + if self.getVisualization() is self.Visualization.BINNED_STATISTIC: + histoInfo = self.__getHistogramInfo() + if histoInfo is None: + data = None + else: + data = getattr( + histoInfo, + self.getVisualizationParameter( + self.VisualizationParameter.BINNED_STATISTIC_FUNCTION)) + else: + data = self.getValueData(copy=False) + self._setColormappedData(data, copy=False) + + @docstring(ScatterVisualizationMixIn) + def setVisualization(self, mode): + previous = self.getVisualization() + if super().setVisualization(mode): + if (bool(mode is self.Visualization.BINNED_STATISTIC) ^ + bool(previous is self.Visualization.BINNED_STATISTIC)): + self._updateColormappedData() + return True + else: + return False @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 + if super(Scatter, self).setVisualizationParameter(parameter, value): + if parameter in (self.VisualizationParameter.GRID_BOUNDS, + self.VisualizationParameter.GRID_MAJOR_ORDER, + self.VisualizationParameter.GRID_SHAPE): + self.__cacheRegularGridInfo = None + + if parameter in (self.VisualizationParameter.BINNED_STATISTIC_SHAPE, + self.VisualizationParameter.BINNED_STATISTIC_FUNCTION): + if parameter == self.VisualizationParameter.BINNED_STATISTIC_SHAPE: + self.__cacheHistogramInfo = None # Clean-up cache + if self.getVisualization() is self.Visualization.BINNED_STATISTIC: + self._updateColormappedData() + return True + else: + return False @docstring(ScatterVisualizationMixIn) def getCurrentVisualizationParameter(self, parameter): @@ -323,6 +366,10 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): grid = self.__getRegularGridInfo() return None if grid is None else grid.shape + elif parameter is self.VisualizationParameter.BINNED_STATISTIC_SHAPE: + info = self.__getHistogramInfo() + return None if info is None else info.shape + else: raise NotImplementedError() @@ -345,6 +392,18 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): if order is None: order = guess[0] + nbpoints = len(self.getXData(copy=False)) + if nbpoints > shape[0] * shape[1]: + # More data points that provided grid shape: enlarge grid + _logger.warning( + "More data points than provided grid shape size: extends grid") + dim0, dim1 = shape + if order == 'row': # keep dim1, enlarge dim0 + dim0 = nbpoints // dim1 + (1 if nbpoints % dim1 else 0) + else: # keep dim0, enlarge dim1 + dim1 = nbpoints // dim0 + (1 if nbpoints % dim0 else 0) + shape = dim0, dim1 + bounds = self.getVisualizationParameter( self.VisualizationParameter.GRID_BOUNDS) if bounds is None: @@ -372,6 +431,47 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): return self.__cacheRegularGridInfo + def __getHistogramInfo(self): + """Get histogram info""" + if self.__cacheHistogramInfo is None: + shape = self.getVisualizationParameter( + self.VisualizationParameter.BINNED_STATISTIC_SHAPE) + if shape is None: + shape = 100, 100 # TODO compute auto shape + + x, y, values = self.getData(copy=False)[:3] + if len(x) == 0: # No histogram + return None + + if not numpy.issubdtype(x.dtype, numpy.floating): + x = x.astype(numpy.float64) + if not numpy.issubdtype(y.dtype, numpy.floating): + y = y.astype(numpy.float64) + if not numpy.issubdtype(values.dtype, numpy.floating): + values = values.astype(numpy.float64) + + ranges = (tuple(min_max(y, finite=True)), + tuple(min_max(x, finite=True))) + points = numpy.transpose(numpy.array((y, x))) + counts, sums, bin_edges = Histogramnd( + points, + histo_range=ranges, + n_bins=shape, + weights=values) + yEdges, xEdges = bin_edges + origin = xEdges[0], yEdges[0] + scale = ((xEdges[-1] - xEdges[0]) / (len(xEdges) - 1), + (yEdges[-1] - yEdges[0]) / (len(yEdges) - 1)) + + with numpy.errstate(divide='ignore', invalid='ignore'): + histo = sums / counts + + self.__cacheHistogramInfo = _HistogramInfo( + mean=histo, count=counts, sum=sums, + origin=origin, scale=scale, shape=shape) + + return self.__cacheHistogramInfo + def _addBackendRenderer(self, backend): """Update backend renderer""" # Filter-out values <= 0 @@ -386,28 +486,47 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): if len(xFiltered) == 0: return None # No data to display, do not add renderer to backend + visualization = self.getVisualization() + + if visualization is self.Visualization.BINNED_STATISTIC: + plot = self.getPlot() + if (plot is None or + plot.getXAxis().getScale() != Axis.LINEAR or + plot.getYAxis().getScale() != Axis.LINEAR): + # Those visualizations are not available with log scaled axes + return None + + histoInfo = self.__getHistogramInfo() + if histoInfo is None: + return None + data = getattr(histoInfo, self.getVisualizationParameter( + self.VisualizationParameter.BINNED_STATISTIC_FUNCTION)) + + return backend.addImage( + data=data, + origin=histoInfo.origin, + scale=histoInfo.scale, + colormap=self.getColormap(), + alpha=self.getAlpha()) + # Compute colors cmap = self.getColormap() - rgbacolors = cmap.applyToData(self._value) + rgbacolors = cmap.applyToData(self) if self.__alpha is not None: rgbacolors[:, -1] = (rgbacolors[:, -1] * self.__alpha).astype(numpy.uint8) - # Apply mask to colors - rgbacolors = rgbacolors[mask] - visualization = self.getVisualization() if visualization is self.Visualization.POINTS: return backend.addCurve(xFiltered, yFiltered, - color=rgbacolors, + color=rgbacolors[mask], symbol=self.getSymbol(), linewidth=0, linestyle="", yaxis='left', xerror=xerror, yerror=yerror, - z=self.getZValue(), fill=False, alpha=self.getAlpha(), symbolsize=self.getSymbolSize(), @@ -432,8 +551,7 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): return backend.addTriangles(xFiltered, yFiltered, triangles, - color=rgbacolors, - z=self.getZValue(), + color=rgbacolors[mask], alpha=self.getAlpha()) elif visualization is self.Visualization.REGULAR_GRID: @@ -461,7 +579,6 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): data=image, origin=gridInfo.origin, scale=gridInfo.scale, - z=self.getZValue(), colormap=None, alpha=self.getAlpha()) @@ -474,31 +591,89 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): 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] + nbpoints = len(xFiltered) + if nbpoints == 1: + # single point, render as a square points + return backend.addCurve(xFiltered, yFiltered, + color=rgbacolors[mask], + symbol='s', + linewidth=0, + linestyle="", + yaxis='left', + xerror=None, + yerror=None, + fill=False, + alpha=self.getAlpha(), + symbolsize=7, + baseline=None) + + # Make shape include all points + gridOrder = gridInfo.order + if nbpoints != numpy.prod(shape): + if gridOrder == 'row': + shape = int(numpy.ceil(nbpoints / 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) + shape = shape[0], int(numpy.ceil(nbpoints / shape[0])) + + if shape[0] < 2 or shape[1] < 2: # Single line, at least 2 points + points = numpy.ones((2, nbpoints, 2), dtype=numpy.float64) + # Use row/column major depending on shape, not on info value + gridOrder = 'row' if shape[0] == 1 else 'column' + + if gridOrder == 'row': + points[0, :, 0] = xFiltered + points[0, :, 1] = yFiltered + else: # column-major order + points[0, :, 0] = yFiltered + points[0, :, 1] = xFiltered + + # Add a second line that will be clipped in the end + points[1, :-1] = points[0, :-1] + numpy.cross( + points[0, 1:] - points[0, :-1], (0., 0., 1.))[:, :2] + points[1, -1] = points[0, -1] + numpy.cross( + points[0, -1] - points[0, -2], (0., 0., 1.))[:2] + + points.shape = 2, nbpoints, 2 # Use same shape for both orders + coords, indices = _quadrilateral_grid_as_triangles(points) + + elif gridOrder == 'row': # row-major order + if nbpoints != numpy.prod(shape): + points = numpy.empty((numpy.prod(shape), 2), dtype=numpy.float64) + points[:nbpoints, 0] = xFiltered + points[:nbpoints, 1] = yFiltered + # Index of last element of last fully filled row + index = (nbpoints // shape[1]) * shape[1] + points[nbpoints:, 0] = xFiltered[index - (numpy.prod(shape) - nbpoints):index] + points[nbpoints:, 1] = yFiltered[-1] + else: + points = numpy.transpose((xFiltered, yFiltered)) + points.shape = shape[0], shape[1], 2 else: # column-major order - points = numpy.transpose((yFiltered[:nbpoints], xFiltered[:nbpoints])) - points = points.reshape(shape[1], shape[0], 2) + if nbpoints != numpy.prod(shape): + points = numpy.empty((numpy.prod(shape), 2), dtype=numpy.float64) + points[:nbpoints, 0] = yFiltered + points[:nbpoints, 1] = xFiltered + # Index of last element of last fully filled column + index = (nbpoints // shape[0]) * shape[0] + points[nbpoints:, 0] = yFiltered[index - (numpy.prod(shape) - nbpoints):index] + points[nbpoints:, 1] = xFiltered[-1] + else: + points = numpy.transpose((yFiltered, xFiltered)) + points.shape = shape[1], shape[0], 2 coords, indices = _quadrilateral_grid_as_triangles(points) - if gridInfo.order == 'row': + # Remove unused extra triangles + coords = coords[:4*nbpoints] + indices = indices[:2*nbpoints] + + if gridOrder == 'row': x, y = coords[:, 0], coords[:, 1] else: # column-major order y, x = coords[:, 0], coords[:, 1] + rgbacolors = rgbacolors[mask] # Filter-out not finite points gridcolors = numpy.empty( (4 * nbpoints, rgbacolors.shape[-1]), dtype=rgbacolors.dtype) for first in range(4): @@ -508,8 +683,8 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): y, indices, color=gridcolors, - z=self.getZValue(), alpha=self.getAlpha()) + else: _logger.error("Unhandled visualization %s", visualization) return None @@ -528,23 +703,15 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): 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: + picked = result.getIndices(copy=False) + if picked is None: return None + row, column = picked[0][0], picked[1][0] 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: @@ -554,6 +721,23 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): result = PickingResult(self, (index,)) + elif visualization is self.Visualization.BINNED_STATISTIC: + picked = result.getIndices(copy=False) + if picked is None or len(picked) == 0 or len(picked[0]) == 0: + return None + row, col = picked[0][0], picked[1][0] + histoInfo = self.__getHistogramInfo() + if histoInfo is None: + return None + sx, sy = histoInfo.scale + ox, oy = histoInfo.origin + xdata = self.getXData(copy=False) + ydata = self.getYData(copy=False) + indices = numpy.nonzero(numpy.logical_and( + numpy.logical_and(xdata >= ox + sx * col, xdata < ox + sx * (col + 1)), + numpy.logical_and(ydata >= oy + sy * row, ydata < oy + sy * (row + 1))))[0] + result = None if len(indices) == 0 else PickingResult(self, indices) + return result def __getExecutor(self): @@ -750,8 +934,10 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): # Data changed, this needs update self.__cacheRegularGridInfo = None + self.__cacheHistogramInfo = None self._value = value + self._updateColormappedData() if alpha is not None: # Make sure alpha is an array of float in [0, 1] diff --git a/silx/gui/plot/items/shape.py b/silx/gui/plot/items/shape.py index 8176be1..26aa03b 100644 --- a/silx/gui/plot/items/shape.py +++ b/silx/gui/plot/items/shape.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2019 European Synchrotron Radiation Facility +# Copyright (c) 2017-2020 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 @@ -68,16 +68,15 @@ class Shape(Item, ColorMixIn, FillMixIn, LineMixIn): """Update backend renderer""" points = self.getPoints(copy=False) x, y = points.T[0], points.T[1] - return backend.addItem(x, - y, - shape=self.getType(), - color=self.getColor(), - fill=self.isFill(), - overlay=self.isOverlay(), - z=self.getZValue(), - linestyle=self.getLineStyle(), - linewidth=self.getLineWidth(), - linebgcolor=self.getLineBgColor()) + return backend.addShape(x, + y, + shape=self.getType(), + color=self.getColor(), + fill=self.isFill(), + overlay=self.isOverlay(), + linestyle=self.getLineStyle(), + linewidth=self.getLineWidth(), + linebgcolor=self.getLineBgColor()) def isOverlay(self): """Return true if shape is drawn as an overlay @@ -216,3 +215,91 @@ class BoundingRect(Item, YAxisMixIn): return tuple(bounds) return self.__bounds + + +class _BaseExtent(Item): + """Base class for :class:`XAxisExtent` and :class:`YAxisExtent`. + + :param str axis: Either 'x' or 'y'. + """ + + def __init__(self, axis='x'): + assert axis in ('x', 'y') + Item.__init__(self) + self.__axis = axis + self.__range = 1., 100. + + def _updated(self, event=None, checkVisibility=True): + if event in (ItemChangedType.VISIBLE, + ItemChangedType.DATA): + # TODO hackish data range implementation + plot = self.getPlot() + if plot is not None: + plot._invalidateDataRange() + + super(_BaseExtent, self)._updated(event, checkVisibility) + + def setRange(self, min_, max_): + """Set the range of the extent of this item in data coordinates. + + :param float min_: Lower bound of the extent + :param float max_: Upper bound of the extent + :raises ValueError: If min > max or not finite bounds + """ + range_ = float(min_), float(max_) + if not numpy.all(numpy.isfinite(range_)): + raise ValueError("min_ and max_ must be finite numbers.") + if range_[0] > range_[1]: + raise ValueError("min_ must be lesser or equal to max_") + + if range_ != self.__range: + self.__range = range_ + self._updated(ItemChangedType.DATA) + + def getRange(self): + """Returns the range (min, max) of the extent in data coordinates. + + :rtype: List[float] + """ + return self.__range + + def _getBounds(self): + min_, max_ = self.getRange() + + plot = self.getPlot() + if plot is not None: + axis = plot.getXAxis() if self.__axis == 'x' else plot.getYAxis() + if axis._isLogarithmic(): + if max_ <= 0: + return None + if min_ <= 0: + min_ = max_ + + if self.__axis == 'x': + return min_, max_, float('nan'), float('nan') + else: + return float('nan'), float('nan'), min_, max_ + + +class XAxisExtent(_BaseExtent): + """Invisible item with a settable horizontal data extent. + + This item do not display anything, but it behaves as a data + item with a horizontal extent regarding plot data bounds, i.e., + :meth:`PlotWidget.resetZoom` will take this horizontal extent into account. + """ + def __init__(self): + _BaseExtent.__init__(self, axis='x') + + +class YAxisExtent(_BaseExtent, YAxisMixIn): + """Invisible item with a settable vertical data extent. + + This item do not display anything, but it behaves as a data + item with a vertical extent regarding plot data bounds, i.e., + :meth:`PlotWidget.resetZoom` will take this vertical extent into account. + """ + + def __init__(self): + _BaseExtent.__init__(self, axis='y') + YAxisMixIn.__init__(self) |