summaryrefslogtreecommitdiff
path: root/silx/gui/plot/items
diff options
context:
space:
mode:
authorAlexandre Marie <alexandre.marie@synchrotron-soleil.fr>2020-07-21 14:45:14 +0200
committerAlexandre Marie <alexandre.marie@synchrotron-soleil.fr>2020-07-21 14:45:14 +0200
commit328032e2317e3ac4859196bbf12bdb71795302fe (patch)
tree8cd13462beab109e3cb53410c42335b6d1e00ee6 /silx/gui/plot/items
parent33ed2a64c92b0311ae35456c016eb284e426afc2 (diff)
New upstream version 0.13.0+dfsg
Diffstat (limited to 'silx/gui/plot/items')
-rw-r--r--silx/gui/plot/items/__init__.py9
-rw-r--r--silx/gui/plot/items/_pick.py6
-rw-r--r--silx/gui/plot/items/axis.py4
-rw-r--r--silx/gui/plot/items/complex.py11
-rw-r--r--silx/gui/plot/items/core.py223
-rw-r--r--silx/gui/plot/items/curve.py64
-rw-r--r--silx/gui/plot/items/histogram.py20
-rw-r--r--silx/gui/plot/items/image.py116
-rwxr-xr-xsilx/gui/plot/items/marker.py22
-rw-r--r--silx/gui/plot/items/roi.py3025
-rw-r--r--silx/gui/plot/items/scatter.py286
-rw-r--r--silx/gui/plot/items/shape.py109
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)