summaryrefslogtreecommitdiff
path: root/silx/gui
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
parent33ed2a64c92b0311ae35456c016eb284e426afc2 (diff)
New upstream version 0.13.0+dfsg
Diffstat (limited to 'silx/gui')
-rw-r--r--silx/gui/_glutils/OpenGLWidget.py48
-rw-r--r--silx/gui/_glutils/font.py6
-rwxr-xr-xsilx/gui/colors.py429
-rw-r--r--silx/gui/data/DataViewer.py38
-rw-r--r--silx/gui/data/DataViews.py337
-rw-r--r--silx/gui/data/Hdf5TableView.py68
-rw-r--r--silx/gui/data/NXdataWidgets.py82
-rw-r--r--silx/gui/data/_RecordPlot.py92
-rw-r--r--silx/gui/data/test/test_arraywidget.py4
-rw-r--r--silx/gui/dialog/ColormapDialog.py1661
-rw-r--r--silx/gui/dialog/DataFileDialog.py4
-rw-r--r--silx/gui/dialog/test/test_colormapdialog.py101
-rw-r--r--silx/gui/dialog/test/test_imagefiledialog.py4
-rw-r--r--silx/gui/fit/FitWidget.py40
-rwxr-xr-xsilx/gui/hdf5/test/test_hdf5.py10
-rw-r--r--silx/gui/plot/ColorBar.py112
-rw-r--r--silx/gui/plot/ComplexImageView.py8
-rw-r--r--silx/gui/plot/CurvesROIWidget.py13
-rw-r--r--silx/gui/plot/ImageStack.py586
-rw-r--r--silx/gui/plot/ImageView.py1
-rw-r--r--silx/gui/plot/Interaction.py120
-rw-r--r--silx/gui/plot/ItemsSelectionDialog.py32
-rwxr-xr-xsilx/gui/plot/LegendSelector.py10
-rw-r--r--silx/gui/plot/MaskToolsWidget.py16
-rw-r--r--silx/gui/plot/PlotInteraction.py396
-rw-r--r--silx/gui/plot/PlotToolButtons.py142
-rwxr-xr-xsilx/gui/plot/PlotWidget.py428
-rw-r--r--silx/gui/plot/PlotWindow.py71
-rw-r--r--silx/gui/plot/Profile.py824
-rw-r--r--silx/gui/plot/ProfileMainWindow.py68
-rw-r--r--silx/gui/plot/ScatterMaskToolsWidget.py14
-rw-r--r--silx/gui/plot/ScatterView.py19
-rw-r--r--silx/gui/plot/StackView.py134
-rw-r--r--silx/gui/plot/StatsWidget.py15
-rw-r--r--silx/gui/plot/_BaseMaskToolsWidget.py155
-rw-r--r--silx/gui/plot/actions/PlotToolAction.py4
-rwxr-xr-xsilx/gui/plot/actions/control.py35
-rw-r--r--silx/gui/plot/actions/fit.py359
-rw-r--r--silx/gui/plot/actions/histogram.py201
-rw-r--r--silx/gui/plot/actions/medfilt.py4
-rwxr-xr-xsilx/gui/plot/backends/BackendBase.py34
-rwxr-xr-xsilx/gui/plot/backends/BackendMatplotlib.py252
-rwxr-xr-xsilx/gui/plot/backends/BackendOpenGL.py78
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotCurve.py15
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotImage.py122
-rw-r--r--silx/gui/plot/backends/glutils/PlotImageFile.py6
-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
-rw-r--r--silx/gui/plot/matplotlib/Colormap.py9
-rw-r--r--silx/gui/plot/matplotlib/__init__.py10
-rw-r--r--silx/gui/plot/test/__init__.py8
-rw-r--r--silx/gui/plot/test/testColorBar.py19
-rw-r--r--silx/gui/plot/test/testCurvesROIWidget.py37
-rw-r--r--silx/gui/plot/test/testImageStack.py197
-rw-r--r--silx/gui/plot/test/testInteraction.py22
-rw-r--r--silx/gui/plot/test/testItem.py4
-rw-r--r--silx/gui/plot/test/testPixelIntensityHistoAction.py55
-rwxr-xr-xsilx/gui/plot/test/testPlotWidget.py145
-rw-r--r--silx/gui/plot/test/testPlotWidgetNoBackend.py50
-rw-r--r--silx/gui/plot/test/testPlotWindow.py59
-rw-r--r--silx/gui/plot/test/testProfile.py287
-rw-r--r--silx/gui/plot/test/testStats.py35
-rw-r--r--silx/gui/plot/tools/CurveLegendsWidget.py8
-rw-r--r--silx/gui/plot/tools/PositionInfo.py9
-rw-r--r--silx/gui/plot/tools/profile/ScatterProfileToolBar.py179
-rw-r--r--silx/gui/plot/tools/profile/_BaseProfileToolBar.py430
-rw-r--r--silx/gui/plot/tools/profile/core.py522
-rw-r--r--silx/gui/plot/tools/profile/editors.py307
-rw-r--r--silx/gui/plot/tools/profile/manager.py1059
-rw-r--r--silx/gui/plot/tools/profile/rois.py1168
-rw-r--r--silx/gui/plot/tools/profile/toolbar.py172
-rw-r--r--silx/gui/plot/tools/roi.py403
-rw-r--r--silx/gui/plot/tools/test/__init__.py2
-rw-r--r--silx/gui/plot/tools/test/testProfile.py673
-rw-r--r--silx/gui/plot/tools/test/testROI.py191
-rw-r--r--silx/gui/plot/tools/test/testScatterProfileToolBar.py137
-rw-r--r--silx/gui/plot/tools/toolbars.py2
-rw-r--r--silx/gui/plot/utils/intersections.py101
-rw-r--r--silx/gui/plot3d/SFViewParamTree.py8
-rw-r--r--silx/gui/plot3d/_model/items.py75
-rw-r--r--silx/gui/plot3d/items/image.py4
-rw-r--r--silx/gui/plot3d/items/mesh.py5
-rw-r--r--silx/gui/plot3d/items/mixins.py38
-rw-r--r--silx/gui/plot3d/items/scatter.py7
-rw-r--r--silx/gui/plot3d/items/volume.py51
-rw-r--r--silx/gui/plot3d/scene/function.py93
-rw-r--r--silx/gui/plot3d/tools/GroupPropertiesWidget.py4
-rw-r--r--silx/gui/plot3d/utils/mng.py4
-rw-r--r--silx/gui/qt/_qt.py8
-rw-r--r--silx/gui/qt/_utils.py8
-rw-r--r--silx/gui/test/__init__.py5
-rwxr-xr-xsilx/gui/test/test_colors.py86
-rwxr-xr-xsilx/gui/utils/__init__.py17
-rw-r--r--silx/gui/utils/glutils.py199
-rwxr-xr-xsilx/gui/utils/qtutils.py26
-rwxr-xr-xsilx/gui/utils/test/__init__.py4
-rw-r--r--silx/gui/utils/test/test_glutils.py66
-rw-r--r--silx/gui/utils/testutils.py16
-rw-r--r--silx/gui/widgets/ElidedLabel.py137
-rwxr-xr-xsilx/gui/widgets/LegendIconWidget.py7
-rw-r--r--silx/gui/widgets/MultiModeAction.py83
-rw-r--r--silx/gui/widgets/RangeSlider.py8
-rw-r--r--silx/gui/widgets/UrlSelectionTable.py8
-rw-r--r--silx/gui/widgets/test/__init__.py2
-rw-r--r--silx/gui/widgets/test/test_elidedlabel.py111
115 files changed, 13713 insertions, 4760 deletions
diff --git a/silx/gui/_glutils/OpenGLWidget.py b/silx/gui/_glutils/OpenGLWidget.py
index c5ece9c..1f7bfae 100644
--- a/silx/gui/_glutils/OpenGLWidget.py
+++ b/silx/gui/_glutils/OpenGLWidget.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
@@ -37,39 +37,25 @@ import logging
import sys
from .. import qt
+from ..utils.glutils import isOpenGLAvailable
from .._glutils import gl
_logger = logging.getLogger(__name__)
-# Probe OpenGL availability and widget
-ERROR = '' # Error message from probing Qt OpenGL support
-_BaseOpenGLWidget = None # Qt OpenGL widget to use
-
-if hasattr(qt, 'QOpenGLWidget'): # PyQt>=5.4
- _logger.info('Using QOpenGLWidget')
- _BaseOpenGLWidget = qt.QOpenGLWidget
-
-elif not qt.HAS_OPENGL: # QtOpenGL not installed
- ERROR = '%s.QtOpenGL not available' % qt.BINDING
-
-elif qt.QApplication.instance() and not qt.QGLFormat.hasOpenGL():
- # qt.QGLFormat.hasOpenGL MUST be called with a QApplication created
- # so this is only checked if the QApplication is already created
- ERROR = 'Qt reports OpenGL not available'
+if not hasattr(qt, 'QOpenGLWidget') and not hasattr(qt, 'QGLWidget'):
+ OpenGLWidget = None
else:
- _logger.info('Using QGLWidget')
- _BaseOpenGLWidget = qt.QGLWidget
-
+ if hasattr(qt, 'QOpenGLWidget'): # PyQt>=5.4
+ _logger.info('Using QOpenGLWidget')
+ _BaseOpenGLWidget = qt.QOpenGLWidget
-# Internal class wrapping Qt OpenGL widget
-if _BaseOpenGLWidget is None:
- _logger.error('OpenGL-based widget disabled: %s', ERROR)
- _OpenGLWidget = None
+ else:
+ _logger.info('Using QGLWidget')
+ _BaseOpenGLWidget = qt.QGLWidget
-else:
class _OpenGLWidget(_BaseOpenGLWidget):
"""Wrapper over QOpenGLWidget and QGLWidget"""
@@ -119,7 +105,6 @@ else:
# Enable receiving mouse move events when no buttons are pressed
self.setMouseTracking(True)
-
def getDevicePixelRatio(self):
"""Returns the ratio device-independent / device pixel size
@@ -285,9 +270,13 @@ class OpenGLWidget(qt.QWidget):
layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(layout)
- if _OpenGLWidget is None:
+ self.__context = None
+
+ _check = isOpenGLAvailable(version=version, runtimeCheck=False)
+ if _OpenGLWidget is None or not _check:
+ _logger.error('OpenGL-based widget disabled: %s', _check.error)
self.__openGLWidget = None
- label = self._createErrorQLabel(ERROR)
+ label = self._createErrorQLabel(_check.error)
self.layout().addWidget(label)
else:
@@ -370,7 +359,10 @@ class OpenGLWidget(qt.QWidget):
if self.__openGLWidget is None:
return None
else:
- return self.__openGLWidget.context()
+ # Keep a reference on QOpenGLContext to make
+ # else PyQt5 keeps creating a new one.
+ self.__context = self.__openGLWidget.context()
+ return self.__context
def defaultFramebufferObject(self):
"""Returns the framebuffer object handle.
diff --git a/silx/gui/_glutils/font.py b/silx/gui/_glutils/font.py
index 8403c5a..6a4c489 100644
--- a/silx/gui/_glutils/font.py
+++ b/silx/gui/_glutils/font.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2016-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
@@ -128,8 +128,8 @@ def rasterText(text, font,
width = bounds.width() * devicePixelRatio + 2
# align line size to 32 bits to ease conversion to numpy array
width = 4 * ((width + 3) // 4)
- image = qt.QImage(width,
- bounds.height() * devicePixelRatio + 2,
+ image = qt.QImage(int(width),
+ int(bounds.height() * devicePixelRatio + 2),
qt.QImage.Format_RGB888)
if (devicePixelRatio != 1.0 and
hasattr(image, 'setDevicePixelRatio')): # Qt 5
diff --git a/silx/gui/colors.py b/silx/gui/colors.py
index 365b569..4d750ba 100755
--- a/silx/gui/colors.py
+++ b/silx/gui/colors.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2015-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,9 +35,8 @@ import numpy
import logging
import collections
from silx.gui import qt
-from silx import config
from silx.math.combo import min_max
-from silx.math.colormap import cmap as _cmap
+from silx.math import colormap as _colormap
from silx.utils.exceptions import NotEditableError
from silx.utils import deprecation
from silx.resources import resource_filename as _resource_filename
@@ -91,16 +90,16 @@ _LUT_DESCRIPTION = collections.namedtuple("_LUT_DESCRIPTION", ["source", "cursor
_AVAILABLE_LUTS = collections.OrderedDict([
('gray', _LUT_DESCRIPTION('builtin', 'pink', True)),
('reversed gray', _LUT_DESCRIPTION('builtin', 'pink', True)),
- ('temperature', _LUT_DESCRIPTION('builtin', 'pink', True)),
('red', _LUT_DESCRIPTION('builtin', 'green', True)),
('green', _LUT_DESCRIPTION('builtin', 'pink', True)),
('blue', _LUT_DESCRIPTION('builtin', 'yellow', True)),
- ('jet', _LUT_DESCRIPTION('matplotlib', 'pink', True)),
('viridis', _LUT_DESCRIPTION('resource', 'pink', True)),
('cividis', _LUT_DESCRIPTION('resource', 'pink', True)),
('magma', _LUT_DESCRIPTION('resource', 'green', True)),
('inferno', _LUT_DESCRIPTION('resource', 'green', True)),
('plasma', _LUT_DESCRIPTION('resource', 'green', True)),
+ ('temperature', _LUT_DESCRIPTION('builtin', 'pink', True)),
+ ('jet', _LUT_DESCRIPTION('matplotlib', 'pink', True)),
('hsv', _LUT_DESCRIPTION('matplotlib', 'black', True)),
])
"""Description for internal porpose of all the default LUT provided by the library."""
@@ -110,10 +109,6 @@ DEFAULT_MIN_LIN = 0
"""Default min value if in linear normalization"""
DEFAULT_MAX_LIN = 1
"""Default max value if in linear normalization"""
-DEFAULT_MIN_LOG = 1
-"""Default min value if in log normalization"""
-DEFAULT_MAX_LOG = 10
-"""Default max value if in log normalization"""
def rgba(color, colorDict=None):
@@ -331,6 +326,167 @@ def _getColormap(name):
return _COLORMAP_CACHE[name]
+# Normalizations
+
+class _NormalizationMixIn:
+ """Colormap normalization mix-in class"""
+
+ DEFAULT_RANGE = 0, 1
+ """Fallback for (vmin, vmax)"""
+
+ def isValid(self, value):
+ """Check if a value is in the valid range for this normalization.
+
+ Override in subclass.
+
+ :param Union[float,numpy.ndarray] value:
+ :rtype: Union[bool,numpy.ndarray]
+ """
+ if isinstance(value, collections.abc.Iterable):
+ return numpy.ones_like(value, dtype=numpy.bool_)
+ else:
+ return True
+
+ def autoscale(self, data, mode):
+ """Returns range for given data and autoscale mode.
+
+ :param Union[None,numpy.ndarray] data:
+ :param str mode: Autoscale mode, see :class:`Colormap`
+ :returns: Range as (min, max)
+ :rtype: Tuple[float,float]
+ """
+ data = None if data is None else numpy.array(data, copy=False)
+ if data is None or data.size == 0:
+ return self.DEFAULT_RANGE
+
+ if mode == Colormap.MINMAX:
+ vmin, vmax = self.autoscaleMinMax(data)
+ elif mode == Colormap.STDDEV3:
+ vmin, vmax = self.autoscaleMean3Std(data)
+ else:
+ raise ValueError('Unsupported mode: %s' % mode)
+
+ # Check returned range and handle fallbacks
+ if vmin is None or not numpy.isfinite(vmin):
+ vmin = self.DEFAULT_RANGE[0]
+ if vmax is None or not numpy.isfinite(vmax):
+ vmax = self.DEFAULT_RANGE[1]
+ if vmax < vmin:
+ vmax = vmin
+ return float(vmin), float(vmax)
+
+ def autoscaleMinMax(self, data):
+ """Autoscale using min/max
+
+ :param numpy.ndarray data:
+ :returns: (vmin, vmax)
+ :rtype: Tuple[float,float]
+ """
+ data = data[self.isValid(data)]
+ if data.size == 0:
+ return None, None
+ result = min_max(data, min_positive=False, finite=True)
+ return result.minimum, result.maximum
+
+ def autoscaleMean3Std(self, data):
+ """Autoscale using mean+/-3std
+
+ This implementation only works for normalization that do NOT
+ use the data range.
+ Override this method for normalization using the range.
+
+ :param numpy.ndarray data:
+ :returns: (vmin, vmax)
+ :rtype: Tuple[float,float]
+ """
+ # Use [0, 1] as data range for normalization not using range
+ normdata = self.apply(data, 0., 1.)
+ if normdata.dtype.kind == 'f': # Replaces inf by NaN
+ normdata[numpy.isfinite(normdata) == False] = numpy.nan
+ if normdata.size == 0: # Fallback
+ return None, None
+ mean, std = numpy.nanmean(normdata), numpy.nanstd(normdata)
+ return self.revert(mean - 3 * std, 0., 1.), self.revert(mean + 3 * std, 0., 1.)
+
+
+class _LinearNormalizationMixIn(_NormalizationMixIn):
+ """Colormap normalization mix-in class specific to autoscale taken from initial range"""
+
+ def autoscaleMean3Std(self, data):
+ """Autoscale using mean+/-3std
+
+ Do the autoscale on the data itself, not the normalized data.
+
+ :param numpy.ndarray data:
+ :returns: (vmin, vmax)
+ :rtype: Tuple[float,float]
+ """
+ if data.dtype.kind == 'f': # Replaces inf by NaN
+ data = numpy.array(data, copy=True) # Work on a copy
+ data[numpy.isfinite(data) == False] = numpy.nan
+ if data.size == 0: # Fallback
+ return None, None
+ mean, std = numpy.nanmean(data), numpy.nanstd(data)
+ return mean - 3 * std, mean + 3 * std
+
+
+class _LinearNormalization(_colormap.LinearNormalization, _LinearNormalizationMixIn):
+ """Linear normalization"""
+ def __init__(self):
+ _colormap.LinearNormalization.__init__(self)
+ _LinearNormalizationMixIn.__init__(self)
+
+
+class _LogarithmicNormalization(_colormap.LogarithmicNormalization, _NormalizationMixIn):
+ """Logarithm normalization"""
+
+ DEFAULT_RANGE = 1, 10
+
+ def __init__(self):
+ _colormap.LogarithmicNormalization.__init__(self)
+ _NormalizationMixIn.__init__(self)
+
+ def isValid(self, value):
+ return value > 0.
+
+ def autoscaleMinMax(self, data):
+ result = min_max(data, min_positive=True, finite=True)
+ return result.min_positive, result.maximum
+
+
+class _SqrtNormalization(_colormap.SqrtNormalization, _NormalizationMixIn):
+ """Square root normalization"""
+
+ DEFAULT_RANGE = 0, 1
+
+ def __init__(self):
+ _colormap.SqrtNormalization.__init__(self)
+ _NormalizationMixIn.__init__(self)
+
+ def isValid(self, value):
+ return value >= 0.
+
+
+class _GammaNormalization(_colormap.PowerNormalization, _LinearNormalizationMixIn):
+ """Gamma correction normalization:
+
+ Linear normalization to [0, 1] followed by power normalization.
+
+ :param gamma: Gamma correction factor
+ """
+ def __init__(self, gamma):
+ _colormap.PowerNormalization.__init__(self, gamma)
+ _LinearNormalizationMixIn.__init__(self)
+
+
+class _ArcsinhNormalization(_colormap.ArcsinhNormalization, _NormalizationMixIn):
+ """Inverse hyperbolic sine normalization"""
+
+ def __init__(self):
+ _colormap.ArcsinhNormalization.__init__(self)
+ _NormalizationMixIn.__init__(self)
+
+
class Colormap(qt.QObject):
"""Description of a colormap
@@ -342,10 +498,10 @@ class Colormap(qt.QObject):
either uint8 or float in [0, 1].
If 'name' is None, then this array is used as the colormap.
:param str normalization: Normalization: 'linear' (default) or 'log'
- :param float vmin:
- Lower bound of the colormap or None for autoscale (default)
- :param float vmax:
- Upper bounds of the colormap or None for autoscale (default)
+ :param vmin: Lower bound of the colormap or None for autoscale (default)
+ :type vmin: Union[None, float]
+ :param vmax: Upper bounds of the colormap or None for autoscale (default)
+ :type vmax: Union[None, float]
"""
LINEAR = 'linear'
@@ -354,17 +510,46 @@ class Colormap(qt.QObject):
LOGARITHM = 'log'
"""constant for logarithmic normalization"""
- NORMALIZATIONS = (LINEAR, LOGARITHM)
+ SQRT = 'sqrt'
+ """constant for square root normalization"""
+
+ GAMMA = 'gamma'
+ """Constant for gamma correction normalization"""
+
+ ARCSINH = 'arcsinh'
+ """constant for inverse hyperbolic sine normalization"""
+
+ _BASIC_NORMALIZATIONS = {
+ LINEAR: _LinearNormalization(),
+ LOGARITHM: _LogarithmicNormalization(),
+ SQRT: _SqrtNormalization(),
+ ARCSINH: _ArcsinhNormalization(),
+ }
+ """Normalizations without parameters"""
+
+ NORMALIZATIONS = LINEAR, LOGARITHM, SQRT, GAMMA, ARCSINH
"""Tuple of managed normalizations"""
+ MINMAX = 'minmax'
+ """constant for autoscale using min/max data range"""
+
+ STDDEV3 = 'stddev3'
+ """constant for autoscale using mean +/- 3*std(data)"""
+
+ AUTOSCALE_MODES = (MINMAX, STDDEV3)
+ """Tuple of managed auto scale algorithms"""
+
sigChanged = qt.Signal()
"""Signal emitted when the colormap has changed."""
- def __init__(self, name=None, colors=None, normalization=LINEAR, vmin=None, vmax=None):
+ def __init__(self, name=None, colors=None, normalization=LINEAR, vmin=None, vmax=None, autoscaleMode=MINMAX):
qt.QObject.__init__(self)
self._editable = True
+ self.__gamma = 2.0
assert normalization in Colormap.NORMALIZATIONS
+ assert autoscaleMode in Colormap.AUTOSCALE_MODES
+
if normalization is Colormap.LOGARITHM:
if (vmin is not None and vmin < 0) or (vmax is not None and vmax < 0):
m = "Unsuported vmin (%s) and/or vmax (%s) given for a log scale."
@@ -395,6 +580,7 @@ class Colormap(qt.QObject):
self.setName("gray")
self._normalization = str(normalization)
+ self._autoscaleMode = str(autoscaleMode)
self._vmin = float(vmin) if vmin is not None else None
self._vmax = float(vmax) if vmax is not None else None
@@ -432,11 +618,12 @@ class Colormap(qt.QObject):
if nbColors is None:
return numpy.array(self._colors, copy=True)
else:
+ nbColors = int(nbColors)
colormap = self.copy()
colormap.setNormalization(Colormap.LINEAR)
- colormap.setVRange(vmin=None, vmax=None)
+ colormap.setVRange(vmin=0, vmax=nbColors - 1)
colors = colormap.applyToData(
- numpy.arange(int(nbColors), dtype=numpy.int))
+ numpy.arange(nbColors, dtype=numpy.int))
return colors
def getName(self):
@@ -503,7 +690,9 @@ class Colormap(qt.QObject):
self.sigChanged.emit()
def getNormalization(self):
- """Return the normalization of the colormap ('log' or 'linear')
+ """Return the normalization of the colormap.
+
+ See :meth:`setNormalization` for returned values.
:return: the normalization of the colormap
:rtype: str
@@ -511,15 +700,58 @@ class Colormap(qt.QObject):
return self._normalization
def setNormalization(self, norm):
- """Set the norm ('log', 'linear')
+ """Set the colormap normalization.
+
+ Accepted normalizations: 'log', 'linear', 'sqrt'
:param str norm: the norm to set
"""
+ assert norm in self.NORMALIZATIONS
if self.isEditable() is False:
raise NotEditableError('Colormap is not editable')
self._normalization = str(norm)
self.sigChanged.emit()
+ def setGammaNormalizationParameter(self, gamma: float) -> None:
+ """Set the gamma correction parameter.
+
+ Only used for gamma correction normalization.
+
+ :param float gamma:
+ :raise ValueError: If gamma is not valid
+ """
+ if gamma < 0. or not numpy.isfinite(gamma):
+ raise ValueError("Gamma value not supported")
+ if gamma != self.__gamma:
+ self.__gamma = gamma
+ self.sigChanged.emit()
+
+ def getGammaNormalizationParameter(self) -> float:
+ """Returns the gamma correction parameter value.
+
+ :rtype: float
+ """
+ return self.__gamma
+
+ def getAutoscaleMode(self):
+ """Return the autoscale mode of the colormap ('minmax' or 'stddev3')
+
+ :rtype: str
+ """
+ return self._autoscaleMode
+
+ def setAutoscaleMode(self, mode):
+ """Set the autoscale mode: either 'minmax' or 'stddev3'
+
+ :param str mode: the mode to set
+ """
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
+ assert mode in self.AUTOSCALE_MODES
+ if mode != self._autoscaleMode:
+ self._autoscaleMode = mode
+ self.sigChanged.emit()
+
def isAutoscale(self):
"""Return True if both min and max are in autoscale mode"""
return self._vmin is None and self._vmax is None
@@ -593,49 +825,57 @@ class Colormap(qt.QObject):
self._editable = editable
self.sigChanged.emit()
+ def _getNormalizer(self):
+ """Returns normalizer object"""
+ normalization = self.getNormalization()
+ if normalization == self.GAMMA:
+ return _GammaNormalization(self.getGammaNormalizationParameter())
+ else:
+ return self._BASIC_NORMALIZATIONS[normalization]
+
+ def _computeAutoscaleRange(self, data):
+ """Compute the data range which will be used in autoscale mode.
+
+ :param numpy.ndarray data: The data for which to compute the range
+ :return: (vmin, vmax) range
+ """
+ return self._getNormalizer().autoscale(
+ data, mode=self.getAutoscaleMode())
+
def getColormapRange(self, data=None):
- """Return (vmin, vmax)
+ """Return (vmin, vmax) the range of the colormap for the given data or item.
- :return: the tuple vmin, vmax fitting vmin, vmax, normalization and
- data if any given
+ :param Union[numpy.ndarray,~silx.gui.plot.items.ColormapMixIn] data:
+ The data or item to use for autoscale bounds.
+ :return: (vmin, vmax) corresponding to the colormap applied to data if provided.
:rtype: tuple
"""
vmin = self._vmin
vmax = self._vmax
assert vmin is None or vmax is None or vmin <= vmax # TODO handle this in setters
- if self.getNormalization() == self.LOGARITHM:
- # Handle negative bounds as autoscale
- if vmin is not None and (vmin is not None and vmin <= 0.):
- mess = 'negative vmin, moving to autoscale for lower bound'
- _logger.warning(mess)
- vmin = None
- if vmax is not None and (vmax is not None and vmax <= 0.):
- mess = 'negative vmax, moving to autoscale for upper bound'
- _logger.warning(mess)
- vmax = None
+ normalizer = self._getNormalizer()
+
+ # Handle invalid bounds as autoscale
+ if vmin is not None and not normalizer.isValid(vmin):
+ _logger.info(
+ 'Invalid vmin, switching to autoscale for lower bound')
+ vmin = None
+ if vmax is not None and not normalizer.isValid(vmax):
+ _logger.info(
+ 'Invalid vmax, switching to autoscale for upper bound')
+ vmax = None
if vmin is None or vmax is None: # Handle autoscale
- # Get min/max from data
- if data is not None:
- data = numpy.array(data, copy=False)
- if data.size == 0: # Fallback an array but no data
- min_, max_ = self._getDefaultMin(), self._getDefaultMax()
- else:
- if self.getNormalization() == self.LOGARITHM:
- result = min_max(data, min_positive=True, finite=True)
- min_ = result.min_positive # >0 or None
- max_ = result.maximum # can be <= 0
- else:
- min_, max_ = min_max(data, min_positive=False, finite=True)
-
- # Handle fallback
- if min_ is None or not numpy.isfinite(min_):
- min_ = self._getDefaultMin()
- if max_ is None or not numpy.isfinite(max_):
- max_ = self._getDefaultMax()
- else: # Fallback if no data is provided
- min_, max_ = self._getDefaultMin(), self._getDefaultMax()
+ from .plot.items.core import ColormapMixIn # avoid cyclic import
+ if isinstance(data, ColormapMixIn):
+ min_, max_ = data._getColormapAutoscaleRange(self)
+ # Make sure min_, max_ are not None
+ min_ = normalizer.DEFAULT_RANGE[0] if min_ is None else min_
+ max_ = normalizer.DEFAULT_RANGE[1] if max_ is None else max_
+ else:
+ min_, max_ = normalizer.autoscale(
+ data, mode=self.getAutoscaleMode())
if vmin is None: # Set vmin respecting provided vmax
vmin = min_ if vmax is None else min(min_, vmax)
@@ -645,6 +885,15 @@ class Colormap(qt.QObject):
return vmin, vmax
+ def getVRange(self):
+ """Get the bounds of the colormap
+
+ :rtype: Tuple(Union[float,None],Union[float,None])
+ :returns: A tuple of 2 values for min and max. Or None instead of float
+ for autoscale
+ """
+ return self.getVMin(), self.getVMax()
+
def setVRange(self, vmin, vmax):
"""Set the bounds of the colormap
@@ -681,6 +930,8 @@ class Colormap(qt.QObject):
return self.getVMax()
elif item == 'colors':
return self.getColormapLUT()
+ elif item == 'autoscaleMode':
+ return self.getAutoscaleMode()
else:
raise KeyError(item)
@@ -697,8 +948,9 @@ class Colormap(qt.QObject):
'vmin': self._vmin,
'vmax': self._vmax,
'autoscale': self.isAutoscale(),
- 'normalization': self._normalization
- }
+ 'normalization': self.getNormalization(),
+ 'autoscaleMode': self.getAutoscaleMode(),
+ }
def _setFromDict(self, dic):
"""Set values to the colormap from a dictionary
@@ -728,7 +980,12 @@ class Colormap(qt.QObject):
err = 'The colormap should have a name defined or a tuple of colors'
raise ValueError(err)
if normalization not in Colormap.NORMALIZATIONS:
- err = 'Given normalization is not recoginized (%s)' % normalization
+ err = 'Given normalization is not recognized (%s)' % normalization
+ raise ValueError(err)
+
+ autoscaleMode = dic.get('autoscaleMode', Colormap.MINMAX)
+ if autoscaleMode not in Colormap.AUTOSCALE_MODES:
+ err = 'Given autoscale mode is not recognized (%s)' % autoscaleMode
raise ValueError(err)
# If autoscale, then set boundaries to None
@@ -743,6 +1000,7 @@ class Colormap(qt.QObject):
self._vmax = vmax
self._autoscale = True if (vmin is None and vmax is None) else False
self._normalization = normalization
+ self._autoscaleMode = autoscaleMode
self.sigChanged.emit()
@@ -757,20 +1015,33 @@ class Colormap(qt.QObject):
:rtype: silx.gui.colors.Colormap
"""
- return Colormap(name=self._name,
+ colormap = Colormap(name=self._name,
colors=self.getColormapLUT(),
vmin=self._vmin,
vmax=self._vmax,
- normalization=self._normalization)
+ normalization=self.getNormalization(),
+ autoscaleMode=self.getAutoscaleMode())
+ colormap.setGammaNormalizationParameter(
+ self.getGammaNormalizationParameter())
+ return colormap
- def applyToData(self, data):
+ def applyToData(self, data, reference=None):
"""Apply the colormap to the data
- :param numpy.ndarray data: The data to convert.
+ :param Union[numpy.ndarray,~silx.gui.plot.item.ColormapMixIn] data:
+ The data to convert or the item for which to apply the colormap.
+ :param Union[numpy.ndarray,~silx.gui.plot.item.ColormapMixIn,None] reference:
+ The data or item to use as reference to compute autoscale
"""
- vmin, vmax = self.getColormapRange(data)
- normalization = self.getNormalization()
- return _cmap(data, self._colors, vmin, vmax, normalization)
+ if reference is None:
+ reference = data
+ vmin, vmax = self.getColormapRange(reference)
+
+ if hasattr(data, "getColormappedData"): # Use item's data
+ data = data.getColormappedData()
+
+ return _colormap.cmap(
+ data, self._colors, vmin, vmax, self._getNormalizer())
@staticmethod
def getSupportedColormaps():
@@ -796,26 +1067,26 @@ class Colormap(qt.QObject):
def __str__(self):
return str(self._toDict())
- def _getDefaultMin(self):
- return DEFAULT_MIN_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MIN_LOG
-
- def _getDefaultMax(self):
- return DEFAULT_MAX_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MAX_LOG
-
def __eq__(self, other):
"""Compare colormap values and not pointers"""
if other is None:
return False
if not isinstance(other, Colormap):
return False
+ if self.getNormalization() != other.getNormalization():
+ return False
+ if self.getNormalization() == self.GAMMA:
+ delta = self.getGammaNormalizationParameter() - other.getGammaNormalizationParameter()
+ if abs(delta) > 0.001:
+ return False
return (self.getName() == other.getName() and
- self.getNormalization() == other.getNormalization() and
+ self.getAutoscaleMode() == other.getAutoscaleMode() and
self.getVMin() == other.getVMin() and
self.getVMax() == other.getVMax() and
numpy.array_equal(self.getColormapLUT(), other.getColormapLUT())
)
- _SERIAL_VERSION = 1
+ _SERIAL_VERSION = 2
def restoreState(self, byteArray):
"""
@@ -835,7 +1106,7 @@ class Colormap(qt.QObject):
return False
version = stream.readUInt32()
- if version != self._SERIAL_VERSION:
+ if version not in (1, self._SERIAL_VERSION):
_logger.warning("Serial version mismatch. Found %d." % version)
return False
@@ -850,14 +1121,27 @@ class Colormap(qt.QObject):
vmax = stream.readQVariant()
else:
vmax = None
+
normalization = stream.readQString()
+ if normalization == Colormap.GAMMA:
+ gamma = stream.readFloat()
+ else:
+ gamma = None
+
+ if version == 1:
+ autoscaleMode = Colormap.MINMAX
+ else:
+ autoscaleMode = stream.readQString()
# emit change event only once
old = self.blockSignals(True)
try:
self.setName(name)
self.setNormalization(normalization)
+ self.setAutoscaleMode(autoscaleMode)
self.setVRange(vmin, vmax)
+ if gamma is not None:
+ self.setGammaNormalizationParameter(gamma)
finally:
self.blockSignals(old)
self.sigChanged.emit()
@@ -882,6 +1166,9 @@ class Colormap(qt.QObject):
if self.getVMax() is not None:
stream.writeQVariant(self.getVMax())
stream.writeQString(self.getNormalization())
+ if self.getNormalization() == Colormap.GAMMA:
+ stream.writeFloat(self.getGammaNormalizationParameter())
+ stream.writeQString(self.getAutoscaleMode())
return data
diff --git a/silx/gui/data/DataViewer.py b/silx/gui/data/DataViewer.py
index bad4362..2e51439 100644
--- a/silx/gui/data/DataViewer.py
+++ b/silx/gui/data/DataViewer.py
@@ -27,10 +27,12 @@ view from the ones provided by silx.
"""
from __future__ import division
-from silx.gui.data import DataViews
-from silx.gui.data.DataViews import _normalizeData
import logging
+import os.path
+import collections
from silx.gui import qt
+from silx.gui.data import DataViews
+from silx.gui.data.DataViews import _normalizeData
from silx.gui.utils import blockSignals
from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector
@@ -43,6 +45,11 @@ __date__ = "12/02/2019"
_logger = logging.getLogger(__name__)
+DataSelection = collections.namedtuple("DataSelection",
+ ["filename", "datapath",
+ "slice", "permutation"])
+
+
class DataViewer(qt.QFrame):
"""Widget to display any kind of data
@@ -150,6 +157,7 @@ class DataViewer(qt.QFrame):
DataViews._Plot3dView,
DataViews._RawView,
DataViews._StackView,
+ DataViews._Plot2dRecordView,
]
views = []
for viewClass in viewClasses:
@@ -238,14 +246,39 @@ class DataViewer(qt.QFrame):
"""
if self.__useAxisSelection:
self.__displayedData = self.__numpySelection.selectedData()
+
+ permutation = self.__numpySelection.permutation()
+ normal = tuple(range(len(permutation)))
+ if permutation == normal:
+ permutation = None
+ slicing = self.__numpySelection.selection()
+ normal = tuple([slice(None)] * len(slicing))
+ if slicing == normal:
+ slicing = None
else:
self.__displayedData = self.__data
+ permutation = None
+ slicing = None
+
+ try:
+ filename = os.path.abspath(self.__data.file.filename)
+ except:
+ filename = None
+
+ try:
+ datapath = self.__data.name
+ except:
+ datapath = None
+
+ # FIXME: maybe use DataUrl, with added support of permutation
+ self.__displayedSelection = DataSelection(filename, datapath, slicing, permutation)
# TODO: would be good to avoid that, it should be synchonous
qt.QTimer.singleShot(10, self.__setDataInView)
def __setDataInView(self):
self.__currentView.setData(self.__displayedData)
+ self.__currentView.setDataSelection(self.__displayedSelection)
def setDisplayedView(self, view):
"""Set the displayed view.
@@ -468,6 +501,7 @@ class DataViewer(qt.QFrame):
self.__data = data
self._invalidateInfo()
self.__displayedData = None
+ self.__displayedSelection = None
self.__updateView()
self.__updateNumpySelectionAxis()
self.__updateDataInView()
diff --git a/silx/gui/data/DataViews.py b/silx/gui/data/DataViews.py
index eb635c4..f3b02b9 100644
--- a/silx/gui/data/DataViews.py
+++ b/silx/gui/data/DataViews.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2016-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
@@ -29,6 +29,7 @@ from collections import OrderedDict
import logging
import numbers
import numpy
+import os
import silx.io
from silx.utils import deprecation
@@ -50,6 +51,7 @@ _logger = logging.getLogger(__name__)
# DataViewer modes
EMPTY_MODE = 0
PLOT1D_MODE = 10
+RECORD_PLOT_MODE = 15
IMAGE_MODE = 20
PLOT2D_MODE = 21
COMPLEX_IMAGE_MODE = 22
@@ -114,6 +116,7 @@ class DataInfo(object):
self.isRecord = False
self.hasNXdata = False
self.isInvalidNXdata = False
+ self.countNumericColumns = 0
self.shape = tuple()
self.dim = 0
self.size = 0
@@ -200,6 +203,12 @@ class DataInfo(object):
else:
self.size = 1
+ if hasattr(data, "dtype"):
+ if data.dtype.fields is not None:
+ for field in data.dtype.fields:
+ if numpy.issubdtype(data.dtype[field], numpy.number):
+ self.countNumericColumns += 1
+
def normalizeData(self, data):
"""Returns a normalized data if the embed a numpy or a dataset.
Else returns the data."""
@@ -223,6 +232,9 @@ class DataViewHooks(object):
"""Returns a color dialog for this view."""
return None
+ def viewWidgetCreated(self, view, plot):
+ """Called when the widget of the view was created"""
+ return
class DataView(object):
"""Holder for the data view."""
@@ -231,6 +243,12 @@ class DataView(object):
"""Priority returned when the requested data can't be displayed by the
view."""
+ TITLE_PATTERN = "{datapath}{slicing} {permuted}"
+ """Pattern used to format the title of the plot.
+
+ Supported fields: `{directory}`, `{filename}`, `{datapath}`, `{slicing}`, `{permuted}`.
+ """
+
def __init__(self, parent, modeId=None, icon=None, label=None):
"""Constructor
@@ -334,6 +352,9 @@ class DataView(object):
"""
if self.__widget is None:
self.__widget = self.createWidget(self.__parent)
+ hooks = self.getHooks()
+ if hooks is not None:
+ hooks.viewWidgetCreated(self, self.__widget)
return self.__widget
def createWidget(self, parent):
@@ -356,6 +377,70 @@ class DataView(object):
"""
return None
+ def __formatSlices(self, indices):
+ """Format an iterable of slice objects
+
+ :param indices: The slices to format
+ :type indices: Union[None,List[Union[slice,int]]]
+ :rtype: str
+ """
+ if indices is None:
+ return ''
+
+ def formatSlice(slice_):
+ start, stop, step = slice_.start, slice_.stop, slice_.step
+ string = ('' if start is None else str(start)) + ':'
+ if stop is not None:
+ string += str(stop)
+ if step not in (None, 1):
+ string += ':' + step
+ return string
+
+ return '[' + ', '.join(
+ formatSlice(index) if isinstance(index, slice) else str(index)
+ for index in indices) + ']'
+
+ def titleForSelection(self, selection):
+ """Build title from given selection information.
+
+ :param NamedTuple selection: Data selected
+ :rtype: str
+ """
+ if selection is None:
+ return None
+ else:
+ directory, filename = os.path.split(selection.filename)
+ try:
+ slicing = self.__formatSlices(selection.slice)
+ except Exception:
+ _logger.debug("Error while formatting slices", exc_info=True)
+ slicing = '[sliced]'
+
+ permuted = '(permuted)' if selection.permutation is not None else ''
+
+ try:
+ title = self.TITLE_PATTERN.format(
+ directory=directory,
+ filename=filename,
+ datapath=selection.datapath,
+ slicing=slicing,
+ permuted=permuted)
+ except Exception:
+ _logger.debug("Error while formatting title", exc_info=True)
+ title = selection.datapath + slicing
+
+ return title
+
+ def setDataSelection(self, selection):
+ """Set the data selection displayed by the view
+
+ If called, it have to be called directly after `setData`.
+
+ :param selection: Data selected
+ :type selection: NamedTuple
+ """
+ pass
+
def axesNames(self, data, info):
"""Returns names of the expected axes of the view, according to the
input data. A none value will disable the default axes selectior.
@@ -579,6 +664,11 @@ class SelectOneDataView(_CompositeDataView):
self.__updateDisplayedView()
self.__currentView.setData(data)
+ def setDataSelection(self, selection):
+ if self.__currentView is None:
+ return
+ self.__currentView.setDataSelection(selection)
+
def axesNames(self, data, info):
view = self.__getBestView(data, info)
self.__currentView = view
@@ -799,12 +889,18 @@ class _Plot1dView(DataView):
def setData(self, data):
data = self.normalizeData(data)
- self.getWidget().addCurve(legend="data",
- x=range(len(data)),
- y=data,
- resetzoom=self.__resetZoomNextTime)
+ plotWidget = self.getWidget()
+ legend = "data"
+ plotWidget.addCurve(legend=legend,
+ x=range(len(data)),
+ y=data,
+ resetzoom=self.__resetZoomNextTime)
+ plotWidget.setActiveCurve(legend)
self.__resetZoomNextTime = True
+ def setDataSelection(self, selection):
+ self.getWidget().setGraphTitle(self.titleForSelection(selection))
+
def axesNames(self, data, info):
return ["y"]
@@ -825,6 +921,107 @@ class _Plot1dView(DataView):
return 10
+class _Plot2dRecordView(DataView):
+ def __init__(self, parent):
+ super(_Plot2dRecordView, self).__init__(
+ parent=parent,
+ modeId=RECORD_PLOT_MODE,
+ label="Curve",
+ icon=icons.getQIcon("view-1d"))
+ self.__resetZoomNextTime = True
+ self._data = None
+ self._xAxisDropDown = None
+ self._yAxisDropDown = None
+ self.__fields = None
+
+ def createWidget(self, parent):
+ from ._RecordPlot import RecordPlot
+ return RecordPlot(parent=parent)
+
+ def clear(self):
+ self.getWidget().clear()
+ self.__resetZoomNextTime = True
+
+ def normalizeData(self, data):
+ data = DataView.normalizeData(self, data)
+ data = _normalizeComplex(data)
+ return data
+
+ def setData(self, data):
+ self._data = self.normalizeData(data)
+
+ all_fields = sorted(self._data.dtype.fields.items(), key=lambda e: e[1][1])
+ numeric_fields = [f[0] for f in all_fields if numpy.issubdtype(f[1][0], numpy.number)]
+ if numeric_fields == self.__fields: # Reuse previously selected fields
+ fieldNameX = self.getWidget().getXAxisFieldName()
+ fieldNameY = self.getWidget().getYAxisFieldName()
+ else:
+ self.__fields = numeric_fields
+
+ self.getWidget().setSelectableXAxisFieldNames(numeric_fields)
+ self.getWidget().setSelectableYAxisFieldNames(numeric_fields)
+ fieldNameX = None
+ fieldNameY = numeric_fields[0]
+
+ # If there is a field called time, use it for the x-axis by default
+ if "time" in numeric_fields:
+ fieldNameX = "time"
+ # Use the first field that is not "time" for the y-axis
+ if fieldNameY == "time" and len(numeric_fields) >= 2:
+ fieldNameY = numeric_fields[1]
+
+ self._plotData(fieldNameX, fieldNameY)
+
+ if not self._xAxisDropDown:
+ self._xAxisDropDown = self.getWidget().getAxesSelectionToolBar().getXAxisDropDown()
+ self._yAxisDropDown = self.getWidget().getAxesSelectionToolBar().getYAxisDropDown()
+ self._xAxisDropDown.activated.connect(self._onAxesSelectionChaned)
+ self._yAxisDropDown.activated.connect(self._onAxesSelectionChaned)
+
+ def setDataSelection(self, selection):
+ self.getWidget().setGraphTitle(self.titleForSelection(selection))
+
+ def _onAxesSelectionChaned(self):
+ fieldNameX = self._xAxisDropDown.currentData()
+ self._plotData(fieldNameX, self._yAxisDropDown.currentText())
+
+ def _plotData(self, fieldNameX, fieldNameY):
+ self.clear()
+ ydata = self._data[fieldNameY]
+ if fieldNameX is None:
+ xdata = numpy.arange(len(ydata))
+ else:
+ xdata = self._data[fieldNameX]
+ self.getWidget().addCurve(legend="data",
+ x=xdata,
+ y=ydata,
+ resetzoom=self.__resetZoomNextTime)
+ self.getWidget().setXAxisFieldName(fieldNameX)
+ self.getWidget().setYAxisFieldName(fieldNameY)
+ self.__resetZoomNextTime = True
+
+ def axesNames(self, data, info):
+ return ["data"]
+
+ def getDataPriority(self, data, info):
+ if info.size <= 0:
+ return DataView.UNSUPPORTED
+ if data is None or not info.isRecord:
+ return DataView.UNSUPPORTED
+ if info.dim < 1:
+ return DataView.UNSUPPORTED
+ if info.countNumericColumns < 2:
+ return DataView.UNSUPPORTED
+ if info.interpretation == "spectrum":
+ return 1000
+ if info.dim == 2 and info.shape[0] == 1:
+ return 210
+ if info.dim == 1:
+ return 40
+ else:
+ return 10
+
+
class _Plot2dView(DataView):
"""View displaying data using a 2d plot"""
@@ -863,6 +1060,9 @@ class _Plot2dView(DataView):
resetzoom=self.__resetZoomNextTime)
self.__resetZoomNextTime = False
+ def setDataSelection(self, selection):
+ self.getWidget().setGraphTitle(self.titleForSelection(selection))
+
def axesNames(self, data, info):
return ["y", "x"]
@@ -969,6 +1169,10 @@ class _ComplexImageView(DataView):
data = self.normalizeData(data)
self.getWidget().setData(data)
+ def setDataSelection(self, selection):
+ self.getWidget().getPlot().setGraphTitle(
+ self.titleForSelection(selection))
+
def axesNames(self, data, info):
return ["y", "x"]
@@ -1045,7 +1249,7 @@ class _StackView(DataView):
from silx.gui import plot
widget = plot.StackView(parent=parent)
widget.setColormap(self.defaultColormap())
- widget.getPlot().getColormapAction().setColorDialog(self.defaultColorDialog())
+ widget.getPlotWidget().getColormapAction().setColorDialog(self.defaultColorDialog())
widget.setKeepDataAspectRatio(True)
widget.setLabels(self.axesNames(None, None))
# hide default option panel
@@ -1068,6 +1272,11 @@ class _StackView(DataView):
self.getWidget().setColormap(self.defaultColormap())
self.__resetZoomNextTime = False
+ def setDataSelection(self, selection):
+ title = self.titleForSelection(selection)
+ self.getWidget().setTitleCallback(
+ lambda idx: "%s z=%d" % (title, idx))
+
def axesNames(self, data, info):
return ["depth", "y", "x"]
@@ -1337,12 +1546,26 @@ class _InvalidNXdataView(DataView):
return 100
-class _NXdataScalarView(DataView):
+class _NXdataBaseDataView(DataView):
+ """Base class for NXdata DataView"""
+
+ def __init__(self, *args, **kwargs):
+ DataView.__init__(self, *args, **kwargs)
+
+ def _updateColormap(self, nxdata):
+ """Update used colormap according to nxdata's SILX_style"""
+ cmap_norm = nxdata.plot_style.signal_scale_type
+ if cmap_norm is not None:
+ self.defaultColormap().setNormalization(
+ 'log' if cmap_norm == 'log' else 'linear')
+
+
+class _NXdataScalarView(_NXdataBaseDataView):
"""DataView using a table view for displaying NXdata scalars:
0-D signal or n-D signal with *@interpretation=scalar*"""
def __init__(self, parent):
- DataView.__init__(self, parent,
- modeId=NXDATA_SCALAR_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent, modeId=NXDATA_SCALAR_MODE)
def createWidget(self, parent):
from silx.gui.data.ArrayTableWidget import ArrayTableWidget
@@ -1375,7 +1598,7 @@ class _NXdataScalarView(DataView):
return DataView.UNSUPPORTED
-class _NXdataCurveView(DataView):
+class _NXdataCurveView(_NXdataBaseDataView):
"""DataView using a Plot1D for displaying NXdata curves:
1-D signal or n-D signal with *@interpretation=spectrum*.
@@ -1383,8 +1606,8 @@ class _NXdataCurveView(DataView):
a 1-D signal with one axis whose values are not monotonically increasing.
"""
def __init__(self, parent):
- DataView.__init__(self, parent,
- modeId=NXDATA_CURVE_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent, modeId=NXDATA_CURVE_MODE)
def createWidget(self, parent):
from silx.gui.data.NXdataWidgets import ArrayCurvePlot
@@ -1422,7 +1645,9 @@ class _NXdataCurveView(DataView):
self.getWidget().setCurvesData([nxd.signal] + nxd.auxiliary_signals, nxd.axes[-1],
yerror=nxd.errors, xerror=x_errors,
ylabels=signals_names, xlabel=nxd.axes_names[-1],
- title=nxd.title or signals_names[0])
+ title=nxd.title or signals_names[0],
+ xscale=nxd.plot_style.axes_scale_types[-1],
+ yscale=nxd.plot_style.signal_scale_type)
def getDataPriority(self, data, info):
data = self.normalizeData(data)
@@ -1432,16 +1657,19 @@ class _NXdataCurveView(DataView):
return DataView.UNSUPPORTED
-class _NXdataXYVScatterView(DataView):
+class _NXdataXYVScatterView(_NXdataBaseDataView):
"""DataView using a Plot1D for displaying NXdata 3D scatters as
a scatter of coloured points (1-D signal with 2 axes)"""
def __init__(self, parent):
- DataView.__init__(self, parent,
- modeId=NXDATA_XYVSCATTER_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent, modeId=NXDATA_XYVSCATTER_MODE)
def createWidget(self, parent):
from silx.gui.data.NXdataWidgets import XYVScatterPlot
widget = XYVScatterPlot(parent)
+ widget.getScatterView().setColormap(self.defaultColormap())
+ widget.getScatterView().getScatterToolBar().getColormapAction().setColorDialog(
+ self.defaultColorDialog())
return widget
def axesNames(self, data, info):
@@ -1472,11 +1700,15 @@ class _NXdataXYVScatterView(DataView):
else:
y_errors = None
+ self._updateColormap(nxd)
+
self.getWidget().setScattersData(y_axis, x_axis, values=[nxd.signal] + nxd.auxiliary_signals,
yerror=y_errors, xerror=x_errors,
ylabel=y_label, xlabel=x_label,
title=nxd.title,
- scatter_titles=[nxd.signal_name] + nxd.auxiliary_signals_names)
+ scatter_titles=[nxd.signal_name] + nxd.auxiliary_signals_names,
+ xscale=nxd.plot_style.axes_scale_types[-2],
+ yscale=nxd.plot_style.axes_scale_types[-1])
def getDataPriority(self, data, info):
data = self.normalizeData(data)
@@ -1488,12 +1720,12 @@ class _NXdataXYVScatterView(DataView):
return DataView.UNSUPPORTED
-class _NXdataImageView(DataView):
+class _NXdataImageView(_NXdataBaseDataView):
"""DataView using a Plot2D for displaying NXdata images:
2-D signal or n-D signals with *@interpretation=image*."""
def __init__(self, parent):
- DataView.__init__(self, parent,
- modeId=NXDATA_IMAGE_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent, modeId=NXDATA_IMAGE_MODE)
def createWidget(self, parent):
from silx.gui.data.NXdataWidgets import ArrayImagePlot
@@ -1514,17 +1746,21 @@ class _NXdataImageView(DataView):
nxd = nxdata.get_default(data, validate=False)
isRgba = nxd.interpretation == "rgba-image"
+ self._updateColormap(nxd)
+
# last two axes are Y & X
img_slicing = slice(-2, None) if not isRgba else slice(-3, -1)
y_axis, x_axis = nxd.axes[img_slicing]
y_label, x_label = nxd.axes_names[img_slicing]
+ y_scale, x_scale = nxd.plot_style.axes_scale_types[img_slicing]
self.getWidget().setImageData(
[nxd.signal] + nxd.auxiliary_signals,
x_axis=x_axis, y_axis=y_axis,
signals_names=[nxd.signal_name] + nxd.auxiliary_signals_names,
xlabel=x_label, ylabel=y_label,
- title=nxd.title, isRgba=isRgba)
+ title=nxd.title, isRgba=isRgba,
+ xscale=x_scale, yscale=y_scale)
def getDataPriority(self, data, info):
data = self.normalizeData(data)
@@ -1536,12 +1772,12 @@ class _NXdataImageView(DataView):
return DataView.UNSUPPORTED
-class _NXdataComplexImageView(DataView):
+class _NXdataComplexImageView(_NXdataBaseDataView):
"""DataView using a ComplexImageView for displaying NXdata complex images:
2-D signal or n-D signals with *@interpretation=image*."""
def __init__(self, parent):
- DataView.__init__(self, parent,
- modeId=NXDATA_IMAGE_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent, modeId=NXDATA_IMAGE_MODE)
def createWidget(self, parent):
from silx.gui.data.NXdataWidgets import ArrayComplexImagePlot
@@ -1556,6 +1792,8 @@ class _NXdataComplexImageView(DataView):
data = self.normalizeData(data)
nxd = nxdata.get_default(data, validate=False)
+ self._updateColormap(nxd)
+
# last two axes are Y & X
img_slicing = slice(-2, None)
y_axis, x_axis = nxd.axes[img_slicing]
@@ -1583,16 +1821,16 @@ class _NXdataComplexImageView(DataView):
return DataView.UNSUPPORTED
-class _NXdataStackView(DataView):
+class _NXdataStackView(_NXdataBaseDataView):
def __init__(self, parent):
- DataView.__init__(self, parent,
- modeId=NXDATA_STACK_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent, modeId=NXDATA_STACK_MODE)
def createWidget(self, parent):
from silx.gui.data.NXdataWidgets import ArrayStackPlot
widget = ArrayStackPlot(parent)
widget.getStackView().setColormap(self.defaultColormap())
- widget.getStackView().getPlot().getColormapAction().setColorDialog(self.defaultColorDialog())
+ widget.getStackView().getPlotWidget().getColormapAction().setColorDialog(self.defaultColorDialog())
return widget
def axesNames(self, data, info):
@@ -1610,6 +1848,8 @@ class _NXdataStackView(DataView):
z_label, y_label, x_label = nxd.axes_names[-3:]
title = nxd.title or signal_name
+ self._updateColormap(nxd)
+
widget = self.getWidget()
widget.setStackData(
nxd.signal, x_axis=x_axis, y_axis=y_axis, z_axis=z_axis,
@@ -1628,12 +1868,13 @@ class _NXdataStackView(DataView):
return DataView.UNSUPPORTED
-class _NXdataVolumeView(DataView):
+class _NXdataVolumeView(_NXdataBaseDataView):
def __init__(self, parent):
- DataView.__init__(self, parent,
- label="NXdata (3D)",
- icon=icons.getQIcon("view-nexus"),
- modeId=NXDATA_VOLUME_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent,
+ label="NXdata (3D)",
+ icon=icons.getQIcon("view-nexus"),
+ modeId=NXDATA_VOLUME_MODE)
try:
import silx.gui.plot3d # noqa
except ImportError:
@@ -1642,7 +1883,7 @@ class _NXdataVolumeView(DataView):
raise
def normalizeData(self, data):
- data = DataView.normalizeData(self, data)
+ data = super(_NXdataVolumeView, self).normalizeData(data)
data = _normalizeComplex(data)
return data
@@ -1682,18 +1923,19 @@ class _NXdataVolumeView(DataView):
return DataView.UNSUPPORTED
-class _NXdataVolumeAsStackView(DataView):
+class _NXdataVolumeAsStackView(_NXdataBaseDataView):
def __init__(self, parent):
- DataView.__init__(self, parent,
- label="NXdata (2D)",
- icon=icons.getQIcon("view-nexus"),
- modeId=NXDATA_VOLUME_AS_STACK_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent,
+ label="NXdata (2D)",
+ icon=icons.getQIcon("view-nexus"),
+ modeId=NXDATA_VOLUME_AS_STACK_MODE)
def createWidget(self, parent):
from silx.gui.data.NXdataWidgets import ArrayStackPlot
widget = ArrayStackPlot(parent)
widget.getStackView().setColormap(self.defaultColormap())
- widget.getStackView().getPlot().getColormapAction().setColorDialog(self.defaultColorDialog())
+ widget.getStackView().getPlotWidget().getColormapAction().setColorDialog(self.defaultColorDialog())
return widget
def axesNames(self, data, info):
@@ -1711,6 +1953,8 @@ class _NXdataVolumeAsStackView(DataView):
z_label, y_label, x_label = nxd.axes_names[-3:]
title = nxd.title or signal_name
+ self._updateColormap(nxd)
+
widget = self.getWidget()
widget.setStackData(
nxd.signal, x_axis=x_axis, y_axis=y_axis, z_axis=z_axis,
@@ -1730,12 +1974,13 @@ class _NXdataVolumeAsStackView(DataView):
return DataView.UNSUPPORTED
-class _NXdataComplexVolumeAsStackView(DataView):
+class _NXdataComplexVolumeAsStackView(_NXdataBaseDataView):
def __init__(self, parent):
- DataView.__init__(self, parent,
- label="NXdata (2D)",
- icon=icons.getQIcon("view-nexus"),
- modeId=NXDATA_VOLUME_AS_STACK_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent,
+ label="NXdata (2D)",
+ icon=icons.getQIcon("view-nexus"),
+ modeId=NXDATA_VOLUME_AS_STACK_MODE)
self._is_complex_data = False
def createWidget(self, parent):
@@ -1759,6 +2004,8 @@ class _NXdataComplexVolumeAsStackView(DataView):
z_label, y_label, x_label = nxd.axes_names[-3:]
title = nxd.title or signal_name
+ self._updateColormap(nxd)
+
self.getWidget().setImageData(
[nxd.signal] + nxd.auxiliary_signals,
x_axis=x_axis, y_axis=y_axis,
diff --git a/silx/gui/data/Hdf5TableView.py b/silx/gui/data/Hdf5TableView.py
index d7c33f3..57d6f7b 100644
--- a/silx/gui/data/Hdf5TableView.py
+++ b/silx/gui/data/Hdf5TableView.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 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
@@ -37,6 +37,7 @@ import functools
import os.path
import logging
import h5py
+import numpy
from silx.gui import qt
import silx.io
@@ -265,7 +266,7 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
return cell.span()
elif role == self.IsHeaderRole:
return cell.isHeader()
- elif role == qt.Qt.DisplayRole:
+ elif role in (qt.Qt.DisplayRole, qt.Qt.EditRole):
value = cell.value()
if callable(value):
try:
@@ -287,12 +288,6 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
return cell.data(role)
return None
- def flags(self, index):
- """QAbstractTableModel method to inform the view whether data
- is editable or not.
- """
- return qt.QAbstractTableModel.flags(self, index)
-
def isSupportedObject(self, h5pyObject):
"""
Returns true if the provided object can be modelized using this model.
@@ -349,6 +344,16 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
shape = self.__hdf5Formatter.humanReadableShape(dataset)
return u"%s = %s" % (shape, size)
+ def __formatChunks(self, dataset):
+ """Format the shape"""
+ chunks = dataset.chunks
+ if chunks is None:
+ return ""
+ shape = " \u00D7 ".join([str(i) for i in chunks])
+ sizes = numpy.product(chunks)
+ text = "%s = %s" % (shape, sizes)
+ return text
+
def __initProperties(self):
"""Initialize the list of available properties according to the defined
h5py-like object."""
@@ -418,7 +423,7 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
if hasattr(obj, "shape"):
self.__data.addHeaderValueRow("shape", self.__formatShape)
if hasattr(obj, "chunks") and obj.chunks is not None:
- self.__data.addHeaderValueRow("chunks", lambda x: x.chunks)
+ self.__data.addHeaderValueRow("chunks", self.__formatChunks)
# relative to compression
# h5py expose compression, compression_opts but are not initialized
@@ -438,8 +443,8 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
self.__data.addRow(pos, hdf5id, name, options, availability)
for index in range(dcpl.get_nfilters()):
filterId, name, options = self.__getFilterInfo(obj, index)
- pos = _CellData(value=index)
- hdf5id = _CellData(value=filterId)
+ pos = _CellData(value=str(index))
+ hdf5id = _CellData(value=str(filterId))
name = _CellData(value=name)
options = _CellData(value=options)
availability = _CellFilterAvailableData(filterId=filterId)
@@ -517,12 +522,42 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
self.reset()
+class Hdf5TableItemDelegate(HierarchicalTableView.HierarchicalItemDelegate):
+ """Item delegate the :class:`Hdf5TableView` with read-only text editor"""
+
+ def createEditor(self, parent, option, index):
+ """See :meth:`QStyledItemDelegate.createEditor`"""
+ editor = super().createEditor(parent, option, index)
+ if isinstance(editor, qt.QLineEdit):
+ editor.setReadOnly(True)
+ editor.deselect()
+ editor.textChanged.connect(self.__textChanged, qt.Qt.QueuedConnection)
+ self.installEventFilter(editor)
+ return editor
+
+ def __textChanged(self, text):
+ sender = self.sender()
+ if sender is not None:
+ sender.deselect()
+
+ def eventFilter(self, watched, event):
+ eventType = event.type()
+ if eventType == qt.QEvent.FocusIn:
+ watched.selectAll()
+ qt.QTimer.singleShot(0, watched.selectAll)
+ elif eventType == qt.QEvent.FocusOut:
+ watched.deselect()
+ return super().eventFilter(watched, event)
+
+
class Hdf5TableView(HierarchicalTableView.HierarchicalTableView):
"""A widget to display metadata about a HDF5 node using a table."""
def __init__(self, parent=None):
super(Hdf5TableView, self).__init__(parent)
self.setModel(Hdf5TableModel(self))
+ self.setItemDelegate(Hdf5TableItemDelegate(self))
+ self.setSelectionMode(qt.QAbstractItemView.NoSelection)
def isSupportedData(self, data):
"""
@@ -538,7 +573,9 @@ class Hdf5TableView(HierarchicalTableView.HierarchicalTableView):
`silx.gui.hdf5.H5Node` which is needed to display some local path
information.
"""
- self.model().setObject(data)
+ model = self.model()
+
+ model.setObject(data)
header = self.horizontalHeader()
if qt.qVersion() < "5.0":
setResizeMode = header.setResizeMode
@@ -550,3 +587,10 @@ class Hdf5TableView(HierarchicalTableView.HierarchicalTableView):
setResizeMode(3, qt.QHeaderView.ResizeToContents)
setResizeMode(4, qt.QHeaderView.ResizeToContents)
header.setStretchLastSection(False)
+
+ for row in range(model.rowCount()):
+ for column in range(model.columnCount()):
+ index = model.index(row, column)
+ if (index.isValid() and index.data(
+ HierarchicalTableView.HierarchicalTableModel.IsHeaderRole) is False):
+ self.openPersistentEditor(index)
diff --git a/silx/gui/data/NXdataWidgets.py b/silx/gui/data/NXdataWidgets.py
index c3aefd3..224f337 100644
--- a/silx/gui/data/NXdataWidgets.py
+++ b/silx/gui/data/NXdataWidgets.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
@@ -99,7 +99,8 @@ class ArrayCurvePlot(qt.QWidget):
def setCurvesData(self, ys, x=None,
yerror=None, xerror=None,
- ylabels=None, xlabel=None, title=None):
+ ylabels=None, xlabel=None, title=None,
+ xscale=None, yscale=None):
"""
:param List[ndarray] ys: List of arrays to be represented by the y (vertical) axis.
@@ -115,6 +116,8 @@ class ArrayCurvePlot(qt.QWidget):
:param str ylabels: Labels for each curve's Y axis
:param str xlabel: Label for X axis
:param str title: Graph title
+ :param str xscale: Scale of X axis in (None, 'linear', 'log')
+ :param str yscale: Scale of Y axis in (None, 'linear', 'log')
"""
self.__signals = ys
self.__signals_names = ylabels or (["Y"] * len(ys))
@@ -135,6 +138,12 @@ class ArrayCurvePlot(qt.QWidget):
self._selector.show()
self._plot.setGraphTitle(title or "")
+ if xscale is not None:
+ self._plot.getXAxis().setScale(
+ 'log' if xscale == 'log' else 'linear')
+ if yscale is not None:
+ self._plot.getYAxis().setScale(
+ 'log' if yscale == 'log' else 'linear')
self._updateCurve()
if not self.__selector_is_connected:
@@ -235,6 +244,13 @@ class XYVScatterPlot(qt.QWidget):
def _sliderIdxChanged(self, value):
self._updateScatter()
+ def getScatterView(self):
+ """Returns the :class:`ScatterView` used for the display
+
+ :rtype: ScatterView
+ """
+ return self._plot
+
def getPlot(self):
"""Returns the plot used for the display
@@ -245,7 +261,8 @@ class XYVScatterPlot(qt.QWidget):
def setScattersData(self, y, x, values,
yerror=None, xerror=None,
ylabel=None, xlabel=None,
- title="", scatter_titles=None):
+ title="", scatter_titles=None,
+ xscale=None, yscale=None):
"""
:param ndarray y: 1D array for y (vertical) coordinates.
@@ -260,6 +277,8 @@ class XYVScatterPlot(qt.QWidget):
:param str xlabel: Label for X axis
:param str title: Main graph title
:param List[str] scatter_titles: Subtitles (one per scatter)
+ :param str xscale: Scale of X axis in (None, 'linear', 'log')
+ :param str yscale: Scale of Y axis in (None, 'linear', 'log')
"""
self.__y_axis = y
self.__x_axis = x
@@ -281,6 +300,13 @@ class XYVScatterPlot(qt.QWidget):
self._slider.setValue(0)
self._slider.valueChanged[int].connect(self._sliderIdxChanged)
+ if xscale is not None:
+ self._plot.getXAxis().setScale(
+ 'log' if xscale == 'log' else 'linear')
+ if yscale is not None:
+ self._plot.getYAxis().setScale(
+ 'log' if yscale == 'log' else 'linear')
+
self._updateScatter()
def _updateScatter(self):
@@ -289,10 +315,13 @@ class XYVScatterPlot(qt.QWidget):
idx = self._slider.value()
- title = ""
if self.__graph_title:
- title += self.__graph_title + "\n" # main NXdata @title
- title += self.__scatter_titles[idx] # scatter dataset name
+ title = self.__graph_title # main NXdata @title
+ if len(self.__scatter_titles) > 1:
+ # Append dataset name only when there is many datasets
+ title += '\n' + self.__scatter_titles[idx]
+ else:
+ title = self.__scatter_titles[idx] # scatter dataset name
self._plot.setGraphTitle(title)
self._plot.setData(x, y, self.__values[idx],
@@ -374,7 +403,8 @@ class ArrayImagePlot(qt.QWidget):
x_axis=None, y_axis=None,
signals_names=None,
xlabel=None, ylabel=None,
- title=None, isRgba=False):
+ title=None, isRgba=False,
+ xscale=None, yscale=None):
"""
:param signals: list of n-D datasets, whose last 2 dimensions are used as the
@@ -390,6 +420,8 @@ class ArrayImagePlot(qt.QWidget):
:param ylabel: Label for Y axis
:param title: Graph title
:param isRgba: True if data is a 3D RGBA image
+ :param str xscale: Scale of X axis in (None, 'linear', 'log')
+ :param str yscale: Scale of Y axis in (None, 'linear', 'log')
"""
self._selector.selectionChanged.disconnect(self._updateImage)
self._auxSigSlider.valueChanged.disconnect(self._sliderIdxChanged)
@@ -423,6 +455,7 @@ class ArrayImagePlot(qt.QWidget):
self._auxSigSlider.hide()
self._auxSigSlider.setValue(0)
+ self._axis_scales = xscale, yscale
self._updateImage()
self._plot.resetZoom()
@@ -473,10 +506,21 @@ class ArrayImagePlot(qt.QWidget):
origin = (xorigin, yorigin)
scale = (xscale, yscale)
+ self._plot.getXAxis().setScale('linear')
+ self._plot.getYAxis().setScale('linear')
self._plot.addImage(image, legend=legend,
origin=origin, scale=scale,
replace=True)
else:
+ xaxisscale, yaxisscale = self._axis_scales
+
+ if xaxisscale is not None:
+ self._plot.getXAxis().setScale(
+ 'log' if xaxisscale == 'log' else 'linear')
+ if yaxisscale is not None:
+ self._plot.getYAxis().setScale(
+ 'log' if yaxisscale == 'log' else 'linear')
+
scatterx, scattery = numpy.meshgrid(x_axis, y_axis)
# fixme: i don't think this can handle "irregular" RGBA images
self._plot.addScatter(numpy.ravel(scatterx),
@@ -484,11 +528,13 @@ class ArrayImagePlot(qt.QWidget):
numpy.ravel(image),
legend=legend)
- title = ""
if self.__title:
- title += self.__title
- if not title.strip().endswith(self.__signals_names[auxSigIdx]):
- title += "\n" + self.__signals_names[auxSigIdx]
+ title = self.__title
+ if len(self.__signals_names) > 1:
+ # Append dataset name only when there is many datasets
+ title += '\n' + self.__signals_names[auxSigIdx]
+ else:
+ title = self.__signals_names[auxSigIdx]
self._plot.setGraphTitle(title)
self._plot.getXAxis().setLabel(self.__x_axis_name)
self._plot.getYAxis().setLabel(self.__y_axis_name)
@@ -672,11 +718,13 @@ class ArrayComplexImagePlot(qt.QWidget):
self._plot.setOrigin((xorigin, yorigin))
self._plot.setScale((xscale, yscale))
- title = ""
if self.__title:
- title += self.__title
- if not title.strip().endswith(self.__signals_names[auxSigIdx]):
- title += "\n" + self.__signals_names[auxSigIdx]
+ title = self.__title
+ if len(self.__signals_names) > 1:
+ # Append dataset name only when there is many datasets
+ title += '\n' + self.__signals_names[auxSigIdx]
+ else:
+ title = self.__signals_names[auxSigIdx]
self._plot.setGraphTitle(title)
self._plot.getXAxis().setLabel(self.__x_axis_name)
self._plot.getYAxis().setLabel(self.__y_axis_name)
@@ -785,8 +833,8 @@ class ArrayStackPlot(qt.QWidget):
self._stack_view.setGraphTitle(title or "")
# by default, the z axis is the image position (dimension not plotted)
- self._stack_view.getPlot().getXAxis().setLabel(self.__x_axis_name or "X")
- self._stack_view.getPlot().getYAxis().setLabel(self.__y_axis_name or "Y")
+ self._stack_view.getPlotWidget().getXAxis().setLabel(self.__x_axis_name or "X")
+ self._stack_view.getPlotWidget().getYAxis().setLabel(self.__y_axis_name or "Y")
self._updateStack()
diff --git a/silx/gui/data/_RecordPlot.py b/silx/gui/data/_RecordPlot.py
new file mode 100644
index 0000000..5be792f
--- /dev/null
+++ b/silx/gui/data/_RecordPlot.py
@@ -0,0 +1,92 @@
+from silx.gui.plot.PlotWindow import PlotWindow
+from silx.gui.plot.PlotWidget import PlotWidget
+from .. import qt
+
+
+class RecordPlot(PlotWindow):
+ def __init__(self, parent=None, backend=None):
+ super(RecordPlot, self).__init__(parent=parent, backend=backend,
+ resetzoom=True, autoScale=True,
+ logScale=True, grid=True,
+ curveStyle=True, colormap=False,
+ aspectRatio=False, yInverted=False,
+ copy=True, save=True, print_=True,
+ control=True, position=True,
+ roi=True, mask=False, fit=True)
+ if parent is None:
+ self.setWindowTitle('RecordPlot')
+ self._axesSelectionToolBar = AxesSelectionToolBar(parent=self, plot=self)
+ self.addToolBar(qt.Qt.BottomToolBarArea, self._axesSelectionToolBar)
+
+ def setXAxisFieldName(self, value):
+ """Set the current selected field for the X axis.
+
+ :param Union[str,None] value:
+ """
+ label = '' if value is None else value
+ index = self._axesSelectionToolBar.getXAxisDropDown().findData(value)
+
+ if index >= 0:
+ self.getXAxis().setLabel(label)
+ self._axesSelectionToolBar.getXAxisDropDown().setCurrentIndex(index)
+
+ def getXAxisFieldName(self):
+ """Returns currently selected field for the X axis or None.
+
+ rtype: Union[str,None]
+ """
+ return self._axesSelectionToolBar.getXAxisDropDown().currentData()
+
+ def setYAxisFieldName(self, value):
+ self.getYAxis().setLabel(value)
+ index = self._axesSelectionToolBar.getYAxisDropDown().findText(value)
+ if index >= 0:
+ self._axesSelectionToolBar.getYAxisDropDown().setCurrentIndex(index)
+
+ def getYAxisFieldName(self):
+ return self._axesSelectionToolBar.getYAxisDropDown().currentText()
+
+ def setSelectableXAxisFieldNames(self, fieldNames):
+ """Add list of field names to X axis
+
+ :param List[str] fieldNames:
+ """
+ comboBox = self._axesSelectionToolBar.getXAxisDropDown()
+ comboBox.clear()
+ comboBox.addItem('-', None)
+ comboBox.insertSeparator(1)
+ for name in fieldNames:
+ comboBox.addItem(name, name)
+
+ def setSelectableYAxisFieldNames(self, fieldNames):
+ self._axesSelectionToolBar.getYAxisDropDown().clear()
+ self._axesSelectionToolBar.getYAxisDropDown().addItems(fieldNames)
+
+ def getAxesSelectionToolBar(self):
+ return self._axesSelectionToolBar
+
+class AxesSelectionToolBar(qt.QToolBar):
+ def __init__(self, parent=None, plot=None, title='Plot Axes Selection'):
+ super(AxesSelectionToolBar, self).__init__(title, parent)
+
+ assert isinstance(plot, PlotWidget)
+
+ self.addWidget(qt.QLabel("Field selection: "))
+
+ self._labelXAxis = qt.QLabel(" X: ")
+ self.addWidget(self._labelXAxis)
+
+ self._selectXAxisDropDown = qt.QComboBox()
+ self.addWidget(self._selectXAxisDropDown)
+
+ self._labelYAxis = qt.QLabel(" Y: ")
+ self.addWidget(self._labelYAxis)
+
+ self._selectYAxisDropDown = qt.QComboBox()
+ self.addWidget(self._selectYAxisDropDown)
+
+ def getXAxisDropDown(self):
+ return self._selectXAxisDropDown
+
+ def getYAxisDropDown(self):
+ return self._selectYAxisDropDown \ No newline at end of file
diff --git a/silx/gui/data/test/test_arraywidget.py b/silx/gui/data/test/test_arraywidget.py
index 6bcbbd3..7785ac5 100644
--- a/silx/gui/data/test/test_arraywidget.py
+++ b/silx/gui/data/test/test_arraywidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016 European Synchrotron Radiation Facility
+# Copyright (c) 2016-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
@@ -199,7 +199,7 @@ class TestH5pyArrayWidget(TestCaseQt):
# create an h5py file with a dataset
self.tempdir = tempfile.mkdtemp()
self.h5_fname = os.path.join(self.tempdir, "array.h5")
- h5f = h5py.File(self.h5_fname)
+ h5f = h5py.File(self.h5_fname, mode='w')
h5f["my_array"] = self.data
h5f["my_scalar"] = 3.14
h5f["my_1D_array"] = numpy.array(numpy.arange(1000))
diff --git a/silx/gui/dialog/ColormapDialog.py b/silx/gui/dialog/ColormapDialog.py
index dddec4c..7e53585 100644
--- a/silx/gui/dialog/ColormapDialog.py
+++ b/silx/gui/dialog/ColormapDialog.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-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
@@ -72,15 +72,20 @@ import logging
import numpy
from .. import qt
-from ..colors import Colormap, preferredColormaps
+from .. import utils
+from ..colors import Colormap
from ..plot import PlotWidget
from ..plot.items.axis import Axis
+from ..plot.items import BoundingRect
from silx.gui.widgets.FloatEdit import FloatEdit
import weakref
from silx.math.combo import min_max
+from silx.gui.plot import items
from silx.gui import icons
+from silx.gui.qt import inspect as qtinspect
from silx.gui.widgets.ColormapNameComboBox import ColormapNameComboBox
from silx.math.histogram import Histogramnd
+from silx.utils import deprecation
_logger = logging.getLogger(__name__)
@@ -88,13 +93,39 @@ _logger = logging.getLogger(__name__)
_colormapIconPreview = {}
+class _DataRefHolder(items.Item, items.ColormapMixIn):
+ """Holder for a weakref of a numpy array.
+
+ It provides features from `ColormapMixIn`.
+ """
+
+ def __init__(self, dataRef):
+ items.Item.__init__(self)
+ items.ColormapMixIn.__init__(self)
+ self.__dataRef = dataRef
+ self._updated(items.ItemChangedType.DATA)
+
+ def getColormappedData(self, copy=True):
+ return self.__dataRef()
+
+
class _BoundaryWidget(qt.QWidget):
- """Widget to edit a boundary of the colormap (vmin, vmax)"""
+ """Widget to edit a boundary of the colormap (vmin or vmax)"""
+
+ sigAutoScaleChanged = qt.Signal(object)
+ """Signal emitted when the autoscale was changed
+
+ True is sent as an argument if autoscale is set to true.
+ """
+
sigValueChanged = qt.Signal(object)
- """Signal emitted when value is changed"""
+ """Signal emitted when value is changed
+
+ The new value is sent as an argument.
+ """
def __init__(self, parent=None, value=0.0):
- qt.QWidget.__init__(self, parent=None)
+ qt.QWidget.__init__(self, parent=parent)
self.setLayout(qt.QHBoxLayout())
self.layout().setContentsMargins(0, 0, 0, 0)
self._numVal = FloatEdit(parent=self, value=value)
@@ -102,23 +133,54 @@ class _BoundaryWidget(qt.QWidget):
self._autoCB = qt.QCheckBox('auto', parent=self)
self.layout().addWidget(self._autoCB)
self._autoCB.setChecked(False)
+ self._autoCB.setVisible(False)
self._autoCB.toggled.connect(self._autoToggled)
- self.sigValueChanged = self._autoCB.toggled
- self.textEdited = self._numVal.textEdited
- self.editingFinished = self._numVal.editingFinished
+ self._numVal.textEdited.connect(self.__textEdited)
+ self._numVal.editingFinished.connect(self.__editingFinished)
+ self.setFocusProxy(self._numVal)
+
self._dataValue = None
+ self.__textWasEdited = False
+ """True if the text was edited, in order to send an event
+ at the end of the user interaction"""
+
+ self.__realValue = None
+ """Store the real value set by setValue/setFiniteValue, to avoid
+ rounding of the widget"""
+
+ def __textEdited(self):
+ self.__textWasEdited = True
+
+ def __editingFinished(self):
+ if self.__textWasEdited:
+ value = self._numVal.value()
+ self.__realValue = value
+ self.sigValueChanged.emit(value)
+ self.__textWasEdited = False
+
def isAutoChecked(self):
return self._autoCB.isChecked()
def getValue(self):
- return None if self._autoCB.isChecked() else self._numVal.value()
+ """Returns the stored range. If autoscale is
+ enabled, this returns None.
+ """
+ if self._autoCB.isChecked():
+ return None
+ if self.__realValue is not None:
+ return self.__realValue
+ return self._numVal.value()
def getFiniteValue(self):
if not self._autoCB.isChecked():
+ if self.__realValue is not None:
+ return self.__realValue
return self._numVal.value()
elif self._dataValue is None:
+ if self.__realValue is not None:
+ return self.__realValue
return self._numVal.value()
else:
return self._dataValue
@@ -126,13 +188,14 @@ class _BoundaryWidget(qt.QWidget):
def _autoToggled(self, enabled):
self._numVal.setEnabled(not enabled)
self._updateDisplayedText()
+ self.sigAutoScaleChanged.emit(enabled)
def _updateDisplayedText(self):
# if dataValue is finite
+ self.__textWasEdited = False
if self._autoCB.isChecked() and self._dataValue is not None:
- old = self._numVal.blockSignals(True)
- self._numVal.setValue(self._dataValue)
- self._numVal.blockSignals(old)
+ with utils.blockSignals(self._numVal):
+ self._numVal.setValue(self._dataValue)
def setDataValue(self, dataValue):
self._dataValue = dataValue
@@ -142,108 +205,360 @@ class _BoundaryWidget(qt.QWidget):
assert(value is not None)
old = self._numVal.blockSignals(True)
self._numVal.setValue(value)
+ self.__realValue = value
self._numVal.blockSignals(old)
def setValue(self, value, isAuto=False):
self._autoCB.setChecked(isAuto or value is None)
if value is not None:
self._numVal.setValue(value)
+ self.__realValue = value
self._updateDisplayedText()
+class _AutoscaleModeComboBox(qt.QComboBox):
+
+ DATA = {
+ Colormap.MINMAX: ("Min/max", "Use the data min/max"),
+ Colormap.STDDEV3: ("Mean ± 3 × stddev", "Use the data mean ± 3 × standard deviation"),
+ }
+
+ def __init__(self, parent: qt.QWidget):
+ super(_AutoscaleModeComboBox, self).__init__(parent=parent)
+ self.currentIndexChanged.connect(self.__updateTooltip)
+ self._init()
+
+ def _init(self):
+ for mode in Colormap.AUTOSCALE_MODES:
+ label, tooltip = self.DATA.get(mode, (mode, None))
+ self.addItem(label, mode)
+ if tooltip is not None:
+ self.setItemData(self.count() - 1, tooltip, qt.Qt.ToolTipRole)
+
+ def setCurrentIndex(self, index):
+ self.__updateTooltip(index)
+ super(_AutoscaleModeComboBox, self).setCurrentIndex(index)
+
+ def __updateTooltip(self, index):
+ if index > -1:
+ tooltip = self.itemData(index, qt.Qt.ToolTipRole)
+ else:
+ tooltip = ""
+ self.setToolTip(tooltip)
+
+ def currentMode(self):
+ index = self.currentIndex()
+ return self.itemData(index)
+
+ def setCurrentMode(self, mode):
+ for index in range(self.count()):
+ if mode == self.itemData(index):
+ self.setCurrentIndex(index)
+ return
+ if mode is None:
+ # If None was not a value
+ self.setCurrentIndex(-1)
+ return
+ self.addItem(mode, mode)
+ self.setCurrentIndex(self.count() - 1)
+
+
+class _AutoScaleButtons(qt.QWidget):
+
+ autoRangeChanged = qt.Signal(object)
+
+ def __init__(self, parent=None):
+ qt.QWidget.__init__(self, parent=parent)
+ layout = qt.QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ self.setFocusPolicy(qt.Qt.NoFocus)
+
+ self._bothAuto = qt.QPushButton(self)
+ self._bothAuto.setText("Autoscale")
+ self._bothAuto.setToolTip("Enable/disable the autoscale for both min and max")
+ self._bothAuto.setCheckable(True)
+ self._bothAuto.toggled[bool].connect(self.__bothToggled)
+ self._bothAuto.setFocusPolicy(qt.Qt.TabFocus)
+
+ self._minAuto = qt.QCheckBox(self)
+ self._minAuto.setText("")
+ self._minAuto.setToolTip("Enable/disable the autoscale for min")
+ self._minAuto.toggled[bool].connect(self.__minToggled)
+ self._minAuto.setFocusPolicy(qt.Qt.TabFocus)
+
+ self._maxAuto = qt.QCheckBox(self)
+ self._maxAuto.setText("")
+ self._maxAuto.setToolTip("Enable/disable the autoscale for max")
+ self._maxAuto.toggled[bool].connect(self.__maxToggled)
+ self._maxAuto.setFocusPolicy(qt.Qt.TabFocus)
+
+ layout.addStretch(1)
+ layout.addWidget(self._minAuto)
+ layout.addSpacing(20)
+ layout.addWidget(self._bothAuto)
+ layout.addSpacing(20)
+ layout.addWidget(self._maxAuto)
+ layout.addStretch(1)
+
+ def __bothToggled(self, checked):
+ autoRange = checked, checked
+ self.setAutoRange(autoRange)
+ self.autoRangeChanged.emit(autoRange)
+
+ def __minToggled(self, checked):
+ autoRange = self.getAutoRange()
+ self.setAutoRange(autoRange)
+ self.autoRangeChanged.emit(autoRange)
+
+ def __maxToggled(self, checked):
+ autoRange = self.getAutoRange()
+ self.setAutoRange(autoRange)
+ self.autoRangeChanged.emit(autoRange)
+
+ def setAutoRangeFromColormap(self, colormap):
+ vRange = colormap.getVRange()
+ autoRange = vRange[0] is None, vRange[1] is None
+ self.setAutoRange(autoRange)
+
+ def setAutoRange(self, autoRange):
+ if autoRange[0] == autoRange[1]:
+ with utils.blockSignals(self._bothAuto):
+ self._bothAuto.setChecked(autoRange[0])
+ else:
+ with utils.blockSignals(self._bothAuto):
+ self._bothAuto.setChecked(False)
+ with utils.blockSignals(self._minAuto):
+ self._minAuto.setChecked(autoRange[0])
+ with utils.blockSignals(self._maxAuto):
+ self._maxAuto.setChecked(autoRange[1])
+
+ def getAutoRange(self):
+ return self._minAuto.isChecked(), self._maxAuto.isChecked()
+
+
@enum.unique
class _DataInPlotMode(enum.Enum):
"""Enum for each mode of display of the data in the plot."""
- NONE = 'none'
RANGE = 'range'
HISTOGRAM = 'histogram'
-class ColormapDialog(qt.QDialog):
- """A QDialog widget to set the colormap.
+class _ColormapHistogram(qt.QWidget):
+ """Display the colormap and the data as a plot."""
- :param parent: See :class:`QDialog`
- :param str title: The QDialog title
+ sigRangeMoving = qt.Signal(object, object)
+ """Emitted when a mouse interaction moves the location
+ of the colormap range in the plot.
+
+ This signal contains 2 elements:
+
+ - vmin: A float value if this range was moved, else None
+ - vmax: A float value if this range was moved, else None
"""
- visibleChanged = qt.Signal(bool)
- """This event is sent when the dialog visibility change"""
+ sigRangeMoved = qt.Signal(object, object)
+ """Emitted when a mouse interaction stop.
- def __init__(self, parent=None, title="Colormap Dialog"):
- qt.QDialog.__init__(self, parent)
- self.setWindowTitle(title)
+ This signal contains 2 elements:
- self._colormap = None
- self._data = None
+ - vmin: A float value if this range was moved, else None
+ - vmax: A float value if this range was moved, else None
+ """
+
+ def __init__(self, parent):
+ qt.QWidget.__init__(self, parent=parent)
self._dataInPlotMode = _DataInPlotMode.RANGE
+ self._finiteRange = None, None
+ self._initPlot()
- self._ignoreColormapChange = False
- """Used as a semaphore to avoid editing the colormap object when we are
- only attempt to display it.
- Used instead of n connect and disconnect of the sigChanged. The
- disconnection to sigChanged was also limiting when this colormapdialog
- is used in the colormapaction and associated to the activeImageChanged.
- (because the activeImageChanged is send when the colormap changed and
- the self.setcolormap is a callback)
+ self._histogramData = {}
+ """Histogram displayed in the plot"""
+
+ self._dataRange = {}
+ """Histogram displayed in the plot"""
+
+ self._invalidated = False
+
+ def paintEvent(self, event):
+ if self._invalidated:
+ self._updateDataInPlot()
+ self._invalidated = False
+ self._updateMarkerPosition()
+ return super(_ColormapHistogram, self).paintEvent(event)
+
+ def getFiniteRange(self):
+ """Returns the colormap range as displayed in the plot."""
+ return self._finiteRange
+
+ def setFiniteRange(self, vRange):
+ """Set the colormap range to use in the plot.
+
+ Here there is no concept of auto. The values should
+ not be None, except if there is no range or marker
+ to display.
"""
+ if vRange == self._finiteRange:
+ return
+ self._finiteRange = vRange
+ self.update()
- self.__displayInvalidated = False
- self._histogramData = None
- self._minMaxWasEdited = False
- self._initialRange = None
+ def getColormap(self):
+ return self.parent().getColormap()
- self._dataRange = None
- """If defined 3-tuple containing information from a data:
- minimum, positive minimum, maximum"""
+ def _getNormalizedHistogram(self):
+ """Return an histogram already normalized according to the colormap
+ normalization.
- self._colormapStoredState = None
+ Returns a tuple edges, counts
+ """
+ norm = self._getNorm()
+ histogram = self._histogramData.get(norm, None)
+ if histogram is None:
+ histogram = self._computeNormalizedHistogram()
+ self._histogramData[norm] = histogram
+ return histogram
+
+ def _computeNormalizedHistogram(self):
+ colormap = self.getColormap()
+ if colormap is None:
+ norm = Colormap.LINEAR
+ else:
+ norm = colormap.getNormalization()
+
+ # Try to use the histogram defined in the dialog
+ histo = self.parent()._getHistogram()
+ if histo is not None:
+ counts, edges = histo
+ normalizer = Colormap(normalization=norm)._getNormalizer()
+ mask = normalizer.isValid(edges[:-1]) # Check lower bin edges only
+ firstValid = numpy.argmax(mask) # edges increases monotonically
+ if firstValid == 0: # Mask is all False or all True
+ return (counts, edges) if mask[0] else (None, None)
+ else: # Clip to valid values
+ return counts[firstValid:], edges[firstValid:]
+
+ data = self.parent()._getArray()
+ if data is None:
+ return None, None
+ dataRange = self._getNormalizedDataRange()
+ if dataRange[0] is None or dataRange[1] is None:
+ return None, None
+ counts, edges = self.parent().computeHistogram(data, scale=norm, dataRange=dataRange)
+ return counts, edges
- # Make the GUI
- vLayout = qt.QVBoxLayout(self)
+ def _getNormalizedDataRange(self):
+ """Return a data range already normalized according to the colormap
+ normalization.
- formWidget = qt.QWidget(parent=self)
- vLayout.addWidget(formWidget)
- formLayout = qt.QFormLayout(formWidget)
- formLayout.setContentsMargins(10, 10, 10, 10)
- formLayout.setSpacing(0)
+ Returns a tuple with min and max
+ """
+ norm = self._getNorm()
+ dataRange = self._dataRange.get(norm, None)
+ if dataRange is None:
+ dataRange = self._computeNormalizedDataRange()
+ self._dataRange[norm] = dataRange
+ return dataRange
- # Colormap row
- self._comboBoxColormap = ColormapNameComboBox(parent=formWidget)
- self._comboBoxColormap.currentIndexChanged[int].connect(self._updateLut)
- formLayout.addRow('Colormap:', self._comboBoxColormap)
+ def _computeNormalizedDataRange(self):
+ colormap = self.getColormap()
+ if colormap is None:
+ norm = Colormap.LINEAR
+ else:
+ norm = colormap.getNormalization()
- # Normalization row
- self._normButtonLinear = qt.QRadioButton('Linear')
- self._normButtonLinear.setChecked(True)
- self._normButtonLog = qt.QRadioButton('Log')
+ # Try to use the one defined in the dialog
+ dataRange = self.parent()._getDataRange()
+ if dataRange is not None:
+ if norm in (Colormap.LINEAR, Colormap.GAMMA, Colormap.ARCSINH):
+ return dataRange[0], dataRange[2]
+ elif norm == Colormap.LOGARITHM:
+ return dataRange[1], dataRange[2]
+ elif norm == Colormap.SQRT:
+ return dataRange[1], dataRange[2]
+ else:
+ _logger.error("Undefined %s normalization", norm)
+
+ # Try to use the histogram defined in the dialog
+ histo = self.parent()._getHistogram()
+ if histo is not None:
+ _histo, edges = histo
+ normalizer = Colormap(normalization=norm)._getNormalizer()
+ edges = edges[normalizer.isValid(edges)]
+ if edges.size == 0:
+ return None, None
+ else:
+ dataRange = min_max(edges, finite=True)
+ return dataRange.minimum, dataRange.maximum
- normButtonGroup = qt.QButtonGroup(self)
- normButtonGroup.setExclusive(True)
- normButtonGroup.addButton(self._normButtonLinear)
- normButtonGroup.addButton(self._normButtonLog)
- normButtonGroup.buttonClicked[qt.QAbstractButton].connect(self._updateNormalization)
+ item = self.parent()._getItem()
+ if item is not None:
+ # Trick to reach data range using colormap cache
+ cm = Colormap()
+ cm.setVRange(None, None)
+ cm.setNormalization(norm)
+ dataRange = item._getColormapAutoscaleRange(cm)
+ return dataRange
- normLayout = qt.QHBoxLayout()
- normLayout.setContentsMargins(0, 0, 0, 0)
- normLayout.setSpacing(10)
- normLayout.addWidget(self._normButtonLinear)
- normLayout.addWidget(self._normButtonLog)
+ # If there is no item, there is no data
+ return None, None
- formLayout.addRow('Normalization:', normLayout)
+ def _getDisplayableRange(self):
+ """Returns the selected min/max range to apply to the data,
+ according to the used scale.
- # Min row
- self._minValue = _BoundaryWidget(parent=self, value=1.0)
- self._minValue.textEdited.connect(self._minMaxTextEdited)
- self._minValue.editingFinished.connect(self._minEditingFinished)
- self._minValue.sigValueChanged.connect(self._updateMinMax)
- formLayout.addRow('\tMin:', self._minValue)
+ One or both limits can be None in case it is not displayable in the
+ current axes scale.
- # Max row
- self._maxValue = _BoundaryWidget(parent=self, value=10.0)
- self._maxValue.textEdited.connect(self._minMaxTextEdited)
- self._maxValue.sigValueChanged.connect(self._updateMinMax)
- self._maxValue.editingFinished.connect(self._maxEditingFinished)
- formLayout.addRow('\tMax:', self._maxValue)
+ :returns: Tuple{float, float}
+ """
+ scale = self._plot.getXAxis().getScale()
+ def isDisplayable(pos):
+ if pos is None:
+ return False
+ if scale == Axis.LOGARITHMIC:
+ return pos > 0.0
+ return True
+
+ posMin, posMax = self.getFiniteRange()
+ if not isDisplayable(posMin):
+ posMin = None
+ if not isDisplayable(posMax):
+ posMax = None
+
+ return posMin, posMax
+
+ def _initPlot(self):
+ """Init the plot to display the range and the values"""
+ self._plot = PlotWidget(self)
+ self._plot.setDataMargins(0.125, 0.125, 0.125, 0.125)
+ self._plot.getXAxis().setLabel("Data Values")
+ self._plot.getYAxis().setLabel("")
+ self._plot.setInteractiveMode('select', zoomOnWheel=False)
+ self._plot.setActiveCurveHandling(False)
+ self._plot.setMinimumSize(qt.QSize(250, 200))
+ self._plot.sigPlotSignal.connect(self._plotEventReceived)
+ palette = self.palette()
+ color = palette.color(qt.QPalette.Normal, qt.QPalette.Window)
+ self._plot.setBackgroundColor(color)
+ self._plot.setDataBackgroundColor("white")
+
+ lut = numpy.arange(256)
+ lut.shape = 1, -1
+ self._plot.addImage(lut, legend='lut')
+ self._lutItem = self._plot._getItem("image", "lut")
+ self._lutItem.setVisible(False)
+
+ self._plot.addScatter(x=[], y=[], value=[], legend='lut2')
+ self._lutItem2 = self._plot._getItem("scatter", "lut2")
+ self._lutItem2.setVisible(False)
+ self.__lutY = numpy.array([-0.05] * 256)
+ self.__lutV = numpy.arange(256)
+
+ self._bound = BoundingRect()
+ self._plot.addItem(self._bound)
+ self._bound.setVisible(True)
# Add plot for histogram
self._plotToolbar = qt.QToolBar(self)
@@ -256,14 +571,6 @@ class ColormapDialog(qt.QDialog):
group = qt.QActionGroup(self._plotToolbar)
group.setExclusive(True)
- action = qt.QAction("Nothing", self)
- action.setToolTip("No range nor histogram are displayed. No extra computation have to be done.")
- action.setIcon(icons.getQIcon('colormap-none'))
- action.setCheckable(True)
- action.setData(_DataInPlotMode.NONE)
- action.setChecked(action.data() == self._dataInPlotMode)
- self._plotToolbar.addAction(action)
- group.addAction(action)
action = qt.QAction("Data range", self)
action.setToolTip("Display the data range within the colormap range. A fast data processing have to be done.")
action.setIcon(icons.getQIcon('colormap-range'))
@@ -282,23 +589,339 @@ class ColormapDialog(qt.QDialog):
group.addAction(action)
group.triggered.connect(self._displayDataInPlotModeChanged)
- self._plotBox = qt.QWidget(self)
- self._plotInit()
-
plotBoxLayout = qt.QHBoxLayout()
plotBoxLayout.setContentsMargins(0, 0, 0, 0)
plotBoxLayout.setSpacing(2)
plotBoxLayout.addWidget(self._plotToolbar)
plotBoxLayout.addWidget(self._plot)
plotBoxLayout.setSizeConstraint(qt.QLayout.SetMinimumSize)
- self._plotBox.setLayout(plotBoxLayout)
- vLayout.addWidget(self._plotBox)
+ self.setLayout(plotBoxLayout)
+
+ def _plotEventReceived(self, event):
+ """Handle events from the plot"""
+ kind = event['event']
+
+ if kind == 'markerMoving':
+ value = event['xdata']
+ if event['label'] == 'Min':
+ self._finiteRange = value, self._finiteRange[1]
+ self._last = value, None
+ self.sigRangeMoving.emit(*self._last)
+ elif event['label'] == 'Max':
+ self._finiteRange = self._finiteRange[0], value
+ self._last = None, value
+ self.sigRangeMoving.emit(*self._last)
+ self._updateLutItem(self._finiteRange)
+ elif kind == 'markerMoved':
+ self.sigRangeMoved.emit(*self._last)
+ self._plot.resetZoom()
+ else:
+ pass
+
+ def _updateMarkerPosition(self):
+ colormap = self.getColormap()
+ posMin, posMax = self._getDisplayableRange()
+
+ if colormap is None:
+ isDraggable = False
+ else:
+ isDraggable = colormap.isEditable()
+
+ with utils.blockSignals(self):
+ if posMin is not None:
+ self._plot.addXMarker(
+ posMin,
+ legend='Min',
+ text='Min',
+ draggable=isDraggable,
+ color="blue",
+ constraint=self._plotMinMarkerConstraint)
+ if posMax is not None:
+ self._plot.addXMarker(
+ posMax,
+ legend='Max',
+ text='Max',
+ draggable=isDraggable,
+ color="blue",
+ constraint=self._plotMaxMarkerConstraint)
+
+ self._updateLutItem((posMin, posMax))
+ self._plot.resetZoom()
+
+ def _updateLutItem(self, vRange):
+ colormap = self.getColormap()
+ if colormap is None:
+ return
+
+ if vRange is None:
+ posMin, posMax = self._getDisplayableRange()
+ else:
+ posMin, posMax = vRange
+ if posMin is None or posMax is None:
+ self._lutItem.setVisible(False)
+ pos = posMax if posMin is None else posMin
+ if pos is not None:
+ self._bound.setBounds((pos, pos, -0.1, 0))
+ else:
+ self._bound.setBounds((0, 0, -0.1, 0))
+ else:
+ norm = colormap.getNormalization()
+ normColormap = colormap.copy()
+ normColormap.setVRange(0, 255)
+ normColormap.setNormalization(Colormap.LINEAR)
+ if norm == Colormap.LINEAR:
+ scale = (posMax - posMin) / 256
+ self._lutItem.setColormap(normColormap)
+ self._lutItem.setOrigin((posMin, -0.09))
+ self._lutItem.setScale((scale, 0.08))
+ self._lutItem.setVisible(True)
+ self._lutItem2.setVisible(False)
+ elif norm == Colormap.LOGARITHM:
+ self._lutItem2.setVisible(False)
+ self._lutItem2.setColormap(normColormap)
+ xx = numpy.geomspace(posMin, posMax, 256)
+ self._lutItem2.setData(x=xx,
+ y=self.__lutY,
+ value=self.__lutV,
+ copy=False)
+ self._lutItem2.setSymbol("|")
+ self._lutItem2.setVisible(True)
+ self._lutItem.setVisible(False)
+ else:
+ # Fallback: Display with linear axis and applied normalization
+ self._lutItem2.setVisible(False)
+ normColormap.setNormalization(norm)
+ self._lutItem2.setColormap(normColormap)
+ xx = numpy.linspace(posMin, posMax, 256, endpoint=True)
+ self._lutItem2.setData(
+ x=xx,
+ y=self.__lutY,
+ value=self.__lutV,
+ copy=False)
+ self._lutItem2.setSymbol("|")
+ self._lutItem2.setVisible(True)
+ self._lutItem.setVisible(False)
+
+ self._bound.setBounds((posMin, posMax, -0.1, 1))
+
+ def _plotMinMarkerConstraint(self, x, y):
+ """Constraint of the min marker"""
+ _vmin, vmax = self.getFiniteRange()
+ if vmax is None:
+ return x, y
+ return min(x, vmax), y
+
+ def _plotMaxMarkerConstraint(self, x, y):
+ """Constraint of the max marker"""
+ vmin, _vmax = self.getFiniteRange()
+ if vmin is None:
+ return x, y
+ return max(x, vmin), y
+
+ def _setDataInPlotMode(self, mode):
+ if self._dataInPlotMode == mode:
+ return
+ self._dataInPlotMode = mode
+ self._updateDataInPlot()
+
+ def _displayDataInPlotModeChanged(self, action):
+ mode = action.data()
+ self._setDataInPlotMode(mode)
+
+ def invalidateData(self):
+ self._histogramData = {}
+ self._dataRange = {}
+ self._invalidated = True
+ self.update()
+
+ def _updateDataInPlot(self):
+ mode = self._dataInPlotMode
+
+ norm = self._getNorm()
+ if norm == Colormap.LINEAR:
+ scale = Axis.LINEAR
+ elif norm == Colormap.LOGARITHM:
+ scale = Axis.LOGARITHMIC
+ else:
+ scale = Axis.LINEAR
+
+ axis = self._plot.getXAxis()
+ axis.setScale(scale)
+
+ if mode == _DataInPlotMode.RANGE:
+ dataRange = self._getNormalizedDataRange()
+ xmin, xmax = dataRange
+ if xmax is None or xmin is None:
+ self._plot.remove(legend='Data', kind='histogram')
+ else:
+ histogram = numpy.array([1])
+ bin_edges = numpy.array([xmin, xmax])
+ self._plot.addHistogram(histogram,
+ bin_edges,
+ legend="Data",
+ color='gray',
+ align='center',
+ fill=True,
+ z=1)
+
+ elif mode == _DataInPlotMode.HISTOGRAM:
+ histogram, bin_edges = self._getNormalizedHistogram()
+ if histogram is None or bin_edges is None:
+ self._plot.remove(legend='Data', kind='histogram')
+ else:
+ histogram = numpy.array(histogram, copy=True)
+ bin_edges = numpy.array(bin_edges, copy=True)
+ norm_histogram = histogram / max(histogram)
+ self._plot.addHistogram(norm_histogram,
+ bin_edges,
+ legend="Data",
+ color='gray',
+ align='center',
+ fill=True,
+ z=1)
+ else:
+ _logger.error("Mode unsupported")
+
+ def sizeHint(self):
+ return self.layout().minimumSize()
+
+ def updateLut(self):
+ self._updateLutItem(None)
+
+ def _getNorm(self):
+ colormap = self.getColormap()
+ if colormap is None:
+ return Axis.LINEAR
+ else:
+ norm = colormap.getNormalization()
+ return norm
+
+ def updateNormalization(self):
+ self._updateDataInPlot()
+ self.update()
+
+
+class ColormapDialog(qt.QDialog):
+ """A QDialog widget to set the colormap.
+
+ :param parent: See :class:`QDialog`
+ :param str title: The QDialog title
+ """
+
+ visibleChanged = qt.Signal(bool)
+ """This event is sent when the dialog visibility change"""
+
+ def __init__(self, parent=None, title="Colormap Dialog"):
+ qt.QDialog.__init__(self, parent)
+ self.setWindowTitle(title)
+
+ self.__aboutToDelete = False
+ self._colormap = None
+
+ self._data = None
+ """Weak ref to an external numpy array
+ """
+ self._itemHolder = None
+ """Hard ref to a private item (used as holder to the data)
+ This allow to reuse the item cache
+ """
+ self._item = None
+ """Weak ref to an external item"""
+
+ self._colormapChange = utils.LockReentrant()
+ """Used as a semaphore to avoid editing the colormap object when we are
+ only attempt to display it.
+ Used instead of n connect and disconnect of the sigChanged. The
+ disconnection to sigChanged was also limiting when this colormapdialog
+ is used in the colormapaction and associated to the activeImageChanged.
+ (because the activeImageChanged is send when the colormap changed and
+ the self.setcolormap is a callback)
+ """
+
+ self.__colormapInvalidated = False
+ self.__dataInvalidated = False
+
+ self._histogramData = None
+
+ self._dataRange = None
+ """If defined 3-tuple containing information from a data:
+ minimum, positive minimum, maximum"""
+
+ self._colormapStoredState = None
+
+ # Colormap row
+ self._comboBoxColormap = ColormapNameComboBox(parent=self)
+ self._comboBoxColormap.currentIndexChanged[int].connect(self._comboBoxColormapUpdated)
+
+ # Normalization row
+ self._comboBoxNormalization = qt.QComboBox(parent=self)
+ normalizations = [
+ ('Linear', Colormap.LINEAR),
+ ('Gamma correction', Colormap.GAMMA),
+ ('Arcsinh', Colormap.ARCSINH),
+ ('Logarithmic', Colormap.LOGARITHM),
+ ('Square root', Colormap.SQRT)]
+ for name, userData in normalizations:
+ try:
+ icon = icons.getQIcon("colormap-norm-%s" % userData)
+ except:
+ icon = qt.QIcon()
+ self._comboBoxNormalization.addItem(icon, name, userData)
+ self._comboBoxNormalization.currentIndexChanged[int].connect(
+ self._normalizationUpdated)
+
+ self._gammaSpinBox = qt.QDoubleSpinBox(parent=self)
+ self._gammaSpinBox.setEnabled(False)
+ self._gammaSpinBox.setRange(0., 1000.)
+ self._gammaSpinBox.setDecimals(4)
+ if hasattr(qt.QDoubleSpinBox, "setStepType"):
+ # Introduced in Qt 5.12
+ self._gammaSpinBox.setStepType(qt.QDoubleSpinBox.AdaptiveDecimalStepType)
+ else:
+ self._gammaSpinBox.setSingleStep(0.1)
+ self._gammaSpinBox.valueChanged.connect(self._gammaUpdated)
+ self._gammaSpinBox.setValue(2.)
+
+ autoScaleCombo = _AutoscaleModeComboBox(self)
+ autoScaleCombo.currentIndexChanged.connect(self._autoscaleModeUpdated)
+ self._autoScaleCombo = autoScaleCombo
+
+ # Min row
+ self._minValue = _BoundaryWidget(parent=self, value=1.0)
+ self._minValue.sigAutoScaleChanged.connect(self._minAutoscaleUpdated)
+ self._minValue.sigValueChanged.connect(self._minValueUpdated)
+
+ # Max row
+ self._maxValue = _BoundaryWidget(parent=self, value=10.0)
+ self._maxValue.sigAutoScaleChanged.connect(self._maxAutoscaleUpdated)
+ self._maxValue.sigValueChanged.connect(self._maxValueUpdated)
+
+ self._autoButtons = _AutoScaleButtons(self)
+ self._autoButtons.autoRangeChanged.connect(self._autoRangeButtonsUpdated)
+
+ rangeLayout = qt.QGridLayout()
+ miniFont = qt.QFont(self.font())
+ miniFont.setPixelSize(8)
+ labelMin = qt.QLabel("Min", self)
+ labelMin.setFont(miniFont)
+ labelMin.setAlignment(qt.Qt.AlignHCenter)
+ labelMax = qt.QLabel("Max", self)
+ labelMax.setAlignment(qt.Qt.AlignHCenter)
+ labelMax.setFont(miniFont)
+ rangeLayout.addWidget(labelMin, 0, 0)
+ rangeLayout.addWidget(labelMax, 0, 1)
+ rangeLayout.addWidget(self._minValue, 1, 0)
+ rangeLayout.addWidget(self._maxValue, 1, 1)
+ rangeLayout.addWidget(self._autoButtons, 2, 0, 1, -1, qt.Qt.AlignCenter)
+
+ self._histoWidget = _ColormapHistogram(self)
+ self._histoWidget.sigRangeMoving.connect(self._histogramRangeMoving)
+ self._histoWidget.sigRangeMoved.connect(self._histogramRangeMoved)
# define modal buttons
types = qt.QDialogButtonBox.Ok | qt.QDialogButtonBox.Cancel
self._buttonsModal = qt.QDialogButtonBox(parent=self)
self._buttonsModal.setStandardButtons(types)
- self.layout().addWidget(self._buttonsModal)
self._buttonsModal.accepted.connect(self.accept)
self._buttonsModal.rejected.connect(self.reject)
@@ -306,9 +929,14 @@ class ColormapDialog(qt.QDialog):
types = qt.QDialogButtonBox.Close | qt.QDialogButtonBox.Reset
self._buttonsNonModal = qt.QDialogButtonBox(parent=self)
self._buttonsNonModal.setStandardButtons(types)
- self.layout().addWidget(self._buttonsNonModal)
- self._buttonsNonModal.button(qt.QDialogButtonBox.Close).clicked.connect(self.accept)
- self._buttonsNonModal.button(qt.QDialogButtonBox.Reset).clicked.connect(self.resetColormap)
+ button = self._buttonsNonModal.button(qt.QDialogButtonBox.Close)
+ button.clicked.connect(self.accept)
+ button.setDefault(True)
+ button = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset)
+ button.clicked.connect(self.resetColormap)
+
+ self._buttonsModal.setFocus(qt.Qt.OtherFocusReason)
+ self._buttonsNonModal.setFocus(qt.Qt.OtherFocusReason)
# Set the colormap to default values
self.setColormap(Colormap(name='gray', normalization='linear',
@@ -316,21 +944,62 @@ class ColormapDialog(qt.QDialog):
self.setModal(self.isModal())
- vLayout.setSizeConstraint(qt.QLayout.SetMinimumSize)
+ formLayout = qt.QFormLayout(self)
+ formLayout.setContentsMargins(10, 10, 10, 10)
+ formLayout.addRow('Colormap:', self._comboBoxColormap)
+ formLayout.addRow('Normalization:', self._comboBoxNormalization)
+ formLayout.addRow('Gamma:', self._gammaSpinBox)
+ formLayout.addRow(self._histoWidget)
+ formLayout.addRow(rangeLayout)
+ label = qt.QLabel('Mode:', self)
+ self._autoscaleModeLabel = label
+ label.setToolTip("Mode for autoscale. Algorithm used to find range in auto scale.")
+ formLayout.addItem(qt.QSpacerItem(1, 1, qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed))
+ formLayout.addRow(label, autoScaleCombo)
+ formLayout.addRow(self._buttonsModal)
+ formLayout.addRow(self._buttonsNonModal)
+ formLayout.setSizeConstraint(qt.QLayout.SetMinimumSize)
+
+ self.setTabOrder(self._comboBoxColormap, self._comboBoxNormalization)
+ self.setTabOrder(self._comboBoxNormalization, self._gammaSpinBox)
+ self.setTabOrder(self._gammaSpinBox, self._minValue)
+ self.setTabOrder(self._minValue, self._maxValue)
+ self.setTabOrder(self._maxValue, self._autoButtons)
+ self.setTabOrder(self._autoButtons, self._autoScaleCombo)
+ self.setTabOrder(self._autoScaleCombo, self._buttonsModal)
+ self.setTabOrder(self._buttonsModal, self._buttonsNonModal)
+
self.setFixedSize(self.sizeHint())
self._applyColormap()
- def _displayLater(self):
- self.__displayInvalidated = True
+ def _invalidateColormap(self):
+ if self.isVisible():
+ self._applyColormap()
+ else:
+ self.__colormapInvalidated = True
+
+ def _invalidateData(self):
+ if self.isVisible():
+ self._updateWidgetRange()
+ self._histoWidget.invalidateData()
+ else:
+ self.__dataInvalidated = True
+
+ def _validate(self):
+ if self.__colormapInvalidated:
+ self._applyColormap()
+ if self.__dataInvalidated:
+ self._histoWidget.invalidateData()
+ if self.__dataInvalidated or self.__colormapInvalidated:
+ self._updateWidgetRange()
+ self.__dataInvalidated = False
+ self.__colormapInvalidated = False
def showEvent(self, event):
self.visibleChanged.emit(True)
super(ColormapDialog, self).showEvent(event)
if self.isVisible():
- if self.__displayInvalidated:
- self._applyColormap()
- self._updateDataInPlot()
- self.__displayInvalidated = False
+ self._validate()
def closeEvent(self, event):
if not self.isModal():
@@ -351,179 +1020,32 @@ class ColormapDialog(qt.QDialog):
self._buttonsModal.setVisible(modal)
qt.QDialog.setModal(self, modal)
+ def event(self, event):
+ if event.type() == qt.QEvent.DeferredDelete:
+ self.__aboutToDelete = True
+ return super(ColormapDialog, self).event(event)
+
def exec_(self):
wasModal = self.isModal()
self.setModal(True)
result = super(ColormapDialog, self).exec_()
- self.setModal(wasModal)
+ if not self.__aboutToDelete:
+ self.setModal(wasModal)
return result
- def _plotInit(self):
- """Init the plot to display the range and the values"""
- self._plot = PlotWidget()
- self._plot.setDataMargins(yMinMargin=0.125, yMaxMargin=0.125)
- self._plot.getXAxis().setLabel("Data Values")
- self._plot.getYAxis().setLabel("")
- self._plot.setInteractiveMode('select', zoomOnWheel=False)
- self._plot.setActiveCurveHandling(False)
- self._plot.setMinimumSize(qt.QSize(250, 200))
- self._plot.sigPlotSignal.connect(self._plotSlot)
-
- self._plotUpdate()
-
- def sizeHint(self):
- return self.layout().minimumSize()
-
- def _computeView(self, dataMin, dataMax):
- """Compute the location of the view according to the bound of the data
-
- :rtype: Tuple(float, float)
- """
- marginRatio = 1.0 / 6.0
- scale = self._plot.getXAxis().getScale()
-
- if self._dataRange is not None:
- if scale == Axis.LOGARITHMIC:
- minRange = self._dataRange[1]
- else:
- minRange = self._dataRange[0]
- maxRange = self._dataRange[2]
- if minRange is not None:
- dataMin = min(dataMin, minRange)
- dataMax = max(dataMax, maxRange)
-
- if self._histogramData is not None:
- info = min_max(self._histogramData[1])
- if scale == Axis.LOGARITHMIC:
- minHisto = info.min_positive
- else:
- minHisto = info.minimum
- maxHisto = info.maximum
- if minHisto is not None:
- dataMin = min(dataMin, minHisto)
- dataMax = max(dataMax, maxHisto)
-
- if scale == Axis.LOGARITHMIC:
- epsilon = numpy.finfo(numpy.float32).eps
- if dataMin == 0:
- dataMin = epsilon
- if dataMax < dataMin:
- dataMax = dataMin + epsilon
- marge = marginRatio * abs(numpy.log10(dataMax) - numpy.log10(dataMin))
- viewMin = 10**(numpy.log10(dataMin) - marge)
- viewMax = 10**(numpy.log10(dataMax) + marge)
- else: # scale == Axis.LINEAR:
- marge = marginRatio * abs(dataMax - dataMin)
- if marge < 0.0001:
- # Smaller that the QLineEdit precision
- marge = 0.0001
- viewMin = dataMin - marge
- viewMax = dataMax + marge
-
- return viewMin, viewMax
-
- def _plotUpdate(self, updateMarkers=True):
- """Update the plot content
-
- :param bool updateMarkers: True to update markers, False otherwith
+ def _getFiniteColormapRange(self):
+ """Return a colormap range where auto ranges are fixed
+ according to the available data.
"""
colormap = self.getColormap()
if colormap is None:
- if self._plotBox.isVisibleTo(self):
- self._plotBox.setVisible(False)
- self.setFixedSize(self.sizeHint())
- return
-
- if not self._plotBox.isVisibleTo(self):
- self._plotBox.setVisible(True)
- self.setFixedSize(self.sizeHint())
-
- minData, maxData = self._minValue.getFiniteValue(), self._maxValue.getFiniteValue()
- if minData > maxData:
- # avoid a full collapse
- minData, maxData = maxData, minData
-
- minView, maxView = self._computeView(minData, maxData)
-
- if updateMarkers:
- # Save the state in we are not moving the markers
- self._initialRange = minView, maxView
- elif self._initialRange is not None:
- minView = min(minView, self._initialRange[0])
- maxView = max(maxView, self._initialRange[1])
-
- if minView > minData:
- # Hide the min range
- minData = minView
- x = [minView, minData, maxData, maxView]
- y = [0, 0, 1, 1]
-
- self._plot.addCurve(x, y,
- legend="ConstrainedCurve",
- color='black',
- symbol='o',
- linestyle='-',
- z=2,
- resetzoom=False)
-
- scale = self._plot.getXAxis().getScale()
-
- if updateMarkers:
- posMin = self._minValue.getFiniteValue()
- posMax = self._maxValue.getFiniteValue()
-
- def isDisplayable(pos):
- if scale == Axis.LOGARITHMIC:
- return pos > 0.0
- return True
-
- if isDisplayable(posMin):
- minDraggable = (self._colormap().isEditable() and
- not self._minValue.isAutoChecked())
- self._plot.addXMarker(
- posMin,
- legend='Min',
- text='Min',
- draggable=minDraggable,
- color='blue',
- constraint=self._plotMinMarkerConstraint)
- if isDisplayable(posMax):
- maxDraggable = (self._colormap().isEditable() and
- not self._maxValue.isAutoChecked())
- self._plot.addXMarker(
- posMax,
- legend='Max',
- text='Max',
- draggable=maxDraggable,
- color='blue',
- constraint=self._plotMaxMarkerConstraint)
+ return 1, 10
- self._plot.resetZoom()
-
- def _plotMinMarkerConstraint(self, x, y):
- """Constraint of the min marker"""
- return min(x, self._maxValue.getFiniteValue()), y
-
- def _plotMaxMarkerConstraint(self, x, y):
- """Constraint of the max marker"""
- return max(x, self._minValue.getFiniteValue()), y
-
- def _plotSlot(self, event):
- """Handle events from the plot"""
- if event['event'] in ('markerMoving', 'markerMoved'):
- value = float(str(event['xdata']))
- if event['label'] == 'Min':
- self._minValue.setValue(value)
- elif event['label'] == 'Max':
- self._maxValue.setValue(value)
-
- # This will recreate the markers while interacting...
- # It might break if marker interaction is changed
- if event['event'] == 'markerMoved':
- self._initialRange = None
- self._updateMinMax()
- else:
- self._plotUpdate(updateMarkers=False)
+ item = self._getItem()
+ if item is not None:
+ return colormap.getColormapRange(item)
+ # If there is not item, there is no data
+ return colormap.getColormapRange(None)
@staticmethod
def computeDataRange(data):
@@ -552,53 +1074,117 @@ class ColormapDialog(qt.QDialog):
return dataRange
@staticmethod
- def computeHistogram(data, scale=Axis.LINEAR):
+ def computeHistogram(data, scale=Axis.LINEAR, dataRange=None):
"""Compute the data histogram as used by :meth:`setHistogram`.
:param data: The data to process
+ :param dataRange: Optional range to compute the histogram, which is a
+ tuple of min, max
:rtype: Tuple(List(float),List(float)
"""
- _data = data
- if _data.ndim == 3: # RGB(A) images
+ # For compatibility
+ if scale == Axis.LOGARITHMIC:
+ scale = Colormap.LOGARITHM
+
+ if data is None:
+ return None, None
+
+ if len(data) == 0:
+ return None, None
+
+ if data.ndim == 3: # RGB(A) images
_logger.info('Converting current image from RGB(A) to grayscale\
in order to compute the intensity distribution')
- _data = (_data[:, :, 0] * 0.299 +
- _data[:, :, 1] * 0.587 +
- _data[:, :, 2] * 0.114)
+ data = (data[:, :, 0] * 0.299 +
+ data[:, :, 1] * 0.587 +
+ data[:, :, 2] * 0.114)
+
+ # bad hack: get 256 continuous bins in the case we have a B&W
+ normalizeData = True
+ if numpy.issubdtype(data.dtype, numpy.ubyte):
+ normalizeData = False
+ elif numpy.issubdtype(data.dtype, numpy.integer):
+ if dataRange is not None:
+ xmin, xmax = dataRange
+ if xmin is not None and xmax is not None:
+ normalizeData = (xmax - xmin) > 255
+
+ if normalizeData:
+ if scale == Colormap.LOGARITHM:
+ with numpy.errstate(divide='ignore', invalid='ignore'):
+ data = numpy.log10(data)
- if len(_data) == 0:
- return None, None
+ if dataRange is not None:
+ xmin, xmax = dataRange
+ if xmin is None:
+ return None, None
+ if normalizeData:
+ if scale == Colormap.LOGARITHM:
+ xmin, xmax = numpy.log10(xmin), numpy.log10(xmax)
+ else:
+ xmin, xmax = min_max(data, min_positive=False, finite=True)
- if scale == Axis.LOGARITHMIC:
- _data = numpy.log10(_data)
- xmin, xmax = min_max(_data, min_positive=False, finite=True)
if xmin is None:
return None, None
- nbins = min(256, int(numpy.sqrt(_data.size)))
+ nbins = min(256, int(numpy.sqrt(data.size)))
data_range = xmin, xmax
# bad hack: get 256 bins in the case we have a B&W
- if numpy.issubdtype(_data.dtype, numpy.integer):
+ if numpy.issubdtype(data.dtype, numpy.integer):
if nbins > xmax - xmin:
- nbins = xmax - xmin
+ nbins = int(xmax - xmin)
nbins = max(2, nbins)
- _data = _data.ravel().astype(numpy.float32)
+ data = data.ravel().astype(numpy.float32)
- histogram = Histogramnd(_data, n_bins=nbins, histo_range=data_range)
+ histogram = Histogramnd(data, n_bins=nbins, histo_range=data_range)
bins = histogram.edges[0]
- if scale == Axis.LOGARITHMIC:
- bins = 10**bins
+ if normalizeData:
+ if scale == Colormap.LOGARITHM:
+ bins = 10**bins
return histogram.histo, bins
+ def _getItem(self):
+ if self._itemHolder is not None:
+ return self._itemHolder
+ if self._item is None:
+ return None
+ return self._item()
+
+ def setItem(self, item):
+ """Store the plot item.
+
+ According to the state of the dialog, the item will be used to display
+ the data range or the histogram of the data using :meth:`setDataRange`
+ and :meth:`setHistogram`
+ """
+ # While event from items are not supported, we can't ignore dup items
+ # old = self._getItem()
+ # if old is item:
+ # return
+ self._data = None
+ self._itemHolder = None
+ try:
+ if item is None:
+ self._item = None
+ else:
+ if not isinstance(item, items.ColormapMixIn):
+ self._item = None
+ raise ValueError("Item %s is not supported" % item)
+ self._item = weakref.ref(item, self._itemAboutToFinalize)
+ finally:
+ self._dataRange = None
+ self._histogramData = None
+ self._invalidateData()
+
def _getData(self):
if self._data is None:
return None
return self._data()
def setData(self, data):
- """Store the data as a weakref.
+ """Store the data
According to the state of the dialog, the data will be used to display
the data range or the histogram of the data using :meth:`setDataRange`
@@ -608,79 +1194,58 @@ class ColormapDialog(qt.QDialog):
if oldData is data:
return
+ self._item = None
if data is None:
self._data = None
+ self._itemHolder = None
else:
self._data = weakref.ref(data, self._dataAboutToFinalize)
+ self._itemHolder = _DataRefHolder(self._data)
- if self.isVisible():
- self._updateDataInPlot()
- else:
- self._displayLater()
-
- def _setDataInPlotMode(self, mode):
- if self._dataInPlotMode == mode:
- return
- self._dataInPlotMode = mode
- self._updateDataInPlot()
+ self._dataRange = None
+ self._histogramData = None
- def _displayDataInPlotModeChanged(self, action):
- mode = action.data()
- self._setDataInPlotMode(mode)
+ self._invalidateData()
- def _updateDataInPlot(self):
+ def _getArray(self):
data = self._getData()
- if data is None:
- self.setDataRange()
- self.setHistogram()
- return
-
- if data.size == 0:
- # One or more dimensions are equal to 0
- self.setHistogram()
- self.setDataRange()
- return
-
- mode = self._dataInPlotMode
-
- if mode == _DataInPlotMode.NONE:
- self.setHistogram()
- self.setDataRange()
- elif mode == _DataInPlotMode.RANGE:
- result = self.computeDataRange(data)
- self.setHistogram()
- self.setDataRange(*result)
- elif mode == _DataInPlotMode.HISTOGRAM:
- # The histogram should be done in a worker thread
- result = self.computeHistogram(data, scale=self._plot.getXAxis().getScale())
- self.setHistogram(*result)
- self.setDataRange()
-
- def _invalidateHistogram(self):
- """Recompute the histogram if it is displayed"""
- if self._dataInPlotMode == _DataInPlotMode.HISTOGRAM:
- self._updateDataInPlot()
+ if data is not None:
+ return data
+ item = self._getItem()
+ if item is not None:
+ return item.getColormappedData(copy=False)
+ return None
def _colormapAboutToFinalize(self, weakrefColormap):
"""Callback when the data weakref is about to be finalized."""
- if self._colormap is weakrefColormap:
+ if self._colormap is weakrefColormap and qtinspect.isValid(self):
self.setColormap(None)
def _dataAboutToFinalize(self, weakrefData):
"""Callback when the data weakref is about to be finalized."""
- if self._data is weakrefData:
+ if self._data is weakrefData and qtinspect.isValid(self):
self.setData(None)
+ def _itemAboutToFinalize(self, weakref):
+ """Callback when the data weakref is about to be finalized."""
+ if self._item is weakref and qtinspect.isValid(self):
+ self.setItem(None)
+
+ @deprecation.deprecated(reason="It is private data", since_version="0.13")
def getHistogram(self):
- """Returns the counts and bin edges of the displayed histogram.
+ histo = self._getHistogram()
+ if histo is None:
+ return None
+ counts, bin_edges = histo
+ return numpy.array(counts, copy=True), numpy.array(bin_edges, copy=True)
+
+ def _getHistogram(self):
+ """Returns the histogram defined by the dialog as metadata
+ to describe the data in order to speed up the dialog.
:return: (hist, bin_edges)
:rtype: 2-tuple of numpy arrays"""
- if self._histogramData is None:
- return None
- else:
- bins, counts = self._histogramData
- return numpy.array(bins, copy=True), numpy.array(counts, copy=True)
+ return self._histogramData
def setHistogram(self, hist=None, bin_edges=None):
"""Set the histogram to display.
@@ -692,20 +1257,10 @@ class ColormapDialog(qt.QDialog):
"""
if hist is None or bin_edges is None:
self._histogramData = None
- self._plot.remove(legend='Histogram', kind='histogram')
else:
- hist = numpy.array(hist, copy=True)
- bin_edges = numpy.array(bin_edges, copy=True)
- self._histogramData = hist, bin_edges
- norm_hist = hist / max(hist)
- self._plot.addHistogram(norm_hist,
- bin_edges,
- legend="Histogram",
- color='gray',
- align='center',
- fill=True,
- z=1)
- self._updateMinMaxData()
+ self._histogramData = numpy.array(hist), numpy.array(bin_edges)
+
+ self._invalidateData()
def getColormap(self):
"""Return the colormap description.
@@ -726,11 +1281,18 @@ class ColormapDialog(qt.QDialog):
colormap = self.getColormap()
if colormap is not None and self._colormapStoredState is not None:
if colormap != self._colormapStoredState:
- self._ignoreColormapChange = True
- colormap.setFromColormap(self._colormapStoredState)
- self._ignoreColormapChange = False
+ with self._colormapChange:
+ colormap.setFromColormap(self._colormapStoredState)
self._applyColormap()
+ def _getDataRange(self):
+ """Returns the data range defined by the dialog as metadata
+ to describe the data in order to speed up the dialog.
+
+ :return: (minimum, positiveMin, maximum)
+ :rtype: 3-tuple of floats or None"""
+ return self._dataRange
+
def setDataRange(self, minimum=None, positiveMin=None, maximum=None):
"""Set the range of data to use for the range of the histogram area.
@@ -738,62 +1300,37 @@ class ColormapDialog(qt.QDialog):
:param float positiveMin: The positive minimum of the data
:param float maximum: The maximum of the data
"""
- scale = self._plot.getXAxis().getScale()
- if scale == Axis.LOGARITHMIC:
- dataMin, dataMax = positiveMin, maximum
- else:
- dataMin, dataMax = minimum, maximum
+ self._dataRange = minimum, positiveMin, maximum
+ self._invalidateData()
- if dataMin is None or dataMax is None:
- self._dataRange = None
- self._plot.remove(legend='Range', kind='histogram')
- else:
- hist = numpy.array([1])
- bin_edges = numpy.array([dataMin, dataMax])
- self._plot.addHistogram(hist,
- bin_edges,
- legend="Range",
- color='gray',
- align='center',
- fill=True,
- z=1)
- self._dataRange = minimum, positiveMin, maximum
- self._updateMinMaxData()
-
- def _updateMinMaxData(self):
- """Update the min and max of the data according to the data range and
- the histogram preset."""
+ def _setColormapRange(self, xmin, xmax):
+ """Set a new range to the held colormap and update the
+ widget."""
colormap = self.getColormap()
+ if colormap is not None:
+ with self._colormapChange:
+ colormap.setVRange(xmin, xmax)
+ self._updateWidgetRange()
- minimum = float("+inf")
- maximum = float("-inf")
-
- if colormap is not None and colormap.getNormalization() == colormap.LOGARITHM:
- # find a range in the positive part of the data
- if self._dataRange is not None:
- minimum = min(minimum, self._dataRange[1])
- maximum = max(maximum, self._dataRange[2])
- if self._histogramData is not None:
- positives = list(filter(lambda x: x > 0, self._histogramData[1]))
- if len(positives) > 0:
- minimum = min(minimum, positives[0])
- maximum = max(maximum, positives[-1])
+ def _updateWidgetRange(self):
+ """Update the colormap range displayed into the widget."""
+ xmin, xmax = self._getFiniteColormapRange()
+ colormap = self.getColormap()
+ if colormap is not None:
+ vRange = colormap.getVRange()
+ autoMin, autoMax = (r is None for r in vRange)
else:
- if self._dataRange is not None:
- minimum = min(minimum, self._dataRange[0])
- maximum = max(maximum, self._dataRange[2])
- if self._histogramData is not None:
- minimum = min(minimum, self._histogramData[1][0])
- maximum = max(maximum, self._histogramData[1][-1])
-
- if not numpy.isfinite(minimum):
- minimum = None
- if not numpy.isfinite(maximum):
- maximum = None
-
- self._minValue.setDataValue(minimum)
- self._maxValue.setDataValue(maximum)
- self._plotUpdate()
+ autoMin, autoMax = False, False
+
+ with utils.blockSignals(self._minValue):
+ self._minValue.setValue(xmin, autoMin)
+ with utils.blockSignals(self._maxValue):
+ self._maxValue.setValue(xmax, autoMax)
+ with utils.blockSignals(self._histoWidget):
+ self._histoWidget.setFiniteRange((xmin, xmax))
+ with utils.blockSignals(self._autoButtons):
+ self._autoButtons.setAutoRange((autoMin, autoMax))
+ self._autoscaleModeLabel.setEnabled(autoMin or autoMax)
def accept(self):
self.storeCurrentState()
@@ -820,7 +1357,7 @@ class ColormapDialog(qt.QDialog):
:param ~silx.gui.colors.Colormap colormap: the colormap to edit
"""
assert colormap is None or isinstance(colormap, Colormap)
- if self._ignoreColormapChange is True:
+ if self._colormapChange.locked():
return
oldColormap = self.getColormap()
@@ -835,11 +1372,7 @@ class ColormapDialog(qt.QDialog):
self._colormap = colormap
self.storeCurrentState()
- if self.isVisible():
- self._applyColormap()
- else:
- self._updateResetButton()
- self._displayLater()
+ self._invalidateColormap()
def _updateResetButton(self):
resetButton = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset)
@@ -852,156 +1385,232 @@ class ColormapDialog(qt.QDialog):
def _applyColormap(self):
self._updateResetButton()
- if self._ignoreColormapChange is True:
+ if self._colormapChange.locked():
return
colormap = self.getColormap()
if colormap is None:
self._comboBoxColormap.setEnabled(False)
- self._normButtonLinear.setEnabled(False)
- self._normButtonLog.setEnabled(False)
+ self._comboBoxNormalization.setEnabled(False)
+ self._gammaSpinBox.setEnabled(False)
+ self._autoScaleCombo.setEnabled(False)
self._minValue.setEnabled(False)
self._maxValue.setEnabled(False)
+ self._autoButtons.setEnabled(False)
+ self._autoscaleModeLabel.setEnabled(False)
+ self._histoWidget.setVisible(False)
+ self._histoWidget.setFiniteRange((None, None))
else:
- self._ignoreColormapChange = True
- self._comboBoxColormap.setCurrentLut(colormap)
- self._comboBoxColormap.setEnabled(colormap.isEditable())
assert colormap.getNormalization() in Colormap.NORMALIZATIONS
- self._normButtonLinear.setChecked(
- colormap.getNormalization() == Colormap.LINEAR)
- self._normButtonLog.setChecked(
- colormap.getNormalization() == Colormap.LOGARITHM)
- vmin = colormap.getVMin()
- vmax = colormap.getVMax()
- dataRange = colormap.getColormapRange()
- self._normButtonLinear.setEnabled(colormap.isEditable())
- self._normButtonLog.setEnabled(colormap.isEditable())
- self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None)
- self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None)
- self._minValue.setEnabled(colormap.isEditable())
- self._maxValue.setEnabled(colormap.isEditable())
+ with utils.blockSignals(self._comboBoxColormap):
+ self._comboBoxColormap.setCurrentLut(colormap)
+ self._comboBoxColormap.setEnabled(colormap.isEditable())
+ with utils.blockSignals(self._comboBoxNormalization):
+ index = self._comboBoxNormalization.findData(
+ colormap.getNormalization())
+ if index < 0:
+ _logger.error('Unsupported normalization: %s' %
+ colormap.getNormalization())
+ else:
+ self._comboBoxNormalization.setCurrentIndex(index)
+ self._comboBoxNormalization.setEnabled(colormap.isEditable())
+ with utils.blockSignals(self._gammaSpinBox):
+ self._gammaSpinBox.setValue(
+ colormap.getGammaNormalizationParameter())
+ self._gammaSpinBox.setEnabled(
+ colormap.getNormalization() == 'gamma' and
+ colormap.isEditable())
+ with utils.blockSignals(self._autoScaleCombo):
+ self._autoScaleCombo.setCurrentMode(colormap.getAutoscaleMode())
+ self._autoScaleCombo.setEnabled(colormap.isEditable())
+ with utils.blockSignals(self._autoButtons):
+ self._autoButtons.setEnabled(colormap.isEditable())
+ self._autoButtons.setAutoRangeFromColormap(colormap)
+
+ vmin, vmax = colormap.getVRange()
+ if vmin is None or vmax is None:
+ # Compute it only if needed
+ dataRange = self._getFiniteColormapRange()
+ else:
+ dataRange = vmin, vmax
+
+ with utils.blockSignals(self._minValue):
+ self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None)
+ self._minValue.setEnabled(colormap.isEditable())
+ with utils.blockSignals(self._maxValue):
+ self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None)
+ self._maxValue.setEnabled(colormap.isEditable())
+ self._autoscaleModeLabel.setEnabled(vmin is None or vmax is None)
+
+ with utils.blockSignals(self._histoWidget):
+ self._histoWidget.setVisible(True)
+ self._histoWidget.setFiniteRange(dataRange)
+ self._histoWidget.updateNormalization()
+
+ def _comboBoxColormapUpdated(self):
+ """Callback executed when the combo box with the colormap LUT
+ is updated by user input.
+ """
+ colormap = self.getColormap()
+ if colormap is not None:
+ with self._colormapChange:
+ name = self._comboBoxColormap.getCurrentName()
+ if name is not None:
+ colormap.setName(name)
+ else:
+ lut = self._comboBoxColormap.getCurrentColors()
+ colormap.setColormapLUT(lut)
+ self._histoWidget.updateLut()
+
+ def _autoRangeButtonsUpdated(self, autoRange):
+ """Callback executed when the autoscale buttons widget
+ is updated by user input.
+ """
+ dataRange = self._getFiniteColormapRange()
- axis = self._plot.getXAxis()
- scale = axis.LINEAR if colormap.getNormalization() == Colormap.LINEAR else axis.LOGARITHMIC
- axis.setScale(scale)
+ # Final colormap range
+ vmin = (dataRange[0] if not autoRange[0] else None)
+ vmax = (dataRange[1] if not autoRange[1] else None)
- self._ignoreColormapChange = False
+ with self._colormapChange:
+ colormap = self.getColormap()
+ colormap.setVRange(vmin, vmax)
- self._plotUpdate()
+ with utils.blockSignals(self._minValue):
+ self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None)
+ with utils.blockSignals(self._maxValue):
+ self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None)
- def _updateMinMax(self):
- if self._ignoreColormapChange is True:
- return
+ self._updateWidgetRange()
- vmin = self._minValue.getFiniteValue()
- vmax = self._maxValue.getFiniteValue()
- if vmax is not None and vmin is not None and vmax < vmin:
- # If only one autoscale is checked constraints are too strong
- # We have to edit a user value anyway it is not requested
- # TODO: It would be better IMO to disable the auto checkbox before
- # this case occur (valls)
- cmin = self._minValue.isAutoChecked()
- cmax = self._maxValue.isAutoChecked()
- if cmin is False:
- self._minValue.setFiniteValue(vmax)
- if cmax is False:
- self._maxValue.setFiniteValue(vmin)
-
- vmin = self._minValue.getValue()
- vmax = self._maxValue.getValue()
- self._ignoreColormapChange = True
- colormap = self._colormap()
+ def _normalizationUpdated(self, index):
+ """Callback executed when the normalization widget
+ is updated by user input.
+ """
+ colormap = self.getColormap()
if colormap is not None:
- colormap.setVRange(vmin, vmax)
- self._ignoreColormapChange = False
- self._plotUpdate()
- self._updateResetButton()
+ normalization = self._comboBoxNormalization.itemData(index)
+ self._gammaSpinBox.setEnabled(normalization == 'gamma')
- def _updateLut(self):
- if self._ignoreColormapChange is True:
- return
+ with self._colormapChange:
+ colormap.setNormalization(normalization)
+ self._histoWidget.updateNormalization()
- colormap = self._colormap()
- if colormap is not None:
- self._ignoreColormapChange = True
- name = self._comboBoxColormap.getCurrentName()
- if name is not None:
- colormap.setName(name)
- else:
- lut = self._comboBoxColormap.getCurrentColors()
- colormap.setColormapLUT(lut)
- self._ignoreColormapChange = False
+ self._updateWidgetRange()
- def _updateNormalization(self, button):
- if self._ignoreColormapChange is True:
- return
- if not button.isChecked():
- return
+ def _gammaUpdated(self, value):
+ """Callback used to update the gamma normalization parameter"""
+ colormap = self.getColormap()
+ if colormap is not None:
+ colormap.setGammaNormalizationParameter(value)
- if button is self._normButtonLinear:
- norm = Colormap.LINEAR
- scale = Axis.LINEAR
- elif button is self._normButtonLog:
- norm = Colormap.LOGARITHM
- scale = Axis.LOGARITHMIC
- else:
- assert(False)
+ def _autoscaleModeUpdated(self):
+ """Callback executed when the autoscale mode widget
+ is updated by user input.
+ """
+ mode = self._autoScaleCombo.currentMode()
colormap = self.getColormap()
if colormap is not None:
- self._ignoreColormapChange = True
- colormap.setNormalization(norm)
- axis = self._plot.getXAxis()
- axis.setScale(scale)
- self._ignoreColormapChange = False
+ with self._colormapChange:
+ colormap.setAutoscaleMode(mode)
- self._invalidateHistogram()
- self._updateMinMaxData()
+ self._updateWidgetRange()
- def _minMaxTextEdited(self, text):
- """Handle _minValue and _maxValue textEdited signal"""
- self._minMaxWasEdited = True
-
- def _minEditingFinished(self):
- """Handle _minValue editingFinished signal
+ def _minAutoscaleUpdated(self, autoEnabled):
+ """Callback executed when the min autoscale from
+ the lineedit is updated by user input"""
+ colormap = self.getColormap()
+ xmin, xmax = colormap.getVRange()
+ if autoEnabled:
+ xmin = None
+ else:
+ xmin, _xmax = self._getFiniteColormapRange()
+ self._setColormapRange(xmin, xmax)
- Together with :meth:`_minMaxTextEdited`, this avoids to notify
- colormap change when the min and max value where not edited.
+ def _maxAutoscaleUpdated(self, autoEnabled):
+ """Callback executed when the max autoscale from
+ the lineedit is updated by user input"""
+ colormap = self.getColormap()
+ xmin, xmax = colormap.getVRange()
+ if autoEnabled:
+ xmax = None
+ else:
+ _xmin, xmax = self._getFiniteColormapRange()
+ self._setColormapRange(xmin, xmax)
+
+ def _minValueUpdated(self, value):
+ """Callback executed when the lineedit min value is
+ updated by user input"""
+ xmin = value
+ xmax = self._maxValue.getValue()
+ if xmax is not None and xmin > xmax:
+ # FIXME: This should be done in the widget itself
+ xmin = xmax
+ with utils.blockSignals(self._minValue):
+ self._minValue.setValue(xmin)
+ self._setColormapRange(xmin, xmax)
+
+ def _maxValueUpdated(self, value):
+ """Callback executed when the lineedit max value is
+ updated by user input"""
+ xmin = self._minValue.getValue()
+ xmax = value
+ if xmin is not None and xmin > xmax:
+ # FIXME: This should be done in the widget itself
+ xmax = xmin
+ with utils.blockSignals(self._maxValue):
+ self._maxValue.setValue(xmax)
+ self._setColormapRange(xmin, xmax)
+
+ def _histogramRangeMoving(self, vmin, vmax):
+ """Callback executed when for colormap range displayed in
+ the histogram widget is moving.
+
+ :param vmin: Update of the minimum range, else None
+ :param vmax: Update of the maximum range, else None
"""
- if self._minMaxWasEdited:
- self._minMaxWasEdited = False
-
- # Fix start value
- if (self._maxValue.getValue() is not None and
- self._minValue.getValue() > self._maxValue.getValue()):
- self._minValue.setValue(self._maxValue.getValue())
- self._updateMinMax()
-
- def _maxEditingFinished(self):
- """Handle _maxValue editingFinished signal
-
- Together with :meth:`_minMaxTextEdited`, this avoids to notify
- colormap change when the min and max value where not edited.
+ colormap = self.getColormap()
+ if vmin is not None:
+ if colormap.getVMin() is None:
+ with self._colormapChange:
+ colormap.setVMin(vmin)
+ self._minValue.setValue(vmin)
+ if vmax is not None:
+ if colormap.getVMax() is None:
+ with self._colormapChange:
+ colormap.setVMax(vmax)
+ self._maxValue.setValue(vmax)
+
+ def _histogramRangeMoved(self, vmin, vmax):
+ """Callback executed when for colormap range displayed in
+ the histogram widget has finished to move
"""
- if self._minMaxWasEdited:
- self._minMaxWasEdited = False
-
- # Fix end value
- if (self._minValue.getValue() is not None and
- self._minValue.getValue() > self._maxValue.getValue()):
- self._maxValue.setValue(self._minValue.getValue())
- self._updateMinMax()
+ xmin = self._minValue.getValue()
+ xmax = self._maxValue.getValue()
+ self._setColormapRange(xmin, xmax)
def keyPressEvent(self, event):
"""Override key handling.
It disables leaving the dialog when editing a text field.
+
+ But several press of Return key can be use to validate and close the
+ dialog.
"""
- if event.key() == qt.Qt.Key_Enter and (self._minValue.hasFocus() or
- self._maxValue.hasFocus()):
+ if event.key() in (qt.Qt.Key_Enter, qt.Qt.Key_Return):
# Bypass QDialog keyPressEvent
# To avoid leaving the dialog when pressing enter on a text field
- super(qt.QDialog, self).keyPressEvent(event)
+ if self._minValue.hasFocus():
+ nextFocus = self._maxValue
+ elif self._maxValue.hasFocus():
+ if self.isModal():
+ nextFocus = self._buttonsModal.button(qt.QDialogButtonBox.Apply)
+ else:
+ nextFocus = self._buttonsNonModal.button(qt.QDialogButtonBox.Close)
+ else:
+ nextFocus = None
+ if nextFocus is not None:
+ nextFocus.setFocus(qt.Qt.OtherFocusReason)
else:
- # Use QDialog keyPressEvent
super(ColormapDialog, self).keyPressEvent(event)
diff --git a/silx/gui/dialog/DataFileDialog.py b/silx/gui/dialog/DataFileDialog.py
index d2d76a3..84605d9 100644
--- a/silx/gui/dialog/DataFileDialog.py
+++ b/silx/gui/dialog/DataFileDialog.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2016-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
@@ -210,7 +210,7 @@ class DataFileDialog(AbstractDataFileDialog):
.. code-block:: python
url = dialog.selectedDataUrl()
- with h5py.File(url.file_path()) as h5:
+ with h5py.File(url.file_path(), mode="r") as h5:
data = h5[url.data_path()]
"""
diff --git a/silx/gui/dialog/test/test_colormapdialog.py b/silx/gui/dialog/test/test_colormapdialog.py
index 8dad196..8efe8bc 100644
--- a/silx/gui/dialog/test/test_colormapdialog.py
+++ b/silx/gui/dialog/test/test_colormapdialog.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2016-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
@@ -37,6 +37,7 @@ from silx.gui.utils.testutils import TestCaseQt
from silx.gui.colors import Colormap, preferredColormaps
from silx.utils.testutils import ParametricTestCase
from silx.gui.plot.PlotWindow import PlotWindow
+from silx.gui.plot.items.image import ImageData
import numpy.random
@@ -50,10 +51,16 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
normalization='linear')
self.colormapDiag = ColormapDialog.ColormapDialog()
- self.colormapDiag.setAttribute(qt.Qt.WA_DeleteOnClose)
def tearDown(self):
- del self.colormapDiag
+ self.qapp.processEvents()
+ colormapDiag = self.colormapDiag
+ self.colormapDiag = None
+ if colormapDiag is not None:
+ colormapDiag.close()
+ colormapDiag.deleteLater()
+ colormapDiag = None
+ self.qapp.processEvents()
ParametricTestCase.tearDown(self)
TestCaseQt.tearDown(self)
@@ -66,9 +73,11 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
colormapDiag2.show()
self.colormapDiag.setColormap(self.colormap)
self.colormapDiag.show()
+ self.qapp.processEvents()
self.colormapDiag._comboBoxColormap._setCurrentName('red')
- self.colormapDiag._normButtonLog.click()
+ self.colormapDiag._comboBoxNormalization.setCurrentIndex(
+ self.colormapDiag._comboBoxNormalization.findData(Colormap.LOGARITHM))
self.assertTrue(self.colormap.getName() == 'red')
self.assertTrue(self.colormapDiag.getColormap().getName() == 'red')
self.assertTrue(self.colormap.getNormalization() == 'log')
@@ -76,7 +85,8 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
self.assertTrue(self.colormap.getVMax() == 20)
# checked second colormap dialog
self.assertTrue(colormapDiag2._comboBoxColormap.getCurrentName() == 'red')
- self.assertTrue(colormapDiag2._normButtonLog.isChecked())
+ self.assertEqual(colormapDiag2._comboBoxNormalization.currentData(),
+ Colormap.LOGARITHM)
self.assertTrue(int(colormapDiag2._minValue.getValue()) == 10)
self.assertTrue(int(colormapDiag2._maxValue.getValue()) == 20)
colormapDiag2.close()
@@ -86,6 +96,7 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
assert self.colormap.isAutoscale() is False
self.colormapDiag.setModal(True)
self.colormapDiag.show()
+ self.qapp.processEvents()
self.colormapDiag.setColormap(self.colormap)
self.assertTrue(self.colormap.getVMin() is not None)
self.colormapDiag._minValue.setValue(None)
@@ -104,6 +115,7 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
assert self.colormap.isAutoscale() is False
self.colormapDiag.setModal(True)
self.colormapDiag.show()
+ self.qapp.processEvents()
self.colormapDiag.setColormap(self.colormap)
self.assertTrue(self.colormap.getVMin() is not None)
self.colormapDiag._minValue.setValue(None)
@@ -118,6 +130,7 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
assert self.colormap.isAutoscale() is False
self.colormapDiag.setModal(False)
self.colormapDiag.show()
+ self.qapp.processEvents()
self.colormapDiag.setColormap(self.colormap)
self.assertTrue(self.colormap.getVMin() is not None)
self.colormapDiag._minValue.setValue(None)
@@ -132,6 +145,7 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
assert self.colormap.isAutoscale() is False
self.colormapDiag.setModal(False)
self.colormapDiag.show()
+ self.qapp.processEvents()
self.colormapDiag.setColormap(self.colormap)
self.assertTrue(self.colormap.getVMin() is not None)
self.colormapDiag._minValue.setValue(None)
@@ -147,17 +161,20 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
"""Make sure the colormap is modify if go through reject"""
assert self.colormap.isAutoscale() is False
self.colormapDiag.show()
+ self.qapp.processEvents()
self.colormapDiag.setColormap(self.colormap)
self.assertTrue(self.colormap.getVMin() is not None)
self.colormapDiag._minValue.setValue(None)
self.assertTrue(self.colormap.getVMin() is None)
self.colormapDiag.close()
+ self.qapp.processEvents()
self.assertTrue(self.colormap.getVMin() is None)
def testSetColormapIsCorrect(self):
"""Make sure the interface fir the colormap when set a new colormap"""
self.colormap.setName('red')
self.colormapDiag.show()
+ self.qapp.processEvents()
for norm in (Colormap.NORMALIZATIONS):
for autoscale in (True, False):
if autoscale is True:
@@ -167,8 +184,8 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
self.colormap.setNormalization(norm)
with self.subTest(colormap=self.colormap):
self.colormapDiag.setColormap(self.colormap)
- self.assertTrue(
- self.colormapDiag._normButtonLinear.isChecked() == (norm is Colormap.LINEAR))
+ self.assertEqual(
+ self.colormapDiag._comboBoxNormalization.currentData(), norm)
self.assertTrue(
self.colormapDiag._comboBoxColormap.getCurrentName() == 'red')
self.assertTrue(
@@ -189,6 +206,7 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
we make sure the colormap is still running and nothing more"""
self.colormapDiag.setColormap(self.colormap)
self.colormapDiag.show()
+ self.qapp.processEvents()
del self.colormap
self.assertTrue(self.colormapDiag.getColormap() is None)
self.colormapDiag._comboBoxColormap._setCurrentName('blue')
@@ -198,12 +216,14 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
outside"""
self.colormapDiag.setColormap(self.colormap)
self.colormapDiag.show()
+ self.qapp.processEvents()
self.colormap.setName('red')
self.assertTrue(
self.colormapDiag._comboBoxColormap.getCurrentName() == 'red')
self.colormap.setNormalization(Colormap.LOGARITHM)
- self.assertFalse(self.colormapDiag._normButtonLinear.isChecked())
+ self.assertEqual(self.colormapDiag._comboBoxNormalization.currentData(),
+ Colormap.LOGARITHM)
self.colormap.setVRange(11, 201)
self.assertTrue(self.colormapDiag._minValue.getValue() == 11)
self.assertTrue(self.colormapDiag._maxValue.getValue() == 201)
@@ -251,11 +271,12 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
colormap = Colormap(name=colormapName)
self.colormapDiag.setColormap(colormap)
self.colormapDiag.show()
+ self.qapp.processEvents()
cb = self.colormapDiag._comboBoxColormap
self.assertTrue(cb.getCurrentName() == colormapName)
cb.setCurrentIndex(0)
index = cb.findLutName(colormapName)
- assert index is not 0 # if 0 then the rest of the test has no sense
+ assert index != 0 # if 0 then the rest of the test has no sense
cb.setCurrentIndex(index)
self.assertTrue(cb.getCurrentName() == colormapName)
@@ -264,6 +285,7 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
colormap editable status"""
colormap = Colormap(normalization='linear', vmin=1.0, vmax=10.0)
self.colormapDiag.show()
+ self.qapp.processEvents()
self.colormapDiag.setColormap(colormap)
for editable in (True, False):
with self.subTest(editable=editable):
@@ -275,15 +297,14 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
self.assertTrue(
self.colormapDiag._maxValue.isEnabled() is editable)
self.assertTrue(
- self.colormapDiag._normButtonLinear.isEnabled() is editable)
- self.assertTrue(
- self.colormapDiag._normButtonLog.isEnabled() is editable)
+ self.colormapDiag._comboBoxNormalization.isEnabled() is editable)
# Make sure the reset button is also set to enable when edition mode is
# False
self.colormapDiag.setModal(False)
colormap.setEditable(True)
- self.colormapDiag._normButtonLog.click()
+ self.colormapDiag._comboBoxNormalization.setCurrentIndex(
+ self.colormapDiag._comboBoxNormalization.findData(Colormap.LOGARITHM))
resetButton = self.colormapDiag._buttonsNonModal.button(qt.QDialogButtonBox.Reset)
self.assertTrue(resetButton.isEnabled())
colormap.setEditable(False)
@@ -302,6 +323,60 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
self.colormapDiag.setData(data)
self.colormapDiag.setData(None)
+ def testImageItem(self):
+ """Check that an ImageData plot item can be used"""
+ dialog = self.colormapDiag
+ colormap = Colormap(name='gray', vmin=None, vmax=None)
+ data = numpy.arange(3**2).reshape(3, 3)
+ item = ImageData()
+ item.setData(data, copy=False)
+
+ dialog.setColormap(colormap)
+ dialog.show()
+ self.qapp.processEvents()
+ dialog.setItem(item)
+ vrange = dialog._getFiniteColormapRange()
+ self.assertEqual(vrange, (0, 8))
+
+ def testItemDel(self):
+ """Check that the plot items are not hard linked to the dialog"""
+ dialog = self.colormapDiag
+ colormap = Colormap(name='gray', vmin=None, vmax=None)
+ data = numpy.arange(3**2).reshape(3, 3)
+ item = ImageData()
+ item.setData(data, copy=False)
+
+ dialog.setColormap(colormap)
+ dialog.show()
+ self.qapp.processEvents()
+ dialog.setItem(item)
+ previousRange = dialog._getFiniteColormapRange()
+ del item
+ vrange = dialog._getFiniteColormapRange()
+ self.assertNotEqual(vrange, previousRange)
+
+ def testDataDel(self):
+ """Check that the data are not hard linked to the dialog"""
+ dialog = self.colormapDiag
+ colormap = Colormap(name='gray', vmin=None, vmax=None)
+ data = numpy.arange(5)
+
+ dialog.setColormap(colormap)
+ dialog.show()
+ self.qapp.processEvents()
+ dialog.setData(data)
+ previousRange = dialog._getFiniteColormapRange()
+ del data
+ vrange = dialog._getFiniteColormapRange()
+ self.assertNotEqual(vrange, previousRange)
+
+ def testDeleteWhileExec(self):
+ colormapDiag = self.colormapDiag
+ self.colormapDiag = None
+ qt.QTimer.singleShot(1000, colormapDiag.deleteLater)
+ result = colormapDiag.exec_()
+ self.assertEqual(result, 0)
+
class TestColormapAction(TestCaseQt):
def setUp(self):
diff --git a/silx/gui/dialog/test/test_imagefiledialog.py b/silx/gui/dialog/test/test_imagefiledialog.py
index c019afb..3cbb492 100644
--- a/silx/gui/dialog/test/test_imagefiledialog.py
+++ b/silx/gui/dialog/test/test_imagefiledialog.py
@@ -61,8 +61,8 @@ def setUpModule():
filename = _tmpDirectory + "/multiframe.edf"
image = fabio.edfimage.EdfImage(data=data)
- image.appendFrame(data=data + 1)
- image.appendFrame(data=data + 2)
+ image.append_frame(data=data + 1)
+ image.append_frame(data=data + 2)
image.write(filename)
filename = _tmpDirectory + "/singleimage.msk"
diff --git a/silx/gui/fit/FitWidget.py b/silx/gui/fit/FitWidget.py
index c3804e1..7279cd9 100644
--- a/silx/gui/fit/FitWidget.py
+++ b/silx/gui/fit/FitWidget.py
@@ -43,7 +43,6 @@ __date__ = "17/07/2018"
import logging
import sys
import traceback
-import warnings
from silx.math.fit import fittheories
from silx.math.fit import fitmanager, functions
@@ -52,6 +51,7 @@ from .FitWidgets import (FitActionsButtons, FitStatusLines,
FitConfigWidget, ParametersTab)
from .FitConfig import getFitConfigDialog
from .BackgroundWidget import getBgDialog, BackgroundDialog
+from ...utils.deprecation import deprecated
QTVERSION = qt.qVersion()
DEBUG = 0
@@ -226,7 +226,9 @@ class FitWidget(qt.QWidget):
self.guibuttons = FitActionsButtons(self)
"""Widget with estimate, start fit and dismiss buttons"""
self.guibuttons.EstimateButton.clicked.connect(self.estimate)
+ self.guibuttons.EstimateButton.setEnabled(False)
self.guibuttons.StartFitButton.clicked.connect(self.startFit)
+ self.guibuttons.StartFitButton.setEnabled(False)
self.guibuttons.DismissButton.clicked.connect(self.dismiss)
layout.addWidget(self.guibuttons)
@@ -314,12 +316,11 @@ class FitWidget(qt.QWidget):
configuration.update(self.configure())
+ @deprecated(replacement='setData', since_version='0.3.0')
def setdata(self, x, y, sigmay=None, xmin=None, xmax=None):
- warnings.warn("Method renamed to setData",
- DeprecationWarning)
self.setData(x, y, sigmay, xmin, xmax)
- def setData(self, x, y, sigmay=None, xmin=None, xmax=None):
+ def setData(self, x=None, y=None, sigmay=None, xmin=None, xmax=None):
"""Set data to be fitted.
:param x: Abscissa data. If ``None``, :attr:`xdata`` is set to
@@ -335,11 +336,17 @@ class FitWidget(qt.QWidget):
:param xmin: Lower value of x values to use for fitting
:param xmax: Upper value of x values to use for fitting
"""
- self.fitmanager.setdata(x=x, y=y, sigmay=sigmay,
- xmin=xmin, xmax=xmax)
- for config_dialog in self.bgconfigdialogs.values():
- if isinstance(config_dialog, BackgroundDialog):
- config_dialog.setData(x, y, xmin=xmin, xmax=xmax)
+ if y is None:
+ self.guibuttons.EstimateButton.setEnabled(False)
+ self.guibuttons.StartFitButton.setEnabled(False)
+ else:
+ self.guibuttons.EstimateButton.setEnabled(True)
+ self.guibuttons.StartFitButton.setEnabled(True)
+ self.fitmanager.setdata(x=x, y=y, sigmay=sigmay,
+ xmin=xmin, xmax=xmax)
+ for config_dialog in self.bgconfigdialogs.values():
+ if isinstance(config_dialog, BackgroundDialog):
+ config_dialog.setData(x, y, xmin=xmin, xmax=xmax)
def associateConfigDialog(self, theory_name, config_widget,
theory_is_background=False):
@@ -505,10 +512,12 @@ class FitWidget(qt.QWidget):
msg.setWindowTitle('FitWidget Message')
msg.exec_()
return
- except: # noqa (we want to catch and report all errors)
+ except Exception as e: # noqa (we want to catch and report all errors)
+ _logger.warning('Estimate error: %s', traceback.format_exc())
msg = qt.QMessageBox(self)
msg.setIcon(qt.QMessageBox.Critical)
- msg.setText("Error on estimate: %s" % traceback.format_exc())
+ msg.setWindowTitle("Estimate Error")
+ msg.setText("Error on estimate: %s" % e)
msg.exec_()
ddict = {
'event': 'EstimateFailed',
@@ -524,9 +533,8 @@ class FitWidget(qt.QWidget):
'data': self.fitmanager.fit_results}
self._emitSignal(ddict)
+ @deprecated(replacement='startFit', since_version='0.3.0')
def startfit(self):
- warnings.warn("Method renamed to startFit",
- DeprecationWarning)
self.startFit()
def startFit(self):
@@ -548,10 +556,12 @@ class FitWidget(qt.QWidget):
'data': None}
self._emitSignal(ddict)
self.fitmanager.runfit(callback=self.fitStatus)
- except: # noqa (we want to catch and report all errors)
+ except Exception as e: # noqa (we want to catch and report all errors)
+ _logger.warning('Estimate error: %s', traceback.format_exc())
msg = qt.QMessageBox(self)
msg.setIcon(qt.QMessageBox.Critical)
- msg.setText("Error on Fit: %s" % traceback.format_exc())
+ msg.setWindowTitle("Fit Error")
+ msg.setText("Error on Fit: %s" % e)
msg.exec_()
ddict = {
'event': 'FitFailed',
diff --git a/silx/gui/hdf5/test/test_hdf5.py b/silx/gui/hdf5/test/test_hdf5.py
index 4bb43ff..5bd4223 100755
--- a/silx/gui/hdf5/test/test_hdf5.py
+++ b/silx/gui/hdf5/test/test_hdf5.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2016-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
@@ -179,7 +179,7 @@ class TestHdf5TreeModel(TestCaseQt):
def testSynchronizeObject(self):
filename = _tmpDirectory + "/data.h5"
- h5 = h5py.File(filename)
+ h5 = h5py.File(filename, mode="r")
model = hdf5.Hdf5TreeModel()
model.insertH5pyObject(h5)
self.assertEqual(model.rowCount(qt.QModelIndex()), 1)
@@ -256,7 +256,7 @@ class TestHdf5TreeModel(TestCaseQt):
internally."""
filename = _tmpDirectory + "/data.h5"
try:
- h5File = h5py.File(filename)
+ h5File = h5py.File(filename, mode="r")
model = hdf5.Hdf5TreeModel()
self.assertEqual(model.rowCount(qt.QModelIndex()), 0)
model.insertH5pyObject(h5File)
@@ -391,7 +391,7 @@ class TestHdf5TreeModelSignals(TestCaseQt):
TestCaseQt.setUp(self)
self.model = hdf5.Hdf5TreeModel()
filename = _tmpDirectory + "/data.h5"
- self.h5 = h5py.File(filename)
+ self.h5 = h5py.File(filename, mode='r')
self.model.insertH5pyObject(self.h5)
self.listener = SignalListener()
@@ -418,7 +418,7 @@ class TestHdf5TreeModelSignals(TestCaseQt):
def testInsert(self):
filename = _tmpDirectory + "/data.h5"
- h5 = h5py.File(filename)
+ h5 = h5py.File(filename, mode='r')
self.model.insertH5pyObject(h5)
self.assertEqual(self.listener.callCount(), 0)
diff --git a/silx/gui/plot/ColorBar.py b/silx/gui/plot/ColorBar.py
index fd4fdf8..2b4677b 100644
--- a/silx/gui/plot/ColorBar.py
+++ b/silx/gui/plot/ColorBar.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2016-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
@@ -166,8 +166,8 @@ class ColorBarWidget(qt.QWidget):
:param ~silx.gui.colors.Colormap colormap:
The colormap to apply on the ColorBarWidget
- :param numpy.ndarray data: the data to display, needed if the colormap
- require an autoscale
+ :param Union[numpy.ndarray,~silx.gui.plot.items.ColormapMixin] data:
+ The data to display or item, needed if the colormap require an autoscale
"""
self._data = data
self.getColorScaleBar().setColormap(colormap=colormap,
@@ -220,10 +220,10 @@ class ColorBarWidget(qt.QWidget):
return
# Sync with active scatter
- activeScatter = plot._getActiveItem(kind='scatter')
+ scatter = plot._getActiveItem(kind='scatter')
- self.setColormap(colormap=activeScatter.getColormap(),
- data=activeScatter.getValueData(copy=False))
+ self.setColormap(colormap=scatter.getColormap(),
+ data=scatter)
def _activeImageChanged(self, previous, legend):
"""Handle plot active image changed"""
@@ -236,18 +236,18 @@ class ColorBarWidget(qt.QWidget):
self._activeScatterChanged(None, activeScatterLegend)
else:
# Sync with active image
- image = plot.getActiveImage().getData(copy=False)
+ image = plot.getActiveImage()
# RGB(A) image, display default colormap
- if image.ndim != 2:
+ array = image.getData(copy=False)
+ if array.ndim != 2:
self.setColormap(colormap=None)
return
# data image, sync with image colormap
# do we need the copy here : used in the case we are changing
# vmin and vmax but should have already be done by the plot
- self.setColormap(colormap=plot.getActiveImage().getColormap(),
- data=image)
+ self.setColormap(colormap=image.getColormap(), data=image)
def _defaultColormapChanged(self, event):
"""Handle plot default colormap changed"""
@@ -259,9 +259,9 @@ class ColorBarWidget(qt.QWidget):
# No active item, take default colormap update into account
self._syncWithDefaultColormap()
- def _syncWithDefaultColormap(self, data=None):
+ def _syncWithDefaultColormap(self):
"""Update colorbar according to plot default colormap"""
- self.setColormap(self.getPlot().getDefaultColormap(), data)
+ self.setColormap(self.getPlot().getDefaultColormap())
def getColorScaleBar(self):
"""
@@ -351,13 +351,14 @@ class ColorScaleBar(qt.QWidget):
margin=ColorScaleBar._TEXT_MARGIN)
if colormap:
vmin, vmax = colormap.getColormapRange(data)
+ normalizer = colormap._getNormalizer()
else:
vmin, vmax = colors.DEFAULT_MIN_LIN, colors.DEFAULT_MAX_LIN
+ normalizer = None
- norm = colormap.getNormalization() if colormap else colors.Colormap.LINEAR
self.tickbar = _TickBar(vmin=vmin,
vmax=vmax,
- norm=norm,
+ normalizer=normalizer,
parent=self,
displayValues=displayTicksValues,
margin=ColorScaleBar._TEXT_MARGIN)
@@ -408,21 +409,21 @@ class ColorScaleBar(qt.QWidget):
"""Set the new colormap to be displayed
:param Colormap colormap: the colormap to set
- :param numpy.ndarray data: the data to display, needed if the colormap
- require an autoscale
+ :param Union[numpy.ndarray,~silx.gui.plot.items.Item] data:
+ The data or item to display, needed if the colormap requires an autoscale
"""
self.colorScale.setColormap(colormap, data)
if colormap is not None:
vmin, vmax = colormap.getColormapRange(data)
- norm = colormap.getNormalization()
+ normalizer = colormap._getNormalizer()
else:
vmin, vmax = None, None
- norm = None
+ normalizer = None
self.tickbar.update(vmin=vmin,
vmax=vmax,
- norm=norm)
+ normalizer=normalizer)
self._setMinMaxLabels(vmin, vmax)
def setMinMaxVisible(self, val=True):
@@ -503,6 +504,8 @@ class _ColorScale(qt.QWidget):
:param colormap: the colormap to be displayed
:param parent: the Qt parent if any
:param int margin: the top and left margin to apply.
+ :param Union[None,numpy.ndarray,~silx.gui.plot.items.ColormapMixin] data:
+ The data or item to use for getting the range for autoscale colormap.
.. warning:: Value drawing will be
done at the center of ticks. So if no margin is done your values
@@ -531,7 +534,8 @@ class _ColorScale(qt.QWidget):
"""Set the new colormap to be displayed
:param dict colormap: the colormap to set
- :param data: Optional data for which to compute colormap range.
+ :param Union[None,numpy.ndarray,~silx.gui.plot.items.ColormapMixin] data:
+ Optional data for which to compute colormap range.
"""
self._colormap = colormap
self.setEnabled(colormap is not None)
@@ -580,8 +584,8 @@ class _ColorScale(qt.QWidget):
painter.drawRect(qt.QRect(
0,
self.margin,
- self.width() - 1.,
- self.height() - 2. * self.margin - 1.))
+ self.width() - 1,
+ self.height() - 2 * self.margin - 1))
def mouseMoveEvent(self, event):
tooltip = str(self.getValueFromRelativePosition(
@@ -606,19 +610,12 @@ class _ColorScale(qt.QWidget):
if colormap is None:
return
- value = max(0.0, value)
- value = min(value, 1.0)
+ value = numpy.clip(value, 0., 1.)
+ normalizer = colormap._getNormalizer()
+ normMin, normMax = normalizer.apply([self.vmin, self.vmax], self.vmin, self.vmax)
- vmin = self.vmin
- vmax = self.vmax
- if colormap.getNormalization() == colors.Colormap.LINEAR:
- return vmin + (vmax - vmin) * value
- elif colormap.getNormalization() == colors.Colormap.LOGARITHM:
- rpos = (numpy.log10(vmax) - numpy.log10(vmin)) * value + numpy.log10(vmin)
- return numpy.power(10., rpos)
- else:
- err = "normalization type (%s) is not managed by the _ColorScale Widget" % colormap['normalization']
- raise ValueError(err)
+ return normalizer.revert(
+ normMin + (normMax - normMin) * value, self.vmin, self.vmax)
def setMargin(self, margin):
"""Define the margin to fit with a TickBar object.
@@ -627,7 +624,7 @@ class _ColorScale(qt.QWidget):
:param int margin: the margin to apply on the top and bottom.
"""
- self.margin = margin
+ self.margin = int(margin)
self.update()
@@ -645,8 +642,7 @@ class _TickBar(qt.QWidget):
:param int vmin: smaller value of the range of values
:param int vmax: higher value of the range of values
- :param str norm: normalization type to be displayed. Valid values are
- 'linear' and 'log'
+ :param normalizer: Normalization object.
:param parent: the Qt parent if any
:param bool displayValues: if True display the values close to the tick,
Otherwise only signal it by '-'
@@ -666,7 +662,7 @@ class _TickBar(qt.QWidget):
DEFAULT_TICK_DENSITY = 0.015
- def __init__(self, vmin, vmax, norm, parent=None, displayValues=True,
+ def __init__(self, vmin, vmax, normalizer, parent=None, displayValues=True,
nticks=None, margin=5):
super(_TickBar, self).__init__(parent)
self.margin = margin
@@ -678,7 +674,7 @@ class _TickBar(qt.QWidget):
self._vmin = vmin
self._vmax = vmax
- self._norm = norm
+ self._normalizer = normalizer
self.displayValues = displayValues
self.setTicksNumber(nticks)
@@ -695,10 +691,10 @@ class _TickBar(qt.QWidget):
width = self._WIDTH_DISP_VAL if self.displayValues else self._WIDTH_NO_DISP_VAL
self.setFixedWidth(width)
- def update(self, vmin, vmax, norm):
+ def update(self, vmin, vmax, normalizer):
self._vmin = vmin
self._vmax = vmax
- self._norm = norm
+ self._normalizer = normalizer
self.computeTicks()
qt.QWidget.update(self)
@@ -742,13 +738,11 @@ class _TickBar(qt.QWidget):
# No range: no ticks
self.ticks = ()
self.subTicks = ()
- elif self._norm == colors.Colormap.LOGARITHM:
+ elif isinstance(self._normalizer, colors._LogarithmicNormalization):
self._computeTicksLog(nticks)
- elif self._norm == colors.Colormap.LINEAR:
+ else: # Fallback: use linear
self._computeTicksLin(nticks)
- else:
- err = 'TickBar - Wrong normalization %s' % self._norm
- raise ValueError(err)
+
# update the form
font = qt.QFont()
font.setPixelSize(_TickBar._FONT_SIZE)
@@ -801,12 +795,17 @@ class _TickBar(qt.QWidget):
def _getRelativePosition(self, val):
"""Return the relative position of val according to min and max value
"""
- if self._norm == colors.Colormap.LINEAR:
- return 1 - (val - self._vmin) / (self._vmax - self._vmin)
- elif self._norm == colors.Colormap.LOGARITHM:
- return 1 - (numpy.log10(val) - numpy.log10(self._vmin)) / (numpy.log10(self._vmax) - numpy.log10(self._vmin))
+ if self._normalizer is None:
+ return 0.
+ normMin, normMax, normVal = self._normalizer.apply(
+ [self._vmin, self._vmax, val],
+ self._vmin,
+ self._vmax)
+
+ if normMin == normMax:
+ return 0.
else:
- raise ValueError('Norm is not recognized')
+ return 1. - (normVal - normMin) / (normMax - normMin)
def _paintTick(self, val, painter, majorTick=True):
"""
@@ -817,19 +816,18 @@ class _TickBar(qt.QWidget):
fm = qt.QFontMetrics(painter.font())
viewportHeight = self.rect().height() - self.margin * 2 - 1
relativePos = self._getRelativePosition(val)
- height = viewportHeight * relativePos
- height += self.margin
+ height = int(viewportHeight * relativePos + self.margin)
lineWidth = _TickBar._LINE_WIDTH
if majorTick is False:
lineWidth /= 2
- painter.drawLine(qt.QLine(self.width() - lineWidth,
+ painter.drawLine(qt.QLine(int(self.width() - lineWidth),
height,
self.width(),
height))
if self.displayValues and majorTick is True:
- painter.drawText(qt.QPoint(0.0, height + (fm.height() / 2)),
+ painter.drawText(qt.QPoint(0, int(height + fm.height() / 2)),
self.form.format(val))
def setDisplayType(self, disType):
@@ -853,9 +851,9 @@ class _TickBar(qt.QWidget):
def _getFormat(self, font):
if self._forcedDisplayType is None:
return self._guessType(font)
- elif self._forcedDisplayType is 'std':
+ elif self._forcedDisplayType == 'std':
return self._getStandardFormat()
- elif self._forcedDisplayType is 'e':
+ elif self._forcedDisplayType == 'e':
return self._getScientificForm()
else:
err = 'Forced type for display %s is not recognized' % self._forcedDisplayType
diff --git a/silx/gui/plot/ComplexImageView.py b/silx/gui/plot/ComplexImageView.py
index c8470ab..cd891cc 100644
--- a/silx/gui/plot/ComplexImageView.py
+++ b/silx/gui/plot/ComplexImageView.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
@@ -287,10 +287,10 @@ class ComplexImageView(qt.QWidget):
# Create and add image to the plot
self._plotImage = ImageComplexData()
- self._plotImage._setLegend('__ComplexImageView__complex_image__')
+ self._plotImage.setName('__ComplexImageView__complex_image__')
self._plotImage.sigItemChanged.connect(self._itemChanged)
- self._plot2D._add(self._plotImage)
- self._plot2D.setActiveImage(self._plotImage.getLegend())
+ self._plot2D.addItem(self._plotImage)
+ self._plot2D.setActiveImage(self._plotImage.getName())
toolBar = qt.QToolBar('Complex', self)
toolBar.addWidget(
diff --git a/silx/gui/plot/CurvesROIWidget.py b/silx/gui/plot/CurvesROIWidget.py
index 4508c60..4865b8e 100644
--- a/silx/gui/plot/CurvesROIWidget.py
+++ b/silx/gui/plot/CurvesROIWidget.py
@@ -410,7 +410,7 @@ class CurvesROIWidget(qt.QWidget):
"""
if visible:
# if no ROI existing yet, add the default one
- if self.roiTable.rowCount() is 0:
+ if self.roiTable.rowCount() == 0:
old = self.blockSignals(True) # avoid several sigROISignal emission
self._add()
self.blockSignals(old)
@@ -703,7 +703,7 @@ class ROITable(TableWidget):
remove the current active roi
"""
activeItems = self.selectedItems()
- if len(activeItems) is 0:
+ if len(activeItems) == 0:
return
old = self.blockSignals(True) # avoid several emission of sigROISignal
roiToRm = set()
@@ -1069,7 +1069,8 @@ class ROI(_RegionOfInterestBase):
"""Signal emitted when the ROI is edited"""
def __init__(self, name, fromdata=None, todata=None, type_=None):
- _RegionOfInterestBase.__init__(self, name=name)
+ _RegionOfInterestBase.__init__(self)
+ self.setName(name)
global _indexNextROI
self._id = _indexNextROI
_indexNextROI += 1
@@ -1255,7 +1256,7 @@ class ROI(_RegionOfInterestBase):
y = y[(x >= self._fromdata) & (x <= self._todata)]
x = x[(x >= self._fromdata) & (x <= self._todata)]
- if x.size is 0:
+ if x.size == 0:
return 0.0, 0.0
rawArea = numpy.trapz(y, x=x)
@@ -1268,6 +1269,10 @@ class ROI(_RegionOfInterestBase):
netArea = rawArea - background
return rawArea, netArea
+ @docstring(_RegionOfInterestBase)
+ def contains(self, position):
+ return self._fromdata <= position[0] <= self._todata
+
class _RoiMarkerManager(object):
"""
diff --git a/silx/gui/plot/ImageStack.py b/silx/gui/plot/ImageStack.py
new file mode 100644
index 0000000..c620d6d
--- /dev/null
+++ b/silx/gui/plot/ImageStack.py
@@ -0,0 +1,586 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 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
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Image stack view with data prefetch capabilty."""
+
+__authors__ = ["H. Payno"]
+__license__ = "MIT"
+__date__ = "04/03/2019"
+
+
+from silx.gui import icons, qt
+from silx.gui.plot import Plot2D
+from silx.gui.utils import concurrent
+from silx.io.url import DataUrl
+from silx.io.utils import get_data
+from collections import OrderedDict
+from silx.gui.widgets.FrameBrowser import HorizontalSliderWithBrowser
+import time
+import threading
+import typing
+import logging
+
+_logger = logging.getLogger(__file__)
+
+
+class _PlotWithWaitingLabel(qt.QWidget):
+ """Image plot widget with an overlay 'waiting' status.
+ """
+
+ class AnimationThread(threading.Thread):
+ def __init__(self, label):
+ self.running = True
+ self._label = label
+ self.animated_icon = icons.getWaitIcon()
+ self.animated_icon.register(self._label)
+ super(_PlotWithWaitingLabel.AnimationThread, self).__init__()
+
+ def run(self):
+ while self.running:
+ time.sleep(0.05)
+ icon = self.animated_icon.currentIcon()
+ self.future_result = concurrent.submitToQtMainThread(
+ self._label.setPixmap, icon.pixmap(30, state=qt.QIcon.On))
+
+ def stop(self):
+ """Stop the update thread"""
+ self.animated_icon.unregister(self._label)
+ self.running = False
+ self.join(2)
+
+ def __init__(self, parent):
+ super(_PlotWithWaitingLabel, self).__init__(parent=parent)
+ layout = qt.QStackedLayout(self)
+ layout.setStackingMode(qt.QStackedLayout.StackAll)
+
+ self._waiting_label = qt.QLabel(parent=self)
+ self._waiting_label.setAlignment(qt.Qt.AlignHCenter | qt.Qt.AlignVCenter)
+ layout.addWidget(self._waiting_label)
+
+ self._plot = Plot2D(parent=self)
+ layout.addWidget(self._plot)
+
+ self.updateThread = _PlotWithWaitingLabel.AnimationThread(self._waiting_label)
+ self.updateThread.start()
+
+ def close(self) -> bool:
+ super(_PlotWithWaitingLabel, self).close()
+ self.updateThread.stop()
+
+ def setWaiting(self, activate=True):
+ if activate is True:
+ self._plot.clear()
+ self._waiting_label.show()
+ else:
+ self._waiting_label.hide()
+
+ def setData(self, data):
+ self.setWaiting(activate=False)
+ self._plot.addImage(data=data)
+
+ def clear(self):
+ self._plot.clear()
+ self.setWaiting(False)
+
+ def getPlotWidget(self):
+ return self._plot
+
+
+class _HorizontalSlider(HorizontalSliderWithBrowser):
+
+ sigCurrentUrlIndexChanged = qt.Signal(int)
+
+ def __init__(self, parent):
+ super(_HorizontalSlider, self).__init__(parent=parent)
+ # connect signal / slot
+ self.valueChanged.connect(self._urlChanged)
+
+ def setUrlIndex(self, index):
+ self.setValue(index)
+ self.sigCurrentUrlIndexChanged.emit(index)
+
+ def _urlChanged(self, value):
+ self.sigCurrentUrlIndexChanged.emit(value)
+
+
+class UrlList(qt.QWidget):
+ """List of URLs the user to select an URL"""
+
+ sigCurrentUrlChanged = qt.Signal(str)
+ """Signal emitted when the active/current url change"""
+
+ def __init__(self, parent=None):
+ super(UrlList, self).__init__(parent)
+ self.setLayout(qt.QVBoxLayout())
+ self.layout().setSpacing(0)
+ self.layout().setContentsMargins(0, 0, 0, 0)
+ self._listWidget = qt.QListWidget(parent=self)
+ self.layout().addWidget(self._listWidget)
+
+ # connect signal / Slot
+ self._listWidget.currentItemChanged.connect(self._notifyCurrentUrlChanged)
+
+ # expose API
+ self.currentItem = self._listWidget.currentItem
+
+ def setUrls(self, urls: list) -> None:
+ url_names = []
+ [url_names.append(url.path()) for url in urls]
+ self._listWidget.addItems(url_names)
+
+ def _notifyCurrentUrlChanged(self, current, previous):
+ self.sigCurrentUrlChanged.emit(current.text())
+
+ def setUrl(self, url: DataUrl) -> None:
+ assert isinstance(url, DataUrl)
+ sel_items = self._listWidget.findItems(url.path(), qt.Qt.MatchExactly)
+ if sel_items is None:
+ _logger.warning(url.path(), ' is not registered in the list.')
+ else:
+ assert len(sel_items) == 1
+ item = sel_items[0]
+ self._listWidget.setCurrentItem(item)
+ self.sigCurrentUrlChanged.emit(item.text())
+
+
+class _ToggleableUrlSelectionTable(qt.QWidget):
+
+ _BUTTON_ICON = qt.QStyle.SP_ToolBarHorizontalExtensionButton # noqa
+
+ sigCurrentUrlChanged = qt.Signal(str)
+ """Signal emitted when the active/current url change"""
+
+ def __init__(self, parent=None) -> None:
+ qt.QWidget.__init__(self, parent)
+ self.setLayout(qt.QGridLayout())
+ self._toggleButton = qt.QPushButton(parent=self)
+ self.layout().addWidget(self._toggleButton, 0, 2, 1, 1)
+ self._toggleButton.setSizePolicy(qt.QSizePolicy.Fixed,
+ qt.QSizePolicy.Fixed)
+
+ self._urlsTable = UrlList(parent=self)
+ self.layout().addWidget(self._urlsTable, 1, 1, 1, 2)
+
+ # set up
+ self._setButtonIcon(show=True)
+
+ # Signal / slot connection
+ self._toggleButton.clicked.connect(self.toggleUrlSelectionTable)
+ self._urlsTable.sigCurrentUrlChanged.connect(self._propagateSignal)
+
+ # expose API
+ self.setUrls = self._urlsTable.setUrls
+ self.setUrl = self._urlsTable.setUrl
+ self.currentItem = self._urlsTable.currentItem
+
+ def toggleUrlSelectionTable(self):
+ visible = not self.urlSelectionTableIsVisible()
+ self._setButtonIcon(show=visible)
+ self._urlsTable.setVisible(visible)
+
+ def _setButtonIcon(self, show):
+ style = qt.QApplication.instance().style()
+ # return a QIcon
+ icon = style.standardIcon(self._BUTTON_ICON)
+ if show is False:
+ pixmap = icon.pixmap(32, 32).transformed(qt.QTransform().scale(-1, 1))
+ icon = qt.QIcon(pixmap)
+ self._toggleButton.setIcon(icon)
+
+ def urlSelectionTableIsVisible(self):
+ return self._urlsTable.isVisible()
+
+ def _propagateSignal(self, url):
+ self.sigCurrentUrlChanged.emit(url)
+
+
+class UrlLoader(qt.QThread):
+ """
+ Thread use to load DataUrl
+ """
+ def __init__(self, parent, url):
+ super(UrlLoader, self).__init__(parent=parent)
+ assert isinstance(url, DataUrl)
+ self.url = url
+ self.data = None
+
+ def run(self):
+ try:
+ self.data = get_data(self.url)
+ except IOError:
+ self.data = None
+
+
+class ImageStack(qt.QMainWindow):
+ """Widget loading on the fly images contained the given urls.
+
+ It prefetches images close to the displayed one.
+ """
+
+ N_PRELOAD = 10
+
+ sigLoaded = qt.Signal(str)
+ """Signal emitted when new data is available"""
+
+ sigCurrentUrlChanged = qt.Signal(str)
+ """Signal emitted when the current url change"""
+
+ def __init__(self, parent=None) -> None:
+ super(ImageStack, self).__init__(parent)
+ self.__n_prefetch = ImageStack.N_PRELOAD
+ self._loadingThreads = []
+ self.setWindowFlags(qt.Qt.Widget)
+ self._current_url = None
+ self._url_loader = UrlLoader
+ "class to instantiate for loading urls"
+
+ # main widget
+ self._plot = _PlotWithWaitingLabel(parent=self)
+ self._plot.setAttribute(qt.Qt.WA_DeleteOnClose, True)
+ self.setWindowTitle("Image stack")
+ self.setCentralWidget(self._plot)
+
+ # dock widget: url table
+ self._tableDockWidget = qt.QDockWidget(parent=self)
+ self._urlsTable = _ToggleableUrlSelectionTable(parent=self)
+ self._tableDockWidget.setWidget(self._urlsTable)
+ self._tableDockWidget.setFeatures(qt.QDockWidget.DockWidgetMovable)
+ self.addDockWidget(qt.Qt.RightDockWidgetArea, self._tableDockWidget)
+ # dock widget: qslider
+ self._sliderDockWidget = qt.QDockWidget(parent=self)
+ self._slider = _HorizontalSlider(parent=self)
+ self._sliderDockWidget.setWidget(self._slider)
+ self.addDockWidget(qt.Qt.BottomDockWidgetArea, self._sliderDockWidget)
+ self._sliderDockWidget.setFeatures(qt.QDockWidget.DockWidgetMovable)
+
+ self.reset()
+
+ # connect signal / slot
+ self._urlsTable.sigCurrentUrlChanged.connect(self.setCurrentUrl)
+ self._slider.sigCurrentUrlIndexChanged.connect(self.setCurrentUrlIndex)
+
+ def close(self) -> bool:
+ self._freeLoadingThreads()
+ self._plot.close()
+ super(ImageStack, self).close()
+
+ def setUrlLoaderClass(self, urlLoader: typing.Type[UrlLoader]) -> None:
+ """
+
+ :param urlLoader: define the class to call for loading urls.
+ warning: this should be a class object and not a
+ class instance.
+ """
+ assert isinstance(urlLoader, type(UrlLoader))
+ self._url_loader = urlLoader
+
+ def getUrlLoaderClass(self):
+ """
+
+ :return: class to instantiate for loading urls
+ :rtype: typing.Type[UrlLoader]
+ """
+ return self._url_loader
+
+ def _freeLoadingThreads(self):
+ for thread in self._loadingThreads:
+ thread.blockSignals(True)
+ thread.wait(5)
+ self._loadingThreads.clear()
+
+ def getPlotWidget(self) -> Plot2D:
+ """
+ Returns the PlotWidget contained in this window
+
+ :return: PlotWidget contained in this window
+ :rtype: Plot2D
+ """
+ return self._plot.getPlotWidget()
+
+ def reset(self) -> None:
+ """Clear the plot and remove any link to url"""
+ self._freeLoadingThreads()
+ self._urls = None
+ self._urlIndexes = None
+ self._urlData = OrderedDict({})
+ self._current_url = None
+ self._plot.clear()
+
+ def _preFetch(self, urls: list) -> None:
+ """Pre-fetch the given urls if necessary
+
+ :param urls: list of DataUrl to prefetch
+ :type: list
+ """
+ for url in urls:
+ if url.path() not in self._urlData:
+ self._load(url)
+
+ def _load(self, url):
+ """
+ Launch background load of a DataUrl
+
+ :param url:
+ :type: DataUrl
+ """
+ assert isinstance(url, DataUrl)
+ url_path = url.path()
+ assert url_path in self._urlIndexes
+ loader = self._url_loader(parent=self, url=url)
+ loader.finished.connect(self._urlLoaded, qt.Qt.QueuedConnection)
+ self._loadingThreads.append(loader)
+ loader.start()
+
+ def _urlLoaded(self) -> None:
+ """
+
+ :param url: restul of DataUrl.path() function
+ :return:
+ """
+ sender = self.sender()
+ assert isinstance(sender, UrlLoader)
+ url = sender.url.path()
+ if url in self._urlIndexes:
+ self._urlData[url] = sender.data
+ if self.getCurrentUrl().path() == url:
+ self._plot.setData(self._urlData[url])
+ if sender in self._loadingThreads:
+ self._loadingThreads.remove(sender)
+ self.sigLoaded.emit(url)
+
+ def setNPrefetch(self, n: int) -> None:
+ """
+ Define the number of url to prefetch around
+
+ :param int n: number of url to prefetch on left and right sides.
+ In total n*2 DataUrl will be prefetch
+ """
+ self.__n_prefetch = n
+ current_url = self.getCurrentUrl()
+ if current_url is not None:
+ self.setCurrentUrl(current_url)
+
+ def getNPrefetch(self) -> int:
+ """
+
+ :return: number of url to prefetch on left and right sides. In total
+ will load 2* NPrefetch DataUrls
+ """
+ return self.__n_prefetch
+
+ def setUrls(self, urls: list) -> None:
+ """list of urls within an index. Warning: urls should contain an image
+ compatible with the silx.gui.plot.Plot class
+
+ :param urls: urls we want to set in the stack. Key is the index
+ (position in the stack), value is the DataUrl
+ :type: list
+ """
+ def createUrlIndexes():
+ indexes = OrderedDict()
+ for index, url in enumerate(urls):
+ indexes[index] = url
+ return indexes
+
+ urls_with_indexes = createUrlIndexes()
+ urlsToIndex = self._urlsToIndex(urls_with_indexes)
+ self.reset()
+ self._urls = urls_with_indexes
+ self._urlIndexes = urlsToIndex
+
+ old_url_table = self._urlsTable.blockSignals(True)
+ self._urlsTable.setUrls(urls=list(self._urls.values()))
+ self._urlsTable.blockSignals(old_url_table)
+
+ old_slider = self._slider.blockSignals(True)
+ self._slider.setMaximum(len(self._urls) - 1)
+ self._slider.blockSignals(old_slider)
+
+ if self.getCurrentUrl() in self._urls:
+ self.setCurrentUrl(self.getCurrentUrl())
+ else:
+ first_url = self._urls[list(self._urls.keys())[0]]
+ self.setCurrentUrl(first_url)
+
+ def getUrls(self) -> tuple:
+ """
+
+ :return: tuple of urls
+ :rtype: tuple
+ """
+ return tuple(self._urlIndexes.keys())
+
+ def _getNextUrl(self, url: DataUrl) -> typing.Union[None, DataUrl]:
+ """
+ return the next url in the stack
+
+ :param url: url for which we want the next url
+ :type: DataUrl
+ :return: next url in the stack or None if `url` is the last one
+ :rtype: Union[None, DataUrl]
+ """
+ assert isinstance(url, DataUrl)
+ if self._urls is None:
+ return None
+ else:
+ index = self._urlIndexes[url.path()]
+ indexes = list(self._urls.keys())
+ res = list(filter(lambda x: x > index, indexes))
+ if len(res) == 0:
+ return None
+ else:
+ return self._urls[res[0]]
+
+ def _getPreviousUrl(self, url: DataUrl) -> typing.Union[None, DataUrl]:
+ """
+ return the previous url in the stack
+
+ :param url: url for which we want the previous url
+ :type: DataUrl
+ :return: next url in the stack or None if `url` is the last one
+ :rtype: Union[None, DataUrl]
+ """
+ if self._urls is None:
+ return None
+ else:
+ index = self._urlIndexes[url.path()]
+ indexes = list(self._urls.keys())
+ res = list(filter(lambda x: x < index, indexes))
+ if len(res) == 0:
+ return None
+ else:
+ return self._urls[res[-1]]
+
+ def _getNNextUrls(self, n: int, url: DataUrl) -> list:
+ """
+ Deduce the next urls in the stack after `url`
+
+ :param n: the number of url store after `url`
+ :type: int
+ :param url: url for which we want n next url
+ :type: DataUrl
+ :return: list of next urls.
+ :rtype: list
+ """
+ res = []
+ next_free = self._getNextUrl(url=url)
+ while len(res) < n and next_free is not None:
+ assert isinstance(next_free, DataUrl)
+ res.append(next_free)
+ next_free = self._getNextUrl(res[-1])
+ return res
+
+ def _getNPreviousUrls(self, n: int, url: DataUrl):
+ """
+ Deduce the previous urls in the stack after `url`
+
+ :param n: the number of url store after `url`
+ :type: int
+ :param url: url for which we want n previous url
+ :type: DataUrl
+ :return: list of previous urls.
+ :rtype: list
+ """
+ res = []
+ next_free = self._getPreviousUrl(url=url)
+ while len(res) < n and next_free is not None:
+ res.insert(0, next_free)
+ next_free = self._getPreviousUrl(res[0])
+ return res
+
+ def setCurrentUrlIndex(self, index: int):
+ """
+ Define the url to be displayed
+
+ :param index: url to be displayed
+ :type: int
+ """
+ if index >= len(self._urls):
+ raise ValueError('requested index out of bounds')
+ else:
+ return self.setCurrentUrl(self._urls[index])
+
+ def setCurrentUrl(self, url: typing.Union[DataUrl, str]) -> None:
+ """
+ Define the url to be displayed
+
+ :param url: url to be displayed
+ :type: DataUrl
+ """
+ assert isinstance(url, (DataUrl, str))
+ if isinstance(url, str):
+ url = DataUrl(path=url)
+ if url != self._current_url:
+ self._current_url = url
+ self.sigCurrentUrlChanged.emit(url.path())
+
+ old_url_table = self._urlsTable.blockSignals(True)
+ old_slider = self._slider.blockSignals(True)
+
+ self._urlsTable.setUrl(url)
+ self._slider.setUrlIndex(self._urlIndexes[url.path()])
+ if self._current_url is None:
+ self._plot.clear()
+ else:
+ if self._current_url.path() in self._urlData:
+ self._plot.setData(self._urlData[url.path()])
+ else:
+ self._load(url)
+ self._notifyLoading()
+ self._preFetch(self._getNNextUrls(self.__n_prefetch, url))
+ self._preFetch(self._getNPreviousUrls(self.__n_prefetch, url))
+ self._urlsTable.blockSignals(old_url_table)
+ self._slider.blockSignals(old_slider)
+
+ def getCurrentUrl(self) -> typing.Union[None, DataUrl]:
+ """
+
+ :return: url currently displayed
+ :rtype: Union[None, DataUrl]
+ """
+ return self._current_url
+
+ def getCurrentUrlIndex(self) -> typing.Union[None, int]:
+ """
+
+ :return: index of the url currently displayed
+ :rtype: Union[None, int]
+ """
+ if self._current_url is None:
+ return None
+ else:
+ return self._urlIndexes[self._current_url.path()]
+
+ @staticmethod
+ def _urlsToIndex(urls):
+ """util, return a dictionary with url as key and index as value"""
+ res = {}
+ for index, url in urls.items():
+ res[url.path()] = index
+ return res
+
+ def _notifyLoading(self):
+ """display a simple image of loading..."""
+ self._plot.setWaiting(activate=True)
+
diff --git a/silx/gui/plot/ImageView.py b/silx/gui/plot/ImageView.py
index eba9bc6..fafd49f 100644
--- a/silx/gui/plot/ImageView.py
+++ b/silx/gui/plot/ImageView.py
@@ -832,6 +832,7 @@ class ImageViewMainWindow(ImageView):
menu = self.menuBar().addMenu('Profile')
menu.addAction(self.profile.hLineAction)
menu.addAction(self.profile.vLineAction)
+ menu.addAction(self.profile.crossAction)
menu.addAction(self.profile.lineAction)
menu.addAction(self.profile.clearAction)
diff --git a/silx/gui/plot/Interaction.py b/silx/gui/plot/Interaction.py
index 358af74..6213889 100644
--- a/silx/gui/plot/Interaction.py
+++ b/silx/gui/plot/Interaction.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2014-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
@@ -137,6 +137,11 @@ class State(object):
"""
pass
+ def validate(self):
+ """Called externally to validate the current interaction in case of a
+ creation.
+ """
+ pass
class StateMachine(object):
"""State machine controller.
@@ -191,6 +196,12 @@ class StateMachine(object):
if handler is not None:
return handler(*args, **kwargs)
+ def validate(self):
+ """Called externally to validate the current interaction in case of a
+ creation.
+ """
+ self.state.validate()
+
# clickOrDrag #################################################################
@@ -209,92 +220,131 @@ class ClickOrDrag(StateMachine):
It is intended to be used through subclassing by overriding
:meth:`click`, :meth:`beginDrag`, :meth:`drag` and :meth:`endDrag`.
+
+ :param Set[str] clickButtons: Set of buttons that provides click interaction
+ :param Set[str] dragButtons: Set of buttons that provides drag interaction
"""
DRAG_THRESHOLD_SQUARE_DIST = 5 ** 2
class Idle(State):
def onPress(self, x, y, btn):
- if btn == LEFT_BTN:
- self.goto('clickOrDrag', x, y)
+ if btn in self.machine.dragButtons:
+ self.goto('clickOrDrag', x, y, btn)
return True
- elif btn == RIGHT_BTN:
- self.goto('rightClick', x, y)
+ elif btn in self.machine.clickButtons:
+ self.goto('click', x, y, btn)
return True
- class RightClick(State):
+ class Click(State):
+ def enterState(self, x, y, btn):
+ self.initPos = x, y
+ self.button = btn
+
def onMove(self, x, y):
- self.goto('idle')
+ dx2 = (x - self.initPos[0]) ** 2
+ dy2 = (y - self.initPos[1]) ** 2
+ if (dx2 + dy2) >= self.machine.DRAG_THRESHOLD_SQUARE_DIST:
+ self.goto('idle')
def onRelease(self, x, y, btn):
- if btn == RIGHT_BTN:
+ if btn == self.button:
self.machine.click(x, y, btn)
self.goto('idle')
class ClickOrDrag(State):
- def enterState(self, x, y):
+ def enterState(self, x, y, btn):
self.initPos = x, y
+ self.button = btn
def onMove(self, x, y):
dx2 = (x - self.initPos[0]) ** 2
dy2 = (y - self.initPos[1]) ** 2
if (dx2 + dy2) >= self.machine.DRAG_THRESHOLD_SQUARE_DIST:
- self.goto('drag', self.initPos, (x, y))
+ self.goto('drag', self.initPos, (x, y), self.button)
def onRelease(self, x, y, btn):
- if btn == LEFT_BTN:
- self.machine.click(x, y, btn)
+ if btn == self.button:
+ if btn in self.machine.clickButtons:
+ self.machine.click(x, y, btn)
self.goto('idle')
class Drag(State):
- def enterState(self, initPos, curPos):
+ def enterState(self, initPos, curPos, btn):
self.initPos = initPos
- self.machine.beginDrag(*initPos)
- self.machine.drag(*curPos)
+ self.button = btn
+ self.machine.beginDrag(*initPos, btn)
+ self.machine.drag(*curPos, btn)
def onMove(self, x, y):
- self.machine.drag(x, y)
+ self.machine.drag(x, y, self.button)
def onRelease(self, x, y, btn):
- if btn == LEFT_BTN:
- self.machine.endDrag(self.initPos, (x, y))
+ if btn == self.button:
+ self.machine.endDrag(self.initPos, (x, y), btn)
self.goto('idle')
- def __init__(self):
+ def __init__(self,
+ clickButtons=(LEFT_BTN, RIGHT_BTN),
+ dragButtons=(LEFT_BTN,)):
states = {
- 'idle': ClickOrDrag.Idle,
- 'rightClick': ClickOrDrag.RightClick,
- 'clickOrDrag': ClickOrDrag.ClickOrDrag,
- 'drag': ClickOrDrag.Drag
+ 'idle': self.Idle,
+ 'click': self.Click,
+ 'clickOrDrag': self.ClickOrDrag,
+ 'drag': self.Drag
}
+ self.__clickButtons = set(clickButtons)
+ self.__dragButtons = set(dragButtons)
super(ClickOrDrag, self).__init__(states, 'idle')
+ clickButtons = property(lambda self: self.__clickButtons,
+ doc="Buttons with click interaction (Set[int])")
+
+ dragButtons = property(lambda self: self.__dragButtons,
+ doc="Buttons with drag interaction (Set[int])")
+
def click(self, x, y, btn):
- """Called upon a left or right button click.
+ """Called upon a button supporting click.
- To override in a subclass.
+ Override in subclass.
+
+ :param int x: X mouse position in pixels.
+ :param int y: Y mouse position in pixels.
+ :param str btn: The mouse button which was clicked.
"""
pass
- def beginDrag(self, x, y):
- """Called at the beginning of a drag gesture with left button
- pressed.
+ def beginDrag(self, x, y, btn):
+ """Called at the beginning of a drag gesture with mouse button pressed.
+
+ Override in subclass.
- To override in a subclass.
+ :param int x: X mouse position in pixels.
+ :param int y: Y mouse position in pixels.
+ :param str btn: The mouse button for which a drag is starting.
"""
pass
- def drag(self, x, y):
+ def drag(self, x, y, btn):
"""Called on mouse moved during a drag gesture.
- To override in a subclass.
+ Override in subclass.
+
+ :param int x: X mouse position in pixels.
+ :param int y: Y mouse position in pixels.
+ :param str btn: The mouse button for which a drag is in progress.
"""
pass
- def endDrag(self, startPoint, endPoint):
- """Called at the end of a drag gesture when the left button is
- released.
+ def endDrag(self, startPoint, endPoint, btn):
+ """Called at the end of a drag gesture when the mouse button is released.
+
+ Override in subclass.
- To override in a subclass.
+ :param List[int] startPoint:
+ (x, y) mouse position in pixels at the beginning of the drag.
+ :param List[int] endPoint:
+ (x, y) mouse position in pixels at the end of the drag.
+ :param str btn: The mouse button for which a drag is done.
"""
pass
diff --git a/silx/gui/plot/ItemsSelectionDialog.py b/silx/gui/plot/ItemsSelectionDialog.py
index acb287a..ebd1c64 100644
--- a/silx/gui/plot/ItemsSelectionDialog.py
+++ b/silx/gui/plot/ItemsSelectionDialog.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 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
@@ -141,20 +141,22 @@ class PlotItemsSelector(qt.QTableWidget):
def updatePlotItems(self):
self._clear()
- nrows = len(self.plot._getItems(kind=self.plot_item_kinds,
- just_legend=True))
- self.setRowCount(nrows)
-
# respect order of kinds as set in method setKindsFilter
- i = 0
+ itemsAndKind = []
for kind in self.plot_item_kinds:
- for plot_item in self.plot._getItems(kind=kind):
- legend_twitem = qt.QTableWidgetItem(plot_item.getLegend())
- self.setItem(i, 0, legend_twitem)
+ itemClasses = self.plot._KIND_TO_CLASSES[kind]
+ for item in self.plot.getItems():
+ if isinstance(item, itemClasses) and item.isVisible():
+ itemsAndKind.append((item, kind))
+
+ self.setRowCount(len(itemsAndKind))
+
+ for index, (item, kind) in enumerate(itemsAndKind):
+ legend_twitem = qt.QTableWidgetItem(item.getName())
+ self.setItem(index, 0, legend_twitem)
- kind_twitem = qt.QTableWidgetItem(kind)
- self.setItem(i, 1, kind_twitem)
- i += 1
+ kind_twitem = qt.QTableWidgetItem(kind)
+ self.setItem(index, 1, kind_twitem)
@property
def selectedPlotItems(self):
@@ -167,7 +169,9 @@ class PlotItemsSelector(qt.QTableWidget):
for row in selected_rows:
legend = self.item(row, 0).text()
kind = self.item(row, 1).text()
- items.append(self.plot._getItem(kind, legend))
+ item = self.plot._getItem(kind, legend)
+ if item is not None:
+ items.append(item)
return items
@@ -192,7 +196,7 @@ class ItemsSelectionDialog(qt.QDialog):
result = isd.exec_()
if result:
for item in isd.getSelectedItems():
- print(item.getLegend(), type(item))
+ print(item.getName(), type(item))
else:
print("Selection cancelled")
"""
diff --git a/silx/gui/plot/LegendSelector.py b/silx/gui/plot/LegendSelector.py
index a9d89db..0ea0fc8 100755
--- a/silx/gui/plot/LegendSelector.py
+++ b/silx/gui/plot/LegendSelector.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2004-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
@@ -925,7 +925,7 @@ class LegendsDockWidget(qt.QDockWidget):
yaxis = 'right' if ddict['event'] == 'mapToRight' else 'left'
self.plot.addCurve(x=curve.getXData(copy=False),
y=curve.getYData(copy=False),
- legend=curve.getLegend(),
+ legend=curve.getName(),
info=curve.getInfo(),
yaxis=yaxis)
@@ -935,7 +935,7 @@ class LegendsDockWidget(qt.QDockWidget):
symbol = ddict['symbol'] if ddict['points'] else ''
self.plot.addCurve(x=curve.getXData(copy=False),
y=curve.getYData(copy=False),
- legend=curve.getLegend(),
+ legend=curve.getName(),
info=curve.getInfo(),
symbol=symbol)
@@ -945,7 +945,7 @@ class LegendsDockWidget(qt.QDockWidget):
linestyle = ddict['linestyle'] if ddict['line'] else ''
self.plot.addCurve(x=curve.getXData(copy=False),
y=curve.getYData(copy=False),
- legend=curve.getLegend(),
+ legend=curve.getName(),
info=curve.getInfo(),
linestyle=linestyle)
@@ -957,7 +957,7 @@ class LegendsDockWidget(qt.QDockWidget):
"""
legendList = []
for curve in self.plot.getAllCurves(withhidden=True):
- legend = curve.getLegend()
+ legend = curve.getName()
# Use active color if curve is active
isActive = legend == self.plot.getActiveCurve(just_legend=True)
style = curve.getCurrentStyle()
diff --git a/silx/gui/plot/MaskToolsWidget.py b/silx/gui/plot/MaskToolsWidget.py
index 9d727e7..a95e277 100644
--- a/silx/gui/plot/MaskToolsWidget.py
+++ b/silx/gui/plot/MaskToolsWidget.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
@@ -187,6 +187,8 @@ class ImageMask(BaseMask):
:param bool mask: True to mask (default), False to unmask.
"""
assert 0 < level < 256
+ if row + height <= 0 or col + width <= 0:
+ return # Rectangle outside image, avoid negative indices
selection = self._mask[max(0, row):row + height + 1,
max(0, col):col + width + 1]
if mask:
@@ -319,7 +321,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
# ensure all mask attributes are synchronized with the active image
# and connect listener
activeImage = self.plot.getActiveImage()
- if activeImage is not None and activeImage.getLegend() != self._maskName:
+ if activeImage is not None and activeImage.getName() != self._maskName:
self._activeImageChanged()
self.plot.sigActiveImageChanged.connect(self._activeImageChanged)
@@ -351,7 +353,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
mustBeAdded = maskItem is None
if mustBeAdded:
maskItem = items.MaskImageData()
- maskItem._setLegend(self._maskName)
+ maskItem.setName(self._maskName)
# update the items
maskItem.setData(mask, copy=False)
maskItem.setColormap(self._colormap)
@@ -360,7 +362,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
maskItem.setZValue(self._z)
if mustBeAdded:
- self.plot._add(maskItem)
+ self.plot.addItem(maskItem)
elif self.plot.getImage(self._maskName):
self.plot.remove(self._maskName, kind='image')
@@ -407,7 +409,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
removed, otherwise it is adjusted to origin, scale and z.
"""
activeImage = self.plot.getActiveImage()
- if activeImage is None or activeImage.getLegend() == self._maskName:
+ if activeImage is None or activeImage.getName() == self._maskName:
# No active image or active image is the mask...
self._data = numpy.zeros((0, 0), dtype=numpy.uint8)
self._mask.setDataItem(None)
@@ -443,7 +445,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
def _activeImageChanged(self, *args):
"""Update widget and mask according to active image changes"""
activeImage = self.plot.getActiveImage()
- if (activeImage is None or activeImage.getLegend() == self._maskName or
+ if (activeImage is None or activeImage.getName() == self._maskName or
activeImage.getData(copy=False).size == 0):
# No active image or active image is the mask or image has no data...
self.setEnabled(False)
@@ -770,7 +772,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
"""Set range from active image colormap range"""
activeImage = self.plot.getActiveImage()
if (isinstance(activeImage, items.ColormapMixIn) and
- activeImage.getLegend() != self._maskName):
+ activeImage.getName() != self._maskName):
# Update thresholds according to colormap
colormap = activeImage.getColormap()
if colormap['autoscale']:
diff --git a/silx/gui/plot/PlotInteraction.py b/silx/gui/plot/PlotInteraction.py
index abfcf79..d182a49 100644
--- a/silx/gui/plot/PlotInteraction.py
+++ b/silx/gui/plot/PlotInteraction.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2014-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
@@ -37,7 +37,7 @@ import weakref
from .. import colors
from .. import qt
from . import items
-from .Interaction import (ClickOrDrag, LEFT_BTN, RIGHT_BTN,
+from .Interaction import (ClickOrDrag, LEFT_BTN, RIGHT_BTN, MIDDLE_BTN,
State, StateMachine)
from .PlotEvents import (prepareCurveSignal, prepareDrawingSignal,
prepareHoverSignal, prepareImageSignal,
@@ -102,11 +102,11 @@ class _PlotInteraction(object):
else:
color2 = "black"
- self.plot.addItem(points[:, 0], points[:, 1], legend=legend,
- replace=False,
- shape=shape, fill=fill,
- color=color, linebgcolor=color2, linestyle="--",
- overlay=True)
+ self.plot.addShape(points[:, 0], points[:, 1], legend=legend,
+ replace=False,
+ shape=shape, fill=fill,
+ color=color, linebgcolor=color2, linestyle="--",
+ overlay=True)
self._selectionAreas.add(legend)
@@ -127,7 +127,7 @@ class _ZoomOnWheel(ClickOrDrag, _PlotInteraction):
_DOUBLE_CLICK_TIMEOUT = 0.4
- class ZoomIdle(ClickOrDrag.Idle):
+ class Idle(ClickOrDrag.Idle):
def onWheel(self, x, y, angle):
scaleF = 1.1 if angle > 0 else 1. / 1.1
applyZoomToPlot(self.machine.plot, scaleF, (x, y))
@@ -170,23 +170,16 @@ class _ZoomOnWheel(ClickOrDrag, _PlotInteraction):
x, y)
self.plot.notify(**eventDict)
- def __init__(self, plot):
+ def __init__(self, plot, **kwargs):
"""Init.
:param plot: The plot to apply modifications to.
"""
- _PlotInteraction.__init__(self, plot)
-
- states = {
- 'idle': _ZoomOnWheel.ZoomIdle,
- 'rightClick': ClickOrDrag.RightClick,
- 'clickOrDrag': ClickOrDrag.ClickOrDrag,
- 'drag': ClickOrDrag.Drag
- }
- StateMachine.__init__(self, states, 'idle')
-
self._lastClick = 0., None
+ _PlotInteraction.__init__(self, plot)
+ ClickOrDrag.__init__(self, **kwargs)
+
# Pan #########################################################################
@@ -198,10 +191,10 @@ class Pan(_ZoomOnWheel):
_, y2Data = self.plot.pixelToData(x, y, axis='right')
return xData, yData, y2Data
- def beginDrag(self, x, y):
+ def beginDrag(self, x, y, btn):
self._previousDataPos = self._pixelToData(x, y)
- def drag(self, x, y):
+ def drag(self, x, y, btn):
xData, yData, y2Data = self._pixelToData(x, y)
lastX, lastY, lastY2 = self._previousDataPos
@@ -266,7 +259,7 @@ class Pan(_ZoomOnWheel):
self._previousDataPos = self._pixelToData(x, y)
- def endDrag(self, startPos, endPos):
+ def endDrag(self, startPos, endPos, btn):
del self._previousDataPos
def cancel(self):
@@ -315,12 +308,12 @@ class Zoom(_ZoomOnWheel):
return areaX0, areaY0, areaX1, areaY1
- def beginDrag(self, x, y):
+ def beginDrag(self, x, y, btn):
dataPos = self.plot.pixelToData(x, y)
assert dataPos is not None
self.x0, self.y0 = x, y
- def drag(self, x1, y1):
+ def drag(self, x1, y1, btn):
if self.color is None:
return # Do not draw zoom area
@@ -388,7 +381,7 @@ class Zoom(_ZoomOnWheel):
self.plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max)
- def endDrag(self, startPos, endPos):
+ def endDrag(self, startPos, endPos, btn):
x0, y0 = startPos
x1, y1 = endPos
@@ -475,6 +468,24 @@ class SelectPolygon(Select):
self.machine.parameters)
self.machine.plot.notify(**eventDict)
+ def validate(self):
+ if len(self.points) > 2:
+ self.closePolygon()
+ else:
+ # It would be nice to have a cancel event.
+ # The plot is not aware that the interaction was cancelled
+ self.machine.cancel()
+
+ def closePolygon(self):
+ self.machine.resetSelectionArea()
+ self.points[-1] = self.points[0]
+ eventDict = prepareDrawingSignal('drawingFinished',
+ 'polygon',
+ self.points,
+ self.machine.parameters)
+ self.machine.plot.notify(**eventDict)
+ self.goto('idle')
+
def onWheel(self, x, y, angle):
self.machine.onWheel(x, y, angle)
self.updateFirstPoint()
@@ -491,16 +502,7 @@ class SelectPolygon(Select):
# Only allow to close polygon after first point
if len(self.points) > 2 and dx <= threshold and dy <= threshold:
- self.machine.resetSelectionArea()
-
- self.points[-1] = self.points[0]
-
- eventDict = prepareDrawingSignal('drawingFinished',
- 'polygon',
- self.points,
- self.machine.parameters)
- self.machine.plot.notify(**eventDict)
- self.goto('idle')
+ self.closePolygon()
return False
# Update polygon last point not too close to previous one
@@ -1023,13 +1025,13 @@ class SelectFreeLine(ClickOrDrag, _PlotInteraction):
if btn == LEFT_BTN:
self._processEvent(x, y, isLast=True)
- def beginDrag(self, x, y):
+ def beginDrag(self, x, y, btn):
self._processEvent(x, y, isLast=False)
- def drag(self, x, y):
+ def drag(self, x, y, btn):
self._processEvent(x, y, isLast=False)
- def endDrag(self, startPos, endPos):
+ def endDrag(self, startPos, endPos, btn):
x, y = endPos
self._processEvent(x, y, isLast=True)
@@ -1079,15 +1081,13 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
applyZoomToPlot(self.machine.plot, scaleF, (x, y))
def onMove(self, x, y):
- result = self.machine.plot._pickTopMost(
- x, y, lambda item: isinstance(item, items.MarkerBase))
- marker = result.getItem() if result is not None else None
+ marker = self.machine.plot._getMarkerAt(x, y)
if marker is not None:
dataPos = self.machine.plot.pixelToData(x, y)
assert dataPos is not None
eventDict = prepareHoverSignal(
- marker.getLegend(), 'marker',
+ marker.getName(), 'marker',
dataPos, (x, y),
marker.isDraggable(),
marker.isSelectable())
@@ -1109,19 +1109,18 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
elif marker.isSelectable():
self.machine.plot.setGraphCursorShape(CURSOR_POINTING)
+ else:
+ self.machine.plot.setGraphCursorShape()
return True
def __init__(self, plot):
- _PlotInteraction.__init__(self, plot)
+ self._pan = Pan(plot)
- states = {
- 'idle': ItemsInteraction.Idle,
- 'rightClick': ClickOrDrag.RightClick,
- 'clickOrDrag': ClickOrDrag.ClickOrDrag,
- 'drag': ClickOrDrag.Drag
- }
- StateMachine.__init__(self, states, 'idle')
+ _PlotInteraction.__init__(self, plot)
+ ClickOrDrag.__init__(self,
+ clickButtons=(LEFT_BTN, RIGHT_BTN),
+ dragButtons=(LEFT_BTN, MIDDLE_BTN))
def click(self, x, y, btn):
"""Handle mouse click
@@ -1169,7 +1168,7 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
eventDict = prepareMarkerSignal('markerClicked',
'left',
- item.getLegend(),
+ item.getName(),
'marker',
item.isDraggable(),
item.isSelectable(),
@@ -1186,7 +1185,7 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
indices = result.getIndices(copy=False)
eventDict = prepareCurveSignal('left',
- item.getLegend(),
+ item.getName(),
'curve',
xData[indices],
yData[indices],
@@ -1201,7 +1200,7 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
indices = result.getIndices(copy=False)
row, column = indices[0][0], indices[1][0]
eventDict = prepareImageSignal('left',
- item.getLegend(),
+ item.getName(),
'image',
column, row,
dataPos[0], dataPos[1],
@@ -1224,7 +1223,7 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
eventDict = prepareMarkerSignal(eventType,
'left',
- marker.getLegend(),
+ marker.getName(),
'marker',
marker.isDraggable(),
marker.isSelectable(),
@@ -1242,65 +1241,79 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
self.plot.setGraphCursorShape()
self.draggedItemRef = None
- def beginDrag(self, x, y):
+ def beginDrag(self, x, y, btn):
"""Handle begining of drag interaction
:param x: X position of the mouse in pixels
:param y: Y position of the mouse in pixels
+ :param str btn: The mouse button for which a drag is starting.
:return: True if drag is catched by an item, False otherwise
"""
- self._lastPos = self.plot.pixelToData(x, y)
- assert self._lastPos is not None
+ if btn == LEFT_BTN:
+ self._lastPos = self.plot.pixelToData(x, y)
+ assert self._lastPos is not None
- result = self.plot._pickTopMost(x, y, self.__isDraggableItem)
- item = result.getItem() if result is not None else None
+ result = self.plot._pickTopMost(x, y, self.__isDraggableItem)
+ item = result.getItem() if result is not None else None
- self.draggedItemRef = None if item is None else weakref.ref(item)
+ self.draggedItemRef = None if item is None else weakref.ref(item)
- if item is None:
- self.__terminateDrag()
- return False
+ if item is None:
+ self.__terminateDrag()
+ return False
- if isinstance(item, items.MarkerBase):
- self._signalMarkerMovingEvent('markerMoving', item, x, y)
+ if isinstance(item, items.MarkerBase):
+ self._signalMarkerMovingEvent('markerMoving', item, x, y)
+ item._startDrag()
- return True
+ return True
+ elif btn == MIDDLE_BTN:
+ self._pan.beginDrag(x, y, btn)
+ return True
- def drag(self, x, y):
- dataPos = self.plot.pixelToData(x, y)
- assert dataPos is not None
+ def drag(self, x, y, btn):
+ if btn == LEFT_BTN:
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
- item = None if self.draggedItemRef is None else self.draggedItemRef()
- if item is not None:
- item.drag(self._lastPos, dataPos)
+ item = None if self.draggedItemRef is None else self.draggedItemRef()
+ if item is not None:
+ item.drag(self._lastPos, dataPos)
- if isinstance(item, items.MarkerBase):
- self._signalMarkerMovingEvent('markerMoving', item, x, y)
+ if isinstance(item, items.MarkerBase):
+ self._signalMarkerMovingEvent('markerMoving', item, x, y)
- self._lastPos = dataPos
-
- def endDrag(self, startPos, endPos):
- item = None if self.draggedItemRef is None else self.draggedItemRef()
- if item is not None and isinstance(item, items.MarkerBase):
- posData = list(item.getPosition())
- if posData[0] is None:
- posData[0] = 1.
- if posData[1] is None:
- posData[1] = 1.
-
- eventDict = prepareMarkerSignal(
- 'markerMoved',
- 'left',
- item.getLegend(),
- 'marker',
- item.isDraggable(),
- item.isSelectable(),
- posData)
- self.plot.notify(**eventDict)
+ self._lastPos = dataPos
+ elif btn == MIDDLE_BTN:
+ self._pan.drag(x, y, btn)
- self.__terminateDrag()
+ def endDrag(self, startPos, endPos, btn):
+ if btn == LEFT_BTN:
+ item = None if self.draggedItemRef is None else self.draggedItemRef()
+ if isinstance(item, items.MarkerBase):
+ posData = list(item.getPosition())
+ if posData[0] is None:
+ posData[0] = 1.
+ if posData[1] is None:
+ posData[1] = 1.
+
+ eventDict = prepareMarkerSignal(
+ 'markerMoved',
+ 'left',
+ item.getLegend(),
+ 'marker',
+ item.isDraggable(),
+ item.isSelectable(),
+ posData)
+ self.plot.notify(**eventDict)
+ item._endDrag()
+
+ self.__terminateDrag()
+ elif btn == MIDDLE_BTN:
+ self._pan.endDrag(startPos, endPos, btn)
def cancel(self):
+ self._pan.cancel()
self.__terminateDrag()
@@ -1319,25 +1332,12 @@ class ItemsInteractionForCombo(ItemsInteraction):
result = self.machine.plot._pickTopMost(
x, y, self.__isItemSelectableOrDraggable)
if result is not None: # Request focus and handle interaction
- self.goto('clickOrDrag', x, y)
+ self.goto('clickOrDrag', x, y, btn)
return True
else: # Do not request focus
return False
-
- elif btn == RIGHT_BTN:
- self.goto('rightClick', x, y)
- return True
-
- def __init__(self, plot):
- _PlotInteraction.__init__(self, plot)
-
- states = {
- 'idle': ItemsInteractionForCombo.Idle,
- 'rightClick': ClickOrDrag.RightClick,
- 'clickOrDrag': ClickOrDrag.ClickOrDrag,
- 'drag': ClickOrDrag.Drag
- }
- StateMachine.__init__(self, states, 'idle')
+ else:
+ return super().onPress(x, y, btn)
# FocusManager ################################################################
@@ -1350,11 +1350,12 @@ class FocusManager(StateMachine):
"""
class Idle(State):
def onPress(self, x, y, btn):
- for eventHandler in self.machine.eventHandlers:
- requestFocus = eventHandler.handleEvent('press', x, y, btn)
- if requestFocus:
- self.goto('focus', eventHandler, btn)
- break
+ if btn == LEFT_BTN:
+ for eventHandler in self.machine.eventHandlers:
+ requestFocus = eventHandler.handleEvent('press', x, y, btn)
+ if requestFocus:
+ self.goto('focus', eventHandler, btn)
+ break
def _processEvent(self, *args):
for eventHandler in self.machine.eventHandlers:
@@ -1366,7 +1367,8 @@ class FocusManager(StateMachine):
self._processEvent('move', x, y)
def onRelease(self, x, y, btn):
- self._processEvent('release', x, y, btn)
+ if btn == LEFT_BTN:
+ self._processEvent('release', x, y, btn)
def onWheel(self, x, y, angle):
self._processEvent('wheel', x, y, angle)
@@ -1376,18 +1378,24 @@ class FocusManager(StateMachine):
self.eventHandler = eventHandler
self.focusBtns = {btn}
+ def validate(self):
+ self.eventHandler.validate()
+ self.goto('idle')
+
def onPress(self, x, y, btn):
- self.focusBtns.add(btn)
- self.eventHandler.handleEvent('press', x, y, btn)
+ if btn == LEFT_BTN:
+ self.focusBtns.add(btn)
+ self.eventHandler.handleEvent('press', x, y, btn)
def onMove(self, x, y):
self.eventHandler.handleEvent('move', x, y)
def onRelease(self, x, y, btn):
- self.focusBtns.discard(btn)
- requestFocus = self.eventHandler.handleEvent('release', x, y, btn)
- if len(self.focusBtns) == 0 and not requestFocus:
- self.goto('idle')
+ if btn == LEFT_BTN:
+ self.focusBtns.discard(btn)
+ requestFocus = self.eventHandler.handleEvent('release', x, y, btn)
+ if len(self.focusBtns) == 0 and not requestFocus:
+ self.goto('idle')
def onWheel(self, x, y, angleInDegrees):
self.eventHandler.handleEvent('wheel', x, y, angleInDegrees)
@@ -1447,37 +1455,40 @@ class ZoomAndSelect(ItemsInteraction):
else:
self._zoom.click(x, y, btn)
- def beginDrag(self, x, y):
+ def beginDrag(self, x, y, btn):
"""Handle start drag and switching between zoom and item drag.
:param x: X position in pixels
:param y: Y position in pixels
+ :param str btn: The mouse button for which a drag is starting.
"""
- self._doZoom = not super(ZoomAndSelect, self).beginDrag(x, y)
+ self._doZoom = not super(ZoomAndSelect, self).beginDrag(x, y, btn)
if self._doZoom:
- self._zoom.beginDrag(x, y)
+ self._zoom.beginDrag(x, y, btn)
- def drag(self, x, y):
+ def drag(self, x, y, btn):
"""Handle drag, eventually forwarding to zoom.
:param x: X position in pixels
:param y: Y position in pixels
+ :param str btn: The mouse button for which a drag is in progress.
"""
if self._doZoom:
- return self._zoom.drag(x, y)
+ return self._zoom.drag(x, y, btn)
else:
- return super(ZoomAndSelect, self).drag(x, y)
+ return super(ZoomAndSelect, self).drag(x, y, btn)
- def endDrag(self, startPos, endPos):
+ def endDrag(self, startPos, endPos, btn):
"""Handle end of drag, eventually forwarding to zoom.
:param startPos: (x, y) position at the beginning of the drag
:param endPos: (x, y) position at the end of the drag
+ :param str btn: The mouse button for which a drag is done.
"""
if self._doZoom:
- return self._zoom.endDrag(startPos, endPos)
+ return self._zoom.endDrag(startPos, endPos, btn)
else:
- return super(ZoomAndSelect, self).endDrag(startPos, endPos)
+ return super(ZoomAndSelect, self).endDrag(startPos, endPos, btn)
class PanAndSelect(ItemsInteraction):
@@ -1515,41 +1526,101 @@ class PanAndSelect(ItemsInteraction):
else:
self._pan.click(x, y, btn)
- def beginDrag(self, x, y):
+ def beginDrag(self, x, y, btn):
"""Handle start drag and switching between zoom and item drag.
:param x: X position in pixels
:param y: Y position in pixels
+ :param str btn: The mouse button for which a drag is starting.
"""
- self._doPan = not super(PanAndSelect, self).beginDrag(x, y)
+ self._doPan = not super(PanAndSelect, self).beginDrag(x, y, btn)
if self._doPan:
- self._pan.beginDrag(x, y)
+ self._pan.beginDrag(x, y, btn)
- def drag(self, x, y):
+ def drag(self, x, y, btn):
"""Handle drag, eventually forwarding to zoom.
:param x: X position in pixels
:param y: Y position in pixels
+ :param str btn: The mouse button for which a drag is in progress.
"""
if self._doPan:
- return self._pan.drag(x, y)
+ return self._pan.drag(x, y, btn)
else:
- return super(PanAndSelect, self).drag(x, y)
+ return super(PanAndSelect, self).drag(x, y, btn)
- def endDrag(self, startPos, endPos):
+ def endDrag(self, startPos, endPos, btn):
"""Handle end of drag, eventually forwarding to zoom.
:param startPos: (x, y) position at the beginning of the drag
:param endPos: (x, y) position at the end of the drag
+ :param str btn: The mouse button for which a drag is done.
"""
if self._doPan:
- return self._pan.endDrag(startPos, endPos)
+ return self._pan.endDrag(startPos, endPos, btn)
else:
- return super(PanAndSelect, self).endDrag(startPos, endPos)
+ return super(PanAndSelect, self).endDrag(startPos, endPos, btn)
# Interaction mode control ####################################################
+# Mapping of draw modes: event handler
+_DRAW_MODES = {
+ 'polygon': SelectPolygon,
+ 'rectangle': SelectRectangle,
+ 'ellipse': SelectEllipse,
+ 'line': SelectLine,
+ 'vline': SelectVLine,
+ 'hline': SelectHLine,
+ 'polylines': SelectFreeLine,
+ 'pencil': DrawFreeHand,
+ }
+
+
+class DrawMode(FocusManager):
+ """Interactive mode for draw and select"""
+
+ def __init__(self, plot, shape, label, color, width):
+ eventHandlerClass = _DRAW_MODES[shape]
+ parameters = {
+ 'shape': shape,
+ 'label': label,
+ 'color': color,
+ 'width': width,
+ }
+ super().__init__((
+ Pan(plot, clickButtons=(), dragButtons=(MIDDLE_BTN,)),
+ eventHandlerClass(plot, parameters)))
+
+ def getDescription(self):
+ """Returns the dict describing this interactive mode"""
+ params = self.eventHandlers[1].parameters.copy()
+ params['mode'] = 'draw'
+ return params
+
+
+class DrawSelectMode(FocusManager):
+ """Interactive mode for draw and select"""
+
+ def __init__(self, plot, shape, label, color, width):
+ eventHandlerClass = _DRAW_MODES[shape]
+ parameters = {
+ 'shape': shape,
+ 'label': label,
+ 'color': color,
+ 'width': width,
+ }
+ super().__init__((
+ ItemsInteractionForCombo(plot),
+ eventHandlerClass(plot, parameters)))
+
+ def getDescription(self):
+ """Returns the dict describing this interactive mode"""
+ params = self.eventHandlers[1].parameters.copy()
+ params['mode'] = 'select-draw'
+ return params
+
+
class PlotInteraction(object):
"""Proxy to currently use state machine for interaction.
@@ -1582,26 +1653,15 @@ class PlotInteraction(object):
"""Returns the current interactive mode as a dict.
The returned dict contains at least the key 'mode'.
- Mode can be: 'draw', 'pan', 'select', 'zoom'.
+ Mode can be: 'draw', 'pan', 'select', 'select-draw', 'zoom'.
It can also contains extra keys (e.g., 'color') specific to a mode
as provided to :meth:`setInteractiveMode`.
"""
if isinstance(self._eventHandler, ZoomAndSelect):
return {'mode': 'zoom', 'color': self._eventHandler.color}
- elif isinstance(self._eventHandler, FocusManager):
- drawHandler = self._eventHandler.eventHandlers[1]
- if not isinstance(drawHandler, Select):
- raise RuntimeError('Unknown interactive mode')
-
- result = drawHandler.parameters.copy()
- result['mode'] = 'draw'
- return result
-
- elif isinstance(self._eventHandler, Select):
- result = self._eventHandler.parameters.copy()
- result['mode'] = 'draw'
- return result
+ elif isinstance(self._eventHandler, (DrawMode, DrawSelectMode)):
+ return self._eventHandler.getDescription()
elif isinstance(self._eventHandler, PanAndSelect):
return {'mode': 'pan'}
@@ -1609,6 +1669,13 @@ class PlotInteraction(object):
else:
return {'mode': 'select'}
+ def validate(self):
+ """Validate the current interaction if possible
+
+ If was designed to close the polygon interaction.
+ """
+ self._eventHandler.validate()
+
def setInteractiveMode(self, mode, color='black',
shape='polygon', label=None, width=None):
"""Switch the interactive mode.
@@ -1636,24 +1703,9 @@ class PlotInteraction(object):
color = colors.rgba(color)
if mode in ('draw', 'select-draw'):
- assert shape in self._DRAW_MODES
- eventHandlerClass = self._DRAW_MODES[shape]
- parameters = {
- 'shape': shape,
- 'label': label,
- 'color': color,
- 'width': width,
- }
- eventHandler = eventHandlerClass(plot, parameters)
-
self._eventHandler.cancel()
-
- if mode == 'draw':
- self._eventHandler = eventHandler
-
- else: # mode == 'select-draw'
- self._eventHandler = FocusManager(
- (ItemsInteractionForCombo(plot), eventHandler))
+ handlerClass = DrawMode if mode == 'draw' else DrawSelectMode
+ self._eventHandler = handlerClass(plot, shape, label, color, width)
elif mode == 'pan':
# Ignores color, shape and label
diff --git a/silx/gui/plot/PlotToolButtons.py b/silx/gui/plot/PlotToolButtons.py
index cd1a43f..3970896 100644
--- a/silx/gui/plot/PlotToolButtons.py
+++ b/silx/gui/plot/PlotToolButtons.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-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
@@ -261,17 +261,21 @@ class ProfileOptionToolButton(PlotToolButton):
self.sumAction = self._createAction('sum')
self.sumAction.triggered.connect(self.setSum)
self.sumAction.setIconVisibleInMenu(True)
+ self.sumAction.setCheckable(True)
+ self.sumAction.setChecked(True)
self.meanAction = self._createAction('mean')
self.meanAction.triggered.connect(self.setMean)
self.meanAction.setIconVisibleInMenu(True)
+ self.meanAction.setCheckable(True)
menu = qt.QMenu(self)
menu.addAction(self.sumAction)
menu.addAction(self.meanAction)
self.setMenu(menu)
self.setPopupMode(qt.QToolButton.InstantPopup)
- self.setMean()
+ self._method = 'mean'
+ self._update()
def _createAction(self, method):
icon = self.STATE[method, "icon"]
@@ -279,22 +283,39 @@ class ProfileOptionToolButton(PlotToolButton):
return qt.QAction(icon, text, self)
def setSum(self):
- """Configure the plot to use y-axis upward"""
- self._method = 'sum'
- self.sigMethodChanged.emit(self._method)
- self._update()
+ self.setMethod('sum')
def _update(self):
icon = self.STATE[self._method, "icon"]
toolTip = self.STATE[self._method, "state"]
self.setIcon(icon)
self.setToolTip(toolTip)
+ self.sumAction.setChecked(self._method == "sum")
+ self.meanAction.setChecked(self._method == "mean")
def setMean(self):
- """Configure the plot to use y-axis downward"""
- self._method = 'mean'
- self.sigMethodChanged.emit(self._method)
- self._update()
+ self.setMethod('mean')
+
+ def setMethod(self, method):
+ """Set the method to use.
+
+ :param str method: Either 'sum' or 'mean'
+ """
+ if method != self._method:
+ if method in ('sum', 'mean'):
+ self._method = method
+ self.sigMethodChanged.emit(self._method)
+ self._update()
+ else:
+ _logger.warning(
+ "Unsupported method '%s'. Setting ignored.", method)
+
+ def getMethod(self):
+ """Returns the current method in use (See :meth:`setMethod`).
+
+ :rtype: str
+ """
+ return self._method
class ProfileToolButton(PlotToolButton):
@@ -319,13 +340,20 @@ class ProfileToolButton(PlotToolButton):
super(ProfileToolButton, self).__init__(parent=parent, plot=plot)
+ self._dimension = 1
+
profile1DAction = self._createAction(1)
profile1DAction.triggered.connect(self.computeProfileIn1D)
profile1DAction.setIconVisibleInMenu(True)
+ profile1DAction.setCheckable(True)
+ profile1DAction.setChecked(True)
+ self._profile1DAction = profile1DAction
profile2DAction = self._createAction(2)
profile2DAction.triggered.connect(self.computeProfileIn2D)
profile2DAction.setIconVisibleInMenu(True)
+ profile2DAction.setCheckable(True)
+ self._profile2DAction = profile2DAction
menu = qt.QMenu(self)
menu.addAction(profile1DAction)
@@ -333,6 +361,7 @@ class ProfileToolButton(PlotToolButton):
self.setMenu(menu)
self.setPopupMode(qt.QToolButton.InstantPopup)
menu.setTitle('Select profile dimension')
+ self.computeProfileIn1D()
def _createAction(self, profileDimension):
icon = self.STATE[profileDimension, "icon"]
@@ -343,7 +372,10 @@ class ProfileToolButton(PlotToolButton):
"""Update icon in toolbar, emit number of dimensions for profile"""
self.setIcon(self.STATE[profileDimension, "icon"])
self.setToolTip(self.STATE[profileDimension, "state"])
+ self._dimension = profileDimension
self.sigDimensionChanged.emit(profileDimension)
+ self._profile1DAction.setChecked(profileDimension == 1)
+ self._profile2DAction.setChecked(profileDimension == 2)
def computeProfileIn1D(self):
self._profileDimensionChanged(1)
@@ -351,6 +383,24 @@ class ProfileToolButton(PlotToolButton):
def computeProfileIn2D(self):
self._profileDimensionChanged(2)
+ def setDimension(self, dimension):
+ """Set the selected dimension"""
+ assert dimension in [1, 2]
+ if self._dimension == dimension:
+ return
+ if dimension == 1:
+ self.computeProfileIn1D()
+ elif dimension == 2:
+ self.computeProfileIn2D()
+ else:
+ _logger.warning("Unsupported dimension '%s'. Setting ignored.", dimension)
+
+ def getDimension(self):
+ """Get the selected dimension.
+
+ :rtype: int (1 or 2)
+ """
+ return self._dimension
class _SymbolToolButtonBase(PlotToolButton):
@@ -370,7 +420,7 @@ class _SymbolToolButtonBase(PlotToolButton):
"""
slider = qt.QSlider(qt.Qt.Horizontal)
slider.setRange(1, 20)
- slider.setValue(config.DEFAULT_PLOT_SYMBOL_SIZE)
+ slider.setValue(int(config.DEFAULT_PLOT_SYMBOL_SIZE))
slider.setTracking(False)
slider.valueChanged.connect(self._sizeChanged)
widgetAction = qt.QWidgetAction(menu)
@@ -399,7 +449,7 @@ class _SymbolToolButtonBase(PlotToolButton):
if plot is None:
return
- for item in plot._getItems(withhidden=True):
+ for item in plot.getItems():
if isinstance(item, SymbolMixIn):
item.setSymbolSize(value)
@@ -412,7 +462,7 @@ class _SymbolToolButtonBase(PlotToolButton):
if plot is None:
return
- for item in plot._getItems(withhidden=True):
+ for item in plot.getItems():
if isinstance(item, SymbolMixIn):
item.setSymbol(marker)
@@ -459,12 +509,40 @@ class ScatterVisualizationToolButton(_SymbolToolButtonBase):
# Add visualization modes
for mode in Scatter.supportedVisualizations():
- name = mode.value.capitalize()
- action = qt.QAction(name, menu)
- action.setCheckable(False)
- action.triggered.connect(
- functools.partial(self._visualizationChanged, mode))
- menu.addAction(action)
+ if mode is not Scatter.Visualization.BINNED_STATISTIC:
+ name = mode.value.capitalize()
+ action = qt.QAction(name, menu)
+ action.setCheckable(False)
+ action.triggered.connect(
+ functools.partial(self._visualizationChanged, mode, None))
+ menu.addAction(action)
+
+ if Scatter.Visualization.BINNED_STATISTIC in Scatter.supportedVisualizations():
+ reductions = Scatter.supportedVisualizationParameterValues(
+ Scatter.VisualizationParameter.BINNED_STATISTIC_FUNCTION)
+ if reductions:
+ submenu = menu.addMenu('Binned Statistic')
+ for reduction in reductions:
+ name = reduction.capitalize()
+ action = qt.QAction(name, menu)
+ action.setCheckable(False)
+ action.triggered.connect(functools.partial(
+ self._visualizationChanged,
+ Scatter.Visualization.BINNED_STATISTIC,
+ {Scatter.VisualizationParameter.BINNED_STATISTIC_FUNCTION: reduction}))
+ submenu.addAction(action)
+
+ submenu.addSeparator()
+ binsmenu = submenu.addMenu('N Bins')
+
+ slider = qt.QSlider(qt.Qt.Horizontal)
+ slider.setRange(10, 1000)
+ slider.setValue(100)
+ slider.setTracking(False)
+ slider.valueChanged.connect(self._binningChanged)
+ widgetAction = qt.QWidgetAction(binsmenu)
+ widgetAction.setDefaultWidget(slider)
+ binsmenu.addAction(widgetAction)
menu.addSeparator()
@@ -477,16 +555,38 @@ class ScatterVisualizationToolButton(_SymbolToolButtonBase):
self.setMenu(menu)
self.setPopupMode(qt.QToolButton.InstantPopup)
- def _visualizationChanged(self, mode):
+ def _visualizationChanged(self, mode, parameters=None):
"""Handle change of visualization mode.
:param ScatterVisualizationMixIn.Visualization mode:
The visualization mode to use for scatter
+ :param Union[dict,None] parameters:
+ Dict of VisualizationParameter: parameter_value to set
+ with the visualization.
"""
plot = self.plot()
if plot is None:
return
- for item in plot._getItems(withhidden=True):
+ for item in plot.getItems():
if isinstance(item, Scatter):
+ if parameters:
+ for parameter, value in parameters.items():
+ item.setVisualizationParameter(parameter, value)
item.setVisualization(mode)
+
+ def _binningChanged(self, value):
+ """Handle change of binning.
+
+ :param int value: The number of bin on each dimension.
+ """
+ plot = self.plot()
+ if plot is None:
+ return
+
+ for item in plot.getItems():
+ if isinstance(item, Scatter):
+ item.setVisualizationParameter(
+ Scatter.VisualizationParameter.BINNED_STATISTIC_SHAPE,
+ (value, value))
+ item.setVisualization(Scatter.Visualization.BINNED_STATISTIC)
diff --git a/silx/gui/plot/PlotWidget.py b/silx/gui/plot/PlotWidget.py
index e47249e..9f9f846 100755
--- a/silx/gui/plot/PlotWidget.py
+++ b/silx/gui/plot/PlotWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-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
@@ -49,7 +49,7 @@ import numpy
import silx
from silx.utils.weakref import WeakMethodProxy
from silx.utils.property import classproperty
-from silx.utils.deprecation import deprecated
+from silx.utils.deprecation import deprecated, deprecated_warning
try:
# Import matplotlib now to init matplotlib our way
from . import matplotlib
@@ -192,6 +192,12 @@ class PlotWidget(qt.QMainWindow):
It provides the item that will be removed.
"""
+ sigItemRemoved = qt.Signal(items.Item)
+ """Signal emitted right after an item was removed from the plot.
+
+ It provides the item that was removed.
+ """
+
sigVisibilityChanged = qt.Signal(bool)
"""Signal emitted when the widget becomes visible (or invisible).
This happens when the widget is hidden or shown.
@@ -215,8 +221,10 @@ class PlotWidget(qt.QMainWindow):
else:
self.setWindowTitle('PlotWidget')
- self._backend = None
- self._setBackend(backend)
+ # Init the backend
+ if backend is None:
+ backend = silx.config.DEFAULT_PLOT_BACKEND
+ self._backend = self.__getBackendClass(backend)(self, self)
self.setCallback() # set _callback
@@ -266,6 +274,7 @@ class PlotWidget(qt.QMainWindow):
self._eventHandler = PlotInteraction.PlotInteraction(self)
self._eventHandler.setInteractiveMode('zoom', color=(0., 0., 0., 1.))
+ self._previousDefaultMode = "zoom", True
self._pressedButtons = [] # Currently pressed mouse buttons
@@ -299,7 +308,7 @@ class PlotWidget(qt.QMainWindow):
If multiple backends are provided, the first available one is used.
- :param Union[str,BackendBase,Iterable] backend:
+ :param Union[str,BackendBase,List[Union[str,BackendBase]]] backend:
The name of the backend or its class or an iterable of those.
:rtype: BackendBase
:raise ValueError: In case the backend is not supported
@@ -316,15 +325,22 @@ class PlotWidget(qt.QMainWindow):
BackendMatplotlibQt as backendClass
except ImportError:
_logger.debug("Backtrace", exc_info=True)
- raise ImportError("matplotlib backend is not available")
+ raise RuntimeError("matplotlib backend is not available")
elif backend in ('gl', 'opengl'):
+ from ..utils.glutils import isOpenGLAvailable
+ checkOpenGL = isOpenGLAvailable(version=(2, 1), runtimeCheck=False)
+ if not checkOpenGL:
+ _logger.debug("OpenGL check failed")
+ raise RuntimeError(
+ "OpenGL backend is not available: %s" % checkOpenGL.error)
+
try:
from .backends.BackendOpenGL import \
BackendOpenGL as backendClass
except ImportError:
_logger.debug("Backtrace", exc_info=True)
- raise ImportError("OpenGL backend is not available")
+ raise RuntimeError("OpenGL backend is not available")
elif backend == 'none':
from .backends.BackendBase import BackendBase as backendClass
@@ -338,25 +354,13 @@ class PlotWidget(qt.QMainWindow):
for b in backend:
try:
return self.__getBackendClass(b)
- except ImportError:
+ except RuntimeError:
pass
else: # No backend was found
- raise ValueError("No supported backend was found")
+ raise RuntimeError("None of the request backends are available")
raise ValueError("Backend not supported %s" % str(backend))
- def _setBackend(self, backend):
- """Setup a new backend
-
- :param backend: Either a str defining the backend to use
- """
- assert(self._backend is None)
-
- if backend is None:
- backend = silx.config.DEFAULT_PLOT_BACKEND
-
- self._backend = self.__getBackendClass(backend)(self, self)
-
# TODO: Can be removed for silx 0.10
@staticmethod
@deprecated(replacement="silx.config.DEFAULT_PLOT_BACKEND", since_version="0.8", skip_backtrace_count=2)
@@ -384,6 +388,27 @@ class PlotWidget(qt.QMainWindow):
"""
return self._dirty
+ # Default Qt context menu
+
+ def contextMenuEvent(self, event):
+ """Override QWidget.contextMenuEvent to implement the context menu"""
+ menu = qt.QMenu(self)
+ from .actions.control import ZoomBackAction # Avoid cyclic import
+ zoomBackAction = ZoomBackAction(plot=self, parent=menu)
+ menu.addAction(zoomBackAction)
+
+ mode = self.getInteractiveMode()
+ if "shape" in mode and mode["shape"] == "polygon":
+ from .actions.control import ClosePolygonInteractionAction # Avoid cyclic import
+ action = ClosePolygonInteractionAction(plot=self, parent=menu)
+ menu.addAction(action)
+
+ # Make sure the plot is updated, especially when the plot is in
+ # draw interaction mode
+ menu.aboutToHide.connect(self.__simulateMouseMove)
+
+ menu.exec_(event.globalPos())
+
def _setDirtyPlot(self, overlayOnly=False):
"""Mark the plot as needing redraw
@@ -537,7 +562,7 @@ class PlotWidget(qt.QMainWindow):
xMin = yMinLeft = yMinRight = float('nan')
xMax = yMaxLeft = yMaxRight = float('nan')
- for item in self._content.values():
+ for item in self.getItems():
if item.isVisible():
bounds = item.getBounds()
if bounds is not None:
@@ -586,43 +611,70 @@ class PlotWidget(qt.QMainWindow):
# Content management
- @staticmethod
- def _itemKey(item):
- """Build the key of given :class:`Item` in the plot
-
- :param Item item: The item to make the key from
- :return: (legend, kind)
- :rtype: (str, str)
- """
- if isinstance(item, items.Curve):
- kind = 'curve'
- elif isinstance(item, items.ImageBase):
- kind = 'image'
- elif isinstance(item, items.Scatter):
- kind = 'scatter'
- elif isinstance(item, (items.Marker,
- items.XMarker, items.YMarker)):
- kind = 'marker'
- elif isinstance(item, (items.Shape, items.BoundingRect)):
- kind = 'item'
- elif isinstance(item, items.Histogram):
- kind = 'histogram'
- else:
- raise ValueError('Unsupported item type %s' % type(item))
+ _KIND_TO_CLASSES = {
+ 'curve': (items.Curve,),
+ 'image': (items.ImageBase,),
+ 'scatter': (items.Scatter,),
+ 'marker': (items.MarkerBase,),
+ 'item': (items.Shape,
+ items.BoundingRect,
+ items.XAxisExtent,
+ items.YAxisExtent),
+ 'histogram': (items.Histogram,),
+ }
+ """Mapping kind to item classes of this kind"""
+
+ @classmethod
+ def _itemKind(cls, item):
+ """Returns the "kind" of a given item
+
+ :param Item item: The item get the kind
+ :rtype: str
+ """
+ for kind, itemClasses in cls._KIND_TO_CLASSES.items():
+ if isinstance(item, itemClasses):
+ return kind
+ raise ValueError('Unsupported item type %s' % type(item))
- return item.getLegend(), kind
+ def _notifyContentChanged(self, item):
+ self.notify('contentChanged', action='add',
+ kind=self._itemKind(item), legend=item.getName())
- def _add(self, item):
- """Add the given :class:`Item` to the plot.
+ def _itemRequiresUpdate(self, item):
+ """Called by items in the plot for asynchronous update
- :param Item item: The item to append to the plot content
+ :param Item item: The item that required update
"""
- key = self._itemKey(item)
- if key in self._content:
- raise RuntimeError('Item already in the plot')
+ assert item.getPlot() == self
+ # Put item at the end of the list
+ if item in self._contentToUpdate:
+ self._contentToUpdate.remove(item)
+ self._contentToUpdate.append(item)
+ self._setDirtyPlot(overlayOnly=item.isOverlay())
+
+ def addItem(self, item=None, *args, **kwargs):
+ """Add an item to the plot content.
+
+ :param ~silx.gui.plot.items.Item item: The item to add.
+ :raises ValueError: If item is already in the plot.
+ """
+ if not isinstance(item, items.Item):
+ deprecated_warning(
+ 'Function',
+ 'addItem',
+ replacement='addShape',
+ since_version='0.13')
+ if item is None and not args: # Only kwargs
+ return self.addShape(**kwargs)
+ else:
+ return self.addShape(item, *args, **kwargs)
+
+ assert not args and not kwargs
+ if item in self.getItems():
+ raise ValueError('Item already in the plot')
# Add item to plot
- self._content[key] = item
+ self._content[(item.getName(), self._itemKind(item))] = item
item._setPlot(self)
self._itemRequiresUpdate(item)
if isinstance(item, items.DATA_ITEMS):
@@ -631,22 +683,29 @@ class PlotWidget(qt.QMainWindow):
self._notifyContentChanged(item)
self.sigItemAdded.emit(item)
- def _notifyContentChanged(self, item):
- legend, kind = self._itemKey(item)
- self.notify('contentChanged', action='add', kind=kind, legend=legend)
-
- def _remove(self, item):
- """Remove the given :class:`Item` from the plot.
+ def removeItem(self, item):
+ """Remove the item from the plot.
- :param Item item: The item to remove from the plot content
+ :param ~silx.gui.plot.items.Item item: Item to remove from the plot.
+ :raises ValueError: If item is not in the plot.
"""
- key = self._itemKey(item)
- if key not in self._content:
- raise RuntimeError('Item not in the plot')
+ if not isinstance(item, items.Item): # Previous method usage
+ deprecated_warning(
+ 'Function',
+ 'removeItem',
+ replacement='remove(legend, kind="item")',
+ since_version='0.13')
+ if item is None:
+ return
+ self.remove(item, kind='item')
+ return
+
+ if item not in self.getItems():
+ raise ValueError('Item not in the plot')
self.sigItemAboutToBeRemoved.emit(item)
- legend, kind = key
+ kind = self._itemKind(item)
if kind in self._ACTIVE_ITEM_KINDS:
if self._getActiveItem(kind) == item:
@@ -654,7 +713,7 @@ class PlotWidget(qt.QMainWindow):
self._setActiveItem(kind, None)
# Remove item from plot
- self._content.pop(key)
+ self._content.pop((item.getName(), kind))
if item in self._contentToUpdate:
self._contentToUpdate.remove(item)
if item.isVisible():
@@ -668,20 +727,25 @@ class PlotWidget(qt.QMainWindow):
withhidden=True)):
self._resetColorAndStyle()
+ self.sigItemRemoved.emit(item)
+
self.notify('contentChanged', action='remove',
- kind=kind, legend=legend)
+ kind=kind, legend=item.getName())
- def _itemRequiresUpdate(self, item):
- """Called by items in the plot for asynchronous update
+ @deprecated(replacement='addItem', since_version='0.13')
+ def _add(self, item):
+ return self.addItem(item)
- :param Item item: The item that required update
+ @deprecated(replacement='removeItem', since_version='0.13')
+ def _remove(self, item):
+ return self.removeItem(item)
+
+ def getItems(self):
+ """Returns the list of items in the plot
+
+ :rtype: List[silx.gui.plot.items.Item]
"""
- assert item.getPlot() == self
- # Pu item at the end of the list
- if item in self._contentToUpdate:
- self._contentToUpdate.remove(item)
- self._contentToUpdate.append(item)
- self._setDirtyPlot(overlayOnly=item.isOverlay())
+ return tuple(self._content.values())
@contextmanager
def _muteActiveItemChangedSignal(self):
@@ -838,7 +902,7 @@ class PlotWidget(qt.QMainWindow):
if curve is None:
# No previous curve, create a default one and add it to the plot
curve = items.Curve() if histogram is None else items.Histogram()
- curve._setLegend(legend)
+ curve.setName(legend)
# Set default color, linestyle and symbol
default_color, default_linestyle = self._getColorAndStyle()
curve.setColor(default_color)
@@ -892,21 +956,21 @@ class PlotWidget(qt.QMainWindow):
if replace: # Then remove all other curves
for c in self.getAllCurves(withhidden=True):
if c is not curve:
- self._remove(c)
+ self.removeItem(c)
if mustBeAdded:
- self._add(curve)
+ self.addItem(curve)
else:
self._notifyContentChanged(curve)
if wasActive:
- self.setActiveCurve(curve.getLegend())
+ self.setActiveCurve(curve.getName())
elif self.getActiveCurveSelectionMode() == "legacy":
if self.getActiveCurve(just_legend=True) is None:
if len(self.getAllCurves(just_legend=True,
withhidden=False)) == 1:
if curve.isVisible():
- self.setActiveCurve(curve.getLegend())
+ self.setActiveCurve(curve.getName())
if resetzoom:
# We ask for a zoom reset in order to handle the plot scaling
@@ -971,7 +1035,7 @@ class PlotWidget(qt.QMainWindow):
# No previous histogram, create a default one and
# add it to the plot
histo = items.Histogram()
- histo._setLegend(legend)
+ histo.setName(legend)
histo.setColor(self._getColorAndStyle()[0])
# Override previous/default values with provided ones
@@ -987,7 +1051,7 @@ class PlotWidget(qt.QMainWindow):
align=align, copy=copy)
if mustBeAdded:
- self._add(histo)
+ self.addItem(histo)
else:
self._notifyContentChanged(histo)
@@ -1071,7 +1135,7 @@ class PlotWidget(qt.QMainWindow):
# Update a data image with RGBA image or the other way around:
# Remove previous image
# In this case, we don't retrieve defaults from the previous image
- self._remove(image)
+ self.removeItem(image)
image = None
mustBeAdded = image is None
@@ -1082,7 +1146,7 @@ class PlotWidget(qt.QMainWindow):
image.setColormap(self.getDefaultColormap())
else:
image = items.ImageRgba()
- image._setLegend(legend)
+ image.setName(legend)
# Do not emit sigActiveImageChanged,
# it will be sent once with _setActiveItem
@@ -1121,10 +1185,10 @@ class PlotWidget(qt.QMainWindow):
if replace:
for img in self.getAllImages():
if img is not image:
- self._remove(img)
+ self.removeItem(img)
if mustBeAdded:
- self._add(image)
+ self.addItem(image)
else:
self._notifyContentChanged(image)
@@ -1198,7 +1262,7 @@ class PlotWidget(qt.QMainWindow):
if scatter is None:
# No previous scatter, create a default one and add it to the plot
scatter = items.Scatter()
- scatter._setLegend(legend)
+ scatter.setName(legend)
scatter.setColormap(self.getDefaultColormap())
# Do not emit sigActiveScatterChanged,
@@ -1231,16 +1295,18 @@ class PlotWidget(qt.QMainWindow):
scatter.setData(x, y, value, xerror, yerror, copy=copy)
if mustBeAdded:
- self._add(scatter)
+ self.addItem(scatter)
else:
self._notifyContentChanged(scatter)
- if len(self._getItems(kind="scatter")) == 1 or wasActive:
- self._setActiveItem('scatter', scatter.getLegend())
+ scatters = [item for item in self.getItems()
+ if isinstance(item, items.Scatter) and item.isVisible()]
+ if len(scatters) == 1 or wasActive:
+ self._setActiveItem('scatter', scatter.getName())
return legend
- def addItem(self, xdata, ydata, legend=None, info=None,
+ def addShape(self, xdata, ydata, legend=None, info=None,
replace=False,
shape="polygon", color='black', fill=True,
overlay=False, z=None, linestyle="-", linewidth=1.0,
@@ -1295,7 +1361,7 @@ class PlotWidget(qt.QMainWindow):
self.remove(legend, kind='item')
item = items.Shape(shape)
- item._setLegend(legend)
+ item.setName(legend)
item.setInfo(info)
item.setColor(color)
item.setFill(fill)
@@ -1306,7 +1372,7 @@ class PlotWidget(qt.QMainWindow):
item.setLineWidth(linewidth)
item.setLineBgColor(linebgcolor)
- self._add(item)
+ self.addItem(item)
return legend
@@ -1324,8 +1390,8 @@ class PlotWidget(qt.QMainWindow):
:meth:`addXMarker` without legend argument adds two markers with
different identifying legends.
- :param float x: Position of the marker on the X axis in data
- coordinates
+ :param x: Position of the marker on the X axis in data coordinates
+ :type x: Union[None, float]
:param str legend: Legend associated to the marker to identify it
:param str text: Text to display on the marker.
:param str color: Color of the marker, e.g., 'blue', 'b', '#FF0000'
@@ -1467,7 +1533,8 @@ class PlotWidget(qt.QMainWindow):
assert (x, y) != (None, None)
if legend is None: # Find an unused legend
- markerLegends = self._getAllMarkers(just_legend=True)
+ markerLegends = [item.getName() for item in self.getItems()
+ if isinstance(item, items.MarkerBase)]
for index in itertools.count():
legend = "Unnamed Marker %d" % index
if legend not in markerLegends:
@@ -1486,14 +1553,14 @@ class PlotWidget(qt.QMainWindow):
if marker is not None and not isinstance(marker, markerClass):
_logger.warning('Adding marker with same legend'
' but different type replaces it')
- self._remove(marker)
+ self.removeItem(marker)
marker = None
mustBeAdded = marker is None
if marker is None:
# No previous marker, create one
marker = markerClass()
- marker._setLegend(legend)
+ marker.setName(legend)
if text is not None:
marker.setText(text)
@@ -1514,7 +1581,7 @@ class PlotWidget(qt.QMainWindow):
marker.setPosition(x, y)
if mustBeAdded:
- self._add(marker)
+ self.addItem(marker)
else:
self._notifyContentChanged(marker)
@@ -1577,7 +1644,7 @@ class PlotWidget(qt.QMainWindow):
By default, it removes all kind of elements.
:type kind: str or tuple of str to specify multiple kinds.
"""
- if kind is 'all': # Replace all by tuple of all kinds
+ if kind == 'all': # Replace all by tuple of all kinds
kind = self.ITEM_KINDS
if kind in self.ITEM_KINDS: # Kind is a str, make it a tuple
@@ -1589,16 +1656,17 @@ class PlotWidget(qt.QMainWindow):
if legend is None: # This is a clear
# Clear each given kind
for aKind in kind:
- for legend in self._getItems(
- kind=aKind, just_legend=True, withhidden=True):
- self.remove(legend=legend, kind=aKind)
+ for item in self.getItems():
+ if (isinstance(item, self._KIND_TO_CLASSES[aKind]) and
+ item.getPlot() is self): # Make sure item is still in the plot
+ self.removeItem(item)
else: # This is removing a single element
# Remove each given kind
for aKind in kind:
item = self._getItem(aKind, legend)
if item is not None:
- self._remove(item)
+ self.removeItem(item)
def removeCurve(self, legend):
"""Remove the curve associated to legend from the graph.
@@ -1618,15 +1686,6 @@ class PlotWidget(qt.QMainWindow):
return
self.remove(legend, kind='image')
- def removeItem(self, legend):
- """Remove the item associated to legend from the graph.
-
- :param str legend: The legend associated to the item to be deleted
- """
- if legend is None:
- return
- self.remove(legend, kind='item')
-
def removeMarker(self, legend):
"""Remove the marker associated to legend from the graph.
@@ -1640,7 +1699,9 @@ class PlotWidget(qt.QMainWindow):
def clear(self):
"""Remove everything from the plot."""
- self.remove()
+ for item in self.getItems():
+ if item.getPlot() is self: # Make sure item is still in the plot
+ self.removeItem(item)
def clearCurves(self):
"""Remove all the curves from the plot."""
@@ -1855,7 +1916,7 @@ class PlotWidget(qt.QMainWindow):
withhidden=False)
if len(curves) == 1:
if curves[0].isVisible():
- self.setActiveCurve(curves[0].getLegend())
+ self.setActiveCurve(curves[0].getName())
def getActiveCurveSelectionMode(self):
"""Returns the current selection mode.
@@ -1888,6 +1949,27 @@ class PlotWidget(qt.QMainWindow):
"""
return self._setActiveItem(kind='image', legend=legend)
+ def getActiveScatter(self, just_legend=False):
+ """Returns the currently active scatter.
+
+ It returns None in case of not having an active scatter.
+
+ :param bool just_legend: True to get the legend of the scatter,
+ False (the default) to get the scatter data
+ and info.
+ :return: Active scatter's legend or corresponding scatter object
+ :rtype: str, :class:`.items.Scatter` or None
+ """
+ return self._getActiveItem(kind='scatter', just_legend=just_legend)
+
+ def setActiveScatter(self, legend):
+ """Make the scatter associated to legend the active scatter.
+
+ :param str legend: The legend associated to the scatter
+ or None to have no active scatter.
+ """
+ return self._setActiveItem(kind='scatter', legend=legend)
+
def _getActiveItem(self, kind, just_legend=False):
"""Return the currently active item of that kind if any
@@ -1901,14 +1983,11 @@ class PlotWidget(qt.QMainWindow):
if self._activeLegend[kind] is None:
return None
- if (self._activeLegend[kind], kind) not in self._content:
- self._activeLegend[kind] = None
+ item = self._getItem(kind, self._activeLegend[kind])
+ if item is None:
return None
- if just_legend:
- return self._activeLegend[kind]
- else:
- return self._getItem(kind, self._activeLegend[kind])
+ return item.getName() if just_legend else item
def _setActiveItem(self, kind, legend):
"""Make the curve associated to legend the active curve.
@@ -1974,7 +2053,7 @@ class PlotWidget(qt.QMainWindow):
if oldActiveItem is None:
oldActiveLegend = None
else:
- oldActiveLegend = oldActiveItem.getLegend()
+ oldActiveLegend = oldActiveItem.getName()
self.notify(
'active' + kind[0].upper() + kind[1:] + 'Changed',
updated=oldActiveLegend != activeLegend,
@@ -1991,22 +2070,15 @@ class PlotWidget(qt.QMainWindow):
if not self.__muteActiveItemChanged:
item = self.sender()
if item is not None:
- legend, kind = self._itemKey(item)
+ kind = self._itemKind(item)
self.notify(
'active' + kind[0].upper() + kind[1:] + 'Changed',
updated=False,
- previous=legend,
- legend=legend)
+ previous=item.getName(),
+ legend=item.getName())
# Getters
- def getItems(self):
- """Returns the list of items in the plot
-
- :rtype: List[silx.gui.plot.items.Item]
- """
- return tuple(self._content.values())
-
def getAllCurves(self, just_legend=False, withhidden=False):
"""Returns all curves legend or info and data.
@@ -2023,9 +2095,10 @@ class PlotWidget(qt.QMainWindow):
:return: list of curves' legend or :class:`.items.Curve`
:rtype: list of str or list of :class:`.items.Curve`
"""
- return self._getItems(kind='curve',
- just_legend=just_legend,
- withhidden=withhidden)
+ curves = [item for item in self.getItems() if
+ isinstance(item, items.Curve) and
+ (withhidden or item.isVisible())]
+ return [curve.getName() for curve in curves] if just_legend else curves
def getCurve(self, legend=None):
"""Get the object describing a specific curve.
@@ -2056,9 +2129,9 @@ class PlotWidget(qt.QMainWindow):
:return: list of images' legend or :class:`.items.ImageBase`
:rtype: list of str or list of :class:`.items.ImageBase`
"""
- return self._getItems(kind='image',
- just_legend=just_legend,
- withhidden=True)
+ images = [item for item in self.getItems()
+ if isinstance(item, items.ImageBase)]
+ return [image.getName() for image in images] if just_legend else images
def getImage(self, legend=None):
"""Get the object describing a specific image.
@@ -2101,6 +2174,7 @@ class PlotWidget(qt.QMainWindow):
"""
return self._getItem(kind='histogram', legend=legend)
+ @deprecated(replacement='getItems', since_version='0.13')
def _getItems(self, kind=ITEM_KINDS, just_legend=False, withhidden=False):
"""Retrieve all items of a kind in the plot
@@ -2115,7 +2189,7 @@ class PlotWidget(qt.QMainWindow):
:param bool withhidden: False (default) to skip hidden curves.
:return: list of legends or item objects
"""
- if kind is 'all': # Replace all by tuple of all kinds
+ if kind == 'all': # Replace all by tuple of all kinds
kind = self.ITEM_KINDS
if kind in self.ITEM_KINDS: # Kind is a str, make it a tuple
@@ -2125,9 +2199,10 @@ class PlotWidget(qt.QMainWindow):
assert aKind in self.ITEM_KINDS
output = []
- for (legend, type_), item in self._content.items():
+ for item in self.getItems():
+ type_ = self._itemKind(item)
if type_ in kind and (withhidden or item.isVisible()):
- output.append(legend if just_legend else item)
+ output.append(item.getName() if just_legend else item)
return output
def _getItem(self, kind, legend=None):
@@ -2151,8 +2226,9 @@ class PlotWidget(qt.QMainWindow):
if item is not None: # Return active item if available
return item
# Return last visible item if any
- allItems = self._getItems(
- kind=kind, just_legend=False, withhidden=False)
+ itemClasses = self._KIND_TO_CLASSES[kind]
+ allItems = [item for item in self.getItems()
+ if isinstance(item, itemClasses) and item.isVisible()]
return allItems[-1] if allItems else None
# Limits
@@ -2911,16 +2987,35 @@ class PlotWidget(qt.QMainWindow):
"""
self._backend.setGraphCursorShape(cursor)
+ @deprecated(replacement='getItems', since_version='0.13')
def _getAllMarkers(self, just_legend=False):
- """Returns all markers' legend or objects
+ markers = [item for item in self.getItems() if isinstance(item, items.MarkerBase)]
+ if just_legend:
+ return [marker.getName() for marker in markers]
+ else:
+ return markers
- :param bool just_legend: True to get the legend of the markers,
- False (the default) to get marker objects.
- :return: list of legend of list of marker objects
- :rtype: list of str or list of marker objects
+ def _getMarkerAt(self, x, y):
+ """Return the most interactive marker at a location, else None
+
+ :param float x: X position in pixels
+ :param float y: Y position in pixels
+ :rtype: None of marker object
"""
- return self._getItems(
- kind='marker', just_legend=just_legend, withhidden=True)
+ def checkDraggable(item):
+ return isinstance(item, items.MarkerBase) and item.isDraggable()
+ def checkSelectable(item):
+ return isinstance(item, items.MarkerBase) and item.isSelectable()
+ def check(item):
+ return isinstance(item, items.MarkerBase)
+
+ result = self._pickTopMost(x, y, checkDraggable)
+ if not result:
+ result = self._pickTopMost(x, y, checkSelectable)
+ if not result:
+ result = self._pickTopMost(x, y, check)
+ marker = result.getItem() if result is not None else None
+ return marker
def _getMarker(self, legend=None):
"""Get the object describing a specific marker.
@@ -3061,12 +3156,21 @@ class PlotWidget(qt.QMainWindow):
"""Returns the current interactive mode as a dict.
The returned dict contains at least the key 'mode'.
- Mode can be: 'draw', 'pan', 'select', 'zoom'.
+ Mode can be: 'draw', 'pan', 'select', 'select-draw', 'zoom'.
It can also contains extra keys (e.g., 'color') specific to a mode
as provided to :meth:`setInteractiveMode`.
"""
return self._eventHandler.getInteractiveMode()
+ def resetInteractiveMode(self):
+ """Reset the interactive mode to use the previous basic interactive
+ mode used.
+
+ It can be one of "zoom" or "pan".
+ """
+ mode, zoomOnWheel = self._previousDefaultMode
+ self.setInteractiveMode(mode=mode, zoomOnWheel=zoomOnWheel)
+
def setInteractiveMode(self, mode, color='black',
shape='polygon', label=None,
zoomOnWheel=True, source=None, width=None):
@@ -3092,6 +3196,8 @@ class PlotWidget(qt.QMainWindow):
"""
self._eventHandler.setInteractiveMode(mode, color, shape, label, width)
self._eventHandler.zoomOnWheel = zoomOnWheel
+ if mode in ["pan", "zoom"]:
+ self._previousDefaultMode = mode, zoomOnWheel
self.notify(
'interactiveModeChanged', source=source)
@@ -3133,6 +3239,16 @@ class PlotWidget(qt.QMainWindow):
qt.Qt.Key_Down: 'down'
}
+ def __simulateMouseMove(self):
+ qapp = qt.QApplication.instance()
+ event = qt.QMouseEvent(
+ qt.QEvent.MouseMove,
+ self.getWidgetHandle().mapFromGlobal(qt.QCursor.pos()),
+ qt.Qt.NoButton,
+ qapp.mouseButtons(),
+ qapp.keyboardModifiers())
+ qapp.sendEvent(self.getWidgetHandle(), event)
+
def keyPressEvent(self, event):
"""Key event handler handling panning on arrow keys.
@@ -3145,15 +3261,7 @@ class PlotWidget(qt.QMainWindow):
# Send a mouse move event to the plot widget to take into account
# that even if mouse didn't move on the screen, it moved relative
# to the plotted data.
- qapp = qt.QApplication.instance()
- event = qt.QMouseEvent(
- qt.QEvent.MouseMove,
- self.getWidgetHandle().mapFromGlobal(qt.QCursor.pos()),
- qt.Qt.NoButton,
- qapp.mouseButtons(),
- qapp.keyboardModifiers())
- qapp.sendEvent(self.getWidgetHandle(), event)
-
+ self.__simulateMouseMove()
else:
# Only call base class implementation when key is not handled.
# See QWidget.keyPressEvent for details.
diff --git a/silx/gui/plot/PlotWindow.py b/silx/gui/plot/PlotWindow.py
index 0196050..a3b70c6 100644
--- a/silx/gui/plot/PlotWindow.py
+++ b/silx/gui/plot/PlotWindow.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-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
@@ -464,7 +464,7 @@ class PlotWindow(PlotWidget):
"""Add a dock widget as a new tab if there are already dock widgets
in the plot. When the first tab is added, the area is chosen
depending on the plot geometry:
- it the window is much wider than it is high, the right dock area
+ if the window is much wider than it is high, the right dock area
is used, else the bottom dock area is used.
:param dock_widget: Instance of :class:`QDockWidget` to be added.
@@ -485,6 +485,17 @@ class PlotWindow(PlotWidget):
self.tabifyDockWidget(self._dockWidgets[0],
dock_widget)
+ def removeDockWidget(self, dockwidget):
+ """Removes the *dockwidget* from the main window layout and hides it.
+
+ Note that the *dockwidget* is *not* deleted.
+
+ :param QDockWidget dockwidget:
+ """
+ if dockwidget in self._dockWidgets:
+ self._dockWidgets.remove(dockwidget)
+ super(PlotWindow, self).removeDockWidget(dockwidget)
+
def __handleFirstDockWidgetShow(self, visible):
"""Handle QDockWidget.visibilityChanged
@@ -828,6 +839,9 @@ class Plot1D(PlotWindow):
self.setWindowTitle('Plot1D')
self.getXAxis().setLabel('X')
self.getYAxis().setLabel('Y')
+ action = self.getFitAction()
+ action.setXRangeUpdatedOnZoom(True)
+ action.setFittedItemUpdatedFromActiveCurve(True)
class Plot2D(PlotWindow):
@@ -916,37 +930,30 @@ class Plot2D(PlotWindow):
:param float y: Y position in plot coordinates
:return: The value at that point or '-'
"""
- value = '-'
- valueZ = -float('inf')
- mask = 0
- maskZ = -float('inf')
-
- for image in self.getAllImages():
- data = image.getData(copy=False)
- isMask = isinstance(image, items.MaskImageData)
- if isMask:
- zIndex = maskZ
+ pickedMask = None
+ for picked in self.pickItems(
+ *self.dataToPixel(x, y, check=False),
+ lambda item: isinstance(item, items.ImageBase)):
+ if isinstance(picked.getItem(), items.MaskImageData):
+ if pickedMask is None: # Use top-most if many masks
+ pickedMask = picked
else:
- zIndex = valueZ
- if image.getZValue() >= zIndex:
- # This image is over the previous one
- ox, oy = image.getOrigin()
- sx, sy = image.getScale()
- row, col = (y - oy) / sy, (x - ox) / sx
- if row >= 0 and col >= 0:
- # Test positive before cast otherwise issue with int(-0.5) = 0
- row, col = int(row), int(col)
- if (row < data.shape[0] and col < data.shape[1]):
- v, z = data[row, col], image.getZValue()
- if not isMask:
- value = v
- valueZ = z
- else:
- mask = v
- maskZ = z
- if maskZ > valueZ and mask > 0:
- return value, "Masked"
- return value
+ image = picked.getItem()
+
+ indices = picked.getIndices(copy=False)
+ if indices is not None:
+ row, col = indices[0][0], indices[1][0]
+ value = image.getData(copy=False)[row, col]
+
+ if pickedMask is not None: # Check if masked
+ maskItem = pickedMask.getItem()
+ indices = pickedMask.getIndices()
+ row, col = indices[0][0], indices[1][0]
+ if maskItem.getData(copy=False)[row, col] != 0:
+ return value, "Masked"
+ return value
+
+ return '-' # No image picked
def _getImageDims(self, *args):
activeImage = self.getActiveImage()
diff --git a/silx/gui/plot/Profile.py b/silx/gui/plot/Profile.py
index e2aa5a7..8abddbe 100644
--- a/silx/gui/plot/Profile.py
+++ b/silx/gui/plot/Profile.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-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
@@ -33,334 +33,45 @@ __date__ = "12/04/2019"
import weakref
-import numpy
-
-from silx.image.bilinear import BilinearImage
-
-from .. import icons
from .. import qt
-from . import items
-from ..colors import cursorColorForColormap
from . import actions
-from .PlotToolButtons import ProfileToolButton, ProfileOptionToolButton
-from .ProfileMainWindow import ProfileMainWindow
+from .tools.profile import core
+from .tools.profile import manager
+from .tools.profile import rois
+from silx.gui.widgets.MultiModeAction import MultiModeAction
from silx.utils.deprecation import deprecated
+from silx.utils.deprecation import deprecated_warning
+from .tools import roi as roi_mdl
+from silx.gui.plot import items
-def _alignedFullProfile(data, origin, scale, position, roiWidth, axis, method):
- """Get a profile along one axis on a stack of images
-
- :param numpy.ndarray data: 3D volume (stack of 2D images)
- The first dimension is the image index.
- :param origin: Origin of image in plot (ox, oy)
- :param scale: Scale of image in plot (sx, sy)
- :param float position: Position of profile line in plot coords
- on the axis orthogonal to the profile direction.
- :param int roiWidth: Width of the profile in image pixels.
- :param int axis: 0 for horizontal profile, 1 for vertical.
- :param str method: method to compute the profile. Can be 'mean' or 'sum'
- :return: profile image + effective ROI area corners in plot coords
- """
- assert axis in (0, 1)
- assert len(data.shape) == 3
- assert method in ('mean', 'sum')
-
- # Convert from plot to image coords
- imgPos = int((position - origin[1 - axis]) / scale[1 - axis])
-
- if axis == 1: # Vertical profile
- # Transpose image to always do a horizontal profile
- data = numpy.transpose(data, (0, 2, 1))
-
- nimages, height, width = data.shape
-
- roiWidth = min(height, roiWidth) # Clip roi width to image size
-
- # Get [start, end[ coords of the roi in the data
- start = int(int(imgPos) + 0.5 - roiWidth / 2.)
- start = min(max(0, start), height - roiWidth)
- end = start + roiWidth
-
- if start < height and end > 0:
- if method == 'mean':
- _fct = numpy.mean
- elif method == 'sum':
- _fct = numpy.sum
- else:
- raise ValueError('method not managed')
- profile = _fct(data[:, max(0, start):min(end, height), :], axis=1).astype(numpy.float32)
- else:
- profile = numpy.zeros((nimages, width), dtype=numpy.float32)
-
- # Compute effective ROI in plot coords
- profileBounds = numpy.array(
- (0, width, width, 0),
- dtype=numpy.float32) * scale[axis] + origin[axis]
- roiBounds = numpy.array(
- (start, start, end, end),
- dtype=numpy.float32) * scale[1 - axis] + origin[1 - axis]
-
- if axis == 0: # Horizontal profile
- area = profileBounds, roiBounds
- else: # vertical profile
- area = roiBounds, profileBounds
-
- return profile, area
-
-
-def _alignedPartialProfile(data, rowRange, colRange, axis, method):
- """Mean of a rectangular region (ROI) of a stack of images
- along a given axis.
-
- Returned values and all parameters are in image coordinates.
-
- :param numpy.ndarray data: 3D volume (stack of 2D images)
- The first dimension is the image index.
- :param rowRange: [min, max[ of ROI rows (upper bound excluded).
- :type rowRange: 2-tuple of int (min, max) with min < max
- :param colRange: [min, max[ of ROI columns (upper bound excluded).
- :type colRange: 2-tuple of int (min, max) with min < max
- :param int axis: The axis along which to take the profile of the ROI.
- 0: Sum rows along columns.
- 1: Sum columns along rows.
- :param str method: method to compute the profile. Can be 'mean' or 'sum'
- :return: Profile image along the ROI as the mean of the intersection
- of the ROI and the image.
- """
- assert axis in (0, 1)
- assert len(data.shape) == 3
- assert rowRange[0] < rowRange[1]
- assert colRange[0] < colRange[1]
- assert method in ('mean', 'sum')
-
- nimages, height, width = data.shape
-
- # Range aligned with the integration direction
- profileRange = colRange if axis == 0 else rowRange
-
- profileLength = abs(profileRange[1] - profileRange[0])
-
- # Subset of the image to use as intersection of ROI and image
- rowStart = min(max(0, rowRange[0]), height)
- rowEnd = min(max(0, rowRange[1]), height)
- colStart = min(max(0, colRange[0]), width)
- colEnd = min(max(0, colRange[1]), width)
-
- if method == 'mean':
- _fct = numpy.mean
- elif method == 'sum':
- _fct = numpy.sum
- else:
- raise ValueError('method not managed')
-
- imgProfile = _fct(data[:, rowStart:rowEnd, colStart:colEnd], axis=axis + 1,
- dtype=numpy.float32)
-
- # Profile including out of bound area
- profile = numpy.zeros((nimages, profileLength), dtype=numpy.float32)
+@deprecated(replacement="silx.gui.plot.tools.profile.createProfile", since_version="0.13.0")
+def createProfile(roiInfo, currentData, origin, scale, lineWidth, method):
+ return core.createProfile(roiInfo, currentData, origin,
+ scale, lineWidth, method)
- # Place imgProfile in full profile
- offset = - min(0, profileRange[0])
- profile[:, offset:offset + imgProfile.shape[1]] = imgProfile
- return profile
+class _CustomProfileManager(manager.ProfileManager):
+ """This custom profile manager uses a single predefined profile window
+ if it is specified. Else the behavior is the same as the default
+ ProfileManager """
+ def setProfileWindow(self, profileWindow):
+ self.__profileWindow = profileWindow
-def createProfile(roiInfo, currentData, origin, scale, lineWidth, method):
- """Create the profile line for the the given image.
-
- :param roiInfo: information about the ROI: start point, end point and
- type ("X", "Y", "D")
- :param numpy.ndarray currentData: the 2D image or the 3D stack of images
- on which we compute the profile.
- :param origin: (ox, oy) the offset from origin
- :type origin: 2-tuple of float
- :param scale: (sx, sy) the scale to use
- :type scale: 2-tuple of float
- :param int lineWidth: width of the profile line
- :param str method: method to compute the profile. Can be 'mean' or 'sum'
- :return: `coords, profile, area, profileName, xLabel`, where:
- - coords is the X coordinate to use to display the profile
- - profile is a 2D array of the profiles of the stack of images.
- For a single image, the profile is a curve, so this parameter
- has a shape *(1, len(curve))*
- - area is a tuple of two 1D arrays with 4 values each. They represent
- the effective ROI area corners in plot coords.
- - profileName is a string describing the ROI, meant to be used as
- title of the profile plot
- - xLabel the label for X in the profile window
-
- :rtype: tuple(ndarray,ndarray,(ndarray,ndarray),str)
- """
- if currentData is None or roiInfo is None or lineWidth is None:
- raise ValueError("createProfile called with invalide arguments")
-
- # force 3D data (stack of images)
- if len(currentData.shape) == 2:
- currentData3D = currentData.reshape((1,) + currentData.shape)
- elif len(currentData.shape) == 3:
- currentData3D = currentData
-
- roiWidth = max(1, lineWidth)
- roiStart, roiEnd, lineProjectionMode = roiInfo
-
- if lineProjectionMode == 'X': # Horizontal profile on the whole image
- profile, area = _alignedFullProfile(currentData3D,
- origin, scale,
- roiStart[1], roiWidth,
- axis=0,
- method=method)
-
- coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
- coords = coords * scale[0] + origin[0]
-
- yMin, yMax = min(area[1]), max(area[1]) - 1
- if roiWidth <= 1:
- profileName = 'Y = %g' % yMin
+ def createProfileWindow(self, plot, roi):
+ if self.__profileWindow is not None:
+ return self.__profileWindow
else:
- profileName = 'Y = [%g, %g]' % (yMin, yMax)
- xLabel = 'X'
-
- elif lineProjectionMode == 'Y': # Vertical profile on the whole image
- profile, area = _alignedFullProfile(currentData3D,
- origin, scale,
- roiStart[0], roiWidth,
- axis=1,
- method=method)
-
- coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
- coords = coords * scale[1] + origin[1]
-
- xMin, xMax = min(area[0]), max(area[0]) - 1
- if roiWidth <= 1:
- profileName = 'X = %g' % xMin
- else:
- profileName = 'X = [%g, %g]' % (xMin, xMax)
- xLabel = 'Y'
-
- else: # Free line profile
-
- # Convert start and end points in image coords as (row, col)
- startPt = ((roiStart[1] - origin[1]) / scale[1],
- (roiStart[0] - origin[0]) / scale[0])
- endPt = ((roiEnd[1] - origin[1]) / scale[1],
- (roiEnd[0] - origin[0]) / scale[0])
-
- if (int(startPt[0]) == int(endPt[0]) or
- int(startPt[1]) == int(endPt[1])):
- # Profile is aligned with one of the axes
-
- # Convert to int
- startPt = int(startPt[0]), int(startPt[1])
- endPt = int(endPt[0]), int(endPt[1])
-
- # Ensure startPt <= endPt
- if startPt[0] > endPt[0] or startPt[1] > endPt[1]:
- startPt, endPt = endPt, startPt
-
- if startPt[0] == endPt[0]: # Row aligned
- rowRange = (int(startPt[0] + 0.5 - 0.5 * roiWidth),
- int(startPt[0] + 0.5 + 0.5 * roiWidth))
- colRange = startPt[1], endPt[1] + 1
- profile = _alignedPartialProfile(currentData3D,
- rowRange, colRange,
- axis=0,
- method=method)
-
- else: # Column aligned
- rowRange = startPt[0], endPt[0] + 1
- colRange = (int(startPt[1] + 0.5 - 0.5 * roiWidth),
- int(startPt[1] + 0.5 + 0.5 * roiWidth))
- profile = _alignedPartialProfile(currentData3D,
- rowRange, colRange,
- axis=1,
- method=method)
-
- # Convert ranges to plot coords to draw ROI area
- area = (
- numpy.array(
- (colRange[0], colRange[1], colRange[1], colRange[0]),
- dtype=numpy.float32) * scale[0] + origin[0],
- numpy.array(
- (rowRange[0], rowRange[0], rowRange[1], rowRange[1]),
- dtype=numpy.float32) * scale[1] + origin[1])
-
- else: # General case: use bilinear interpolation
-
- # Ensure startPt <= endPt
- if (startPt[1] > endPt[1] or (
- startPt[1] == endPt[1] and startPt[0] > endPt[0])):
- startPt, endPt = endPt, startPt
-
- profile = []
- for slice_idx in range(currentData3D.shape[0]):
- bilinear = BilinearImage(currentData3D[slice_idx, :, :])
-
- profile.append(bilinear.profile_line(
- (startPt[0] - 0.5, startPt[1] - 0.5),
- (endPt[0] - 0.5, endPt[1] - 0.5),
- roiWidth,
- method=method))
- profile = numpy.array(profile)
-
- # Extend ROI with half a pixel on each end, and
- # Convert back to plot coords (x, y)
- length = numpy.sqrt((endPt[0] - startPt[0]) ** 2 +
- (endPt[1] - startPt[1]) ** 2)
- dRow = (endPt[0] - startPt[0]) / length
- dCol = (endPt[1] - startPt[1]) / length
-
- # Extend ROI with half a pixel on each end
- roiStartPt = startPt[0] - 0.5 * dRow, startPt[1] - 0.5 * dCol
- roiEndPt = endPt[0] + 0.5 * dRow, endPt[1] + 0.5 * dCol
-
- # Rotate deltas by 90 degrees to apply line width
- dRow, dCol = dCol, -dRow
-
- area = (
- numpy.array((roiStartPt[1] - 0.5 * roiWidth * dCol,
- roiStartPt[1] + 0.5 * roiWidth * dCol,
- roiEndPt[1] + 0.5 * roiWidth * dCol,
- roiEndPt[1] - 0.5 * roiWidth * dCol),
- dtype=numpy.float32) * scale[0] + origin[0],
- numpy.array((roiStartPt[0] - 0.5 * roiWidth * dRow,
- roiStartPt[0] + 0.5 * roiWidth * dRow,
- roiEndPt[0] + 0.5 * roiWidth * dRow,
- roiEndPt[0] - 0.5 * roiWidth * dRow),
- dtype=numpy.float32) * scale[1] + origin[1])
-
- # Convert start and end points back to plot coords
- y0 = startPt[0] * scale[1] + origin[1]
- x0 = startPt[1] * scale[0] + origin[0]
- y1 = endPt[0] * scale[1] + origin[1]
- x1 = endPt[1] * scale[0] + origin[0]
-
- if startPt[1] == endPt[1]:
- profileName = 'X = %g; Y = [%g, %g]' % (x0, y0, y1)
- coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
- coords = coords * scale[1] + y0
- xLabel = 'Y'
-
- elif startPt[0] == endPt[0]:
- profileName = 'Y = %g; X = [%g, %g]' % (y0, x0, x1)
- coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
- coords = coords * scale[0] + x0
- xLabel = 'X'
+ return super(_CustomProfileManager, self).createProfileWindow(plot, roi)
+ def clearProfileWindow(self, profileWindow):
+ if self.__profileWindow is not None:
+ self.__profileWindow.setProfile(None)
else:
- m = (y1 - y0) / (x1 - x0)
- b = y0 - m * x0
- profileName = 'y = %g * x %+g ; width=%d' % (m, b, roiWidth)
- coords = numpy.linspace(x0, x1, len(profile[0]),
- endpoint=True,
- dtype=numpy.float32)
- xLabel = 'X'
-
- return coords, profile, area, profileName, xLabel
-
+ return super(_CustomProfileManager, self).clearProfileWindow(profileWindow)
-# ProfileToolBar ##############################################################
class ProfileToolBar(qt.QToolBar):
"""QToolBar providing profile tools operating on a :class:`PlotWindow`.
@@ -387,76 +98,36 @@ class ProfileToolBar(qt.QToolBar):
:param str title: See :class:`QToolBar`.
:param parent: See :class:`QToolBar`.
"""
- # TODO Make it a QActionGroup instead of a QToolBar
-
- _POLYGON_LEGEND = '__ProfileToolBar_ROI_Polygon'
-
- DEFAULT_PROF_METHOD = 'mean'
def __init__(self, parent=None, plot=None, profileWindow=None,
- title='Profile Selection'):
+ title=None):
super(ProfileToolBar, self).__init__(title, parent)
assert plot is not None
- self._plotRef = weakref.ref(plot)
- self._overlayColor = None
- self._defaultOverlayColor = 'red' # update when active image change
- self._method = self.DEFAULT_PROF_METHOD
+ if title is not None:
+ deprecated_warning("Attribute",
+ name="title",
+ reason="removed",
+ since_version="0.13.0",
+ only_once=True,
+ skip_backtrace_count=1)
- self._roiInfo = None # Store start and end points and type of ROI
-
- self._profileWindow = profileWindow
- """User provided plot widget in which the profile curve is plotted.
- None if no custom profile plot was provided."""
-
- self._profileMainWindow = None
- """Main window providing 2 profile plot widgets for 1D or 2D profiles.
- The window provides two public methods
- - :meth:`setProfileDimensions`
- - :meth:`getPlot`: return handle on the actual plot widget
- currently being used
- None if the user specified a custom profile plot window.
- """
+ self._plotRef = weakref.ref(plot)
- if self._profileWindow is None:
- backend = type(plot._backend)
- self._profileMainWindow = ProfileMainWindow(self, backend=backend)
+ # If a profileWindow is defined,
+ # It will be used to display all the profiles
+ self._manager = _CustomProfileManager(self, plot)
+ self._manager.setProfileWindow(profileWindow)
+ self._manager.setDefaultColorFromCursorColor(True)
+ self._manager.setItemType(image=True)
+ self._manager.setActiveItemTracking(True)
# Actions
- self._browseAction = actions.mode.ZoomModeAction(self.plot, parent=self)
+ self._browseAction = actions.mode.ZoomModeAction(plot, parent=self)
self._browseAction.setVisible(False)
-
- self.hLineAction = qt.QAction(icons.getQIcon('shape-horizontal'),
- 'Horizontal Profile Mode',
- self)
- self.hLineAction.setToolTip(
- 'Enables horizontal profile selection mode')
- self.hLineAction.setCheckable(True)
- self.hLineAction.toggled[bool].connect(self._hLineActionToggled)
-
- self.vLineAction = qt.QAction(icons.getQIcon('shape-vertical'),
- 'Vertical Profile Mode',
- self)
- self.vLineAction.setToolTip(
- 'Enables vertical profile selection mode')
- self.vLineAction.setCheckable(True)
- self.vLineAction.toggled[bool].connect(self._vLineActionToggled)
-
- self.lineAction = qt.QAction(icons.getQIcon('shape-diagonal'),
- 'Free Line Profile Mode',
- self)
- self.lineAction.setToolTip(
- 'Enables line profile selection mode')
- self.lineAction.setCheckable(True)
- self.lineAction.toggled[bool].connect(self._lineActionToggled)
-
- self.clearAction = qt.QAction(icons.getQIcon('profile-clear'),
- 'Clear Profile',
- self)
- self.clearAction.setToolTip(
- 'Clear the profile Region of interest')
- self.clearAction.setCheckable(False)
- self.clearAction.triggered.connect(self.clearProfile)
+ self.freeLineAction = None
+ self._createProfileActions()
+ self._editor = self._manager.createEditorAction(self)
# ActionGroup
self.actionGroup = qt.QActionGroup(self)
@@ -464,44 +135,62 @@ class ProfileToolBar(qt.QToolBar):
self.actionGroup.addAction(self.hLineAction)
self.actionGroup.addAction(self.vLineAction)
self.actionGroup.addAction(self.lineAction)
+ self.actionGroup.addAction(self._editor)
+
+ modes = MultiModeAction(self)
+ modes.addAction(self.hLineAction)
+ modes.addAction(self.vLineAction)
+ modes.addAction(self.lineAction)
+ if self.freeLineAction is not None:
+ modes.addAction(self.freeLineAction)
+ modes.addAction(self.crossAction)
+ self.__multiAction = modes
# Add actions to ToolBar
self.addAction(self._browseAction)
- self.addAction(self.hLineAction)
- self.addAction(self.vLineAction)
- self.addAction(self.lineAction)
+ self.addAction(modes)
+ self.addAction(self._editor)
self.addAction(self.clearAction)
- # Add width spin box to toolbar
- self.addWidget(qt.QLabel('W:'))
- self.lineWidthSpinBox = qt.QSpinBox(self)
- self.lineWidthSpinBox.setRange(1, 1000)
- self.lineWidthSpinBox.setValue(1)
- self.lineWidthSpinBox.valueChanged[int].connect(
- self._lineWidthSpinBoxValueChangedSlot)
- self.addWidget(self.lineWidthSpinBox)
-
- self.methodsButton = ProfileOptionToolButton(parent=self, plot=self)
- self.__profileOptionToolAction = self.addWidget(self.methodsButton)
- # TODO: add connection with the signal
- self.methodsButton.sigMethodChanged.connect(self.setProfileMethod)
-
- self.plot.sigInteractiveModeChanged.connect(
- self._interactiveModeChanged)
+ plot.sigActiveImageChanged.connect(self._activeImageChanged)
+ self._activeImageChanged()
- # Enable toolbar only if there is an active image
- self.setEnabled(self.plot.getActiveImage(just_legend=True) is not None)
- self.plot.sigActiveImageChanged.connect(
- self._activeImageChanged)
+ def _createProfileActions(self):
+ self.hLineAction = self._manager.createProfileAction(rois.ProfileImageHorizontalLineROI, self)
+ self.vLineAction = self._manager.createProfileAction(rois.ProfileImageVerticalLineROI, self)
+ self.lineAction = self._manager.createProfileAction(rois.ProfileImageLineROI, self)
+ self.freeLineAction = self._manager.createProfileAction(rois.ProfileImageDirectedLineROI, self)
+ self.crossAction = self._manager.createProfileAction(rois.ProfileImageCrossROI, self)
+ self.clearAction = self._manager.createClearAction(self)
- # listen to the profile window signals to clear profile polygon on close
- if self.getProfileMainWindow() is not None:
- self.getProfileMainWindow().sigClose.connect(self.clearProfile)
+ def getPlotWidget(self):
+ """The :class:`.PlotWidget` associated to the toolbar."""
+ return self._plotRef()
@property
+ @deprecated(since_version="0.13.0", replacement="getPlotWidget()")
def plot(self):
- """The :class:`.PlotWidget` associated to the toolbar."""
- return self._plotRef()
+ return self.getPlotWidget()
+
+ def _setRoiActionEnabled(self, itemKind, enabled):
+ for action in self.__multiAction.getMenu().actions():
+ if not isinstance(action, roi_mdl.CreateRoiModeAction):
+ continue
+ roiClass = action.getRoiClass()
+ if issubclass(itemKind, roiClass.ITEM_KIND):
+ action.setEnabled(enabled)
+
+ def _activeImageChanged(self, previous=None, legend=None):
+ """Handle active image change to toggle actions"""
+ if legend is None:
+ self._setRoiActionEnabled(items.ImageStack, False)
+ self._setRoiActionEnabled(items.ImageBase, False)
+ else:
+ plot = self.getPlotWidget()
+ image = plot.getActiveImage()
+ # Disable for empty image
+ enabled = image.getData(copy=False).size > 0
+ self._setRoiActionEnabled(type(image), enabled)
@property
@deprecated(since_version="0.6.0")
@@ -513,253 +202,96 @@ class ProfileToolBar(qt.QToolBar):
def profileWindow(self):
return self.getProfilePlot()
+ def getProfileManager(self):
+ """Return the manager of the profiles.
+
+ :rtype: ProfileManager
+ """
+ return self._manager
+
+ @deprecated(since_version="0.13.0")
def getProfilePlot(self):
"""Return plot widget in which the profile curve or the
profile image is plotted.
"""
- if self.getProfileMainWindow() is not None:
- return self.getProfileMainWindow().getPlot()
-
- # in case the user provided a custom plot for profiles
- return self._profileWindow
+ window = self.getProfileMainWindow()
+ if window is None:
+ return None
+ return window.getCurrentPlotWidget()
+ @deprecated(replacement="getProfileManager().getCurrentRoi().getProfileWindow()", since_version="0.13.0")
def getProfileMainWindow(self):
"""Return window containing the profile curve widget.
- This can return *None* if a custom profile plot window was
- specified in the constructor.
- """
- return self._profileMainWindow
-
- def _activeImageChanged(self, previous, legend):
- """Handle active image change: toggle enabled toolbar, update curve"""
- if legend is None:
- self.setEnabled(False)
- else:
- activeImage = self.plot.getActiveImage()
-
- # Disable for empty image
- self.setEnabled(activeImage.getData(copy=False).size > 0)
-
- # Update default profile color
- if isinstance(activeImage, items.ColormapMixIn):
- self._defaultOverlayColor = cursorColorForColormap(
- activeImage.getColormap()['name'])
- else:
- self._defaultOverlayColor = 'black'
-
- self.updateProfile()
- def _lineWidthSpinBoxValueChangedSlot(self, value):
- """Listen to ROI width widget to refresh ROI and profile"""
- self.updateProfile()
-
- def _interactiveModeChanged(self, source):
- """Handle plot interactive mode changed:
-
- If changed from elsewhere, disable drawing tool
+ This can return None if no profile was computed.
"""
- if source is not self:
- self.clearProfile()
-
- # Uncheck all drawing profile modes
- self.hLineAction.setChecked(False)
- self.vLineAction.setChecked(False)
- self.lineAction.setChecked(False)
-
- if self.getProfileMainWindow() is not None:
- self.getProfileMainWindow().hide()
-
- def _hLineActionToggled(self, checked):
- """Handle horizontal line profile action toggle"""
- if checked:
- self.plot.setInteractiveMode('draw', shape='hline',
- color=None, source=self)
- self.plot.sigPlotSignal.connect(self._plotWindowSlot)
- else:
- self.plot.sigPlotSignal.disconnect(self._plotWindowSlot)
-
- def _vLineActionToggled(self, checked):
- """Handle vertical line profile action toggle"""
- if checked:
- self.plot.setInteractiveMode('draw', shape='vline',
- color=None, source=self)
- self.plot.sigPlotSignal.connect(self._plotWindowSlot)
- else:
- self.plot.sigPlotSignal.disconnect(self._plotWindowSlot)
-
- def _lineActionToggled(self, checked):
- """Handle line profile action toggle"""
- if checked:
- self.plot.setInteractiveMode('draw', shape='line',
- color=None, source=self)
- self.plot.sigPlotSignal.connect(self._plotWindowSlot)
- else:
- self.plot.sigPlotSignal.disconnect(self._plotWindowSlot)
-
- def _plotWindowSlot(self, event):
- """Listen to Plot to handle drawing events to refresh ROI and profile.
- """
- if event['event'] not in ('drawingProgress', 'drawingFinished'):
- return
-
- checkedAction = self.actionGroup.checkedAction()
- if checkedAction == self.hLineAction:
- lineProjectionMode = 'X'
- elif checkedAction == self.vLineAction:
- lineProjectionMode = 'Y'
- elif checkedAction == self.lineAction:
- lineProjectionMode = 'D'
- else:
- return
-
- roiStart, roiEnd = event['points'][0], event['points'][1]
-
- self._roiInfo = roiStart, roiEnd, lineProjectionMode
- self.updateProfile()
+ roi = self._manager.getCurrentRoi()
+ if roi is None:
+ return None
+ return roi.getProfileWindow()
@property
+ @deprecated(since_version="0.13.0")
def overlayColor(self):
- """The color to use for the ROI.
+ """This method does nothing anymore. But could be implemented if needed.
+
+ It was used to set color to use for the ROI.
If set to None (the default), the overlay color is adapted to the
active image colormap and changes if the active image colormap changes.
"""
- return self._overlayColor or self._defaultOverlayColor
+ pass
@overlayColor.setter
+ @deprecated(since_version="0.13.0")
def overlayColor(self, color):
- self._overlayColor = color
- self.updateProfile()
+ """This method does nothing anymore. But could be implemented if needed.
+ """
+ pass
def clearProfile(self):
"""Remove profile curve and profile area."""
- self._roiInfo = None
- self.updateProfile()
+ self._manager.clearProfile()
+ @deprecated(since_version="0.13.0")
def updateProfile(self):
- """Update the displayed profile and profile ROI.
+ """This method does nothing anymore. But could be implemented if needed.
+
+ It was used to update the displayed profile and profile ROI.
This uses the current active image of the plot and the current ROI.
"""
- image = self.plot.getActiveImage()
- if image is None:
- return
-
- # Clean previous profile area, and previous curve
- self.plot.remove(self._POLYGON_LEGEND, kind='item')
- self.getProfilePlot().clear()
- self.getProfilePlot().setGraphTitle('')
- self.getProfilePlot().getXAxis().setLabel('X')
- self.getProfilePlot().getYAxis().setLabel('Y')
-
- self._createProfile(currentData=image.getData(copy=False),
- origin=image.getOrigin(),
- scale=image.getScale(),
- colormap=None, # Not used for 2D data
- z=image.getZValue(),
- method=self.getProfileMethod())
-
- def _createProfile(self, currentData, origin, scale, colormap, z, method):
- """Create the profile line for the the given image.
-
- :param numpy.ndarray currentData: the image or the stack of images
- on which we compute the profile
- :param origin: (ox, oy) the offset from origin
- :type origin: 2-tuple of float
- :param scale: (sx, sy) the scale to use
- :type scale: 2-tuple of float
- :param dict colormap: The colormap to use
- :param int z: The z layer of the image
- """
- if self._roiInfo is None:
- return
-
- coords, profile, area, profileName, xLabel = createProfile(
- roiInfo=self._roiInfo,
- currentData=currentData,
- origin=origin,
- scale=scale,
- lineWidth=self.lineWidthSpinBox.value(),
- method=method)
-
- profilePlot = self.getProfilePlot()
-
- profilePlot.setGraphTitle(profileName)
- profilePlot.getXAxis().setLabel(xLabel)
-
- dataIs3D = len(currentData.shape) > 2
- if dataIs3D:
- profileScale = (coords[-1] - coords[0]) / profile.shape[1], 1
- profilePlot.addImage(profile,
- legend=profileName,
- colormap=colormap,
- origin=(coords[0], 0),
- scale=profileScale)
- profilePlot.getYAxis().setLabel("Frame index (depth)")
- else:
- profilePlot.addCurve(coords,
- profile[0],
- legend=profileName,
- color=self.overlayColor)
-
- self.plot.addItem(area[0], area[1],
- legend=self._POLYGON_LEGEND,
- color=self.overlayColor,
- shape='polygon', fill=True,
- replace=False, z=z + 1)
-
- self._showProfileMainWindow()
-
- def _showProfileMainWindow(self):
- """If profile window was created by this toolbar,
- try to avoid overlapping with the toolbar's parent window.
- """
- profileMainWindow = self.getProfileMainWindow()
- if profileMainWindow is not None:
- winGeom = self.window().frameGeometry()
- qapp = qt.QApplication.instance()
- screenGeom = qapp.desktop().availableGeometry(self)
- spaceOnLeftSide = winGeom.left()
- spaceOnRightSide = screenGeom.width() - winGeom.right()
-
- profileWindowWidth = profileMainWindow.frameGeometry().width()
- if (profileWindowWidth < spaceOnRightSide):
- # Place profile on the right
- profileMainWindow.move(winGeom.right(), winGeom.top())
- elif(profileWindowWidth < spaceOnLeftSide):
- # Place profile on the left
- profileMainWindow.move(
- max(0, winGeom.left() - profileWindowWidth), winGeom.top())
-
- profileMainWindow.show()
- profileMainWindow.raise_()
- else:
- self.getProfilePlot().show()
- self.getProfilePlot().raise_()
+ pass
+ @deprecated(replacement="clearProfile()", since_version="0.13.0")
def hideProfileWindow(self):
"""Hide profile window.
"""
- # this method is currently only used by StackView when the perspective
- # is changed
- if self.getProfileMainWindow() is not None:
- self.getProfileMainWindow().hide()
+ self.clearProfile()
+ @deprecated(since_version="0.13.0")
def setProfileMethod(self, method):
assert method in ('sum', 'mean')
- self._method = method
- self.updateProfile()
+ roi = self._manager.getCurrentRoi()
+ if roi is None:
+ raise RuntimeError("No profile ROI selected")
+ roi.setProfileMethod(method)
+ @deprecated(since_version="0.13.0")
def getProfileMethod(self):
- return self._method
+ roi = self._manager.getCurrentRoi()
+ if roi is None:
+ raise RuntimeError("No profile ROI selected")
+ return roi.getProfileMethod()
+ @deprecated(since_version="0.13.0")
def getProfileOptionToolAction(self):
- return self.__profileOptionToolAction
+ return self._editor
class Profile3DToolBar(ProfileToolBar):
def __init__(self, parent=None, stackview=None,
- title='Profile Selection'):
+ title=None):
"""QToolBar providing profile tools for an image or a stack of images.
:param parent: the parent QWidget
@@ -769,66 +301,22 @@ class Profile3DToolBar(ProfileToolBar):
"""
# TODO: add param profileWindow (specify the plot used for profiles)
super(Profile3DToolBar, self).__init__(parent=parent,
- plot=stackview.getPlot(),
- title=title)
- self.stackView = stackview
- """:class:`StackView` instance"""
-
- self.profile3dAction = ProfileToolButton(
- parent=self, plot=self.plot)
- self.profile3dAction.computeProfileIn2D()
- self.profile3dAction.setVisible(True)
- self.addWidget(self.profile3dAction)
- self.profile3dAction.sigDimensionChanged.connect(self._setProfileType)
-
- # create the 3D toolbar
- self._profileType = None
- self._setProfileType(2)
- self._method3D = 'sum'
-
- def _setProfileType(self, dimensions):
- """Set the profile type: "1D" for a curve (profile on a single image)
- or "2D" for an image (profile on a stack of images).
+ plot=stackview.getPlotWidget())
- :param int dimensions: 1 for a "1D" profile or 2 for a "2D" profile
- """
- # fixme this assumes that we created _profileMainWindow
- self._profileType = "1D" if dimensions == 1 else "2D"
- self.getProfileMainWindow().setProfileType(self._profileType)
- self.updateProfile()
-
- def updateProfile(self):
- """Method overloaded from :class:`ProfileToolBar`,
- to pass the stack of images instead of just the active image.
-
- In 1D profile mode, use the regular parent method.
- """
- if self._profileType == "1D":
- super(Profile3DToolBar, self).updateProfile()
- elif self._profileType == "2D":
- stackData = self.stackView.getCurrentView(copy=False,
- returnNumpyArray=True)
- if stackData is None:
- return
- self.plot.remove(self._POLYGON_LEGEND, kind='item')
- self.getProfilePlot().clear()
- self.getProfilePlot().setGraphTitle('')
- self.getProfilePlot().getXAxis().setLabel('X')
- self.getProfilePlot().getYAxis().setLabel('Y')
- self._createProfile(currentData=stackData[0],
- origin=stackData[1]['origin'],
- scale=stackData[1]['scale'],
- colormap=stackData[1]['colormap'],
- z=stackData[1]['z'],
- method=self.getProfileMethod())
- else:
- raise ValueError(
- "Profile type must be 1D or 2D, not %s" % self._profileType)
+ if title is not None:
+ deprecated_warning("Attribute",
+ name="title",
+ reason="removed",
+ since_version="0.13.0",
+ only_once=True,
+ skip_backtrace_count=1)
- def setProfileMethod(self, method):
- assert method in ('sum', 'mean')
- self._method3D = method
- self.updateProfile()
+ self.stackView = stackview
+ """:class:`StackView` instance"""
- def getProfileMethod(self):
- return self._method3D
+ def _createProfileActions(self):
+ self.hLineAction = self._manager.createProfileAction(rois.ProfileImageStackHorizontalLineROI, self)
+ self.vLineAction = self._manager.createProfileAction(rois.ProfileImageStackVerticalLineROI, self)
+ self.lineAction = self._manager.createProfileAction(rois.ProfileImageStackLineROI, self)
+ self.crossAction = self._manager.createProfileAction(rois.ProfileImageStackCrossROI, self)
+ self.clearAction = self._manager.createClearAction(self)
diff --git a/silx/gui/plot/ProfileMainWindow.py b/silx/gui/plot/ProfileMainWindow.py
index aaedd1c..ce56cfd 100644
--- a/silx/gui/plot/ProfileMainWindow.py
+++ b/silx/gui/plot/ProfileMainWindow.py
@@ -24,15 +24,24 @@
# ###########################################################################*/
"""This module contains a QMainWindow class used to display profile plots.
"""
-from silx.gui import qt
-
__authors__ = ["P. Knobel"]
__license__ = "MIT"
__date__ = "21/02/2017"
+import silx.utils.deprecation
+from silx.gui import qt
+from .tools.profile.manager import ProfileWindow
+
+silx.utils.deprecation.deprecated_warning("Module",
+ name="silx.gui.plot.ProfileMainWindow",
+ reason="moved",
+ replacement="silx.gui.plot.tools.profile.manager.ProfileWindow",
+ since_version="0.13.0",
+ only_once=True,
+ skip_backtrace_count=1)
-class ProfileMainWindow(qt.QMainWindow):
+class ProfileMainWindow(ProfileWindow):
"""QMainWindow providing 2 plot widgets specialized in
1D and 2D plotting, with different toolbars.
@@ -48,73 +57,50 @@ class ProfileMainWindow(qt.QMainWindow):
"""This signal is emitted when :meth:`setProfileDimensions` is called.
It carries the number of dimensions for the profile data (1 or 2).
It can be used to be notified that the profile plot widget has changed.
- """
- sigClose = qt.Signal()
- """Emitted by :meth:`closeEvent` (e.g. when the window is closed
- through the window manager's close icon)."""
+ Note: This signal should be removed.
+ """
sigProfileMethodChanged = qt.Signal(str)
"""Emitted when the method to compute the profile changed (for now can be
- sum or mean)"""
+ sum or mean)
- def __init__(self, parent=None, backend=None):
- qt.QMainWindow.__init__(self, parent=parent)
+ Note: This signal should be removed.
+ """
- self.setWindowTitle('Profile window')
- # plots are created on demand, in self.setProfileDimensions()
- self._plot1D = None
- self._plot2D = None
- self._backend = backend
+ def __init__(self, parent=None, backend=None):
+ ProfileWindow.__init__(self, parent=parent, backend=backend)
# by default, profile is assumed to be a 1D curve
self._profileType = None
- self.setProfileType("1D")
- self.setProfileMethod('sum')
def setProfileType(self, profileType):
"""Set which profile plot widget (1D or 2D) is to be used
+ Note: This method should be removed.
+
:param str profileType: Type of profile data,
"1D" for a curve or "2D" for an image
"""
- # import here to avoid circular import
- from .PlotWindow import Plot1D, Plot2D # noqa
self._profileType = profileType
if self._profileType == "1D":
- if self._plot2D is not None:
- self._plot2D.setParent(None) # necessary to avoid widget destruction
- if self._plot1D is None:
- self._plot1D = Plot1D(backend=self._backend)
- self._plot1D.setDataMargins(yMinMargin=0.1, yMaxMargin=0.1)
- self._plot1D.setGraphYLabel('Profile')
- self._plot1D.setGraphXLabel('')
- self.setCentralWidget(self._plot1D)
+ self._showPlot1D()
elif self._profileType == "2D":
- if self._plot1D is not None:
- self._plot1D.setParent(None) # necessary to avoid widget destruction
- if self._plot2D is None:
- self._plot2D = Plot2D(backend=self._backend)
- self.setCentralWidget(self._plot2D)
+ self._showPlot2D()
else:
raise ValueError("Profile type must be '1D' or '2D'")
-
self.sigProfileDimensionsChanged.emit(profileType)
def getPlot(self):
"""Return the profile plot widget which is currently in use.
This can be the 2D profile plot or the 1D profile plot.
- """
- if self._profileType == "2D":
- return self._plot2D
- else:
- return self._plot1D
- def closeEvent(self, qCloseEvent):
- self.sigClose.emit()
- qCloseEvent.accept()
+ Note: This method should be removed.
+ """
+ return self.getCurrentPlotWidget()
def setProfileMethod(self, method):
"""
+ Note: This method should be removed.
:param str method: method to manage the 'width' in the profile
(computing mean or sum).
diff --git a/silx/gui/plot/ScatterMaskToolsWidget.py b/silx/gui/plot/ScatterMaskToolsWidget.py
index 9a15763..8ff2483 100644
--- a/silx/gui/plot/ScatterMaskToolsWidget.py
+++ b/silx/gui/plot/ScatterMaskToolsWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018 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
@@ -48,6 +48,7 @@ from .. import qt
from ...math.combo import min_max
from ...image import shapes
+from .items import ItemChangedType, Scatter
from ._BaseMaskToolsWidget import BaseMask, BaseMaskToolsWidget, BaseMaskToolsDockWidget
from ..colors import cursorColorForColormap, rgba
@@ -260,10 +261,17 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
legend=self._maskName)
self._mask_scatter.setSymbolSize(
self._data_scatter.getSymbolSize() + 2.0)
+ self._mask_scatter.sigItemChanged.connect(self.__maskScatterChanged)
elif self.plot._getItem(kind="scatter",
legend=self._maskName) is not None:
self.plot.remove(self._maskName, kind='scatter')
+ def __maskScatterChanged(self, event):
+ """Handles update of mask scatter"""
+ if (event is ItemChangedType.VISUALIZATION_MODE and
+ self._mask_scatter is not None):
+ self._mask_scatter.setVisualization(Scatter.Visualization.POINTS)
+
# track widget visibility and plot active image changes
def showEvent(self, event):
@@ -318,7 +326,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
# check that content changed was the active scatter
activeScatter = self.plot._getActiveItem(kind="scatter")
- if activeScatter is None or activeScatter.getLegend() == self._maskName:
+ if activeScatter is None or activeScatter.getName() == self._maskName:
# No active scatter or active scatter is the mask...
self.plot.sigActiveScatterChanged.disconnect(
self._activeScatterChangedAfterCare)
@@ -347,7 +355,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
"""Update widget and mask according to active scatter changes"""
activeScatter = self.plot._getActiveItem(kind="scatter")
- if activeScatter is None or activeScatter.getLegend() == self._maskName:
+ if activeScatter is None or activeScatter.getName() == self._maskName:
# No active scatter or active scatter is the mask...
self.setEnabled(False)
diff --git a/silx/gui/plot/ScatterView.py b/silx/gui/plot/ScatterView.py
index bdbf3ab..0423648 100644
--- a/silx/gui/plot/ScatterView.py
+++ b/silx/gui/plot/ScatterView.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
@@ -41,6 +41,7 @@ import numpy
from . import items
from . import PlotWidget
from . import tools
+from .actions import histogram as actions_histogram
from .tools.profile import ScatterProfileToolBar
from .ColorBar import ColorBarWidget
from .ScatterMaskToolsWidget import ScatterMaskToolsWidget
@@ -124,6 +125,8 @@ class ScatterView(qt.QMainWindow):
self._maskAction.setIcon(icons.getQIcon('image-mask'))
self._maskAction.setToolTip("Display/hide mask tools")
+ self._intensityHistoAction = actions_histogram.PixelIntensitiesHistoAction(plot=plot, parent=self)
+
# Create toolbars
self._interactiveModeToolBar = tools.InteractiveModeToolBar(
parent=self, plot=plot)
@@ -131,6 +134,7 @@ class ScatterView(qt.QMainWindow):
self._scatterToolBar = tools.ScatterToolBar(
parent=self, plot=plot)
self._scatterToolBar.addAction(self._maskAction)
+ self._scatterToolBar.addAction(self._intensityHistoAction)
self._profileToolBar = ScatterProfileToolBar(parent=self, plot=plot)
@@ -181,10 +185,17 @@ class ScatterView(qt.QMainWindow):
pixelPos[0], pixelPos[1],
lambda item: isinstance(item, items.Scatter))
if result is not None:
- # Get last index
- # with matplotlib it should be the top-most point
- dataIndex = result.getIndices(copy=False)[-1]
item = result.getItem()
+ if item.getVisualization() is items.Scatter.Visualization.BINNED_STATISTIC:
+ # Get highest index of closest points
+ selected = result.getIndices(copy=False)[::-1]
+ dataIndex = selected[numpy.argmin(
+ (item.getXData(copy=False)[selected] - x)**2 +
+ (item.getYData(copy=False)[selected] - y)**2)]
+ else:
+ # Get last index
+ # with matplotlib it should be the top-most point
+ dataIndex = result.getIndices(copy=False)[-1]
self.__pickingCache = (
dataIndex,
item.getXData(copy=False)[dataIndex],
diff --git a/silx/gui/plot/StackView.py b/silx/gui/plot/StackView.py
index 7e4c389..cb7ece1 100644
--- a/silx/gui/plot/StackView.py
+++ b/silx/gui/plot/StackView.py
@@ -78,6 +78,7 @@ import silx
from silx.gui import qt
from .. import icons
from . import items, PlotWindow, actions
+from .items.image import ImageStack
from ..colors import Colormap
from ..colors import cursorColorForColormap
from .tools import LimitsToolBar
@@ -90,6 +91,7 @@ from silx.io.nxdata import save_NXdata
from silx.utils.array_like import DatasetView, ListOfImages
from silx.math import calibration
from silx.utils.deprecation import deprecated_warning
+from silx.utils.deprecation import deprecated
import h5py
from silx.io.utils import is_dataset
@@ -185,7 +187,11 @@ class StackView(qt.QMainWindow):
self._perspective = 0
"""Orthogonal dimension (depth) in :attr:`_stack`"""
- self.__imageLegend = '__StackView__image' + str(id(self))
+ self._stackItem = ImageStack()
+ """Hold the item displaying the stack"""
+ imageLegend = '__StackView__image' + str(id(self))
+ self._stackItem.setName(imageLegend)
+
self.__autoscaleCmap = False
"""Flag to disable/enable colormap auto-scaling
based on the min/max values of the entire 3D volume"""
@@ -215,6 +221,7 @@ class StackView(qt.QMainWindow):
copy=copy, save=save, print_=print_,
control=control, position=position,
roi=False, mask=mask)
+ self._plot.addItem(self._stackItem)
self._plot.getIntensityHistogramAction().setVisible(True)
self.sigInteractiveModeChanged = self._plot.sigInteractiveModeChanged
self.sigActiveImageChanged = self._plot.sigActiveImageChanged
@@ -225,9 +232,9 @@ class StackView(qt.QMainWindow):
self._addColorBarAction()
- self._plot.profile = Profile3DToolBar(parent=self._plot,
- stackview=self)
- self._plot.addToolBar(self._plot.profile)
+ self._profileToolBar = Profile3DToolBar(parent=self._plot,
+ stackview=self)
+ self._plot.addToolBar(self._profileToolBar)
self._plot.getXAxis().setLabel('Columns')
self._plot.getYAxis().setLabel('Rows')
self._plot.sigPlotSignal.connect(self._plotCallback)
@@ -255,9 +262,7 @@ class StackView(qt.QMainWindow):
# clear profile lines when the perspective changes (plane browsed changed)
self.__planeSelection.sigPlaneSelectionChanged.connect(
- self._plot.profile.getProfilePlot().clear)
- self.__planeSelection.sigPlaneSelectionChanged.connect(
- self._plot.profile.clearProfile)
+ self._profileToolBar.clearProfile)
def _saveImageStack(self, plot, filename, nameFilter):
"""Save all images from the stack into a volume.
@@ -293,7 +298,7 @@ class StackView(qt.QMainWindow):
Emit :attr:`valueChanged` signal, with (x, y, value) tuple of the
cursor location in the plot."""
if eventDict['event'] == 'mouseMoved':
- activeImage = self._plot.getActiveImage()
+ activeImage = self.getActiveImage()
if activeImage is not None:
data = activeImage.getData()
height, width = data.shape
@@ -386,6 +391,12 @@ class StackView(qt.QMainWindow):
self._browser.setRange(0, self.__transposed_view.shape[0] - 1)
self._browser.setValue(0)
+ # Update the item structure
+ self._stackItem.setStackData(self.__transposed_view, 0, copy=False)
+ self._stackItem.setColormap(self.getColormap())
+ self._stackItem.setOrigin(self._getImageOrigin())
+ self._stackItem.setScale(self._getImageScale())
+
def __updateFrameNumber(self, index):
"""Update the current image.
@@ -394,11 +405,9 @@ class StackView(qt.QMainWindow):
if self.__transposed_view is None:
# no data set
return
- self._plot.addImage(self.__transposed_view[index, :, :],
- origin=self._getImageOrigin(),
- scale=self._getImageScale(),
- legend=self.__imageLegend,
- resetzoom=False)
+
+ self._stackItem.setStackPosition(index)
+
self._updateTitle()
self.sigFrameChanged.emit(index)
@@ -506,7 +515,7 @@ class StackView(qt.QMainWindow):
:param calibrations: Sequence of 3 calibration objects for each axis.
These objects can be a subclass of :class:`AbstractCalibration`,
or 2-tuples *(a, b)* where *a* is the y-intercept and *b* is the
- slope of a linear calibration (:math:`x \mapsto a + b x`)
+ slope of a linear calibration (:math:`x \\mapsto a + b x`)
"""
if stack is None:
self.clear()
@@ -550,14 +559,18 @@ class StackView(qt.QMainWindow):
self.setColormap(colormap=colormap)
# init plot
- self._plot.addImage(self.__transposed_view[0, :, :],
- legend=self.__imageLegend,
- colormap=self.getColormap(),
- origin=self._getImageOrigin(),
- scale=self._getImageScale(),
- replace=True,
- resetzoom=False)
- self._plot.setActiveImage(self.__imageLegend)
+ self._stackItem.setStackData(self.__transposed_view, 0, copy=False)
+ self._stackItem.setColormap(self.getColormap())
+ self._stackItem.setOrigin(self._getImageOrigin())
+ self._stackItem.setScale(self._getImageScale())
+ self._stackItem.setVisible(True)
+
+ # Put back the item in the plot in case it was cleared
+ exists = self._plot.getImage(self._stackItem.getName())
+ if exists is None:
+ self._plot.addItem(self._stackItem)
+
+ self._plot.setActiveImage(self._stackItem.getName())
self.__updatePlotLabels()
self._updateTitle()
@@ -586,14 +599,11 @@ class StackView(qt.QMainWindow):
:return: 3D stack and parameters.
:rtype: (numpy.ndarray, dict)
"""
- image = self._plot.getActiveImage()
- if image is None:
+ if self._stack is None:
return None
- if isinstance(image, items.ColormapMixIn):
- colormap = image.getColormap()
- else:
- colormap = None
+ image = self._stackItem
+ colormap = image.getColormap()
params = {
'info': image.getInfo(),
@@ -637,7 +647,7 @@ class StackView(qt.QMainWindow):
:return: 3D stack and parameters.
:rtype: (numpy.ndarray, dict)
"""
- image = self._plot.getActiveImage()
+ image = self.getActiveImage()
if image is None:
return None
@@ -882,11 +892,15 @@ class StackView(qt.QMainWindow):
self._plot.setDefaultColormap(_colormap)
# Update active image colormap
- activeImage = self._plot.getActiveImage()
+ activeImage = self.getActiveImage()
if isinstance(activeImage, items.ColormapMixIn):
activeImage.setColormap(self.getColormap())
+ @deprecated(replacement="getPlotWidget", since_version="0.13")
def getPlot(self):
+ return self.getPlotWidget()
+
+ def getPlotWidget(self):
"""Return the :class:`PlotWidget`.
This gives access to advanced plot configuration options.
@@ -898,20 +912,6 @@ class StackView(qt.QMainWindow):
"""
return self._plot
- def getProfileWindow1D(self):
- """Plot window used to display 1D profile curve.
-
- :return: :class:`Plot1D`
- """
- return self._plot.profile.getProfileWindow1D()
-
- def getProfileWindow2D(self):
- """Plot window used to display 2D profile image.
-
- :return: :class:`Plot2D`
- """
- return self._plot.profile.getProfileWindow2D()
-
def setOptionVisible(self, isVisible):
"""
Set the visibility of the browsing options.
@@ -924,10 +924,8 @@ class StackView(qt.QMainWindow):
# proxies to PlotWidget or PlotWindow methods
def getProfileToolbar(self):
"""Profile tools attached to this plot
-
- See :class:`silx.gui.plot.Profile.Profile3DToolBar`
"""
- return self._plot.profile
+ return self._profileToolBar
def getGraphTitle(self):
"""Return the plot main title as a str.
@@ -1046,23 +1044,11 @@ class StackView(qt.QMainWindow):
# kind of private methods, but needed by Profile
def getActiveImage(self, just_legend=False):
- """Returns the currently active image object.
-
- It returns None in case of not having an active image.
-
- This method is a simple proxy to the legacy :class:`PlotWidget` method
- of the same name. Using the object oriented approach is now
- preferred::
-
- stackview.getPlot().getActiveImage()
-
- :param bool just_legend: True to get the legend of the image,
- False (the default) to get the image data and info.
- Note: :class:`StackView` uses the same legend for all frames.
- :return: legend or image object
- :rtype: str or list or None
+ """Returns the stack image object.
"""
- return self._plot.getActiveImage(just_legend=just_legend)
+ if just_legend:
+ return self._stackItem.getName()
+ return self._stackItem
def getColorBarAction(self):
"""Returns the action managing the visibility of the colorbar.
@@ -1085,11 +1071,15 @@ class StackView(qt.QMainWindow):
"""
self._plot.setInteractiveMode(*args, **kwargs)
+ @deprecated(replacement="addShape", since_version="0.13")
def addItem(self, *args, **kwargs):
+ self.addShape(*args, **kwargs)
+
+ def addShape(self, *args, **kwargs):
"""
- See :meth:`Plot.Plot.addItem`
+ See :meth:`Plot.Plot.addShape`
"""
- self._plot.addItem(*args, **kwargs)
+ self._plot.addShape(*args, **kwargs)
class PlanesWidget(qt.QWidget):
@@ -1210,13 +1200,15 @@ class StackViewMainWindow(StackView):
menu.addAction(actions.control.YAxisInvertedAction(self._plot, self))
menu = self.menuBar().addMenu('Profile')
- menu.addAction(self._plot.profile.hLineAction)
- menu.addAction(self._plot.profile.vLineAction)
- menu.addAction(self._plot.profile.lineAction)
+ profileToolBar = self._profileToolBar
+ menu.addAction(profileToolBar.hLineAction)
+ menu.addAction(profileToolBar.vLineAction)
+ menu.addAction(profileToolBar.lineAction)
+ menu.addAction(profileToolBar.crossAction)
+ menu.addSeparator()
+ menu.addAction(profileToolBar._editor)
menu.addSeparator()
- menu.addAction(self._plot.profile.clearAction)
- self._plot.profile.profile3dAction.computeProfileIn2D()
- menu.addMenu(self._plot.profile.profile3dAction.menu())
+ menu.addAction(profileToolBar.clearAction)
# Connect to StackView's signal
self.valueChanged.connect(self._statusBarSlot)
diff --git a/silx/gui/plot/StatsWidget.py b/silx/gui/plot/StatsWidget.py
index 52b7e5c..6b92ea0 100644
--- a/silx/gui/plot/StatsWidget.py
+++ b/silx/gui/plot/StatsWidget.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
@@ -180,7 +180,10 @@ class _PlotWidgetWrapper(_Wrapper):
def getItems(self):
plot = self.getPlot()
- return () if plot is None else plot._getItems()
+ if plot is None:
+ return ()
+ else:
+ return [item for item in plot.getItems() if item.isVisible()]
def getSelectedItems(self):
plot = self.getPlot()
@@ -198,10 +201,10 @@ class _PlotWidgetWrapper(_Wrapper):
kind = self.getKind(item)
if kind in plot._ACTIVE_ITEM_KINDS:
if plot._getActiveItem(kind) != item:
- plot._setActiveItem(kind, item.getLegend())
+ plot._setActiveItem(kind, item.getName())
def getLabel(self, item):
- return item.getLegend()
+ return item.getName()
def getKind(self, item):
if isinstance(item, plotitems.Curve):
@@ -1371,7 +1374,7 @@ class _BaseLineStatsWidget(_StatsWidgetBase, qt.QWidget):
return self._plotWrapper.getKind(_item) == self.getKind()
items = list(filter(kind_filter, _items))
assert len(items) in (0, 1)
- if len(items) is 1:
+ if len(items) == 1:
self._setItem(items[0])
def setKind(self, kind):
@@ -1413,7 +1416,7 @@ class _BaseLineStatsWidget(_StatsWidgetBase, qt.QWidget):
return self._plotWrapper.getKind(_item) == self.getKind()
items = list(filter(kind_filter, _items))
assert len(items) in (0, 1)
- _item = items[0] if len(items) is 1 else None
+ _item = items[0] if len(items) == 1 else None
self._setItem(_item)
def _updateCurrentItem(self):
diff --git a/silx/gui/plot/_BaseMaskToolsWidget.py b/silx/gui/plot/_BaseMaskToolsWidget.py
index d8e9fb5..aa4921c 100644
--- a/silx/gui/plot/_BaseMaskToolsWidget.py
+++ b/silx/gui/plot/_BaseMaskToolsWidget.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
@@ -456,8 +456,8 @@ class BaseMaskToolsWidget(qt.QWidget):
assert mode in ('exclusive', 'single')
if mode != self._multipleMasks:
self._multipleMasks = mode
- self.levelWidget.setVisible(self._multipleMasks != 'single')
- self.clearAllBtn.setVisible(self._multipleMasks != 'single')
+ self._levelWidget.setVisible(self._multipleMasks != 'single')
+ self._clearAllBtn.setVisible(self._multipleMasks != 'single')
@property
def maskFileDir(self):
@@ -545,62 +545,109 @@ class BaseMaskToolsWidget(qt.QWidget):
'Choose which mask level is edited.\n'
'A mask can have up to 255 non-overlapping levels.')
self.levelSpinBox.valueChanged[int].connect(self._updateColors)
- self.levelWidget = self._hboxWidget(qt.QLabel('Mask level:'),
+ self._levelWidget = self._hboxWidget(qt.QLabel('Mask level:'),
self.levelSpinBox)
# Transparency
- self.transparencyWidget = self._initTransparencyWidget()
+ self._transparencyWidget = self._initTransparencyWidget()
+
+ style = qt.QApplication.style()
+
+ def getIcon(*identifiyers):
+ for i in identifiyers:
+ if isinstance(i, str):
+ if qt.QIcon.hasThemeIcon(i):
+ return qt.QIcon.fromTheme(i)
+ elif isinstance(i, qt.QIcon):
+ return i
+ else:
+ return style.standardIcon(i)
+ return qt.QIcon()
+
+ undoAction = qt.QAction(self)
+ undoAction.setText('Undo')
+ icon = getIcon("edit-undo", qt.QStyle.SP_ArrowBack)
+ undoAction.setIcon(icon)
+ undoAction.setShortcut(qt.QKeySequence.Undo)
+ undoAction.setToolTip('Undo last mask change <b>%s</b>' %
+ undoAction.shortcut().toString())
+ self._mask.sigUndoable.connect(undoAction.setEnabled)
+ undoAction.triggered.connect(self._mask.undo)
+
+ redoAction = qt.QAction(self)
+ redoAction.setText('Redo')
+ icon = getIcon("edit-redo", qt.QStyle.SP_ArrowForward)
+ redoAction.setIcon(icon)
+ redoAction.setShortcut(qt.QKeySequence.Redo)
+ redoAction.setToolTip('Redo last undone mask change <b>%s</b>' %
+ redoAction.shortcut().toString())
+ self._mask.sigRedoable.connect(redoAction.setEnabled)
+ redoAction.triggered.connect(self._mask.redo)
+
+ loadAction = qt.QAction(self)
+ loadAction.setText('Load...')
+ icon = icons.getQIcon("document-open")
+ loadAction.setIcon(icon)
+ loadAction.setToolTip('Load mask from file')
+ loadAction.triggered.connect(self._loadMask)
+
+ saveAction = qt.QAction(self)
+ saveAction.setText('Save...')
+ icon = icons.getQIcon("document-save")
+ saveAction.setIcon(icon)
+ saveAction.setToolTip('Save mask to file')
+ saveAction.triggered.connect(self._saveMask)
+
+ invertAction = qt.QAction(self)
+ invertAction.setText('Invert')
+ icon = icons.getQIcon("mask-invert")
+ invertAction.setIcon(icon)
+ invertAction.setShortcut(qt.Qt.CTRL + qt.Qt.Key_I)
+ invertAction.setToolTip('Invert current mask <b>%s</b>' %
+ invertAction.shortcut().toString())
+ invertAction.triggered.connect(self._handleInvertMask)
+
+ clearAction = qt.QAction(self)
+ clearAction.setText('Clear')
+ icon = icons.getQIcon("mask-clear")
+ clearAction.setIcon(icon)
+ clearAction.setShortcut(qt.QKeySequence.Delete)
+ clearAction.setToolTip('Clear current mask level <b>%s</b>' %
+ clearAction.shortcut().toString())
+ clearAction.triggered.connect(self._handleClearMask)
+
+ clearAllAction = qt.QAction(self)
+ clearAllAction.setText('Clear all')
+ icon = icons.getQIcon("mask-clear-all")
+ clearAllAction.setIcon(icon)
+ clearAllAction.setToolTip('Clear all mask levels')
+ clearAllAction.triggered.connect(self.resetSelectionMask)
# Buttons group
- invertBtn = qt.QPushButton('Invert')
- invertBtn.setShortcut(qt.Qt.CTRL + qt.Qt.Key_I)
- invertBtn.setToolTip('Invert current mask <b>%s</b>' %
- invertBtn.shortcut().toString())
- invertBtn.clicked.connect(self._handleInvertMask)
-
- clearBtn = qt.QPushButton('Clear')
- clearBtn.setShortcut(qt.QKeySequence.Delete)
- clearBtn.setToolTip('Clear current mask level <b>%s</b>' %
- clearBtn.shortcut().toString())
- clearBtn.clicked.connect(self._handleClearMask)
-
- invertClearWidget = self._hboxWidget(
- invertBtn, clearBtn, stretch=False)
-
- undoBtn = qt.QPushButton('Undo')
- undoBtn.setShortcut(qt.QKeySequence.Undo)
- undoBtn.setToolTip('Undo last mask change <b>%s</b>' %
- undoBtn.shortcut().toString())
- self._mask.sigUndoable.connect(undoBtn.setEnabled)
- undoBtn.clicked.connect(self._mask.undo)
-
- redoBtn = qt.QPushButton('Redo')
- redoBtn.setShortcut(qt.QKeySequence.Redo)
- redoBtn.setToolTip('Redo last undone mask change <b>%s</b>' %
- redoBtn.shortcut().toString())
- self._mask.sigRedoable.connect(redoBtn.setEnabled)
- redoBtn.clicked.connect(self._mask.redo)
-
- undoRedoWidget = self._hboxWidget(undoBtn, redoBtn, stretch=False)
-
- self.clearAllBtn = qt.QPushButton('Clear all')
- self.clearAllBtn.setToolTip('Clear all mask levels')
- self.clearAllBtn.clicked.connect(self.resetSelectionMask)
-
- loadBtn = qt.QPushButton('Load...')
- loadBtn.clicked.connect(self._loadMask)
-
- saveBtn = qt.QPushButton('Save...')
- saveBtn.clicked.connect(self._saveMask)
-
- self.loadSaveWidget = self._hboxWidget(loadBtn, saveBtn, stretch=False)
+ margin1 = qt.QWidget(self)
+ margin1.setMinimumWidth(6)
+ margin2 = qt.QWidget(self)
+ margin2.setMinimumWidth(6)
+
+ actions = (loadAction, saveAction, margin1,
+ undoAction, redoAction, margin2,
+ invertAction, clearAction, clearAllAction)
+ widgets = []
+ for action in actions:
+ if isinstance(action, qt.QWidget):
+ widgets.append(action)
+ continue
+ btn = qt.QToolButton()
+ btn.setDefaultAction(action)
+ widgets.append(btn)
+ if action is clearAllAction:
+ self._clearAllBtn = btn
+ container = self._hboxWidget(*widgets)
+ container.layout().setSpacing(1)
layout = qt.QVBoxLayout()
- layout.addWidget(self.levelWidget)
- layout.addWidget(self.transparencyWidget)
- layout.addWidget(invertClearWidget)
- layout.addWidget(undoRedoWidget)
- layout.addWidget(self.clearAllBtn)
- layout.addWidget(self.loadSaveWidget)
+ layout.addWidget(container)
+ layout.addWidget(self._levelWidget)
+ layout.addWidget(self._transparencyWidget)
layout.addStretch(1)
maskGroup = qt.QGroupBox('Mask')
@@ -813,6 +860,7 @@ class BaseMaskToolsWidget(qt.QWidget):
layout.addWidget(toolBar)
layout.addLayout(config)
layout.addWidget(self.applyMaskBtn)
+ layout.addStretch(1)
self.thresholdGroup = qt.QGroupBox('Threshold')
self.thresholdGroup.setLayout(layout)
@@ -830,6 +878,7 @@ class BaseMaskToolsWidget(qt.QWidget):
self.maskNanBtn.setToolTip('Mask Not a Number and infinite values')
self.maskNanBtn.clicked.connect(self._maskNotFiniteBtnClicked)
layout.addWidget(self.maskNanBtn)
+ layout.addStretch(1)
self.otherToolGroup = qt.QGroupBox('Other tools')
self.otherToolGroup.setLayout(layout)
diff --git a/silx/gui/plot/actions/PlotToolAction.py b/silx/gui/plot/actions/PlotToolAction.py
index 77e8be2..fbb0b0f 100644
--- a/silx/gui/plot/actions/PlotToolAction.py
+++ b/silx/gui/plot/actions/PlotToolAction.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-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
@@ -131,7 +131,7 @@ class PlotToolAction(PlotAction):
return PlotAction.eventFilter(self, qobject, event)
def _getToolWindow(self):
- """Returns the window containg tohe tool.
+ """Returns the window containing the tool.
It uses lazy loading to create this tool..
"""
diff --git a/silx/gui/plot/actions/control.py b/silx/gui/plot/actions/control.py
index e2fa6b1..ba69748 100755
--- a/silx/gui/plot/actions/control.py
+++ b/silx/gui/plot/actions/control.py
@@ -391,9 +391,8 @@ class ColormapAction(PlotAction):
elif isinstance(image, items.ColormapMixIn):
# Set dialog from active image
colormap = image.getColormap()
- data = image.getData(copy=False)
# Set histogram and range if any
- self._dialog.setData(data)
+ self._dialog.setItem(image)
else:
# No active image or active image is RGBA,
@@ -401,8 +400,7 @@ class ColormapAction(PlotAction):
scatter = self.plot._getActiveItem(kind='scatter')
if scatter is not None:
colormap = scatter.getColormap()
- data = scatter.getValueData(copy=False)
- self._dialog.setData(data)
+ self._dialog.setItem(scatter)
else:
# No active data image nor scatter,
@@ -605,3 +603,32 @@ class ShowAxisAction(PlotAction):
def _actionTriggered(self, checked=False):
self.plot.setAxesDisplayed(checked)
+
+class ClosePolygonInteractionAction(PlotAction):
+ """QAction controlling closure of a polygon in draw interaction mode
+ if the :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ tooltip = 'Close the current polygon drawn'
+ PlotAction.__init__(self,
+ plot,
+ icon='add-shape-polygon',
+ text='Close the polygon',
+ tooltip=tooltip,
+ triggered=self._actionTriggered,
+ checkable=True,
+ parent=parent)
+ self.plot.sigInteractiveModeChanged.connect(self._modeChanged)
+ self._modeChanged(None)
+
+ def _modeChanged(self, source):
+ mode = self.plot.getInteractiveMode()
+ enabled = "shape" in mode and mode["shape"] == "polygon"
+ self.setEnabled(enabled)
+
+ def _actionTriggered(self, checked=False):
+ self.plot._eventHandler.validate()
diff --git a/silx/gui/plot/actions/fit.py b/silx/gui/plot/actions/fit.py
index 6fc5c75..f3c9e1c 100644
--- a/silx/gui/plot/actions/fit.py
+++ b/silx/gui/plot/actions/fit.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-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
@@ -38,52 +38,43 @@ __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
__date__ = "10/10/2018"
-from .PlotToolAction import PlotToolAction
import logging
+
+import numpy
+
+from .PlotToolAction import PlotToolAction
+from .. import items
+from ....utils.deprecation import deprecated
from silx.gui import qt
from silx.gui.plot.ItemsSelectionDialog import ItemsSelectionDialog
-from silx.gui.plot.items import Curve, Histogram
_logger = logging.getLogger(__name__)
-def _getUniqueCurve(plt):
- """Get a single curve from the plot.
- Get the active curve if any, else if a single curve is plotted
- get it, else return None.
+def _getUniqueCurveOrHistogram(plot):
+ """Returns unique :class:`Curve` or :class:`Histogram` in a `PlotWidget`.
- :param plt: :class:`.PlotWidget` instance on which to operate
+ If there is an active curve, returns it, else return curve or histogram
+ only if alone in the plot.
- :return: return value of plt.getActiveCurve(), or plt.getAllCurves()[0],
- or None
+ :param PlotWidget plot:
+ :rtype: Union[None,~silx.gui.plot.items.Curve,~silx.gui.plot.items.Histogram]
"""
- curve = plt.getActiveCurve()
+ curve = plot.getActiveCurve()
if curve is not None:
return curve
- curves = plt.getAllCurves()
- if len(curves) == 0:
- return None
+ histograms = [item for item in plot.getItems()
+ if isinstance(item, items.Histogram) and item.isVisible()]
+ curves = [item for item in plot.getItems()
+ if isinstance(item, items.Curve) and item.isVisible()]
- if len(curves) == 1 and len(plt._getItems(kind='histogram')) == 0:
+ if len(histograms) == 1 and len(curves) == 0:
+ return histograms[0]
+ elif len(curves) == 1 and len(histograms) == 0:
return curves[0]
-
- return None
-
-
-def _getUniqueHistogram(plt):
- """Return the histogram if there is a single histogram and no curve in
- the plot. In all other cases, return None.
-
- :param plt: :class:`.PlotWidget` instance on which to operate
- :return: histogram or None
- """
- histograms = plt._getItems(kind='histogram')
- if len(histograms) != 1:
- return None
- if plt.getAllCurves(just_legend=True):
+ else:
return None
- return histograms[0]
class FitAction(PlotToolAction):
@@ -93,78 +84,303 @@ class FitAction(PlotToolAction):
:param plot: :class:`.PlotWidget` instance on which to operate
:param parent: See :class:`QAction`
"""
+
def __init__(self, plot, parent=None):
+ self.__item = None
+ self.__activeCurveSynchroEnabled = False
+ self.__range = 0, 1
+ self.__rangeAutoUpdate = False
+ self.__x, self.__y = None, None # Data to fit
+ self.__curveParams = {} # Store curve parameters to use for fit result
+ self.__legend = None
+
super(FitAction, self).__init__(
plot, icon='math-fit', text='Fit curve',
tooltip='Open a fit dialog',
parent=parent)
+ @property
+ @deprecated(replacement='getXRange()[0]', since_version='0.13.0')
+ def xmin(self):
+ return self.getXRange()[0]
+
+ @property
+ @deprecated(replacement='getXRange()[1]', since_version='0.13.0')
+ def xmax(self):
+ return self.getXRange()[1]
+
+ @property
+ @deprecated(replacement='getXData()', since_version='0.13.0')
+ def x(self):
+ return self.getXData()
+
+ @property
+ @deprecated(replacement='getYData()', since_version='0.13.0')
+ def y(self):
+ return self.getYData()
+
+ @property
+ @deprecated(since_version='0.13.0')
+ def xlabel(self):
+ return self.__curveParams.get('xlabel', None)
+
+ @property
+ @deprecated(since_version='0.13.0')
+ def ylabel(self):
+ return self.__curveParams.get('ylabel', None)
+
+ @property
+ @deprecated(since_version='0.13.0')
+ def legend(self):
+ return self.__legend
+
def _createToolWindow(self):
# import done here rather than at module level to avoid circular import
# FitWidget -> BackgroundWidget -> PlotWindow -> actions -> fit -> FitWidget
from ...fit.FitWidget import FitWidget
window = FitWidget(parent=self.plot)
- window.setWindowFlags(qt.Qt.Window)
+ window.setWindowFlags(qt.Qt.Dialog)
window.sigFitWidgetSignal.connect(self.handle_signal)
return window
def _connectPlot(self, window):
- # Wait for the next iteration, else the plot is not yet initialized
- # No curve available
- qt.QTimer.singleShot(10, lambda: self._initFit(window))
+ if self.isXRangeUpdatedOnZoom():
+ self.__setAutoXRangeEnabled(True)
+ else:
+ plot = self.plot
+ if plot is None:
+ _logger.error("No associated PlotWidget")
+ return
+ self._setXRange(*plot.getXAxis().getLimits())
- def _initFit(self, window):
- plot = self.plot
- self.xlabel = plot.getXAxis().getLabel()
- self.ylabel = plot.getYAxis().getLabel()
- self.xmin, self.xmax = plot.getXAxis().getLimits()
+ if self.isFittedItemUpdatedFromActiveCurve():
+ self.__setFittedItemAutoUpdateEnabled(True)
+ else:
+ # Wait for the next iteration, else the plot is not yet initialized
+ # No curve available
+ qt.QTimer.singleShot(10, self._initFit)
- histo = _getUniqueHistogram(self.plot)
- curve = _getUniqueCurve(self.plot)
+ def _disconnectPlot(self, window):
+ if self.isXRangeUpdatedOnZoom():
+ self.__setAutoXRangeEnabled(False)
- if histo is None and curve is None:
+ if self.isFittedItemUpdatedFromActiveCurve():
+ self.__setFittedItemAutoUpdateEnabled(False)
+
+ def _initFit(self):
+ plot = self.plot
+ if plot is None:
+ _logger.error("No associated PlotWidget")
+ return
+
+ item = _getUniqueCurveOrHistogram(plot)
+ if item is None:
# ambiguous case, we need to ask which plot item to fit
- isd = ItemsSelectionDialog(parent=plot, plot=self.plot)
+ isd = ItemsSelectionDialog(parent=plot, plot=plot)
isd.setWindowTitle("Select item to be fitted")
isd.setItemsSelectionMode(qt.QTableWidget.SingleSelection)
isd.setAvailableKinds(["curve", "histogram"])
isd.selectAllKinds()
- result = isd.exec_()
- if result and len(isd.getSelectedItems()) == 1:
- item = isd.getSelectedItems()[0]
+ if not isd.exec_(): # Cancel
+ self._getToolWindow().setVisible(False)
else:
- return
- elif histo is not None:
- # presence of a unique histo and no curve
- item = histo
- elif curve is not None:
- # presence of a unique or active curve
- item = curve
+ selectedItems = isd.getSelectedItems()
+ item = selectedItems[0] if len(selectedItems) == 1 else None
+
+ self._setXRange(*plot.getXAxis().getLimits())
+ self._setFittedItem(item)
+
+ def __updateFitWidget(self):
+ """Update the data/range used by the FitWidget"""
+ fitWidget = self._getToolWindow()
+
+ item = self._getFittedItem()
+ xdata = self.getXData(copy=False)
+ ydata = self.getYData(copy=False)
+ if item is None or xdata is None or ydata is None:
+ fitWidget.setData(y=None)
+ fitWidget.setWindowTitle("No curve selected")
+
+ else:
+ xmin, xmax = self.getXRange()
+ fitWidget.setData(
+ xdata, ydata, xmin=xmin, xmax=xmax)
+ fitWidget.setWindowTitle(
+ "Fitting " + item.getName() +
+ " on x range %f-%f" % (xmin, xmax))
+
+ # X Range management
+
+ def getXRange(self):
+ """Returns the range on the X axis on which to perform the fit."""
+ return self.__range
+
+ def _setXRange(self, xmin, xmax):
+ """Set the range on which the fit is done.
+
+ :param float xmin:
+ :param float xmax:
+ """
+ range_ = float(xmin), float(xmax)
+ if self.__range != range_:
+ self.__range = range_
+ self.__updateFitWidget()
+
+ def __setAutoXRangeEnabled(self, enabled):
+ """Implement the change of update mode of the X range.
+
+ :param bool enabled:
+ """
+ plot = self.plot
+ if plot is None:
+ _logger.error("No associated PlotWidget")
+ return
- self.legend = item.getLegend()
+ if enabled:
+ self._setXRange(*plot.getXAxis().getLimits())
+ plot.getXAxis().sigLimitsChanged.connect(self._setXRange)
+ else:
+ plot.getXAxis().sigLimitsChanged.disconnect(self._setXRange)
- if isinstance(item, Histogram):
+ def setXRangeUpdatedOnZoom(self, enabled):
+ """Set whether or not to update the X range on zoom change.
+
+ :param bool enabled:
+ """
+ if enabled != self.__rangeAutoUpdate:
+ self.__rangeAutoUpdate = enabled
+ if self._getToolWindow().isVisible():
+ self.__setAutoXRangeEnabled(enabled)
+
+ def isXRangeUpdatedOnZoom(self):
+ """Returns the current mode of fitted data X range update.
+
+ :rtype: bool
+ """
+ return self.__rangeAutoUpdate
+
+ # Fitted item update
+
+ def getXData(self, copy=True):
+ """Returns the X data used for the fit or None if undefined.
+
+ :param bool copy:
+ True to get a copy of the data, False to get the internal data.
+ :rtype: Union[numpy.ndarray,None]
+ """
+ return None if self.__x is None else numpy.array(self.__x, copy=copy)
+
+ def getYData(self, copy=True):
+ """Returns the Y data used for the fit or None if undefined.
+
+ :param bool copy:
+ True to get a copy of the data, False to get the internal data.
+ :rtype: Union[numpy.ndarray,None]
+ """
+ return None if self.__y is None else numpy.array(self.__y, copy=copy)
+
+ def _getFittedItem(self):
+ """Returns the current item used for the fit
+
+ :rtype: Union[~silx.gui.plot.items.Curve,~silx.gui.plot.items.Histogram,None]
+ """
+ return self.__item
+
+ def _setFittedItem(self, item):
+ """Set the curve to use for fitting.
+
+ :param Union[~silx.gui.plot.items.Curve,~silx.gui.plot.items.Histogram,None] item:
+ """
+ plot = self.plot
+ if plot is None:
+ _logger.error("No associated PlotWidget")
+
+ if plot is None or item is None:
+ self.__item = None
+ self.__curveParams = {}
+ self.__updateFitWidget()
+ return
+
+ axis = item.getYAxis() if isinstance(item, items.YAxisMixIn) else 'left'
+ self.__curveParams = {
+ 'yaxis': axis,
+ 'xlabel': plot.getXAxis().getLabel(),
+ 'ylabel': plot.getYAxis(axis).getLabel(),
+ }
+ self.__legend = item.getName()
+
+ if isinstance(item, items.Histogram):
bin_edges = item.getBinEdgesData(copy=False)
# take the middle coordinate between adjacent bin edges
- self.x = (bin_edges[1:] + bin_edges[:-1]) / 2
- self.y = item.getValueData(copy=False)
+ self.__x = (bin_edges[1:] + bin_edges[:-1]) / 2
+ self.__y = item.getValueData(copy=False)
# else take the active curve, or else the unique curve
- elif isinstance(item, Curve):
- self.x = item.getXData(copy=False)
- self.y = item.getYData(copy=False)
+ elif isinstance(item, items.Curve):
+ self.__x = item.getXData(copy=False)
+ self.__y = item.getYData(copy=False)
+
+ self.__item = item
+ self.__updateFitWidget()
+
+ def __activeCurveChanged(self, previous, current):
+ """Handle change of active curve in the PlotWidget
+ """
+ if current is None:
+ self._setFittedItem(None)
+ else:
+ item = self.plot.getCurve(current)
+ self._setFittedItem(item)
+
+ def __setFittedItemAutoUpdateEnabled(self, enabled):
+ """Implement the change of fitted item update mode
+
+ :param bool enabled:
+ """
+ plot = self.plot
+ if plot is None:
+ _logger.error("No associated PlotWidget")
+ return
+
+ if enabled:
+ self._setFittedItem(plot.getActiveCurve())
+ plot.sigActiveCurveChanged.connect(self.__activeCurveChanged)
+
+ else:
+ plot.sigActiveCurveChanged.disconnect(
+ self.__activeCurveChanged)
+
+ def setFittedItemUpdatedFromActiveCurve(self, enabled):
+ """Toggle fitted data synchronization with plot active curve.
+
+ :param bool enabled:
+ """
+ enabled = bool(enabled)
+ if enabled != self.__activeCurveSynchroEnabled:
+ self.__activeCurveSynchroEnabled = enabled
+ if self._getToolWindow().isVisible():
+ self.__setFittedItemAutoUpdateEnabled(enabled)
+
+ def isFittedItemUpdatedFromActiveCurve(self):
+ """Returns True if fitted data is synchronized with plot.
+
+ :rtype: bool
+ """
+ return self.__activeCurveSynchroEnabled
- window.setData(self.x, self.y,
- xmin=self.xmin, xmax=self.xmax)
- window.setWindowTitle(
- "Fitting " + self.legend +
- " on x range %f-%f" % (self.xmin, self.xmax))
+ # Handle fit completed
def handle_signal(self, ddict):
- x_fit = self.x[self.xmin <= self.x]
- x_fit = x_fit[x_fit <= self.xmax]
- fit_legend = "Fit <%s>" % self.legend
+ xdata = self.getXData(copy=False)
+ if xdata is None:
+ _logger.error("No reference data to display fit result for")
+ return
+
+ xmin, xmax = self.getXRange()
+ x_fit = xdata[xmin <= xdata]
+ x_fit = x_fit[x_fit <= xmax]
+ fit_legend = "Fit <%s>" % self.__legend
fit_curve = self.plot.getCurve(fit_legend)
if ddict["event"] == "FitFinished":
@@ -175,11 +391,12 @@ class FitAction(PlotToolAction):
if fit_curve is None:
self.plot.addCurve(x_fit, y_fit,
fit_legend,
- xlabel=self.xlabel, ylabel=self.ylabel,
- resetzoom=False)
+ resetzoom=False,
+ **self.__curveParams)
else:
fit_curve.setData(x_fit, y_fit)
fit_curve.setVisible(True)
+ fit_curve.setYAxis(self.__curveParams.get('yaxis', 'left'))
if ddict["event"] in ["FitStarted", "FitFailed"]:
if fit_curve is not None:
diff --git a/silx/gui/plot/actions/histogram.py b/silx/gui/plot/actions/histogram.py
index 3bb3e6a..f3e6370 100644
--- a/silx/gui/plot/actions/histogram.py
+++ b/silx/gui/plot/actions/histogram.py
@@ -37,16 +37,83 @@ __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__date__ = "10/10/2018"
__license__ = "MIT"
+import numpy
+import logging
+import weakref
+
from .PlotToolAction import PlotToolAction
from silx.math.histogram import Histogramnd
from silx.math.combo import min_max
-import numpy
-import logging
from silx.gui import qt
+from silx.gui.plot import items
_logger = logging.getLogger(__name__)
+class _LastActiveItem(qt.QObject):
+
+ sigActiveItemChanged = qt.Signal(object, object)
+ """Emitted when the active plot item have changed"""
+
+ def __init__(self, parent, plot):
+ assert plot is not None
+ super(_LastActiveItem, self).__init__(parent=parent)
+ self.__plot = weakref.ref(plot)
+ self.__item = None
+ item = self.__findActiveItem()
+ self.setActiveItem(item)
+ plot.sigActiveImageChanged.connect(self._activeImageChanged)
+ plot.sigActiveScatterChanged.connect(self._activeScatterChanged)
+
+ def getPlotWidget(self):
+ return self.__plot()
+
+ def __findActiveItem(self):
+ plot = self.getPlotWidget()
+ image = plot.getActiveImage()
+ if image is not None:
+ return image
+ scatter = plot.getActiveScatter()
+ if scatter is not None:
+ return scatter
+
+ def getActiveItem(self):
+ if self.__item is None:
+ return None
+ item = self.__item()
+ if item is None:
+ self.__item = None
+ return item
+
+ def setActiveItem(self, item):
+ previous = self.getActiveItem()
+ if previous is item:
+ return
+ if item is None:
+ self.__item = None
+ else:
+ self.__item = weakref.ref(item)
+ self.sigActiveItemChanged.emit(previous, item)
+
+ def _activeImageChanged(self, previous, current):
+ """Handle active image change"""
+ plot = self.getPlotWidget()
+ item = plot.getImage(current)
+ if item is None:
+ self.setActiveItem(None)
+ elif isinstance(item, items.ImageBase):
+ self.setActiveItem(item)
+ else:
+ # Do not touch anything, which is consistent with silx v0.12 behavior
+ pass
+
+ def _activeScatterChanged(self, previous, current):
+ """Handle active scatter change"""
+ plot = self.getPlotWidget()
+ item = plot.getScatter(current)
+ self.setActiveItem(item)
+
+
class PixelIntensitiesHistoAction(PlotToolAction):
"""QAction to plot the pixels intensities diagram
@@ -61,66 +128,110 @@ class PixelIntensitiesHistoAction(PlotToolAction):
text='pixels intensity',
tooltip='Compute image intensity distribution',
parent=parent)
- self._connectedToActiveImage = False
+ self._lastItemFilter = _LastActiveItem(self, plot)
self._histo = None
+ self._item = None
def _connectPlot(self, window):
- if not self._connectedToActiveImage:
- self.plot.sigActiveImageChanged.connect(
- self._activeImageChanged)
- self._connectedToActiveImage = True
- self.computeIntensityDistribution()
+ self._lastItemFilter.sigActiveItemChanged.connect(self._activeItemChanged)
+ item = self._lastItemFilter.getActiveItem()
+ self._setSelectedItem(item)
PlotToolAction._connectPlot(self, window)
def _disconnectPlot(self, window):
- if self._connectedToActiveImage:
- self.plot.sigActiveImageChanged.disconnect(
- self._activeImageChanged)
- self._connectedToActiveImage = False
+ self._lastItemFilter.sigActiveItemChanged.disconnect(self._activeItemChanged)
PlotToolAction._disconnectPlot(self, window)
+ self._setSelectedItem(None)
- def _activeImageChanged(self, previous, legend):
- """Handle active image change: toggle enabled toolbar, update curve"""
+ def _getSelectedItem(self):
+ item = self._item
+ if item is None:
+ return None
+ else:
+ return item()
+
+ def _activeItemChanged(self, previous, current):
if self._isWindowInUse():
+ self._setSelectedItem(current)
+
+ def _setSelectedItem(self, item):
+ if item is not None:
+ if not isinstance(item, (items.ImageBase, items.Scatter)):
+ # Filter out other things
+ return
+
+ old = self._getSelectedItem()
+ if item is old:
+ return
+ if old is not None:
+ old.sigItemChanged.disconnect(self._itemUpdated)
+ if item is None:
+ self._item = None
+ else:
+ self._item = weakref.ref(item)
+ item.sigItemChanged.connect(self._itemUpdated)
+ self.computeIntensityDistribution()
+
+ def _itemUpdated(self, event):
+ if event == items.ItemChangedType.DATA:
self.computeIntensityDistribution()
+ def _cleanUp(self):
+ plot = self.getHistogramPlotWidget()
+ try:
+ plot.remove('pixel intensity', kind='item')
+ except Exception:
+ pass
+
def computeIntensityDistribution(self):
"""Get the active image and compute the image intensity distribution
"""
- activeImage = self.plot.getActiveImage()
+ item = self._getSelectedItem()
- if activeImage is not None:
- image = activeImage.getData(copy=False)
- if image.ndim == 3: # RGB(A) images
+ if item is None:
+ self._cleanUp()
+ return
+
+ if isinstance(item, items.ImageBase):
+ array = item.getData(copy=False)
+ if array.ndim == 3: # RGB(A) images
_logger.info('Converting current image from RGB(A) to grayscale\
in order to compute the intensity distribution')
- image = (image[:, :, 0] * 0.299 +
- image[:, :, 1] * 0.587 +
- image[:, :, 2] * 0.114)
-
- xmin, xmax = min_max(image, min_positive=False, finite=True)
- nbins = min(1024, int(numpy.sqrt(image.size)))
- data_range = xmin, xmax
-
- # bad hack: get 256 bins in the case we have a B&W
- if numpy.issubdtype(image.dtype, numpy.integer):
- if nbins > xmax - xmin:
- nbins = xmax - xmin
-
- nbins = max(2, nbins)
-
- data = image.ravel().astype(numpy.float32)
- histogram = Histogramnd(data, n_bins=nbins, histo_range=data_range)
- assert len(histogram.edges) == 1
- self._histo = histogram.histo
- edges = histogram.edges[0]
- plot = self.getHistogramPlotWidget()
- plot.addHistogram(histogram=self._histo,
- edges=edges,
- legend='pixel intensity',
- fill=True,
- color='#66aad7')
- plot.resetZoom()
+ array = (array[:, :, 0] * 0.299 +
+ array[:, :, 1] * 0.587 +
+ array[:, :, 2] * 0.114)
+ elif isinstance(item, items.Scatter):
+ array = item.getValueData(copy=False)
+ else:
+ assert(False)
+
+ if array.size == 0:
+ self._cleanUp()
+ return
+
+ xmin, xmax = min_max(array, min_positive=False, finite=True)
+ nbins = min(1024, int(numpy.sqrt(array.size)))
+ data_range = xmin, xmax
+
+ # bad hack: get 256 bins in the case we have a B&W
+ if numpy.issubdtype(array.dtype, numpy.integer):
+ if nbins > xmax - xmin:
+ nbins = xmax - xmin
+
+ nbins = max(2, nbins)
+
+ data = array.ravel().astype(numpy.float32)
+ histogram = Histogramnd(data, n_bins=nbins, histo_range=data_range)
+ assert len(histogram.edges) == 1
+ self._histo = histogram.histo
+ edges = histogram.edges[0]
+ plot = self.getHistogramPlotWidget()
+ plot.addHistogram(histogram=self._histo,
+ edges=edges,
+ legend='pixel intensity',
+ fill=True,
+ color='#66aad7')
+ plot.resetZoom()
def getHistogramPlotWidget(self):
"""Create the plot histogram if needed, otherwise create it
diff --git a/silx/gui/plot/actions/medfilt.py b/silx/gui/plot/actions/medfilt.py
index 276f970..f86a377 100644
--- a/silx/gui/plot/actions/medfilt.py
+++ b/silx/gui/plot/actions/medfilt.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2004-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
@@ -89,7 +89,7 @@ class MedianFilterAction(PlotToolAction):
self._legend = None
else:
self._originalImage = self.plot.getImage(self._activeImageLegend).getData(copy=False)
- self._legend = self.plot.getImage(self._activeImageLegend).getLegend()
+ self._legend = self.plot.getImage(self._activeImageLegend).getName()
def _updateFilter(self, kernelWidth, conditional=False):
if self._originalImage is None:
diff --git a/silx/gui/plot/backends/BackendBase.py b/silx/gui/plot/backends/BackendBase.py
index 75d999b..bcc93a5 100755
--- a/silx/gui/plot/backends/BackendBase.py
+++ b/silx/gui/plot/backends/BackendBase.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-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
@@ -63,8 +63,6 @@ class BackendBase(object):
# Store a weakref to get access to the plot state.
self._setPlot(plot)
- self.__zoomBackAction = None
-
@property
def _plot(self):
"""The plot this backend is attached to."""
@@ -83,24 +81,12 @@ class BackendBase(object):
"""
self._plotRef = weakref.ref(plot)
- # Default Qt context menu
-
- def contextMenuEvent(self, event):
- """Override QWidget.contextMenuEvent to implement the context menu"""
- if self.__zoomBackAction is None:
- from ..actions.control import ZoomBackAction # Avoid cyclic import
- self.__zoomBackAction = ZoomBackAction(plot=self._plot,
- parent=self._plot)
- menu = qt.QMenu(self)
- menu.addAction(self.__zoomBackAction)
- menu.exec_(event.globalPos())
-
# Add methods
def addCurve(self, x, y,
color, symbol, linewidth, linestyle,
yaxis,
- xerror, yerror, z,
+ xerror, yerror,
fill, alpha, symbolsize, baseline):
"""Add a 1D curve given by x an y to the graph.
@@ -134,7 +120,6 @@ class BackendBase(object):
:type xerror: numpy.ndarray or None
:param yerror: Values with the uncertainties on the y values
:type yerror: numpy.ndarray or None
- :param int z: Layer on which to draw the cuve
:param bool fill: True to fill the curve, False otherwise
:param float alpha: Curve opacity, as a float in [0., 1.]
:param float symbolsize: Size of the symbol (if any) drawn
@@ -144,7 +129,7 @@ class BackendBase(object):
return object()
def addImage(self, data,
- origin, scale, z,
+ origin, scale,
colormap, alpha):
"""Add an image to the plot.
@@ -156,7 +141,6 @@ class BackendBase(object):
:param scale: (scale X, scale Y) of the data.
Default: (1., 1.)
:type scale: 2-tuple of float
- :param int z: Layer on which to draw the image
:param ~silx.gui.colors.Colormap colormap: Colormap object to use.
Ignored if data is RGB(A).
:param float alpha: Opacity of the image, as a float in range [0, 1].
@@ -165,7 +149,7 @@ class BackendBase(object):
return object()
def addTriangles(self, x, y, triangles,
- color, z, alpha):
+ color, alpha):
"""Add a set of triangles.
:param numpy.ndarray x: The data corresponding to the x axis
@@ -173,14 +157,13 @@ class BackendBase(object):
:param numpy.ndarray triangles: The indices to make triangles
as a (Ntriangle, 3) array
:param numpy.ndarray color: color(s) as (npoints, 4) array
- :param int z: Layer on which to draw the cuve
:param float alpha: Opacity as a float in [0., 1.]
:returns: The triangles' unique identifier used by the backend
"""
return object()
- def addItem(self, x, y, shape, color, fill, overlay, z,
- linestyle, linewidth, linebgcolor):
+ def addShape(self, x, y, shape, color, fill, overlay,
+ linestyle, linewidth, linebgcolor):
"""Add an item (i.e. a shape) to the plot.
:param numpy.ndarray x: The X coords of the points of the shape
@@ -190,7 +173,6 @@ class BackendBase(object):
:param str color: Color of the item
:param bool fill: True to fill the shape
:param bool overlay: True if item is an overlay, False otherwise
- :param int z: Layer on which to draw the item
:param str linestyle: Style of the line.
Only relevant for line markers where X or Y is None.
Value in:
@@ -545,7 +527,7 @@ class BackendBase(object):
"""
raise NotImplementedError()
- def pixelToData(self, x, y, axis, check):
+ def pixelToData(self, x, y, axis):
"""Convert a position in pixels in the widget to a position in
the data space.
@@ -553,8 +535,6 @@ class BackendBase(object):
:param float y: The Y coordinate in pixels.
:param str axis: The Y axis to use for the conversion
('left' or 'right').
- :param bool check: True to check if the coordinates are in the
- plot area.
:returns: The corresponding position in data space or
None if the pixel position is not in the plot area.
:rtype: A tuple of 2 floats: (xData, yData) or None.
diff --git a/silx/gui/plot/backends/BackendMatplotlib.py b/silx/gui/plot/backends/BackendMatplotlib.py
index 2336494..036e630 100755
--- a/silx/gui/plot/backends/BackendMatplotlib.py
+++ b/silx/gui/plot/backends/BackendMatplotlib.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-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
@@ -52,6 +52,7 @@ from matplotlib.patches import Rectangle, Polygon
from matplotlib.image import AxesImage
from matplotlib.backend_bases import MouseEvent
from matplotlib.lines import Line2D
+from matplotlib.text import Text
from matplotlib.collections import PathCollection, LineCollection
from matplotlib.ticker import Formatter, ScalarFormatter, Locator
from matplotlib.tri import Triangulation
@@ -252,6 +253,60 @@ class _PickableContainer(Container):
return False, {}
+class _TextWithOffset(Text):
+ """Text object which can be displayed at a specific position
+ of the plot, but with a pixel offset"""
+
+ def __init__(self, *args, **kwargs):
+ Text.__init__(self, *args, **kwargs)
+ self.pixel_offset = (0, 0)
+ self.__cache = None
+
+ def draw(self, renderer):
+ self.__cache = None
+ return Text.draw(self, renderer)
+
+ def __get_xy(self):
+ if self.__cache is not None:
+ return self.__cache
+
+ align = self.get_horizontalalignment()
+ if align == "left":
+ xoffset = self.pixel_offset[0]
+ elif align == "right":
+ xoffset = -self.pixel_offset[0]
+ else:
+ xoffset = 0
+
+ align = self.get_verticalalignment()
+ if align == "top":
+ yoffset = -self.pixel_offset[1]
+ elif align == "bottom":
+ yoffset = self.pixel_offset[1]
+ else:
+ yoffset = 0
+
+ trans = self.get_transform()
+ invtrans = self.get_transform().inverted()
+
+ x = super(_TextWithOffset, self).convert_xunits(self._x)
+ y = super(_TextWithOffset, self).convert_xunits(self._y)
+ pos = x, y
+ proj = trans.transform_point(pos)
+ proj = proj + numpy.array((xoffset, yoffset))
+ pos = invtrans.transform_point(proj)
+ self.__cache = pos
+ return pos
+
+ def convert_xunits(self, x):
+ """Return the pixel position of the annotated point."""
+ return self.__get_xy()[0]
+
+ def convert_yunits(self, y):
+ """Return the pixel position of the annotated point."""
+ return self.__get_xy()[1]
+
+
class _MarkerContainer(_PickableContainer):
"""Marker artists container supporting draw/remove and text position update
@@ -263,9 +318,10 @@ class _MarkerContainer(_PickableContainer):
:param y: Y coordinate of the marker (None for vertical lines)
"""
- def __init__(self, artists, x, y, yAxis):
+ def __init__(self, artists, symbol, x, y, yAxis):
self.line = artists[0]
self.text = artists[1] if len(artists) > 1 else None
+ self.symbol = symbol
self.x = x
self.y = y
self.yAxis = yAxis
@@ -278,27 +334,39 @@ class _MarkerContainer(_PickableContainer):
if self.text is not None:
self.text.draw(*args, **kwargs)
- def updateMarkerText(self, xmin, xmax, ymin, ymax):
+ def updateMarkerText(self, xmin, xmax, ymin, ymax, yinverted):
"""Update marker text position and visibility according to plot limits
:param xmin: X axis lower limit
:param xmax: X axis upper limit
:param ymin: Y axis lower limit
- :param ymax: Y axis upprt limit
+ :param ymax: Y axis upper limit
+ :param yinverted: True if the y axis is inverted
"""
if self.text is not None:
visible = ((self.x is None or xmin <= self.x <= xmax) and
(self.y is None or ymin <= self.y <= ymax))
self.text.set_visible(visible)
- if self.x is not None and self.y is None: # vertical line
- delta = abs(ymax - ymin)
- if ymin > ymax:
- ymax = ymin
- ymax -= 0.005 * delta
- self.text.set_y(ymax)
-
- if self.x is None and self.y is not None: # Horizontal line
+ if self.x is not None and self.y is not None:
+ if self.symbol is None:
+ valign = 'baseline'
+ else:
+ if yinverted:
+ valign = 'bottom'
+ else:
+ valign = 'top'
+ self.text.set_verticalalignment(valign)
+
+ elif self.y is None: # vertical line
+ # Always display it on top
+ center = (ymax + ymin) * 0.5
+ pos = (ymax - ymin) * 0.5 * 0.99
+ if yinverted:
+ pos = -pos
+ self.text.set_y(center + pos)
+
+ elif self.x is None: # Horizontal line
delta = abs(xmax - xmin)
if xmin > xmax:
xmax = xmin
@@ -354,9 +422,35 @@ class _DoubleColoredLinePatch(matplotlib.patches.Patch):
class Image(AxesImage):
- """An AxesImage with a fast path for uint8 RGBA images"""
+ """An AxesImage with a fast path for uint8 RGBA images.
+
+ :param List[float] silx_origin: (ox, oy) Offset of the image.
+ :param List[float] silx_scale: (sx, sy) Scale of the image.
+ """
+
+ def __init__(self, *args,
+ silx_origin=(0., 0.),
+ silx_scale=(1., 1.),
+ **kwargs):
+ super().__init__(*args, **kwargs)
+ self.__silx_origin = silx_origin
+ self.__silx_scale = silx_scale
+
+ def contains(self, mouseevent):
+ """Overridden to fill 'ind' with row and column"""
+ inside, info = super().contains(mouseevent)
+ if inside:
+ x, y = mouseevent.xdata, mouseevent.ydata
+ ox, oy = self.__silx_origin
+ sx, sy = self.__silx_scale
+ height, width = self.get_size()
+ column = numpy.clip(int((x - ox) / sx), 0, width - 1)
+ row = numpy.clip(int((y - oy) / sy), 0, height - 1)
+ info['ind'] = (row,), (column,)
+ return inside, info
def set_data(self, A):
+ """Overridden to add a fast path for RGBA unit8 images"""
A = numpy.array(A, copy=False)
if A.ndim != 3 or A.shape[2] != 4 or A.dtype != numpy.uint8:
super(Image, self).set_data(A)
@@ -402,10 +496,15 @@ class BackendMatplotlib(BackendBase.BackendBase):
# disable the use of offsets
try:
- self.ax.get_yaxis().get_major_formatter().set_useOffset(False)
- self.ax.get_xaxis().get_major_formatter().set_useOffset(False)
- self.ax2.get_yaxis().get_major_formatter().set_useOffset(False)
- self.ax2.get_xaxis().get_major_formatter().set_useOffset(False)
+ axes = [
+ self.ax.get_yaxis().get_major_formatter(),
+ self.ax.get_xaxis().get_major_formatter(),
+ self.ax2.get_yaxis().get_major_formatter(),
+ self.ax2.get_xaxis().get_major_formatter(),
+ ]
+ for axis in axes:
+ axis.set_useOffset(False)
+ axis.set_scientific(False)
except:
_logger.warning('Cannot disabled axes offsets in %s '
% matplotlib.__version__)
@@ -485,10 +584,10 @@ class BackendMatplotlib(BackendBase.BackendBase):
def addCurve(self, x, y,
color, symbol, linewidth, linestyle,
yaxis,
- xerror, yerror, z,
+ xerror, yerror,
fill, alpha, symbolsize, baseline):
for parameter in (x, y, color, symbol, linewidth, linestyle,
- yaxis, z, fill, alpha, symbolsize):
+ yaxis, fill, alpha, symbolsize):
assert parameter is not None
assert yaxis in ('left', 'right')
@@ -584,12 +683,12 @@ class BackendMatplotlib(BackendBase.BackendBase):
return _PickableContainer(artists)
- def addImage(self, data, origin, scale, z, colormap, alpha):
+ def addImage(self, data, origin, scale, colormap, alpha):
# Non-uniform image
# http://wiki.scipy.org/Cookbook/Histograms
# Non-linear axes
# http://stackoverflow.com/questions/11488800/non-linear-axes-for-imshow-in-matplotlib
- for parameter in (data, origin, scale, z):
+ for parameter in (data, origin, scale):
assert parameter is not None
origin = float(origin[0]), float(origin[1])
@@ -600,7 +699,9 @@ class BackendMatplotlib(BackendBase.BackendBase):
image = Image(self.ax,
interpolation='nearest',
picker=True,
- origin='lower')
+ origin='lower',
+ silx_origin=origin,
+ silx_scale=scale)
if alpha < 1:
image.set_alpha(alpha)
@@ -627,13 +728,17 @@ class BackendMatplotlib(BackendBase.BackendBase):
if data.ndim == 2: # Data image, convert to RGBA image
data = colormap.applyToData(data)
-
+ elif data.dtype == numpy.uint16:
+ # Normalize uint16 data to have a similar behavior as opengl backend
+ data = data.astype(numpy.float32)
+ data /= 65535
+
image.set_data(data)
self.ax.add_artist(image)
return image
- def addTriangles(self, x, y, triangles, color, z, alpha):
- for parameter in (x, y, triangles, color, z, alpha):
+ def addTriangles(self, x, y, triangles, color, alpha):
+ for parameter in (x, y, triangles, color, alpha):
assert parameter is not None
color = numpy.array(color, copy=False)
@@ -651,8 +756,8 @@ class BackendMatplotlib(BackendBase.BackendBase):
return collection
- def addItem(self, x, y, shape, color, fill, overlay, z,
- linestyle, linewidth, linebgcolor):
+ def addShape(self, x, y, shape, color, fill, overlay,
+ linestyle, linewidth, linebgcolor):
if (linebgcolor is not None and
shape not in ('rectangle', 'polygon', 'polylines')):
_logger.warning(
@@ -755,17 +860,11 @@ class BackendMatplotlib(BackendBase.BackendBase):
markersize=10.)[-1]
if text is not None:
- if symbol is None:
- valign = 'baseline'
- else:
- valign = 'top'
- text = " " + text
-
- textArtist = ax.text(x, y, text,
- color=color,
- horizontalalignment='left',
- verticalalignment=valign)
-
+ textArtist = _TextWithOffset(x, y, text,
+ color=color,
+ horizontalalignment='left')
+ if symbol is not None:
+ textArtist.pixel_offset = 10, 3
elif x is not None:
line = ax.axvline(x,
color=color,
@@ -773,11 +872,11 @@ class BackendMatplotlib(BackendBase.BackendBase):
linestyle=linestyle)
if text is not None:
# Y position will be updated in updateMarkerText call
- textArtist = ax.text(x, 1., " " + text,
- color=color,
- horizontalalignment='left',
- verticalalignment='top')
-
+ textArtist = _TextWithOffset(x, 1., text,
+ color=color,
+ horizontalalignment='left',
+ verticalalignment='top')
+ textArtist.pixel_offset = 5, 3
elif y is not None:
line = ax.axhline(y,
color=color,
@@ -786,11 +885,11 @@ class BackendMatplotlib(BackendBase.BackendBase):
if text is not None:
# X position will be updated in updateMarkerText call
- textArtist = ax.text(1., y, " " + text,
- color=color,
- horizontalalignment='right',
- verticalalignment='top')
-
+ textArtist = _TextWithOffset(1., y, text,
+ color=color,
+ horizontalalignment='right',
+ verticalalignment='top')
+ textArtist.pixel_offset = 5, 3
else:
raise RuntimeError('A marker must at least have one coordinate')
@@ -799,11 +898,12 @@ class BackendMatplotlib(BackendBase.BackendBase):
# All markers are overlays
line.set_animated(True)
if textArtist is not None:
+ ax.add_artist(textArtist)
textArtist.set_animated(True)
artists = [line] if textArtist is None else [line, textArtist]
- container = _MarkerContainer(artists, x, y, yaxis)
- container.updateMarkerText(xmin, xmax, ymin, ymax)
+ container = _MarkerContainer(artists, symbol, x, y, yaxis)
+ container.updateMarkerText(xmin, xmax, ymin, ymax, self.isYAxisInverted())
return container
@@ -811,12 +911,13 @@ class BackendMatplotlib(BackendBase.BackendBase):
xmin, xmax = self.ax.get_xbound()
ymin1, ymax1 = self.ax.get_ybound()
ymin2, ymax2 = self.ax2.get_ybound()
+ yinverted = self.isYAxisInverted()
for item in self._overlayItems():
if isinstance(item, _MarkerContainer):
if item.yAxis == 'left':
- item.updateMarkerText(xmin, xmax, ymin1, ymax1)
+ item.updateMarkerText(xmin, xmax, ymin1, ymax1, yinverted)
else:
- item.updateMarkerText(xmin, xmax, ymin2, ymax2)
+ item.updateMarkerText(xmin, xmax, ymin2, ymax2, yinverted)
# Remove methods
@@ -1076,6 +1177,7 @@ class BackendMatplotlib(BackendBase.BackendBase):
def setYAxisInverted(self, flag):
if self.ax.yaxis_inverted() != bool(flag):
self.ax.invert_yaxis()
+ self._updateMarkers()
def isYAxisInverted(self):
return self.ax.yaxis_inverted()
@@ -1143,15 +1245,15 @@ class BackendMatplotlib(BackendBase.BackendBase):
BackendBase.BackendBase.setAxesDisplayed(self, displayed)
if displayed:
# show axes and viewbox rect
- self.ax.set_axis_on()
- self.ax2.set_axis_on()
+ self.ax.set_frame_on(True)
+ self.ax2.set_frame_on(True)
# set the default margins
self.ax.set_position([.15, .15, .75, .75])
self.ax2.set_position([.15, .15, .75, .75])
else:
# hide axes and viewbox rect
- self.ax.set_axis_off()
- self.ax2.set_axis_off()
+ self.ax.set_frame_on(False)
+ self.ax2.set_frame_on(False)
# remove external margins
self.ax.set_position([0, 0, 1, 1])
self.ax2.set_position([0, 0, 1, 1])
@@ -1168,7 +1270,7 @@ class BackendMatplotlib(BackendBase.BackendBase):
else:
dataBackgroundColor = backgroundColor
- if self.ax.axison:
+ if self.ax.get_frame_on():
self.fig.patch.set_facecolor(backgroundColor)
if self._matplotlibVersion < _parse_version('2'):
self.ax.set_axis_bgcolor(dataBackgroundColor)
@@ -1187,7 +1289,7 @@ class BackendMatplotlib(BackendBase.BackendBase):
gridColor = foregroundColor
for axes in (self.ax, self.ax2):
- if axes.axison:
+ if axes.get_frame_on():
axes.spines['bottom'].set_color(foregroundColor)
axes.spines['top'].set_color(foregroundColor)
axes.spines['right'].set_color(foregroundColor)
@@ -1244,11 +1346,6 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
self.mpl_connect('motion_notify_event', self._onMouseMove)
self.mpl_connect('scroll_event', self._onMouseWheel)
- def contextMenuEvent(self, event):
- """Override QWidget.contextMenuEvent to implement the context menu"""
- # Makes sure it is overridden (issue with PySide)
- BackendBase.BackendBase.contextMenuEvent(self, event)
-
def postRedisplay(self):
self._sigPostRedisplay.emit()
@@ -1265,17 +1362,22 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
def _onMouseMove(self, event):
if self._graphCursor:
+ position = self._plot.pixelToData(
+ event.x,
+ self._mplQtYAxisCoordConversion(event.y),
+ axis='left',
+ check=True)
lineh, linev = self._graphCursor
- if event.inaxes not in (self.ax, self.ax2) and lineh.get_visible():
- lineh.set_visible(False)
- linev.set_visible(False)
- self._plot._setDirtyPlot(overlayOnly=True)
- else:
+ if position is not None:
linev.set_visible(True)
- linev.set_xdata((event.xdata, event.xdata))
+ linev.set_xdata((position[0], position[0]))
lineh.set_visible(True)
- lineh.set_ydata((event.ydata, event.ydata))
+ lineh.set_ydata((position[1], position[1]))
self._plot._setDirtyPlot(overlayOnly=True)
+ elif lineh.get_visible():
+ lineh.set_visible(False)
+ linev.set_visible(False)
+ self._plot._setDirtyPlot(overlayOnly=True)
# onMouseMove must trigger replot if dirty flag is raised
self._plot.onMouseMove(
@@ -1294,14 +1396,22 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
def leaveEvent(self, event):
"""QWidget event handler"""
- self._plot.onMouseLeaveWidget()
+ try:
+ plot = self._plot
+ except RuntimeError:
+ pass
+ else:
+ plot.onMouseLeaveWidget()
# picking
def pickItem(self, x, y, item):
mouseEvent = MouseEvent(
'button_press_event', self, x, self._mplQtYAxisCoordConversion(y))
+ # Override axes and data position with the axes
mouseEvent.inaxes = item.axes
+ mouseEvent.xdata, mouseEvent.ydata = self.pixelToData(
+ x, y, axis='left' if item.axes is self.ax else 'right')
picked, info = item.contains(mouseEvent)
if not picked:
diff --git a/silx/gui/plot/backends/BackendOpenGL.py b/silx/gui/plot/backends/BackendOpenGL.py
index 27f3894..cf1da31 100755
--- a/silx/gui/plot/backends/BackendOpenGL.py
+++ b/silx/gui/plot/backends/BackendOpenGL.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2014-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
@@ -31,7 +31,6 @@ __license__ = "MIT"
__date__ = "21/12/2018"
import logging
-import warnings
import weakref
import numpy
@@ -62,7 +61,7 @@ _logger = logging.getLogger(__name__)
# Content #####################################################################
class _ShapeItem(dict):
- def __init__(self, x, y, shape, color, fill, overlay, z,
+ def __init__(self, x, y, shape, color, fill, overlay,
linestyle, linewidth, linebgcolor):
super(_ShapeItem, self).__init__()
@@ -249,11 +248,6 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
_MOUSE_BTNS = {1: 'left', 2: 'right', 4: 'middle'}
- def contextMenuEvent(self, event):
- """Override QWidget.contextMenuEvent to implement the context menu"""
- # Makes sure it is overridden (issue with PySide)
- BackendBase.BackendBase.contextMenuEvent(self, event)
-
def sizeHint(self):
return qt.QSize(8 * 80, 6 * 80) # Mimic MatplotlibBackend
@@ -431,6 +425,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:]
isXLog = self._plotFrame.xAxis.isLog
isYLog = self._plotFrame.yAxis.isLog
+ isYInverted = self._plotFrame.isYAxisInverted
# Used by marker rendering
labels = []
@@ -572,13 +567,20 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
# Do not render markers outside visible plot area
continue
+ if isYInverted:
+ valign = BOTTOM
+ vPixelOffset = -pixelOffset
+ else:
+ valign = TOP
+ vPixelOffset = pixelOffset
+
if item['text'] is not None:
x = pixelPos[0] + pixelOffset
- y = pixelPos[1] + pixelOffset
+ y = pixelPos[1] + vPixelOffset
label = Text2D(item['text'], x, y,
color=item['color'],
bgColor=(1., 1., 1., 0.5),
- align=LEFT, valign=TOP)
+ align=LEFT, valign=valign)
labels.append(label)
# For now simple implementation: using a curve for each marker
@@ -726,10 +728,10 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
def addCurve(self, x, y,
color, symbol, linewidth, linestyle,
yaxis,
- xerror, yerror, z,
+ xerror, yerror,
fill, alpha, symbolsize, baseline):
for parameter in (x, y, color, symbol, linewidth, linestyle,
- yaxis, z, fill, symbolsize):
+ yaxis, fill, symbolsize):
assert parameter is not None
assert yaxis in ('left', 'right')
@@ -767,8 +769,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
xErrorMinus, xErrorPlus = xerror[0], xerror[1]
else:
xErrorMinus, xErrorPlus = xerror, xerror
- with warnings.catch_warnings():
- warnings.simplefilter('ignore', category=RuntimeWarning)
+ with numpy.errstate(divide='ignore', invalid='ignore'):
# Ignore divide by zero, invalid value encountered in log10
xErrorMinus = logX - numpy.log10(x - xErrorMinus)
xErrorPlus = numpy.log10(x + xErrorPlus) - logX
@@ -790,8 +791,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
yErrorMinus, yErrorPlus = yerror[0], yerror[1]
else:
yErrorMinus, yErrorPlus = yerror, yerror
- with warnings.catch_warnings():
- warnings.simplefilter('ignore', category=RuntimeWarning)
+ with numpy.errstate(divide='ignore', invalid='ignore'):
# Ignore divide by zero, invalid value encountered in log10
yErrorMinus = logY - numpy.log10(y - yErrorMinus)
yErrorPlus = numpy.log10(y + yErrorPlus) - logY
@@ -846,9 +846,9 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
return curve
def addImage(self, data,
- origin, scale, z,
+ origin, scale,
colormap, alpha):
- for parameter in (data, origin, scale, z):
+ for parameter in (data, origin, scale):
assert parameter is not None
if data.ndim == 2:
@@ -860,17 +860,25 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
'addImage: Convert %s data to float32', str(data.dtype))
data = numpy.array(data, dtype=numpy.float32, order='C')
- colormapIsLog = colormap.getNormalization() == 'log'
- cmapRange = colormap.getColormapRange(data=data)
- colormapLut = colormap.getNColors(nbColors=256)
-
- image = GLPlotColormap(data,
- origin,
- scale,
- colormapLut,
- colormapIsLog,
- cmapRange,
- alpha)
+ normalization = colormap.getNormalization()
+ if normalization in GLPlotColormap.SUPPORTED_NORMALIZATIONS:
+ # Fast path applying colormap on the GPU
+ cmapRange = colormap.getColormapRange(data=data)
+ colormapLut = colormap.getNColors(nbColors=256)
+ gamma = colormap.getGammaNormalizationParameter()
+
+ image = GLPlotColormap(data,
+ origin,
+ scale,
+ colormapLut,
+ normalization,
+ gamma,
+ cmapRange,
+ alpha)
+
+ else: # Fallback applying colormap on CPU
+ rgba = colormap.applyToData(data)
+ image = GLPlotRGBAImage(rgba, origin, scale, alpha)
elif len(data.shape) == 3:
# For RGB, RGBA data
@@ -878,6 +886,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
if numpy.issubdtype(data.dtype, numpy.floating):
data = numpy.array(data, dtype=numpy.float32, copy=False)
+ elif data.dtype in [numpy.uint8, numpy.uint16]:
+ pass
elif numpy.issubdtype(data.dtype, numpy.integer):
data = numpy.array(data, dtype=numpy.uint8, copy=False)
else:
@@ -899,7 +909,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
return image
def addTriangles(self, x, y, triangles,
- color, z, alpha):
+ color, alpha):
# Handle axes log scale: convert data
if self._plotFrame.xAxis.isLog:
x = numpy.log10(x)
@@ -910,8 +920,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
return triangles
- def addItem(self, x, y, shape, color, fill, overlay, z,
- linestyle, linewidth, linebgcolor):
+ def addShape(self, x, y, shape, color, fill, overlay,
+ linestyle, linewidth, linebgcolor):
x = numpy.array(x, copy=False)
y = numpy.array(y, copy=False)
@@ -923,7 +933,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
raise RuntimeError(
'Cannot add item with Y <= 0 with Y axis log scale')
- return _ShapeItem(x, y, shape, color, fill, overlay, z,
+ return _ShapeItem(x, y, shape, color, fill, overlay,
linestyle, linewidth, linebgcolor)
def addMarker(self, x, y, text, color,
@@ -971,7 +981,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
super(BackendOpenGL, self).setCursor(qt.QCursor(cursor))
def setGraphCursor(self, flag, color, linewidth, linestyle):
- if linestyle is not '-':
+ if linestyle != '-':
_logger.warning(
"BackendOpenGL.setGraphCursor linestyle parameter ignored")
diff --git a/silx/gui/plot/backends/glutils/GLPlotCurve.py b/silx/gui/plot/backends/glutils/GLPlotCurve.py
index 3a0ebac..9ab85fd 100644
--- a/silx/gui/plot/backends/glutils/GLPlotCurve.py
+++ b/silx/gui/plot/backends/glutils/GLPlotCurve.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2014-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,6 @@ __date__ = "03/04/2017"
import math
import logging
-import warnings
import numpy
@@ -1129,11 +1128,9 @@ class GLPlotCurve2D(object):
_baseline = numpy.repeat(_baseline,
len(self.xData))
if isYLog is True:
- with warnings.catch_warnings(): # Ignore NaN comparison warnings
- warnings.simplefilter('ignore',
- category=RuntimeWarning)
+ with numpy.errstate(divide='ignore', invalid='ignore'):
log_val = numpy.log10(_baseline)
- _baseline = numpy.where(_baseline>0.0, log_val, -38)
+ _baseline = numpy.where(_baseline>0.0, log_val, -38)
return _baseline
_baseline = deduce_baseline(baseline)
@@ -1277,8 +1274,7 @@ class GLPlotCurve2D(object):
if self.lineStyle is not None:
# Using Cohen-Sutherland algorithm for line clipping
- with warnings.catch_warnings(): # Ignore NaN comparison warnings
- warnings.simplefilter('ignore', category=RuntimeWarning)
+ with numpy.errstate(invalid='ignore'): # Ignore NaN comparison warnings
codes = ((self.yData > yPickMax) << 3) | \
((self.yData < yPickMin) << 2) | \
((self.xData > xPickMax) << 1) | \
@@ -1335,8 +1331,7 @@ class GLPlotCurve2D(object):
indices.sort()
else:
- with warnings.catch_warnings(): # Ignore NaN comparison warnings
- warnings.simplefilter('ignore', category=RuntimeWarning)
+ with numpy.errstate(invalid='ignore'): # Ignore NaN comparison warnings
indices = numpy.nonzero((self.xData >= xPickMin) &
(self.xData <= xPickMax) &
(self.yData >= yPickMin) &
diff --git a/silx/gui/plot/backends/glutils/GLPlotImage.py b/silx/gui/plot/backends/glutils/GLPlotImage.py
index 5d79023..e985a3d 100644
--- a/silx/gui/plot/backends/glutils/GLPlotImage.py
+++ b/silx/gui/plot/backends/glutils/GLPlotImage.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2014-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
@@ -56,7 +56,7 @@ class _GLPlotData2D(object):
sx, sy = self.scale
col = int((x - ox) / sx)
row = int((y - oy) / sy)
- return ((row, col),)
+ return (row,), (col,)
else:
return None
@@ -141,10 +141,8 @@ class GLPlotColormap(_GLPlotData2D):
""",
'fragTransform': """
uniform bvec2 isLog;
- uniform struct {
- vec2 oneOverRange;
- vec2 originOverRange;
- } bounds;
+ uniform vec2 bounds_oneOverRange;
+ uniform vec2 bounds_originOverRange;
vec2 textureCoords(void) {
vec2 pos = coords;
@@ -154,7 +152,7 @@ class GLPlotColormap(_GLPlotData2D):
if (isLog.y) {
pos.y = pow(10., coords.y);
}
- return pos * bounds.oneOverRange - bounds.originOverRange;
+ return pos * bounds_oneOverRange - bounds_originOverRange;
// TODO texture coords in range different from [0, 1]
}
"""},
@@ -163,12 +161,11 @@ class GLPlotColormap(_GLPlotData2D):
#version 120
uniform sampler2D data;
- uniform struct {
- sampler2D texture;
- bool isLog;
- float min;
- float oneOverRange;
- } cmap;
+ uniform sampler2D cmap_texture;
+ uniform int cmap_normalization;
+ uniform float cmap_parameter;
+ uniform float cmap_min;
+ uniform float cmap_oneOverRange;
uniform float alpha;
varying vec2 coords;
@@ -179,19 +176,33 @@ class GLPlotColormap(_GLPlotData2D):
void main(void) {
float value = texture2D(data, textureCoords()).r;
- if (cmap.isLog) {
+ if (cmap_normalization == 1) { /*Logarithm mapping*/
if (value > 0.) {
- value = clamp(cmap.oneOverRange *
- (oneOverLog10 * log(value) - cmap.min),
+ value = clamp(cmap_oneOverRange *
+ (oneOverLog10 * log(value) - cmap_min),
0., 1.);
} else {
value = 0.;
}
- } else { /*Linear mapping*/
- value = clamp(cmap.oneOverRange * (value - cmap.min), 0., 1.);
+ } else if (cmap_normalization == 2) { /*Square root mapping*/
+ if (value >= 0.) {
+ value = clamp(cmap_oneOverRange * (sqrt(value) - cmap_min),
+ 0., 1.);
+ } else {
+ value = 0.;
+ }
+ } else if (cmap_normalization == 3) { /*Gamma correction mapping*/
+ value = pow(
+ clamp(cmap_oneOverRange * (value - cmap_min), 0., 1.),
+ cmap_parameter);
+ } else if (cmap_normalization == 4) { /* arcsinh mapping */
+ /* asinh = log(x + sqrt(x*x + 1) for compatibility with GLSL 1.20 */
+ value = clamp(cmap_oneOverRange * (log(value + sqrt(value*value + 1.0)) - cmap_min), 0., 1.);
+ } else { /*Linear mapping and fallback*/
+ value = clamp(cmap_oneOverRange * (value - cmap_min), 0., 1.);
}
- gl_FragColor = texture2D(cmap.texture, vec2(value, 0.5));
+ gl_FragColor = texture2D(cmap_texture, vec2(value, 0.5));
gl_FragColor.a *= alpha;
}
"""
@@ -217,8 +228,10 @@ class GLPlotColormap(_GLPlotData2D):
_SHADERS['log']['fragTransform'],
attrib0='position')
+ SUPPORTED_NORMALIZATIONS = 'linear', 'log', 'sqrt', 'gamma', 'arcsinh'
+
def __init__(self, data, origin, scale,
- colormap, cmapIsLog=False, cmapRange=None,
+ colormap, normalization='linear', gamma=0., cmapRange=None,
alpha=1.0):
"""Create a 2D colormap
@@ -231,7 +244,9 @@ class GLPlotColormap(_GLPlotData2D):
:type scale: 2-tuple of floats.
:param str colormap: Name of the colormap to use
TODO: Accept a 1D scalar array as the colormap
- :param bool cmapIsLog: If True, uses log10 of the data value
+ :param str normalization: The colormap normalization.
+ One of: 'linear', 'log', 'sqrt', 'gamma'
+ ;param float gamma: The gamma parameter (for 'gamma' normalization)
:param cmapRange: The range of colormap or None for autoscale colormap
For logarithmic colormap, the range is in the untransformed data
TODO: check consistency with matplotlib
@@ -239,10 +254,12 @@ class GLPlotColormap(_GLPlotData2D):
:param float alpha: Opacity from 0 (transparent) to 1 (opaque)
"""
assert data.dtype in self._INTERNAL_FORMATS
+ assert normalization in self.SUPPORTED_NORMALIZATIONS
super(GLPlotColormap, self).__init__(data, origin, scale)
self.colormap = numpy.array(colormap, copy=False)
- self.cmapIsLog = cmapIsLog
+ self.normalization = normalization
+ self.gamma = gamma
self._cmapRange = (1., 10.) # Colormap range
self.cmapRange = cmapRange # Update _cmapRange
self._alpha = numpy.clip(alpha, 0., 1.)
@@ -263,8 +280,10 @@ class GLPlotColormap(_GLPlotData2D):
@property
def cmapRange(self):
- if self.cmapIsLog:
+ if self.normalization == 'log':
assert self._cmapRange[0] > 0. and self._cmapRange[1] > 0.
+ elif self.normalization == 'sqrt':
+ assert self._cmapRange[0] >= 0. and self._cmapRange[1] > 0.
return self._cmapRange
@cmapRange.setter
@@ -319,6 +338,7 @@ class GLPlotColormap(_GLPlotData2D):
def _setCMap(self, prog):
dataMin, dataMax = self.cmapRange # If log, it is stricly positive
+ param = 0.
if self.data.dtype in (numpy.uint16, numpy.uint8):
# Using unsigned int as normalized integer in OpenGL
@@ -326,19 +346,35 @@ class GLPlotColormap(_GLPlotData2D):
maxInt = float(numpy.iinfo(self.data.dtype).max)
dataMin, dataMax = dataMin / maxInt, dataMax / maxInt
- if self.cmapIsLog:
+ if self.normalization == 'log':
dataMin = math.log10(dataMin)
dataMax = math.log10(dataMax)
-
- gl.glUniform1i(prog.uniforms['cmap.texture'],
+ normID = 1
+ elif self.normalization == 'sqrt':
+ dataMin = math.sqrt(dataMin)
+ dataMax = math.sqrt(dataMax)
+ normID = 2
+ elif self.normalization == 'gamma':
+ # Keep dataMin, dataMax as is
+ param = self.gamma
+ normID = 3
+ elif self.normalization == 'arcsinh':
+ dataMin = numpy.arcsinh(dataMin)
+ dataMax = numpy.arcsinh(dataMax)
+ normID = 4
+ else: # Linear and fallback
+ normID = 0
+
+ gl.glUniform1i(prog.uniforms['cmap_texture'],
self._cmap_texture.texUnit)
- gl.glUniform1i(prog.uniforms['cmap.isLog'], self.cmapIsLog)
- gl.glUniform1f(prog.uniforms['cmap.min'], dataMin)
+ gl.glUniform1i(prog.uniforms['cmap_normalization'], normID)
+ gl.glUniform1f(prog.uniforms['cmap_parameter'], param)
+ gl.glUniform1f(prog.uniforms['cmap_min'], dataMin)
if dataMax > dataMin:
oneOverRange = 1. / (dataMax - dataMin)
else:
oneOverRange = 0. # Fall-back
- gl.glUniform1f(prog.uniforms['cmap.oneOverRange'], oneOverRange)
+ gl.glUniform1f(prog.uniforms['cmap_oneOverRange'], oneOverRange)
self._cmap_texture.bind()
@@ -393,9 +429,9 @@ class GLPlotColormap(_GLPlotData2D):
xOneOverRange = 1. / (ex - ox)
yOneOverRange = 1. / (ey - oy)
- gl.glUniform2f(prog.uniforms['bounds.originOverRange'],
+ gl.glUniform2f(prog.uniforms['bounds_originOverRange'],
ox * xOneOverRange, oy * yOneOverRange)
- gl.glUniform2f(prog.uniforms['bounds.oneOverRange'],
+ gl.glUniform2f(prog.uniforms['bounds_oneOverRange'],
xOneOverRange, yOneOverRange)
gl.glUniform1f(prog.uniforms['alpha'], self.alpha)
@@ -500,10 +536,8 @@ class GLPlotRGBAImage(_GLPlotData2D):
uniform sampler2D tex;
uniform bvec2 isLog;
- uniform struct {
- vec2 oneOverRange;
- vec2 originOverRange;
- } bounds;
+ uniform vec2 bounds_oneOverRange;
+ uniform vec2 bounds_originOverRange;
uniform float alpha;
varying vec2 coords;
@@ -516,7 +550,7 @@ class GLPlotRGBAImage(_GLPlotData2D):
if (isLog.y) {
pos.y = pow(10., coords.y);
}
- return pos * bounds.oneOverRange - bounds.originOverRange;
+ return pos * bounds_oneOverRange - bounds_originOverRange;
// TODO texture coords in range different from [0, 1]
}
@@ -530,7 +564,8 @@ class GLPlotRGBAImage(_GLPlotData2D):
_DATA_TEX_UNIT = 0
_SUPPORTED_DTYPES = (numpy.dtype(numpy.float32),
- numpy.dtype(numpy.uint8))
+ numpy.dtype(numpy.uint8),
+ numpy.dtype(numpy.uint16))
_linearProgram = Program(_SHADERS['linear']['vertex'],
_SHADERS['linear']['fragment'],
@@ -582,9 +617,14 @@ class GLPlotRGBAImage(_GLPlotData2D):
def prepare(self):
if self._texture is None:
- format_ = gl.GL_RGBA if self.data.shape[2] == 4 else gl.GL_RGB
+ formatName = 'GL_RGBA' if self.data.shape[2] == 4 else 'GL_RGB'
+ format_ = getattr(gl, formatName)
- self._texture = Image(format_,
+ if self.data.dtype == numpy.uint16:
+ formatName += '16' # Use sized internal format for uint16
+ internalFormat = getattr(gl, formatName)
+
+ self._texture = Image(internalFormat,
self.data,
format_=format_,
texUnit=self._DATA_TEX_UNIT)
@@ -639,9 +679,9 @@ class GLPlotRGBAImage(_GLPlotData2D):
xOneOverRange = 1. / (ex - ox)
yOneOverRange = 1. / (ey - oy)
- gl.glUniform2f(prog.uniforms['bounds.originOverRange'],
+ gl.glUniform2f(prog.uniforms['bounds_originOverRange'],
ox * xOneOverRange, oy * yOneOverRange)
- gl.glUniform2f(prog.uniforms['bounds.oneOverRange'],
+ gl.glUniform2f(prog.uniforms['bounds_oneOverRange'],
xOneOverRange, yOneOverRange)
try:
diff --git a/silx/gui/plot/backends/glutils/PlotImageFile.py b/silx/gui/plot/backends/glutils/PlotImageFile.py
index 83c7ae0..5fb6853 100644
--- a/silx/gui/plot/backends/glutils/PlotImageFile.py
+++ b/silx/gui/plot/backends/glutils/PlotImageFile.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2014-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
@@ -59,7 +59,7 @@ def convertRGBDataToPNG(data):
0, 0, interlace)
# Add filter 'None' before each scanline
- preparedData = b'\x00' + b'\x00'.join(line.tostring() for line in data)
+ preparedData = b'\x00' + b'\x00'.join(line.tobytes() for line in data)
compressedData = zlib.compress(preparedData, 8)
IDATdata = struct.pack("cccc", b'I', b'D', b'A', b'T')
@@ -134,7 +134,7 @@ def saveImageToFile(data, fileNameOrObj, fileFormat):
fileObj.write(b'P6\n')
fileObj.write(b'%d %d\n' % (width, height))
fileObj.write(b'255\n')
- fileObj.write(data.tostring())
+ fileObj.write(data.tobytes())
elif fileFormat == 'png':
fileObj.write(convertRGBDataToPNG(data))
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 WeakL