summaryrefslogtreecommitdiff
path: root/silx/gui
diff options
context:
space:
mode:
authorPicca Frédéric-Emmanuel <picca@debian.org>2021-09-07 14:39:36 +0200
committerPicca Frédéric-Emmanuel <picca@debian.org>2021-09-07 14:39:36 +0200
commitd3194b1a9c4404ba93afac43d97172ab24c57098 (patch)
treea1604130e1401dc1cbd084518ed72869dc92b86f /silx/gui
parentb3bea947efa55d2c0f198b6c6795b3177be27f45 (diff)
New upstream version 0.15.2+dfsg
Diffstat (limited to 'silx/gui')
-rwxr-xr-xsilx/gui/colors.py4
-rw-r--r--silx/gui/data/ArrayTableModel.py78
-rw-r--r--silx/gui/data/DataViews.py6
-rw-r--r--silx/gui/data/NXdataWidgets.py9
-rw-r--r--silx/gui/data/RecordTableView.py42
-rw-r--r--silx/gui/data/test/test_arraywidget.py15
-rw-r--r--silx/gui/dialog/ColormapDialog.py178
-rw-r--r--silx/gui/plot/ColorBar.py18
-rw-r--r--silx/gui/plot/Colormap.py4
-rw-r--r--silx/gui/plot/ImageStack.py43
-rw-r--r--silx/gui/plot/ImageView.py812
-rwxr-xr-xsilx/gui/plot/LegendSelector.py48
-rw-r--r--silx/gui/plot/MaskToolsWidget.py187
-rwxr-xr-xsilx/gui/plot/PlotWidget.py191
-rw-r--r--silx/gui/plot/Profile.py34
-rw-r--r--silx/gui/plot/StatsWidget.py6
-rw-r--r--silx/gui/plot/_BaseMaskToolsWidget.py31
-rwxr-xr-xsilx/gui/plot/actions/control.py17
-rw-r--r--silx/gui/plot/actions/histogram.py366
-rwxr-xr-xsilx/gui/plot/backends/BackendMatplotlib.py12
-rwxr-xr-xsilx/gui/plot/backends/BackendOpenGL.py5
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotCurve.py17
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotImage.py11
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotItem.py7
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotTriangles.py7
-rw-r--r--silx/gui/plot/items/_arc_roi.py11
-rw-r--r--silx/gui/plot/items/complex.py84
-rw-r--r--silx/gui/plot/items/core.py36
-rw-r--r--silx/gui/plot/items/histogram.py54
-rw-r--r--silx/gui/plot/items/image.py125
-rw-r--r--silx/gui/plot/items/scatter.py8
-rw-r--r--silx/gui/plot/stats/stats.py52
-rw-r--r--silx/gui/plot/test/testMaskToolsWidget.py168
-rw-r--r--silx/gui/plot/test/testPixelIntensityHistoAction.py16
-rwxr-xr-xsilx/gui/plot/test/testPlotWidget.py179
-rw-r--r--silx/gui/plot/tools/PositionInfo.py90
-rw-r--r--silx/gui/plot/tools/RadarView.py361
-rw-r--r--silx/gui/plot/tools/profile/core.py5
-rw-r--r--silx/gui/plot/tools/profile/manager.py6
-rw-r--r--silx/gui/plot/tools/profile/rois.py28
-rw-r--r--silx/gui/plot3d/items/__init__.py4
-rw-r--r--silx/gui/plot3d/items/image.py251
-rw-r--r--silx/gui/plot3d/scene/primitives.py9
-rw-r--r--silx/gui/plot3d/test/testSceneWindow.py40
-rw-r--r--silx/gui/plot3d/tools/PositionInfoWidget.py4
-rw-r--r--silx/gui/qt/__init__.py4
-rw-r--r--silx/gui/setup.py3
-rw-r--r--silx/gui/utils/glutils/__init__.py (renamed from silx/gui/utils/glutils.py)13
-rw-r--r--silx/gui/widgets/FlowLayout.py2
-rw-r--r--silx/gui/widgets/UrlSelectionTable.py4
50 files changed, 2788 insertions, 917 deletions
diff --git a/silx/gui/colors.py b/silx/gui/colors.py
index 4a96ae0..db837b5 100755
--- a/silx/gui/colors.py
+++ b/silx/gui/colors.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2021 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
@@ -45,7 +45,7 @@ from silx.utils import deprecation
from silx.resources import resource_filename as _resource_filename
-_logger = logging.getLogger(__file__)
+_logger = logging.getLogger(__name__)
try:
import silx.gui.utils.matplotlib # noqa Initalize matplotlib
diff --git a/silx/gui/data/ArrayTableModel.py b/silx/gui/data/ArrayTableModel.py
index 8805241..b7bd9c4 100644
--- a/silx/gui/data/ArrayTableModel.py
+++ b/silx/gui/data/ArrayTableModel.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2021 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
@@ -74,6 +74,10 @@ class ArrayTableModel(qt.QAbstractTableModel):
:param sequence[int] perspective: See documentation
of :meth:`setPerspective`.
"""
+
+ MAX_NUMBER_OF_SECTIONS = 10e6
+ """Maximum number of displayed rows and columns"""
+
def __init__(self, parent=None, data=None, perspective=None):
qt.QAbstractTableModel.__init__(self, parent)
@@ -173,7 +177,7 @@ class ArrayTableModel(qt.QAbstractTableModel):
if row_dim is None:
# 0-D and 1-D arrays
return 1
- return self._array.shape[row_dim]
+ return min(self._array.shape[row_dim], self.MAX_NUMBER_OF_SECTIONS)
def columnCount(self, parent_idx=None):
"""QAbstractTableModel method
@@ -182,14 +186,55 @@ class ArrayTableModel(qt.QAbstractTableModel):
if col_dim is None:
# 0-D array
return 1
- return self._array.shape[col_dim]
+ return min(self._array.shape[col_dim], self.MAX_NUMBER_OF_SECTIONS)
+
+ def __isClipped(self, orientation=qt.Qt.Vertical) -> bool:
+ """Returns whether or not array is clipped in a given orientation"""
+ if orientation == qt.Qt.Vertical:
+ dim = self._getRowDim()
+ else:
+ dim = self._getColumnDim()
+ return (dim is not None and
+ self._array.shape[dim] > self.MAX_NUMBER_OF_SECTIONS)
+
+ def __isClippedIndex(self, index) -> bool:
+ """Returns whether or not index's cell represents clipped data."""
+ if not index.isValid():
+ return False
+ if index.row() == self.MAX_NUMBER_OF_SECTIONS - 2:
+ return self.__isClipped(qt.Qt.Vertical)
+ if index.column() == self.MAX_NUMBER_OF_SECTIONS - 2:
+ return self.__isClipped(qt.Qt.Horizontal)
+ return False
+
+ def __clippedData(self, role=qt.Qt.DisplayRole):
+ """Return data for cells representing clipped data"""
+ if role == qt.Qt.DisplayRole:
+ return "..."
+ elif role == qt.Qt.ToolTipRole:
+ return "Dataset is too large: display is clipped"
+ else:
+ return None
def data(self, index, role=qt.Qt.DisplayRole):
"""QAbstractTableModel method to access data values
in the format ready to be displayed"""
if index.isValid():
- selection = self._getIndexTuple(index.row(),
- index.column())
+ if self.__isClippedIndex(index): # Special displayed for clipped data
+ return self.__clippedData(role)
+
+ row, column = index.row(), index.column()
+
+ # When clipped, display last data of the array in last column of the table
+ if (self.__isClipped(qt.Qt.Vertical) and
+ row == self.MAX_NUMBER_OF_SECTIONS - 1):
+ row = self._array.shape[self._getRowDim()] - 1
+ if (self.__isClipped(qt.Qt.Horizontal) and
+ column == self.MAX_NUMBER_OF_SECTIONS - 1):
+ column = self._array.shape[self._getColumnDim()] - 1
+
+ selection = self._getIndexTuple(row, column)
+
if role == qt.Qt.DisplayRole:
return self._formatter.toString(self._array[selection], self._array.dtype)
@@ -224,17 +269,30 @@ class ArrayTableModel(qt.QAbstractTableModel):
"""QAbstractTableModel method
Return the 0-based row or column index, for display in the
horizontal and vertical headers"""
+ if self.__isClipped(orientation): # Header is clipped
+ if section == self.MAX_NUMBER_OF_SECTIONS - 2:
+ # Represent clipped data
+ return self.__clippedData(role)
+
+ elif section == self.MAX_NUMBER_OF_SECTIONS - 1:
+ # Display last index from data not table
+ if role == qt.Qt.DisplayRole:
+ if orientation == qt.Qt.Vertical:
+ dim = self._getRowDim()
+ else:
+ dim = self._getColumnDim()
+ return str(self._array.shape[dim] - 1)
+ else:
+ return None
+
if role == qt.Qt.DisplayRole:
- if orientation == qt.Qt.Vertical:
- return "%d" % section
- if orientation == qt.Qt.Horizontal:
- return "%d" % section
+ return "%d" % section
return None
def flags(self, index):
"""QAbstractTableModel method to inform the view whether data
is editable or not."""
- if not self._editable:
+ if not self._editable or self.__isClippedIndex(index):
return qt.QAbstractTableModel.flags(self, index)
return qt.QAbstractTableModel.flags(self, index) | qt.Qt.ItemIsEditable
diff --git a/silx/gui/data/DataViews.py b/silx/gui/data/DataViews.py
index d9958de..b18a813 100644
--- a/silx/gui/data/DataViews.py
+++ b/silx/gui/data/DataViews.py
@@ -1042,6 +1042,8 @@ class _Plot2dView(DataView):
widget.setKeepDataAspectRatio(True)
widget.getXAxis().setLabel('X')
widget.getYAxis().setLabel('Y')
+ maskToolsWidget = widget.getMaskToolsDockWidget().widget()
+ maskToolsWidget.setItemMaskUpdated(True)
return widget
def clear(self):
@@ -1156,6 +1158,8 @@ class _ComplexImageView(DataView):
widget.getPlot().setKeepDataAspectRatio(True)
widget.getXAxis().setLabel('X')
widget.getYAxis().setLabel('Y')
+ maskToolsWidget = widget.getPlot().getMaskToolsDockWidget().widget()
+ maskToolsWidget.setItemMaskUpdated(True)
return widget
def clear(self):
@@ -1254,6 +1258,8 @@ class _StackView(DataView):
widget.setLabels(self.axesNames(None, None))
# hide default option panel
widget.setOptionVisible(False)
+ maskToolWidget = widget.getPlotWidget().getMaskToolsDockWidget().widget()
+ maskToolWidget.setItemMaskUpdated(True)
return widget
def clear(self):
diff --git a/silx/gui/data/NXdataWidgets.py b/silx/gui/data/NXdataWidgets.py
index 271b267..be7d0e3 100644
--- a/silx/gui/data/NXdataWidgets.py
+++ b/silx/gui/data/NXdataWidgets.py
@@ -371,6 +371,8 @@ class ArrayImagePlot(qt.QWidget):
normalization=Colormap.LINEAR))
self._plot.getIntensityHistogramAction().setVisible(True)
self._plot.setKeepDataAspectRatio(True)
+ maskToolWidget = self._plot.getMaskToolsDockWidget().widget()
+ maskToolWidget.setItemMaskUpdated(True)
# not closable
self._selector = NumpyAxesSelector(self)
@@ -511,7 +513,7 @@ class ArrayImagePlot(qt.QWidget):
self._plot.getYAxis().setScale('linear')
self._plot.addImage(image, legend=legend,
origin=origin, scale=scale,
- replace=True)
+ replace=True, resetzoom=False)
else:
xaxisscale, yaxisscale = self._axis_scales
@@ -587,6 +589,8 @@ class ArrayComplexImagePlot(qt.QWidget):
self._plot.getPlot().getIntensityHistogramAction().setVisible(True)
self._plot.setKeepDataAspectRatio(True)
+ maskToolWidget = self._plot.getPlot().getMaskToolsDockWidget().widget()
+ maskToolWidget.setItemMaskUpdated(True)
# not closable
self._selector = NumpyAxesSelector(self)
@@ -769,6 +773,9 @@ class ArrayStackPlot(qt.QWidget):
self.__x_axis_name = None
self._stack_view = StackView(self)
+ maskToolWidget = self._stack_view.getPlotWidget().getMaskToolsDockWidget().widget()
+ maskToolWidget.setItemMaskUpdated(True)
+
self._hline = qt.QFrame(self)
self._hline.setFrameStyle(qt.QFrame.HLine)
self._hline.setFrameShadow(qt.QFrame.Sunken)
diff --git a/silx/gui/data/RecordTableView.py b/silx/gui/data/RecordTableView.py
index b1b7dcd..2c0011a 100644
--- a/silx/gui/data/RecordTableView.py
+++ b/silx/gui/data/RecordTableView.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2021 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
@@ -149,6 +149,10 @@ class RecordTableModel(qt.QAbstractTableModel):
:param qt.QObject parent: Parent object
:param numpy.ndarray data: A numpy array or a h5py dataset
"""
+
+ MAX_NUMBER_OF_ROWS = 10e6
+ """Maximum number of display values of the dataset"""
+
def __init__(self, parent=None, data=None):
qt.QAbstractTableModel.__init__(self, parent)
@@ -170,7 +174,7 @@ class RecordTableModel(qt.QAbstractTableModel):
elif not self.__is_array:
return 1
else:
- return len(self.__data)
+ return min(len(self.__data), self.MAX_NUMBER_OF_ROWS)
def columnCount(self, parent_idx=None):
"""Returns number of columns to be displayed in table"""
@@ -179,6 +183,15 @@ class RecordTableModel(qt.QAbstractTableModel):
else:
return len(self.__fields)
+ def __clippedData(self, role=qt.Qt.DisplayRole):
+ """Return data for cells representing clipped data"""
+ if role == qt.Qt.DisplayRole:
+ return "..."
+ elif role == qt.Qt.ToolTipRole:
+ return "Dataset is too large: display is clipped"
+ else:
+ return None
+
def data(self, index, role=qt.Qt.DisplayRole):
"""QAbstractTableModel method to access data values
in the format ready to be displayed"""
@@ -188,10 +201,19 @@ class RecordTableModel(qt.QAbstractTableModel):
if self.__data is None:
return None
+ # Special display of one before last data for clipped table
+ if self.__isClipped() and index.row() == self.rowCount() - 2:
+ return self.__clippedData(role)
+
if self.__is_array:
- if index.row() >= len(self.__data):
+ row = index.row()
+ if row >= self.rowCount():
return None
- data = self.__data[index.row()]
+ elif self.__isClipped() and row == self.rowCount() - 1:
+ # Clipped array, display last value at the end
+ data = self.__data[-1]
+ else:
+ data = self.__data[row]
else:
if index.row() > 0:
return None
@@ -221,10 +243,18 @@ class RecordTableModel(qt.QAbstractTableModel):
# PyQt4 send -1 when there is columns but no rows
return None
+ # Handle clipping of huge tables
+ if (self.__isClipped() and
+ orientation == qt.Qt.Vertical and
+ section == self.rowCount() - 2):
+ return self.__clippedData(role)
+
if role == qt.Qt.DisplayRole:
if orientation == qt.Qt.Vertical:
if not self.__is_array:
return "Scalar"
+ elif section == self.MAX_NUMBER_OF_ROWS - 1:
+ return str(len(self.__data) - 1)
else:
return str(section)
if orientation == qt.Qt.Horizontal:
@@ -246,6 +276,10 @@ class RecordTableModel(qt.QAbstractTableModel):
"""
return qt.QAbstractTableModel.flags(self, index)
+ def __isClipped(self) -> bool:
+ """Returns whether the displayed array is clipped or not"""
+ return self.__data is not None and self.__is_array and len(self.__data) > self.MAX_NUMBER_OF_ROWS
+
def setArrayData(self, data):
"""Set the data array and the viewing perspective.
diff --git a/silx/gui/data/test/test_arraywidget.py b/silx/gui/data/test/test_arraywidget.py
index 7785ac5..87081ed 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-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2021 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
@@ -34,6 +34,7 @@ import numpy
from silx.gui import qt
from silx.gui.data import ArrayTableWidget
+from silx.gui.data.ArrayTableModel import ArrayTableModel
from silx.gui.utils.testutils import TestCaseQt
import h5py
@@ -186,6 +187,18 @@ class TestArrayWidget(TestCaseQt):
b1 = self.aw.getData(copy=False)
self.assertIs(b0, b1)
+ def testClipping(self):
+ """Test clipping of large arrays"""
+ self.aw.show()
+ self.qWaitForWindowExposed(self.aw)
+
+ data = numpy.arange(ArrayTableModel.MAX_NUMBER_OF_SECTIONS + 10)
+
+ for shape in [(1, -1), (-1, 1)]:
+ with self.subTest(shape=shape):
+ self.aw.setArrayData(data.reshape(shape), editable=True)
+ self.qapp.processEvents()
+
class TestH5pyArrayWidget(TestCaseQt):
"""Basic test for ArrayTableWidget with a dataset.
diff --git a/silx/gui/dialog/ColormapDialog.py b/silx/gui/dialog/ColormapDialog.py
index 6b5d83b..ca7ee97 100644
--- a/silx/gui/dialog/ColormapDialog.py
+++ b/silx/gui/dialog/ColormapDialog.py
@@ -59,12 +59,9 @@ The updates of the colormap description are also available through the signal:
:attr:`ColormapDialog.sigColormapChanged`.
""" # noqa
-from __future__ import division
-
__authors__ = ["V.A. Sole", "T. Vincent", "H. Payno"]
__license__ = "MIT"
-__date__ = "27/11/2018"
-
+__date__ = "08/12/2020"
import enum
import logging
@@ -73,7 +70,7 @@ import numpy
from .. import qt
from .. import utils
-from ..colors import Colormap
+from ..colors import Colormap, cursorColorForColormap
from ..plot import PlotWidget
from ..plot.items.axis import Axis
from ..plot.items import BoundingRect
@@ -84,12 +81,14 @@ 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.gui.widgets.WaitingPushButton import WaitingPushButton
from silx.math.histogram import Histogramnd
from silx.utils import deprecation
+from silx.gui.plot.items.roi import RectangleROI
+from silx.gui.plot.tools.roi import RegionOfInterestManager
_logger = logging.getLogger(__name__)
-
_colormapIconPreview = {}
@@ -510,6 +509,7 @@ class _ColormapHistogram(qt.QWidget):
:returns: Tuple{float, float}
"""
scale = self._plot.getXAxis().getScale()
+
def isDisplayable(pos):
if pos is None:
return False
@@ -770,7 +770,8 @@ class _ColormapHistogram(qt.QWidget):
else:
histogram = numpy.array(histogram, copy=True)
bin_edges = numpy.array(bin_edges, copy=True)
- norm_histogram = histogram / max(histogram)
+ with numpy.errstate(invalid='ignore'):
+ norm_histogram = histogram / numpy.nanmax(histogram)
self._plot.addHistogram(norm_histogram,
bin_edges,
legend="Data",
@@ -917,6 +918,27 @@ class ColormapDialog(qt.QDialog):
self._histoWidget.sigRangeMoving.connect(self._histogramRangeMoving)
self._histoWidget.sigRangeMoved.connect(self._histogramRangeMoved)
+ # Scale to buttons
+ self._visibleAreaButton = qt.QPushButton(self)
+ self._visibleAreaButton.setEnabled(False)
+ self._visibleAreaButton.setText("Visible Area")
+ self._visibleAreaButton.clicked.connect(
+ self._handleScaleToVisibleAreaClicked,
+ type=qt.Qt.QueuedConnection)
+
+ # Place-holder for selected area ROI manager
+ self._roiForColormapManager = None
+
+ self._selectedAreaButton = WaitingPushButton(self)
+ self._selectedAreaButton.setEnabled(False)
+ self._selectedAreaButton.setText("Selection")
+ self._selectedAreaButton.setIcon(icons.getQIcon("add-shape-rectangle"))
+ self._selectedAreaButton.setCheckable(True)
+ self._selectedAreaButton.setDisabledWhenWaiting(False)
+ self._selectedAreaButton.toggled.connect(
+ self._handleScaleToSelectionToggled,
+ type=qt.Qt.QueuedConnection)
+
# define modal buttons
types = qt.QDialogButtonBox.Ok | qt.QDialogButtonBox.Cancel
self._buttonsModal = qt.QDialogButtonBox(parent=self)
@@ -955,6 +977,16 @@ class ColormapDialog(qt.QDialog):
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)
+
+ layout = qt.QHBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self._visibleAreaButton)
+ layout.addWidget(self._selectedAreaButton)
+ self._scaleToAreaGroup = qt.QGroupBox('Scale to:', self)
+ self._scaleToAreaGroup.setLayout(layout)
+ self._scaleToAreaGroup.setVisible(False)
+ formLayout.addRow(self._scaleToAreaGroup)
+
formLayout.addRow(self._buttonsModal)
formLayout.addRow(self._buttonsNonModal)
formLayout.setSizeConstraint(qt.QLayout.SetMinimumSize)
@@ -965,7 +997,9 @@ class ColormapDialog(qt.QDialog):
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._autoScaleCombo, self._visibleAreaButton)
+ self.setTabOrder(self._visibleAreaButton, self._selectedAreaButton)
+ self.setTabOrder(self._selectedAreaButton, self._buttonsModal)
self.setTabOrder(self._buttonsModal, self._buttonsNonModal)
self.setFixedSize(self.sizeHint())
@@ -1094,9 +1128,9 @@ class ColormapDialog(qt.QDialog):
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
@@ -1141,7 +1175,7 @@ class ColormapDialog(qt.QDialog):
bins = histogram.edges[0]
if normalizeData:
if scale == Colormap.LOGARITHM:
- bins = 10**bins
+ bins = 10 ** bins
return histogram.histo, bins
def _getItem(self):
@@ -1173,6 +1207,7 @@ class ColormapDialog(qt.QDialog):
raise ValueError("Item %s is not supported" % item)
self._item = weakref.ref(item, self._itemAboutToFinalize)
finally:
+ self._syncScaleToButtonsEnabled()
self._dataRange = None
self._histogramData = None
self._invalidateData()
@@ -1194,6 +1229,7 @@ class ColormapDialog(qt.QDialog):
return
self._item = None
+ self._syncScaleToButtonsEnabled()
if data is None:
self._data = None
self._itemHolder = None
@@ -1311,6 +1347,55 @@ class ColormapDialog(qt.QDialog):
colormap.setVRange(xmin, xmax)
self._updateWidgetRange()
+ def setColormapRangeFromDataBounds(self, bounds):
+ """Set the range of the colormap from current item and rect.
+
+ If there is no ColormapMixIn item attached to the ColormapDialog,
+ nothing is done.
+
+ :param Union[List[float],None] bounds:
+ (xmin, xmax, ymin, ymax) Rectangular region in data space
+ """
+ if bounds is None:
+ return None # no-op
+
+ colormap = self.getColormap()
+ if colormap is None:
+ return # no-op
+
+ item = self._getItem()
+ if not isinstance(item, items.ColormapMixIn):
+ return None # no-op
+
+ data = item.getColormappedData(copy=False)
+
+ xmin, xmax, ymin, ymax = bounds
+
+ if isinstance(item, items.ImageBase):
+ ox, oy = item.getOrigin()
+ sx, sy = item.getScale()
+
+ ystart = max(0, int((ymin - oy) / sy))
+ ystop = max(0, int(numpy.ceil((ymax - oy) / sy)))
+ xstart = max(0, int((xmin - ox) / sx))
+ xstop = max(0, int(numpy.ceil((xmax - ox) / sx)))
+
+ subset = data[ystart:ystop, xstart:xstop]
+
+ elif isinstance(item, items.Scatter):
+ x = item.getXData(copy=False)
+ y = item.getYData(copy=False)
+ subset = data[
+ numpy.logical_and(
+ numpy.logical_and(xmin <= x, x <= xmax),
+ numpy.logical_and(ymin <= y, y <= ymax))]
+
+ if subset.size == 0:
+ return # no-op
+
+ vmin, vmax = colormap._computeAutoscaleRange(subset)
+ self._setColormapRange(vmin, vmax)
+
def _updateWidgetRange(self):
"""Update the colormap range displayed into the widget."""
xmin, xmax = self._getFiniteColormapRange()
@@ -1387,6 +1472,8 @@ class ColormapDialog(qt.QDialog):
if self._colormapChange.locked():
return
+ self._syncScaleToButtonsEnabled()
+
colormap = self.getColormap()
if colormap is None:
self._comboBoxColormap.setEnabled(False)
@@ -1591,6 +1678,73 @@ class ColormapDialog(qt.QDialog):
vmax = xmax
self._setColormapRange(vmin, vmax)
+ def _syncScaleToButtonsEnabled(self):
+ """Set the state of scale to buttons according to current item and colormap"""
+ colormap = self.getColormap()
+ enabled = self._item is not None and colormap is not None and colormap.isEditable()
+ self._scaleToAreaGroup.setVisible(enabled)
+ self._visibleAreaButton.setEnabled(enabled)
+ if not enabled:
+ self._selectedAreaButton.setChecked(False)
+ self._selectedAreaButton.setEnabled(enabled)
+
+ def _handleScaleToVisibleAreaClicked(self):
+ """Set colormap range from current item's visible area"""
+ item = self._getItem()
+ if item is None:
+ return # no-op
+
+ bounds = item.getVisibleBounds()
+ if bounds is None:
+ return # no-op
+
+ self.setColormapRangeFromDataBounds(bounds)
+
+ def _handleScaleToSelectionToggled(self, checked=False):
+ """Handle toggle of scale to selected are button"""
+ # Reset any previous ROI manager
+ if self._roiForColormapManager is not None:
+ self._roiForColormapManager.clear()
+ self._roiForColormapManager.stop()
+ self._roiForColormapManager = None
+
+ if not checked: # Reset button status
+ self._selectedAreaButton.setWaiting(False)
+ self._selectedAreaButton.setText("Selection")
+ return
+
+ item = self._getItem()
+ if item is None:
+ self._selectedAreaButton.setChecked(False)
+ return # no-op
+
+ plotWidget = item.getPlot()
+ if plotWidget is None:
+ self._selectedAreaButton.setChecked(False)
+ return # no-op
+
+ self._selectedAreaButton.setWaiting(True)
+ self._selectedAreaButton.setText("Draw Area...")
+
+ self._roiForColormapManager = RegionOfInterestManager(parent=plotWidget)
+ cmap = self.getColormap()
+ self._roiForColormapManager.setColor(
+ 'black' if cmap is None else cursorColorForColormap(cmap.getName()))
+ self._roiForColormapManager.sigInteractiveModeFinished.connect(
+ self.__roiInteractiveModeFinished)
+ self._roiForColormapManager.sigInteractiveRoiFinalized.connect(self.__roiFinalized)
+ self._roiForColormapManager.start(RectangleROI)
+
+ def __roiInteractiveModeFinished(self):
+ self._selectedAreaButton.setChecked(False)
+
+ def __roiFinalized(self, roi):
+ self._selectedAreaButton.setChecked(False)
+ if roi is not None:
+ ox, oy = roi.getOrigin()
+ width, height = roi.getSize()
+ self.setColormapRangeFromDataBounds((ox, ox+width, oy, oy+height))
+
def keyPressEvent(self, event):
"""Override key handling.
diff --git a/silx/gui/plot/ColorBar.py b/silx/gui/plot/ColorBar.py
index eff7689..d869825 100644
--- a/silx/gui/plot/ColorBar.py
+++ b/silx/gui/plot/ColorBar.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2021 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
@@ -36,6 +36,7 @@ import numpy
from ._utils import ticklayout
from .. import qt
+from ..qt import inspect as qt_inspect
from silx.gui import colors
_logger = logging.getLogger(__name__)
@@ -112,14 +113,15 @@ class ColorBarWidget(qt.QWidget):
def _disconnectPlot(self):
"""Disconnect from Plot signals"""
- plot = self.getPlot()
- if plot is not None and self._isConnected:
+ if self._isConnected:
self._isConnected = False
- plot.sigActiveImageChanged.disconnect(
- self._activeImageChanged)
- plot.sigActiveScatterChanged.disconnect(
- self._activeScatterChanged)
- plot.sigPlotSignal.disconnect(self._defaultColormapChanged)
+ plot = self.getPlot()
+ if plot is not None and qt_inspect.isValid(plot):
+ plot.sigActiveImageChanged.disconnect(
+ self._activeImageChanged)
+ plot.sigActiveScatterChanged.disconnect(
+ self._activeScatterChanged)
+ plot.sigPlotSignal.disconnect(self._defaultColormapChanged)
def _connectPlot(self):
"""Connect to Plot signals"""
diff --git a/silx/gui/plot/Colormap.py b/silx/gui/plot/Colormap.py
index e797d89..22fea7f 100644
--- a/silx/gui/plot/Colormap.py
+++ b/silx/gui/plot/Colormap.py
@@ -25,11 +25,9 @@
"""Deprecated module providing the Colormap object
"""
-from __future__ import absolute_import
-
__authors__ = ["T. Vincent", "H.Payno"]
__license__ = "MIT"
-__date__ = "24/04/2018"
+__date__ = "27/11/2020"
import silx.utils.deprecation
diff --git a/silx/gui/plot/ImageStack.py b/silx/gui/plot/ImageStack.py
index 3b652ca..fe4b451 100644
--- a/silx/gui/plot/ImageStack.py
+++ b/silx/gui/plot/ImageStack.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2020 European Synchrotron Radiation Facility
+# Copyright (c) 2020-2021 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,7 +41,7 @@ import threading
import typing
import logging
-_logger = logging.getLogger(__file__)
+_logger = logging.getLogger(__name__)
class _PlotWithWaitingLabel(qt.QWidget):
@@ -71,6 +71,7 @@ class _PlotWithWaitingLabel(qt.QWidget):
def __init__(self, parent):
super(_PlotWithWaitingLabel, self).__init__(parent=parent)
+ self._autoResetZoom = True
layout = qt.QStackedLayout(self)
layout.setStackingMode(qt.QStackedLayout.StackAll)
@@ -88,6 +89,24 @@ class _PlotWithWaitingLabel(qt.QWidget):
super(_PlotWithWaitingLabel, self).close()
self.updateThread.stop()
+ def setAutoResetZoom(self, reset):
+ """
+ Should we reset the zoom when adding an image (eq. when browsing)
+
+ :param bool reset:
+ """
+ self._autoResetZoom = reset
+ if self._autoResetZoom:
+ self._plot.resetZoom()
+
+ def isAutoResetZoom(self):
+ """
+
+ :return: True if a reset is done when the image change
+ :rtype: bool
+ """
+ return self._autoResetZoom
+
def setWaiting(self, activate=True):
if activate is True:
self._plot.clear()
@@ -97,7 +116,7 @@ class _PlotWithWaitingLabel(qt.QWidget):
def setData(self, data):
self.setWaiting(activate=False)
- self._plot.addImage(data=data)
+ self._plot.addImage(data=data, resetzoom=self._autoResetZoom)
def clear(self):
self._plot.clear()
@@ -160,8 +179,7 @@ class UrlList(qt.QWidget):
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
+ elif len(sel_items) > 0:
item = sel_items[0]
self._listWidget.setCurrentItem(item)
self.sigCurrentUrlChanged.emit(item.text())
@@ -601,3 +619,18 @@ class ImageStack(qt.QMainWindow):
"""display a simple image of loading..."""
self._plot.setWaiting(activate=True)
+ def setAutoResetZoom(self, reset):
+ """
+ Should we reset the zoom when adding an image (eq. when browsing)
+
+ :param bool reset:
+ """
+ self._plot.setAutoResetZoom(reset)
+
+ def isAutoResetZoom(self) -> bool:
+ """
+
+ :return: True if a reset is done when the image change
+ :rtype: bool
+ """
+ return self._plot.isAutoResetZoom()
diff --git a/silx/gui/plot/ImageView.py b/silx/gui/plot/ImageView.py
index 8cc0cc6..1befe58 100644
--- a/silx/gui/plot/ImageView.py
+++ b/silx/gui/plot/ImageView.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2021 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,9 +47,13 @@ __date__ = "26/04/2018"
import logging
import numpy
+import collections
+from typing import Union
+import weakref
import silx
from .. import qt
+from .. import colors
from . import items, PlotWindow, PlotWidget, actions
from ..colors import Colormap
@@ -57,192 +61,262 @@ from ..colors import cursorColorForColormap
from .tools import LimitsToolBar
from .Profile import ProfileToolBar
from ...utils.proxy import docstring
+from ...utils.enum import Enum
+from .tools.RadarView import RadarView
+from .utils.axis import SyncAxes
+from ..utils import blockSignals
+from . import _utils
+from .tools.profile import manager
+from .tools.profile import rois
_logger = logging.getLogger(__name__)
-# RadarView ###################################################################
+ProfileSumResult = collections.namedtuple("ProfileResult",
+ ["dataXRange", "dataYRange",
+ 'histoH', 'histoHRange',
+ 'histoV', 'histoVRange',
+ "xCoords", "xData",
+ "yCoords", "yData"])
-class RadarView(qt.QGraphicsView):
- """Widget presenting a synthetic view of a 2D area and
- the current visible area.
- Coordinates are as in QGraphicsView:
- x goes from left to right and y goes from top to bottom.
- This widget preserves the aspect ratio of the areas.
+def computeProfileSumOnRange(imageItem, xRange, yRange, cache=None):
+ """
+ Compute a full vertical and horizontal profile on an image item using a
+ a range in the plot referential.
- The 2D area and the visible area can be set with :meth:`setDataRect`
- and :meth:`setVisibleRect`.
- When the visible area has been dragged by the user, its new position
- is signaled by the *visibleRectDragged* signal.
+ Optionally takes a previous computed result to be able to skip the
+ computation.
- It is possible to invert the direction of the axes by using the
- :meth:`scale` method of QGraphicsView.
+ :rtype: ProfileSumResult
"""
+ data = imageItem.getValueData(copy=False)
+ origin = imageItem.getOrigin()
+ scale = imageItem.getScale()
+ height, width = data.shape
+
+ xMin, xMax = xRange
+ yMin, yMax = yRange
+
+ # Convert plot area limits to image coordinates
+ # and work in image coordinates (i.e., in pixels)
+ xMin = int((xMin - origin[0]) / scale[0])
+ xMax = int((xMax - origin[0]) / scale[0])
+ yMin = int((yMin - origin[1]) / scale[1])
+ yMax = int((yMax - origin[1]) / scale[1])
+
+ if (xMin >= width or xMax < 0 or
+ yMin >= height or yMax < 0):
+ return None
+
+ # The image is at least partly in the plot area
+ # Get the visible bounds in image coords (i.e., in pixels)
+ subsetXMin = 0 if xMin < 0 else xMin
+ subsetXMax = (width if xMax >= width else xMax) + 1
+ subsetYMin = 0 if yMin < 0 else yMin
+ subsetYMax = (height if yMax >= height else yMax) + 1
+
+ if cache is not None:
+ if ((subsetXMin, subsetXMax) == cache.dataXRange and
+ (subsetYMin, subsetYMax) == cache.dataYRange):
+ # The visible area of data is the same
+ return cache
+
+ # Rebuild histograms for visible area
+ visibleData = data[subsetYMin:subsetYMax,
+ subsetXMin:subsetXMax]
+ histoHVisibleData = numpy.nansum(visibleData, axis=0)
+ histoVVisibleData = numpy.nansum(visibleData, axis=1)
+ histoHMin = numpy.nanmin(histoHVisibleData)
+ histoHMax = numpy.nanmax(histoHVisibleData)
+ histoVMin = numpy.nanmin(histoVVisibleData)
+ histoVMax = numpy.nanmax(histoVVisibleData)
+
+ # Convert to histogram curve and update plots
+ # Taking into account origin and scale
+ coords = numpy.arange(2 * histoHVisibleData.size)
+ xCoords = (coords + 1) // 2 + subsetXMin
+ xCoords = origin[0] + scale[0] * xCoords
+ xData = numpy.take(histoHVisibleData, coords // 2)
+ coords = numpy.arange(2 * histoVVisibleData.size)
+ yCoords = (coords + 1) // 2 + subsetYMin
+ yCoords = origin[1] + scale[1] * yCoords
+ yData = numpy.take(histoVVisibleData, coords // 2)
+
+ result = ProfileSumResult(
+ dataXRange=(subsetXMin, subsetXMax),
+ dataYRange=(subsetYMin, subsetYMax),
+ histoH=histoHVisibleData,
+ histoHRange=(histoHMin, histoHMax),
+ histoV=histoVVisibleData,
+ histoVRange=(histoVMin, histoVMax),
+ xCoords=xCoords,
+ xData=xData,
+ yCoords=yCoords,
+ yData=yData)
+
+ return result
+
+
+class _SideHistogram(PlotWidget):
+ """
+ Widget displaying one of the side profile of the ImageView.
- visibleRectDragged = qt.Signal(float, float, float, float)
- """Signals that the visible rectangle has been dragged.
-
- It provides: left, top, width, height in data coordinates.
+ Implement ProfileWindow
"""
- _DATA_PEN = qt.QPen(qt.QColor('white'))
- _DATA_BRUSH = qt.QBrush(qt.QColor('light gray'))
- _VISIBLE_PEN = qt.QPen(qt.QColor('red'))
- _VISIBLE_PEN.setWidth(2)
- _VISIBLE_PEN.setCosmetic(True)
- _VISIBLE_BRUSH = qt.QBrush(qt.QColor(0, 0, 0, 0))
- _TOOLTIP = 'Radar View:\nRed contour: Visible area\nGray area: The image'
-
- _PIXMAP_SIZE = 256
-
- class _DraggableRectItem(qt.QGraphicsRectItem):
- """RectItem which signals its change through visibleRectDragged."""
- def __init__(self, *args, **kwargs):
- super(RadarView._DraggableRectItem, self).__init__(
- *args, **kwargs)
-
- self._previousCursor = None
- self.setFlag(qt.QGraphicsItem.ItemIsMovable)
- self.setFlag(qt.QGraphicsItem.ItemSendsGeometryChanges)
- self.setAcceptHoverEvents(True)
- self._ignoreChange = False
- self._constraint = 0, 0, 0, 0
-
- def setConstraintRect(self, left, top, width, height):
- """Set the constraint rectangle for dragging.
-
- The coordinates are in the _DraggableRectItem coordinate system.
-
- This constraint only applies to modification through interaction
- (i.e., this constraint is not applied to change through API).
-
- If the _DraggableRectItem is smaller than the constraint rectangle,
- the _DraggableRectItem remains within the constraint rectangle.
- If the _DraggableRectItem is wider than the constraint rectangle,
- the constraint rectangle remains within the _DraggableRectItem.
- """
- self._constraint = left, left + width, top, top + height
-
- def setPos(self, *args, **kwargs):
- """Overridden to ignore changes from API in itemChange."""
- self._ignoreChange = True
- super(RadarView._DraggableRectItem, self).setPos(*args, **kwargs)
- self._ignoreChange = False
-
- def moveBy(self, *args, **kwargs):
- """Overridden to ignore changes from API in itemChange."""
- self._ignoreChange = True
- super(RadarView._DraggableRectItem, self).moveBy(*args, **kwargs)
- self._ignoreChange = False
-
- def itemChange(self, change, value):
- """Callback called before applying changes to the item."""
- if (change == qt.QGraphicsItem.ItemPositionChange and
- not self._ignoreChange):
- # Makes sure that the visible area is in the data
- # or that data is in the visible area if area is too wide
- x, y = value.x(), value.y()
- xMin, xMax, yMin, yMax = self._constraint
-
- if self.rect().width() <= (xMax - xMin):
- if x < xMin:
- value.setX(xMin)
- elif x > xMax - self.rect().width():
- value.setX(xMax - self.rect().width())
- else:
- if x > xMin:
- value.setX(xMin)
- elif x < xMax - self.rect().width():
- value.setX(xMax - self.rect().width())
-
- if self.rect().height() <= (yMax - yMin):
- if y < yMin:
- value.setY(yMin)
- elif y > yMax - self.rect().height():
- value.setY(yMax - self.rect().height())
- else:
- if y > yMin:
- value.setY(yMin)
- elif y < yMax - self.rect().height():
- value.setY(yMax - self.rect().height())
-
- if self.pos() != value:
- # Notify change through signal
- views = self.scene().views()
- assert len(views) == 1
- views[0].visibleRectDragged.emit(
- value.x() + self.rect().left(),
- value.y() + self.rect().top(),
- self.rect().width(),
- self.rect().height())
-
- return value
-
- return super(RadarView._DraggableRectItem, self).itemChange(
- change, value)
-
- def hoverEnterEvent(self, event):
- """Called when the mouse enters the rectangle area"""
- self._previousCursor = self.cursor()
- self.setCursor(qt.Qt.OpenHandCursor)
-
- def hoverLeaveEvent(self, event):
- """Called when the mouse leaves the rectangle area"""
- if self._previousCursor is not None:
- self.setCursor(self._previousCursor)
- self._previousCursor = None
-
- def __init__(self, parent=None):
- self._scene = qt.QGraphicsScene()
- self._dataRect = self._scene.addRect(0, 0, 1, 1,
- self._DATA_PEN,
- self._DATA_BRUSH)
- self._visibleRect = self._DraggableRectItem(0, 0, 1, 1)
- self._visibleRect.setPen(self._VISIBLE_PEN)
- self._visibleRect.setBrush(self._VISIBLE_BRUSH)
- self._scene.addItem(self._visibleRect)
-
- super(RadarView, self).__init__(self._scene, parent)
- self.setHorizontalScrollBarPolicy(qt.Qt.ScrollBarAlwaysOff)
- self.setVerticalScrollBarPolicy(qt.Qt.ScrollBarAlwaysOff)
- self.setFocusPolicy(qt.Qt.NoFocus)
- self.setStyleSheet('border: 0px')
- self.setToolTip(self._TOOLTIP)
-
- def sizeHint(self):
- # """Overridden to avoid sizeHint to depend on content size."""
- return self.minimumSizeHint()
-
- def wheelEvent(self, event):
- # """Overridden to disable vertical scrolling with wheel."""
- event.ignore()
-
- def resizeEvent(self, event):
- # """Overridden to fit current content to new size."""
- self.fitInView(self._scene.itemsBoundingRect(), qt.Qt.KeepAspectRatio)
- super(RadarView, self).resizeEvent(event)
-
- def setDataRect(self, left, top, width, height):
- """Set the bounds of the data rectangular area.
-
- This sets the coordinate system.
+ sigClose = qt.Signal()
+
+ sigMouseMoved = qt.Signal(float, float)
+
+ def __init__(self, parent=None, backend=None, direction=qt.Qt.Horizontal):
+ super(_SideHistogram, self).__init__(parent=parent, backend=backend)
+ self._direction = direction
+ self.sigPlotSignal.connect(self._plotEvents)
+ self._color = "blue"
+ self.__profile = None
+ self.__profileSum = None
+
+ def _plotEvents(self, eventDict):
+ """Callback for horizontal histogram plot events."""
+ if eventDict['event'] == 'mouseMoved':
+ self.sigMouseMoved.emit(eventDict['x'], eventDict['y'])
+
+ def setProfileColor(self, color):
+ self._color = color
+
+ def setProfileSum(self, result):
+ self.__profileSum = result
+ if self.__profile is None:
+ self.__drawProfileSum()
+
+ def prepareWidget(self, roi):
+ """Implements `ProfileWindow`"""
+ pass
+
+ def setRoiProfile(self, roi):
+ """Implements `ProfileWindow`"""
+ if roi is None:
+ return
+ self._roiColor = colors.rgba(roi.getColor())
+
+ def getProfile(self):
+ """Implements `ProfileWindow`"""
+ return self.__profile
+
+ def setProfile(self, data):
+ """Implements `ProfileWindow`"""
+ self.__profile = data
+ if data is None:
+ self.__drawProfileSum()
+ else:
+ self.__drawProfile()
+
+ def __drawProfileSum(self):
+ """Only draw the profile sum on the plot.
+
+ Other elements are removed
"""
- self._dataRect.setRect(left, top, width, height)
- self._visibleRect.setConstraintRect(left, top, width, height)
- self.fitInView(self._scene.itemsBoundingRect(), qt.Qt.KeepAspectRatio)
+ profileSum = self.__profileSum
+
+ try:
+ self.removeCurve('profile')
+ except Exception:
+ pass
+
+ if profileSum is None:
+ try:
+ self.removeCurve('profilesum')
+ except Exception:
+ pass
+ return
+
+ if self._direction == qt.Qt.Horizontal:
+ xx, yy = profileSum.xCoords, profileSum.xData
+ elif self._direction == qt.Qt.Vertical:
+ xx, yy = profileSum.yData, profileSum.yCoords
+ else:
+ assert False
+
+ self.addCurve(xx, yy,
+ xlabel='', ylabel='',
+ legend="profilesum",
+ color=self._color,
+ linestyle='-',
+ selectable=False,
+ resetzoom=False)
+
+ self.__updateLimits()
- def setVisibleRect(self, left, top, width, height):
- """Set the visible rectangular area.
+ def __drawProfile(self):
+ """Only draw the profile on the plot.
- The coordinates are relative to the data rect.
+ Other elements are removed
"""
- self._visibleRect.setRect(0, 0, width, height)
- self._visibleRect.setPos(left, top)
- self.fitInView(self._scene.itemsBoundingRect(), qt.Qt.KeepAspectRatio)
+ profile = self.__profile
+
+ try:
+ self.removeCurve('profilesum')
+ except Exception:
+ pass
+
+ if profile is None:
+ try:
+ self.removeCurve('profile')
+ except Exception:
+ pass
+ self.setProfileSum(self.__profileSum)
+ return
+
+ if self._direction == qt.Qt.Horizontal:
+ xx, yy = profile.coords, profile.profile
+ elif self._direction == qt.Qt.Vertical:
+ xx, yy = profile.profile, profile.coords
+ else:
+ assert False
+
+ self.addCurve(xx,
+ yy,
+ legend="profile",
+ color=self._roiColor,
+ resetzoom=False)
+
+ self.__updateLimits()
+
+ def __updateLimits(self):
+ if self.__profile:
+ data = self.__profile.profile
+ vMin = numpy.nanmin(data)
+ vMax = numpy.nanmax(data)
+ elif self.__profileSum is not None:
+ if self._direction == qt.Qt.Horizontal:
+ vMin, vMax = self.__profileSum.histoHRange
+ elif self._direction == qt.Qt.Vertical:
+ vMin, vMax = self.__profileSum.histoVRange
+ else:
+ assert False
+ else:
+ vMin, vMax = 0, 0
+
+ # Tune the result using the data margins
+ margins = self.getDataMargins()
+ if self._direction == qt.Qt.Horizontal:
+ _, _, vMin, vMax = _utils.addMarginsToLimits(margins, False, False, 0, 0, vMin, vMax)
+ elif self._direction == qt.Qt.Vertical:
+ vMin, vMax, _, _ = _utils.addMarginsToLimits(margins, False, False, vMin, vMax, 0, 0)
+ else:
+ assert False
+
+ if self._direction == qt.Qt.Horizontal:
+ dataAxis = self.getYAxis()
+ elif self._direction == qt.Qt.Vertical:
+ dataAxis = self.getXAxis()
+ else:
+ assert False
+ with blockSignals(dataAxis):
+ dataAxis.setLimits(vMin, vMax)
-# ImageView ###################################################################
class ImageView(PlotWindow):
"""Display a single image with horizontal and vertical histograms.
@@ -281,10 +355,20 @@ class ImageView(PlotWindow):
Row and columns are either Nan or integer values.
"""
+ class ProfileWindowBehavior(Enum):
+ """ImageView's profile window behavior options"""
+
+ POPUP = 'popup'
+ """All profiles are displayed in pop-up windows"""
+
+ EMBEDDED = 'embedded'
+ """Horizontal, vertical and cross profiles are displayed in
+ sides widgets, others are displayed in pop-up windows.
+ """
+
def __init__(self, parent=None, backend=None):
self._imageLegend = '__ImageView__image' + str(id(self))
self._cache = None # Store currently visible data information
- self._updatingLimits = False
super(ImageView, self).__init__(parent=parent, backend=backend,
resetzoom=True, autoScale=False,
@@ -294,6 +378,11 @@ class ImageView(PlotWindow):
copy=True, save=True, print_=True,
control=False, position=False,
roi=False, mask=True)
+
+ # Enable mask synchronisation to use it in profiles
+ maskToolsWidget = self.getMaskToolsDockWidget().widget()
+ maskToolsWidget.setItemMaskUpdated(True)
+
if parent is None:
self.setWindowTitle('ImageView')
@@ -302,44 +391,40 @@ class ImageView(PlotWindow):
self._initWidgets(backend)
- self.profile = ProfileToolBar(plot=self)
- """"Profile tools attached to this plot.
-
- See :class:`silx.gui.plot.PlotTools.ProfileToolBar`
- """
-
- self.addToolBar(self.profile)
-
- # Sync PlotBackend and ImageView
- self._updateYAxisInverted()
+ self.__profileWindowBehavior = self.ProfileWindowBehavior.POPUP
+ self.__profile = ProfileToolBar(plot=self)
+ self.addToolBar(self.__profile)
def _initWidgets(self, backend):
"""Set-up layout and plots."""
- self._histoHPlot = PlotWidget(backend=backend, parent=self)
- self._histoHPlot.getWidgetHandle().setMinimumHeight(
- self.HISTOGRAMS_HEIGHT)
- self._histoHPlot.getWidgetHandle().setMaximumHeight(
- self.HISTOGRAMS_HEIGHT)
+ self._histoHPlot = _SideHistogram(backend=backend, parent=self, direction=qt.Qt.Horizontal)
+ widgetHandle = self._histoHPlot.getWidgetHandle()
+ widgetHandle.setMinimumHeight(self.HISTOGRAMS_HEIGHT)
+ widgetHandle.setMaximumHeight(self.HISTOGRAMS_HEIGHT)
self._histoHPlot.setInteractiveMode('zoom')
- self._histoHPlot.sigPlotSignal.connect(self._histoHPlotCB)
+ self._histoHPlot.setDataMargins(0., 0., 0.1, 0.1)
+ self._histoHPlot.sigMouseMoved.connect(self._mouseMovedOnHistoH)
+ self._histoHPlot.setProfileColor(self.HISTOGRAMS_COLOR)
+
+ self._histoVPlot = _SideHistogram(backend=backend, parent=self, direction=qt.Qt.Vertical)
+ widgetHandle = self._histoVPlot.getWidgetHandle()
+ widgetHandle.setMinimumWidth(self.HISTOGRAMS_HEIGHT)
+ widgetHandle.setMaximumWidth(self.HISTOGRAMS_HEIGHT)
+ self._histoVPlot.setInteractiveMode('zoom')
+ self._histoVPlot.setDataMargins(0.1, 0.1, 0., 0.)
+ self._histoVPlot.sigMouseMoved.connect(self._mouseMovedOnHistoV)
+ self._histoVPlot.setProfileColor(self.HISTOGRAMS_COLOR)
self.setPanWithArrowKeys(True)
-
self.setInteractiveMode('zoom') # Color set in setColormap
self.sigPlotSignal.connect(self._imagePlotCB)
- self.getYAxis().sigInvertedChanged.connect(self._updateYAxisInverted)
self.sigActiveImageChanged.connect(self._activeImageChangedSlot)
- self._histoVPlot = PlotWidget(backend=backend, parent=self)
- self._histoVPlot.getWidgetHandle().setMinimumWidth(
- self.HISTOGRAMS_HEIGHT)
- self._histoVPlot.getWidgetHandle().setMaximumWidth(
- self.HISTOGRAMS_HEIGHT)
- self._histoVPlot.setInteractiveMode('zoom')
- self._histoVPlot.sigPlotSignal.connect(self._histoVPlotCB)
-
self._radarView = RadarView(parent=self)
- self._radarView.visibleRectDragged.connect(self._radarViewCB)
+ self._radarView.setPlotWidget(self)
+
+ self.__syncXAxis = SyncAxes([self.getXAxis(), self._histoHPlot.getXAxis()])
+ self.__syncYAxis = SyncAxes([self.getYAxis(), self._histoVPlot.getYAxis()])
self.__setCentralWidget()
@@ -382,113 +467,12 @@ class ImageView(PlotWindow):
"""Update histograms content using current active image."""
activeImage = self.getActiveImage()
if activeImage is not None:
- wasUpdatingLimits = self._updatingLimits
- self._updatingLimits = True
-
- data = activeImage.getData(copy=False)
- origin = activeImage.getOrigin()
- scale = activeImage.getScale()
- height, width = data.shape
-
- xMin, xMax = self.getXAxis().getLimits()
- yMin, yMax = self.getYAxis().getLimits()
-
- # Convert plot area limits to image coordinates
- # and work in image coordinates (i.e., in pixels)
- xMin = int((xMin - origin[0]) / scale[0])
- xMax = int((xMax - origin[0]) / scale[0])
- yMin = int((yMin - origin[1]) / scale[1])
- yMax = int((yMax - origin[1]) / scale[1])
-
- if (xMin < width and xMax >= 0 and
- yMin < height and yMax >= 0):
- # The image is at least partly in the plot area
- # Get the visible bounds in image coords (i.e., in pixels)
- subsetXMin = 0 if xMin < 0 else xMin
- subsetXMax = (width if xMax >= width else xMax) + 1
- subsetYMin = 0 if yMin < 0 else yMin
- subsetYMax = (height if yMax >= height else yMax) + 1
-
- if (self._cache is None or
- subsetXMin != self._cache['dataXMin'] or
- subsetXMax != self._cache['dataXMax'] or
- subsetYMin != self._cache['dataYMin'] or
- subsetYMax != self._cache['dataYMax']):
- # The visible area of data has changed, update histograms
-
- # Rebuild histograms for visible area
- visibleData = data[subsetYMin:subsetYMax,
- subsetXMin:subsetXMax]
- histoHVisibleData = numpy.sum(visibleData, axis=0)
- histoVVisibleData = numpy.sum(visibleData, axis=1)
-
- self._cache = {
- 'dataXMin': subsetXMin,
- 'dataXMax': subsetXMax,
- 'dataYMin': subsetYMin,
- 'dataYMax': subsetYMax,
-
- 'histoH': histoHVisibleData,
- 'histoHMin': numpy.min(histoHVisibleData),
- 'histoHMax': numpy.max(histoHVisibleData),
-
- 'histoV': histoVVisibleData,
- 'histoVMin': numpy.min(histoVVisibleData),
- 'histoVMax': numpy.max(histoVVisibleData)
- }
-
- # Convert to histogram curve and update plots
- # Taking into account origin and scale
- coords = numpy.arange(2 * histoHVisibleData.size)
- xCoords = (coords + 1) // 2 + subsetXMin
- xCoords = origin[0] + scale[0] * xCoords
- xData = numpy.take(histoHVisibleData, coords // 2)
- self._histoHPlot.addCurve(xCoords, xData,
- xlabel='', ylabel='',
- replace=False,
- color=self.HISTOGRAMS_COLOR,
- linestyle='-',
- selectable=False)
- vMin = self._cache['histoHMin']
- vMax = self._cache['histoHMax']
- vOffset = 0.1 * (vMax - vMin)
- if vOffset == 0.:
- vOffset = 1.
- self._histoHPlot.getYAxis().setLimits(vMin - vOffset,
- vMax + vOffset)
-
- coords = numpy.arange(2 * histoVVisibleData.size)
- yCoords = (coords + 1) // 2 + subsetYMin
- yCoords = origin[1] + scale[1] * yCoords
- yData = numpy.take(histoVVisibleData, coords // 2)
- self._histoVPlot.addCurve(yData, yCoords,
- xlabel='', ylabel='',
- replace=False,
- color=self.HISTOGRAMS_COLOR,
- linestyle='-',
- selectable=False)
- vMin = self._cache['histoVMin']
- vMax = self._cache['histoVMax']
- vOffset = 0.1 * (vMax - vMin)
- if vOffset == 0.:
- vOffset = 1.
- self._histoVPlot.getXAxis().setLimits(vMin - vOffset,
- vMax + vOffset)
- else:
- self._dirtyCache()
- self._histoHPlot.remove(kind='curve')
- self._histoVPlot.remove(kind='curve')
-
- self._updatingLimits = wasUpdatingLimits
-
- def _updateRadarView(self):
- """Update radar view visible area.
-
- Takes care of y coordinate conversion.
- """
- xMin, xMax = self.getXAxis().getLimits()
- yMin, yMax = self.getYAxis().getLimits()
- self._radarView.setVisibleRect(xMin, yMin, xMax - xMin, yMax - yMin)
+ xRange = self.getXAxis().getLimits()
+ yRange = self.getYAxis().getLimits()
+ result = computeProfileSumOnRange(activeImage, xRange, yRange, self._cache)
+ self._cache = result
+ self._histoHPlot.setProfileSum(result)
+ self._histoVPlot.setProfileSum(result)
# Plots event listeners
@@ -513,104 +497,49 @@ class ImageView(PlotWindow):
data[y][x])
elif eventDict['event'] == 'limitsChanged':
- self._updateHistogramsLimits()
-
- def _updateHistogramsLimits(self):
- # Do not handle histograms limitsChanged while
- # updating their limits from here.
- self._updatingLimits = True
-
- # Refresh histograms
self._updateHistograms()
- xMin, xMax = self.getXAxis().getLimits()
- yMin, yMax = self.getYAxis().getLimits()
+ def _mouseMovedOnHistoH(self, x, y):
+ if self._cache is None:
+ return
+ activeImage = self.getActiveImage()
+ if activeImage is None:
+ return
- # Set horizontal histo limits
- self._histoHPlot.getXAxis().setLimits(xMin, xMax)
+ xOrigin = activeImage.getOrigin()[0]
+ xScale = activeImage.getScale()[0]
- # Set vertical histo limits
- self._histoVPlot.getYAxis().setLimits(yMin, yMax)
+ minValue = xOrigin + xScale * self._cache.dataXRange[0]
- self._updateRadarView()
+ if x >= minValue:
+ data = self._cache.histoH
+ column = int((x - minValue) / xScale)
+ if column >= 0 and column < data.shape[0]:
+ self.valueChanged.emit(
+ float('nan'),
+ float(column + self._cache.dataXRange[0]),
+ data[column])
- self._updatingLimits = False
+ def _mouseMovedOnHistoV(self, x, y):
+ if self._cache is None:
+ return
+ activeImage = self.getActiveImage()
+ if activeImage is None:
+ return
- def _histoHPlotCB(self, eventDict):
- """Callback for horizontal histogram plot events."""
- if eventDict['event'] == 'mouseMoved':
- if self._cache is not None:
- activeImage = self.getActiveImage()
- if activeImage is not None:
- xOrigin = activeImage.getOrigin()[0]
- xScale = activeImage.getScale()[0]
-
- minValue = xOrigin + xScale * self._cache['dataXMin']
-
- if eventDict['x'] >= minValue:
- data = self._cache['histoH']
- column = int((eventDict['x'] - minValue) / xScale)
- if column >= 0 and column < data.shape[0]:
- self.valueChanged.emit(
- float('nan'),
- float(column + self._cache['dataXMin']),
- data[column])
+ yOrigin = activeImage.getOrigin()[1]
+ yScale = activeImage.getScale()[1]
- elif eventDict['event'] == 'limitsChanged':
- if (not self._updatingLimits and
- eventDict['xdata'] != self.getXAxis().getLimits()):
- xMin, xMax = eventDict['xdata']
- self.getXAxis().setLimits(xMin, xMax)
+ minValue = yOrigin + yScale * self._cache.dataYRange[0]
- def _histoVPlotCB(self, eventDict):
- """Callback for vertical histogram plot events."""
- if eventDict['event'] == 'mouseMoved':
- if self._cache is not None:
- activeImage = self.getActiveImage()
- if activeImage is not None:
- yOrigin = activeImage.getOrigin()[1]
- yScale = activeImage.getScale()[1]
-
- minValue = yOrigin + yScale * self._cache['dataYMin']
-
- if eventDict['y'] >= minValue:
- data = self._cache['histoV']
- row = int((eventDict['y'] - minValue) / yScale)
- if row >= 0 and row < data.shape[0]:
- self.valueChanged.emit(
- float(row + self._cache['dataYMin']),
- float('nan'),
- data[row])
-
- elif eventDict['event'] == 'limitsChanged':
- if (not self._updatingLimits and
- eventDict['ydata'] != self.getYAxis().getLimits()):
- yMin, yMax = eventDict['ydata']
- self.getYAxis().setLimits(yMin, yMax)
-
- def _radarViewCB(self, left, top, width, height):
- """Slot for radar view visible rectangle changes."""
- if not self._updatingLimits:
- # Takes care of Y axis conversion
- self.setLimits(left, left + width, top, top + height)
-
- def _updateYAxisInverted(self, inverted=None):
- """Sync image, vertical histogram and radar view axis orientation."""
- if inverted is None:
- # Do not perform this when called from plot signal
- inverted = self.getYAxis().isInverted()
-
- self._histoVPlot.getYAxis().setInverted(inverted)
-
- # Use scale to invert radarView
- # RadarView default Y direction is from top to bottom
- # As opposed to Plot. So invert RadarView when Plot is NOT inverted.
- self._radarView.resetTransform()
- if not inverted:
- self._radarView.scale(1., -1.)
- self._updateRadarView()
-
- self._radarView.update()
+ if y >= minValue:
+ data = self._cache.histoV
+ row = int((y - minValue) / yScale)
+ if row >= 0 and row < data.shape[0]:
+ self.valueChanged.emit(
+ float(row + self._cache.dataYRange[0]),
+ float('nan'),
+ data[row])
def _activeImageChangedSlot(self, previous, legend):
"""Handle Plot active image change.
@@ -620,6 +549,53 @@ class ImageView(PlotWindow):
self._dirtyCache()
self._updateHistograms()
+ def setProfileWindowBehavior(self, behavior: Union[str, ProfileWindowBehavior]):
+ """Set where profile widgets are displayed.
+
+ :param ProfileWindowBehavior behavior:
+ - 'popup': All profiles are displayed in pop-up windows
+ - 'embedded': Horizontal, vertical and cross profiles are displayed in
+ sides widgets, others are displayed in pop-up windows.
+ """
+ behavior = self.ProfileWindowBehavior.from_value(behavior)
+ if behavior is not self.getProfileWindowBehavior():
+ manager = self.__profile.getProfileManager()
+ manager.clearProfile()
+ manager.requestUpdateAllProfile()
+
+ if behavior is self.ProfileWindowBehavior.EMBEDDED:
+ horizontalProfileWindow = self._histoHPlot
+ verticalProfileWindow = self._histoVPlot
+ else:
+ horizontalProfileWindow = None
+ verticalProfileWindow = None
+
+ manager.setSpecializedProfileWindow(
+ rois.ProfileImageHorizontalLineROI, horizontalProfileWindow
+ )
+ manager.setSpecializedProfileWindow(
+ rois.ProfileImageVerticalLineROI, verticalProfileWindow
+ )
+ self.__profileWindowBehavior = behavior
+
+ def getProfileWindowBehavior(self) -> ProfileWindowBehavior:
+ """Returns current profile display behavior.
+
+ See :meth:`setProfileWindowBehavior` and :class:`ProfileWindowBehavior`
+ """
+ return self.__profileWindowBehavior
+
+ def getProfileToolBar(self):
+ """"Returns profile tools attached to this plot.
+
+ :rtype: silx.gui.plot.PlotTools.ProfileToolBar
+ """
+ return self.__profile
+
+ @property
+ def profile(self):
+ return self.getProfileToolBar()
+
def getHistogram(self, axis):
"""Return the histogram and corresponding row or column extent.
@@ -639,12 +615,12 @@ class ImageView(PlotWindow):
else:
if axis == 'x':
return dict(
- data=numpy.array(self._cache['histoH'], copy=True),
- extent=(self._cache['dataXMin'], self._cache['dataXMax']))
+ data=numpy.array(self._cache.histoH, copy=True),
+ extent=self._cache.dataXRange)
else:
return dict(
- data=numpy.array(self._cache['histoV'], copy=True),
- extent=(self._cache['dataYMin'], self._cache['dataYMax']))
+ data=numpy.array(self._cache.histoV, copy=True),
+ extent=(self._cache.dataYRange))
def radarView(self):
"""Get the lower right radarView widget."""
@@ -656,13 +632,10 @@ class ImageView(PlotWindow):
:param RadarView radarView: Widget subclassing RadarView to replace
the lower right corner widget.
"""
- self._radarView.visibleRectDragged.disconnect(self._radarViewCB)
self._radarView = radarView
- self._radarView.visibleRectDragged.connect(self._radarViewCB)
+ self._radarView.setPlotWidget(self)
self.centralWidget().layout().addWidget(self._radarView, 1, 1)
- self._updateYAxisInverted()
-
# High-level API
def getColormap(self):
@@ -782,7 +755,6 @@ class ImageView(PlotWindow):
data = numpy.array(image, order='C', copy=copy)
assert data.size != 0
assert len(data.shape) == 2
- height, width = data.shape
self.addImage(data,
legend=self._imageLegend,
@@ -791,16 +763,8 @@ class ImageView(PlotWindow):
resetzoom=False)
self.setActiveImage(self._imageLegend)
self._updateHistograms()
-
- self._radarView.setDataRect(origin[0],
- origin[1],
- width * scale[0],
- height * scale[1])
-
if reset:
self.resetZoom()
- else:
- self._updateHistogramsLimits()
# ImageViewMainWindow #########################################################
@@ -839,16 +803,22 @@ class ImageViewMainWindow(ImageView):
menu.addAction(actions.control.KeepAspectRatioAction(self, self))
menu.addAction(actions.control.YAxisInvertedAction(self, self))
- 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)
+ self.__profileMenu = self.menuBar().addMenu('Profile')
+ self.__updateProfileMenu()
# Connect to ImageView's signal
self.valueChanged.connect(self._statusBarSlot)
+ def __updateProfileMenu(self):
+ """Update actions available in 'Profile' menu"""
+ profile = self.getProfileToolBar()
+ self.__profileMenu.clear()
+ self.__profileMenu.addAction(profile.hLineAction)
+ self.__profileMenu.addAction(profile.vLineAction)
+ self.__profileMenu.addAction(profile.crossAction)
+ self.__profileMenu.addAction(profile.lineAction)
+ self.__profileMenu.addAction(profile.clearAction)
+
def _statusBarSlot(self, row, column, value):
"""Update status bar with coordinates/value from plots."""
if numpy.isnan(row):
@@ -863,11 +833,13 @@ class ImageViewMainWindow(ImageView):
self.statusBar().showMessage(msg)
- def setImage(self, image, *args, **kwargs):
- """Set the displayed image.
+ @docstring(ImageView)
+ def setProfileWindowBehavior(self, behavior: str):
+ super().setProfileWindowBehavior(behavior)
+ self.__updateProfileMenu()
- See :meth:`ImageView.setImage` for details.
- """
+ @docstring(ImageView)
+ def setImage(self, image, *args, **kwargs):
if hasattr(image, 'dtype') and hasattr(image, 'shape'):
assert len(image.shape) == 2
height, width = image.shape
diff --git a/silx/gui/plot/LegendSelector.py b/silx/gui/plot/LegendSelector.py
index 0ea0fc8..94112aa 100755
--- a/silx/gui/plot/LegendSelector.py
+++ b/silx/gui/plot/LegendSelector.py
@@ -524,11 +524,49 @@ class LegendListView(qt.QListView):
self.setContextMenu(contextMenu)
def setLegendList(self, legendList, row=None):
- self.clear()
- if row is None:
- row = 0
- model = self.model()
- model.insertLegendList(row, legendList)
+ if row is not None:
+ model = self.model()
+ model.insertLegendList(row, legendList)
+ elif len(legendList) != self.model().rowCount():
+ self.clear()
+ model = self.model()
+ model.insertLegendList(0, legendList)
+ else:
+ model = self.model()
+ for i, (new_legend, icon) in enumerate(legendList):
+ modelIndex = model.index(i)
+ legend = str(modelIndex.data(qt.Qt.DisplayRole))
+ if new_legend != legend:
+ model.setData(modelIndex, new_legend, qt.Qt.DisplayRole)
+
+ color = modelIndex.data(LegendModel.iconColorRole)
+ new_color = icon.get('color', None)
+ if new_color != color:
+ model.setData(modelIndex, new_color, LegendModel.iconColorRole)
+
+ linewidth = modelIndex.data(LegendModel.iconLineWidthRole)
+ new_linewidth = icon.get('linewidth', 1.0)
+ if new_linewidth != linewidth:
+ model.setData(modelIndex, new_linewidth, LegendModel.iconLineWidthRole)
+
+ linestyle = modelIndex.data(LegendModel.iconLineStyleRole)
+ new_linestyle = icon.get('linestyle', None)
+ visible = not LegendIconWidget.isEmptyLineStyle(new_linestyle)
+ model.setData(modelIndex, visible, LegendModel.showLineRole)
+ if new_linestyle != linestyle:
+ model.setData(modelIndex, new_linestyle, LegendModel.iconLineStyleRole)
+
+ symbol = modelIndex.data(LegendModel.iconSymbolRole)
+ new_symbol = icon.get('symbol', None)
+ visible = not LegendIconWidget.isEmptySymbol(new_symbol)
+ model.setData(modelIndex, visible, LegendModel.showSymbolRole)
+ if new_symbol != symbol:
+ model.setData(modelIndex, new_symbol, LegendModel.iconSymbolRole)
+
+ selected = modelIndex.data(qt.Qt.CheckStateRole)
+ new_selected = icon.get('selected', True)
+ if new_selected != selected:
+ model.setData(modelIndex, new_selected, qt.Qt.CheckStateRole)
_logger.debug('LegendListView.setLegendList(legendList) finished')
def clear(self):
diff --git a/silx/gui/plot/MaskToolsWidget.py b/silx/gui/plot/MaskToolsWidget.py
index 8ff8641..1ec1e7f 100644
--- a/silx/gui/plot/MaskToolsWidget.py
+++ b/silx/gui/plot/MaskToolsWidget.py
@@ -32,11 +32,9 @@ This widget is meant to work with :class:`silx.gui.plot.PlotWidget`.
"""
from __future__ import division
-
__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "15/02/2019"
-
+__date__ = "08/12/2020"
import os
import sys
@@ -53,16 +51,15 @@ from ._BaseMaskToolsWidget import BaseMask, BaseMaskToolsWidget, BaseMaskToolsDo
from . import items
from ..colors import cursorColorForColormap, rgba
from .. import qt
+from ..utils import LockReentrant
from silx.third_party.EdfFile import EdfFile
from silx.third_party.TiffIO import TiffIO
import fabio
-
_logger = logging.getLogger(__name__)
-
_HDF5_EXT_STR = ' '.join(['*' + ext for ext in NEXUS_HDF5_EXT])
@@ -91,6 +88,7 @@ class ImageMask(BaseMask):
This is meant for internal use by :class:`MaskToolsWidget`.
"""
+
def __init__(self, image=None):
"""
@@ -193,7 +191,7 @@ class ImageMask(BaseMask):
selection = self._mask[max(0, row):row + height + 1,
max(0, col):col + width + 1]
if mask:
- selection[:, :] = level
+ selection[:,:] = level
else:
selection[selection == level] = 0
self._notify()
@@ -289,6 +287,38 @@ class MaskToolsWidget(BaseMaskToolsWidget):
self._z = 1 # Mask layer in plot
self._data = numpy.zeros((0, 0), dtype=numpy.uint8) # Store image
+ self.__itemMaskUpdatedLock = LockReentrant()
+ self.__itemMaskUpdated = False
+
+ def __maskStateChanged(self) -> None:
+ """Handle mask commit to update item mask"""
+ item = self._mask.getDataItem()
+ if item is not None:
+ with self.__itemMaskUpdatedLock:
+ item.setMaskData(self._mask.getMask(copy=True), copy=False)
+
+ def setItemMaskUpdated(self, enabled: bool) -> None:
+ """Toggle item mask and mask tool synchronisation.
+
+ :param bool enabled: True to synchronise. Default: False
+ """
+ enabled = bool(enabled)
+ if enabled != self.__itemMaskUpdated:
+ if self.__itemMaskUpdated:
+ self._mask.sigStateChanged.disconnect(self.__maskStateChanged)
+ self.__itemMaskUpdated = enabled
+ if self.__itemMaskUpdated:
+ # Synchronize item and tool mask
+ self._setMaskedImage(self._mask.getDataItem())
+ self._mask.sigStateChanged.connect(self.__maskStateChanged)
+
+ def isItemMaskUpdated(self) -> bool:
+ """Returns whether or not item and mask tool masks are synchronised.
+
+ :rtype: bool
+ """
+ return self.__itemMaskUpdated
+
def setSelectionMask(self, mask, copy=True):
"""Set the mask to a new array.
@@ -319,13 +349,6 @@ class MaskToolsWidget(BaseMaskToolsWidget):
if numpy.array_equal(mask, self.getSelectionMask()):
return mask.shape
- # ensure all mask attributes are synchronized with the active image
- # and connect listener
- activeImage = self.plot.getActiveImage()
- if activeImage is not None and activeImage.getName() != self._maskName:
- self._activeImageChanged()
- self.plot.sigActiveImageChanged.connect(self._activeImageChanged)
-
if self._data.shape[0:2] == (0, 0) or mask.shape == self._data.shape[0:2]:
self._mask.setMask(mask, copy=copy)
self._mask.commit()
@@ -339,7 +362,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
dtype=numpy.uint8)
height = min(self._data.shape[0], mask.shape[0])
width = min(self._data.shape[1], mask.shape[1])
- resizedMask[:height, :width] = mask[:height, :width]
+ resizedMask[:height,:width] = mask[:height,:width]
self._mask.setMask(resizedMask, copy=False)
self._mask.commit()
return resizedMask.shape
@@ -374,7 +397,9 @@ class MaskToolsWidget(BaseMaskToolsWidget):
self._activeImageChangedAfterCare)
except (RuntimeError, TypeError):
pass
- self._activeImageChanged() # Init mask + enable/disable widget
+
+ # Sync with current active image
+ self._setMaskedImage(self.plot.getActiveImage())
self.plot.sigActiveImageChanged.connect(self._activeImageChanged)
def hideEvent(self, event):
@@ -383,14 +408,41 @@ class MaskToolsWidget(BaseMaskToolsWidget):
self._activeImageChanged)
except (RuntimeError, TypeError):
pass
+
+ image = self.getMaskedItem()
+ if image is not None:
+ try:
+ image.sigItemChanged.disconnect(self.__imageChanged)
+ except (RuntimeError, TypeError):
+ pass # TODO should not happen
+
if self.isMaskInteractionActivated():
# Disable drawing tool
self.browseAction.trigger()
- if self.getSelectionMask(copy=False) is not None:
+ if self.isItemMaskUpdated(): # No "after-care"
+ self._data = numpy.zeros((0, 0), dtype=numpy.uint8)
+ self._mask.setDataItem(None)
+ self._mask.reset()
+
+ if self.plot.getImage(self._maskName):
+ self.plot.remove(self._maskName, kind='image')
+
+ elif self.getSelectionMask(copy=False) is not None:
self.plot.sigActiveImageChanged.connect(
self._activeImageChangedAfterCare)
+ def _activeImageChanged(self, previous, current):
+ """Reacts upon active image change.
+
+ Only handle change of active image items here.
+ """
+ if previous != current:
+ image = self.plot.getActiveImage()
+ if image is not None and image.getName() == self._maskName:
+ image = None # Active image is the mask
+ self._setMaskedImage(image)
+
def _setOverlayColorForImage(self, image):
"""Set the color of overlay adapted to image
@@ -443,41 +495,93 @@ class MaskToolsWidget(BaseMaskToolsWidget):
self._mask.setDataItem(activeImage)
self._updatePlotMask()
- def _activeImageChanged(self, *args):
- """Update widget and mask according to active image changes"""
- activeImage = self.plot.getActiveImage()
- 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...
+ def _setMaskedImage(self, image):
+ """Change the image that is used a reference to author the mask"""
+ previous = self.getMaskedItem()
+ if previous is not None and self.isVisible():
+ # Disconnect from previous image
+ try:
+ previous.sigItemChanged.disconnect(self.__imageChanged)
+ except TypeError:
+ pass # TODO fixme should not happen
+
+ # Set the image
+ self._mask.setDataItem(image)
+
+ if image is None: # No image, disable mask
self.setEnabled(False)
self._data = numpy.zeros((0, 0), dtype=numpy.uint8)
self._mask.reset()
self._mask.commit()
- else: # There is an active image
- self.setEnabled(True)
+ self._updateInteractiveMode()
+
+ else: # Update and connect to image's sigItemChanged
+ if self.isItemMaskUpdated():
+ if image.getMaskData(copy=False) is None:
+ # Image item has no mask: use current mask from the tool
+ image.setMaskData(
+ self.getSelectionMask(copy=False), copy=True)
+ else: # Image item has a mask: set it in tool
+ self.setSelectionMask(
+ image.getMaskData(copy=False), copy=True)
+ self._mask.resetHistory()
+ self.__imageUpdated()
+ if self.isVisible():
+ image.sigItemChanged.connect(self.__imageChanged)
+
+ def __imageChanged(self, event):
+ """Reacts upon image item changes"""
+ image = self._mask.getDataItem()
+ if image is None:
+ _logger.error("Mask is not attached to an image")
+ return
- self._setOverlayColorForImage(activeImage)
+ if event in (items.ItemChangedType.COLORMAP,
+ items.ItemChangedType.DATA,
+ items.ItemChangedType.POSITION,
+ items.ItemChangedType.SCALE,
+ items.ItemChangedType.VISIBLE,
+ items.ItemChangedType.ZVALUE):
+ self.__imageUpdated()
+
+ elif (event == items.ItemChangedType.MASK and
+ self.isItemMaskUpdated() and
+ not self.__itemMaskUpdatedLock.locked()):
+ # Update mask from the image item unless mask tool is updating it
+ self.setSelectionMask(image.getMaskData(copy=False), copy=True)
+
+ def __imageUpdated(self):
+ """Synchronize mask with current state of the image"""
+ image = self._mask.getDataItem()
+ if image is None:
+ _logger.error("No active image while expecting one")
+ return
- self._setMaskColors(self.levelSpinBox.value(),
- self.transparencySlider.value() /
- self.transparencySlider.maximum())
+ self._setOverlayColorForImage(image)
- self._origin = activeImage.getOrigin()
- self._scale = activeImage.getScale()
- self._z = activeImage.getZValue() + 1
- self._data = activeImage.getData(copy=False)
- self._mask.setDataItem(activeImage)
- if self._data.shape[:2] != self._mask.getMask(copy=False).shape:
- self._mask.reset(self._data.shape[:2])
- self._mask.commit()
- else:
- # Refresh in case origin, scale, z changed
- self._updatePlotMask()
+ self._setMaskColors(self.levelSpinBox.value(),
+ self.transparencySlider.value() /
+ self.transparencySlider.maximum())
+
+ self._origin = image.getOrigin()
+ self._scale = image.getScale()
+ self._z = image.getZValue() + 1
+ self._data = image.getData(copy=False)
+ self._mask.setDataItem(image)
+ if self._data.shape[:2] != self._mask.getMask(copy=False).shape:
+ self._mask.reset(self._data.shape[:2])
+ self._mask.commit()
+ else:
+ # Refresh in case origin, scale, z changed
+ self._updatePlotMask()
+
+ # Visible and with data
+ self.setEnabled(image.isVisible() and self._data.size != 0)
- # Threshold tools only available for data with colormap
- self.thresholdGroup.setEnabled(self._data.ndim == 2)
+ # Threshold tools only available for data with colormap
+ self.thresholdGroup.setEnabled(self._data.ndim == 2)
self._updateInteractiveMode()
@@ -809,6 +913,7 @@ class MaskToolsDockWidget(BaseMaskToolsDockWidget):
:param plot: The PlotWidget this widget is operating on
:paran str name: The title of this widget
"""
+
def __init__(self, parent=None, plot=None, name='Mask'):
widget = MaskToolsWidget(plot=plot)
super(MaskToolsDockWidget, self).__init__(parent, name, widget)
diff --git a/silx/gui/plot/PlotWidget.py b/silx/gui/plot/PlotWidget.py
index 23b7fe9..2a211de 100755
--- a/silx/gui/plot/PlotWidget.py
+++ b/silx/gui/plot/PlotWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2021 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,6 +42,7 @@ from collections import OrderedDict, namedtuple
from contextlib import contextmanager
import datetime as dt
import itertools
+import typing
import warnings
import numpy
@@ -84,6 +85,166 @@ _PlotDataRange = namedtuple('PlotDataRange',
['x', 'y', 'yright'])
+class _PlotWidgetSelection(qt.QObject):
+ """Object managing a :class:`PlotWidget` selection.
+
+ It is a wrapper over :class:`PlotWidget`'s active items API.
+
+ :param PlotWidget parent:
+ """
+
+ sigCurrentItemChanged = qt.Signal(object, object)
+ """This signal is emitted whenever the current item changes.
+
+ It provides the current and previous items.
+ """
+
+ sigSelectedItemsChanged = qt.Signal()
+ """Signal emitted whenever the list of selected items changes."""
+
+ def __init__(self, parent):
+ assert isinstance(parent, PlotWidget)
+ super(_PlotWidgetSelection, self).__init__(parent=parent)
+
+ # Init history
+ self.__history = [ # Store active items from most recent to oldest
+ item for item in (parent.getActiveCurve(),
+ parent.getActiveImage(),
+ parent.getActiveScatter())
+ if item is not None]
+
+ self.__current = self.__mostRecentActiveItem()
+
+ parent.sigActiveImageChanged.connect(self._activeImageChanged)
+ parent.sigActiveCurveChanged.connect(self._activeCurveChanged)
+ parent.sigActiveScatterChanged.connect(self._activeScatterChanged)
+
+ def __mostRecentActiveItem(self) -> typing.Optional[items.Item]:
+ """Returns most recent active item."""
+ return self.__history[0] if len(self.__history) >= 1 else None
+
+ def getSelectedItems(self) -> typing.Tuple[items.Item]:
+ """Returns the list of currently selected items in the :class:`PlotWidget`.
+
+ The list is given from most recently current item to oldest one."""
+ plot = self.parent()
+ if plot is None:
+ return ()
+
+ active = tuple(self.__history)
+
+ current = self.getCurrentItem()
+ if current is not None and current not in active:
+ # Current might not be an active item, if so add it
+ active = (current,) + active
+
+ return active
+
+ def getCurrentItem(self) -> typing.Optional[items.Item]:
+ """Returns the current item in the :class:`PlotWidget` or None. """
+ return self.__current
+
+ def setCurrentItem(self, item: typing.Optional[items.Item]):
+ """Set the current item in the :class:`PlotWidget`.
+
+ :param item:
+ The new item to select or None to clear the selection.
+ :raise ValueError: If the item is not the :class:`PlotWidget`
+ """
+ previous = self.getCurrentItem()
+ if previous is item:
+ return
+
+ previousSelected = self.getSelectedItems()
+
+ if item is None:
+ self.__current = None
+
+ # Reset all PlotWidget active items
+ plot = self.parent()
+ if plot is not None:
+ for kind in PlotWidget._ACTIVE_ITEM_KINDS:
+ if plot._getActiveItem(kind) is not None:
+ plot._setActiveItem(kind, None)
+
+ elif isinstance(item, items.Item):
+ plot = self.parent()
+ if plot is None or item.getPlot() is not plot:
+ raise ValueError(
+ "Item is not in the PlotWidget: %s" % str(item))
+ self.__current = item
+
+ kind = plot._itemKind(item)
+
+ # Clean-up history to be safe
+ self.__history = [item for item in self.__history
+ if PlotWidget._itemKind(item) != kind]
+
+ # Sync active item if needed
+ if (kind in plot._ACTIVE_ITEM_KINDS and
+ item is not plot._getActiveItem(kind)):
+ plot._setActiveItem(kind, item.getName())
+ else:
+ raise ValueError("Not an Item: %s" % str(item))
+
+ self.sigCurrentItemChanged.emit(previous, item)
+
+ if previousSelected != self.getSelectedItems():
+ self.sigSelectedItemsChanged.emit()
+
+ def __activeItemChanged(self,
+ kind: str,
+ previous: typing.Optional[str],
+ legend: typing.Optional[str]):
+ """Set current item from kind and legend"""
+ if previous == legend:
+ return # No-op for update of item
+
+ plot = self.parent()
+ if plot is None:
+ return
+
+ previousSelected = self.getSelectedItems()
+
+ # Remove items of this kind from the history
+ self.__history = [item for item in self.__history
+ if PlotWidget._itemKind(item) != kind]
+
+ # Retrieve current item
+ if legend is None: # Use most recent active item
+ currentItem = self.__mostRecentActiveItem()
+ else:
+ currentItem = plot._getItem(kind=kind, legend=legend)
+ if currentItem is None: # Fallback in case something went wrong
+ currentItem = self.__mostRecentActiveItem()
+
+ # Update history
+ if currentItem is not None:
+ while currentItem in self.__history:
+ self.__history.remove(currentItem)
+ self.__history.insert(0, currentItem)
+
+ if currentItem != self.__current:
+ previousItem = self.__current
+ self.__current = currentItem
+ self.sigCurrentItemChanged.emit(previousItem, currentItem)
+
+ if previousSelected != self.getSelectedItems():
+ self.sigSelectedItemsChanged.emit()
+
+ def _activeImageChanged(self, previous, current):
+ """Handle active image change"""
+ self.__activeItemChanged('image', previous, current)
+
+ def _activeCurveChanged(self, previous, current):
+ """Handle active curve change"""
+ self.__activeItemChanged('curve', previous, current)
+
+ def _activeScatterChanged(self, previous, current):
+ """Handle active scatter change"""
+ self.__activeItemChanged('scatter', previous, current)
+
+
class PlotWidget(qt.QMainWindow):
"""Qt Widget providing a 1D/2D plot.
@@ -313,6 +474,9 @@ class PlotWidget(qt.QMainWindow):
self._foregroundColorsUpdated()
self._backgroundColorsUpdated()
+ # selection handling
+ self.__selection = None
+
def __getBackendClass(self, backend):
"""Returns backend class corresponding to backend.
@@ -374,6 +538,12 @@ class PlotWidget(qt.QMainWindow):
raise ValueError("Backend not supported %s" % str(backend))
+ def selection(self):
+ """Returns the selection hander"""
+ if self.__selection is None: # Lazy initialization
+ self.__selection = _PlotWidgetSelection(parent=self)
+ return self.__selection
+
# TODO: Can be removed for silx 0.10
@staticmethod
@deprecated(replacement="silx.config.DEFAULT_PLOT_BACKEND", since_version="0.8", skip_backtrace_count=2)
@@ -849,6 +1019,21 @@ class PlotWidget(qt.QMainWindow):
self.notify('contentChanged', action='remove',
kind=kind, legend=item.getName())
+ def discardItem(self, item) -> bool:
+ """Remove the item from the plot.
+
+ Same as :meth:`removeItem` but do not raise an exception.
+
+ :param ~silx.gui.plot.items.Item item: Item to remove from the plot.
+ :returns: True if the item was present, False otherwise.
+ """
+ try:
+ self.removeItem(item)
+ except ValueError:
+ return False
+ else:
+ return True
+
@deprecated(replacement='addItem', since_version='0.13')
def _add(self, item):
return self.addItem(item)
@@ -910,8 +1095,8 @@ class PlotWidget(qt.QMainWindow):
:param numpy.ndarray y: The data corresponding to the y coordinates
:param str legend: The legend to be associated to the curve (or None)
:param info: User-defined information associated to the curve
- :param bool replace: True (the default) to delete already existing
- curves
+ :param bool replace: True to delete already existing curves
+ (the default is False)
:param color: color(s) to be used
:type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or
one of the predefined color names defined in colors.py
diff --git a/silx/gui/plot/Profile.py b/silx/gui/plot/Profile.py
index 8abddbe..7565155 100644
--- a/silx/gui/plot/Profile.py
+++ b/silx/gui/plot/Profile.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2021 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
@@ -57,16 +57,43 @@ class _CustomProfileManager(manager.ProfileManager):
if it is specified. Else the behavior is the same as the default
ProfileManager """
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.__profileWindow = None
+ self.__specializedProfileWindows = {}
+
+ def setSpecializedProfileWindow(self, roiClass, profileWindow):
+ """Set a profile window for a given class or ROI.
+
+ Setting profileWindow to None removes the roiClass from the list.
+
+ :param roiClass:
+ :param profileWindow:
+ """
+ if profileWindow is None:
+ self.__specializedProfileWindows.pop(roiClass, None)
+ else:
+ self.__specializedProfileWindows[roiClass] = profileWindow
+
def setProfileWindow(self, profileWindow):
self.__profileWindow = profileWindow
def createProfileWindow(self, plot, roi):
+ for roiClass, specializedProfileWindow in self.__specializedProfileWindows.items():
+ if isinstance(roi, roiClass):
+ return specializedProfileWindow
+
if self.__profileWindow is not None:
return self.__profileWindow
else:
return super(_CustomProfileManager, self).createProfileWindow(plot, roi)
def clearProfileWindow(self, profileWindow):
+ for specializedProfileWindow in self.__specializedProfileWindows.values():
+ if profileWindow is specializedProfileWindow:
+ profileWindow.setProfile(None)
+ return
+
if self.__profileWindow is not None:
self.__profileWindow.setProfile(None)
else:
@@ -116,7 +143,7 @@ class ProfileToolBar(qt.QToolBar):
# If a profileWindow is defined,
# It will be used to display all the profiles
- self._manager = _CustomProfileManager(self, plot)
+ self._manager = self.createProfileManager(self, plot)
self._manager.setProfileWindow(profileWindow)
self._manager.setDefaultColorFromCursorColor(True)
self._manager.setItemType(image=True)
@@ -155,6 +182,9 @@ class ProfileToolBar(qt.QToolBar):
plot.sigActiveImageChanged.connect(self._activeImageChanged)
self._activeImageChanged()
+ def createProfileManager(self, parent, plot):
+ return _CustomProfileManager(parent, plot)
+
def _createProfileActions(self):
self.hLineAction = self._manager.createProfileAction(rois.ProfileImageHorizontalLineROI, self)
self.vLineAction = self._manager.createProfileAction(rois.ProfileImageVerticalLineROI, self)
diff --git a/silx/gui/plot/StatsWidget.py b/silx/gui/plot/StatsWidget.py
index 26b48db..6d8739e 100644
--- a/silx/gui/plot/StatsWidget.py
+++ b/silx/gui/plot/StatsWidget.py
@@ -868,6 +868,12 @@ class StatsTable(_StatsWidgetBase, TableWidget):
statsHandler = self.getStatsHandler()
if statsHandler is not None:
+ # _updateStats is call when the plot visible area change.
+ # to force stats update we consider roi changed
+ if self._statsOnVisibleData:
+ roi_changed = True
+ else:
+ roi_changed = False
stats = statsHandler.calculate(
item, plot, self._statsOnVisibleData,
data_changed=data_changed, roi_changed=roi_changed)
diff --git a/silx/gui/plot/_BaseMaskToolsWidget.py b/silx/gui/plot/_BaseMaskToolsWidget.py
index 3298498..407ab11 100644
--- a/silx/gui/plot/_BaseMaskToolsWidget.py
+++ b/silx/gui/plot/_BaseMaskToolsWidget.py
@@ -29,7 +29,7 @@ from __future__ import division
__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "12/04/2019"
+__date__ = "08/12/2020"
import os
import weakref
@@ -60,6 +60,9 @@ class BaseMask(qt.QObject):
sigChanged = qt.Signal()
"""Signal emitted when the mask has changed"""
+ sigStateChanged = qt.Signal()
+ """Signal emitted for each mask commit/undo/redo operation"""
+
sigUndoable = qt.Signal(bool)
"""Signal emitted when undo becomes possible/impossible"""
@@ -81,7 +84,6 @@ class BaseMask(qt.QObject):
if dataItem is not None:
self.setDataItem(dataItem)
self.reset(self.getDataValues().shape)
-
super(BaseMask, self).__init__()
def setDataItem(self, item):
@@ -92,6 +94,13 @@ class BaseMask(qt.QObject):
"""
self._dataItem = item
+ def getDataItem(self):
+ """Returns current plot item the mask is on.
+
+ :rtype: Union[~silx.gui.plot.items.Item,None]
+ """
+ return self._dataItem
+
def getDataValues(self):
"""Return data values, as a numpy array with the same shape
as the mask.
@@ -152,6 +161,7 @@ class BaseMask(qt.QObject):
if len(self._history) == 2:
self.sigUndoable.emit(True)
+ self.sigStateChanged.emit()
def undo(self):
"""Restore previous mask if any"""
@@ -164,6 +174,7 @@ class BaseMask(qt.QObject):
self.sigRedoable.emit(True)
if len(self._history) == 1: # Last value in history
self.sigUndoable.emit(False)
+ self.sigStateChanged.emit()
def redo(self):
"""Restore previously undone modification if any"""
@@ -176,8 +187,9 @@ class BaseMask(qt.QObject):
self.sigRedoable.emit(False)
if len(self._history) == 2: # Something to undo
self.sigUndoable.emit(True)
+ self.sigStateChanged.emit()
- # Whole mask operations
+ # Whole mask operations
def clear(self, level):
"""Set all values of the given mask level to 0.
@@ -211,7 +223,7 @@ class BaseMask(qt.QObject):
"""
if shape is None:
# assume dimensionality never changes
- shape = (0, ) * len(self._mask.shape) # empty array
+ shape = (0,) * len(self._mask.shape) # empty array
shapeChanged = (shape != self._mask.shape)
self._mask = numpy.zeros(shape, dtype=numpy.uint8)
if shapeChanged:
@@ -415,6 +427,13 @@ class BaseMaskToolsWidget(qt.QWidget):
"""Notify mask changes"""
self.sigMaskChanged.emit()
+ def getMaskedItem(self):
+ """Returns the item that is currently being masked
+
+ :rtype: Union[~silx.gui.plot.items.Item,None]
+ """
+ return self._mask.getDataItem()
+
def getSelectionMask(self, copy=True):
"""Get the current mask as a numpy array.
@@ -935,11 +954,11 @@ class BaseMaskToolsWidget(qt.QWidget):
colors = numpy.empty((self._maxLevelNumber + 1, 4), dtype=numpy.float32)
# Set color
- colors[:, :3] = self._defaultOverlayColor[:3]
+ colors[:,:3] = self._defaultOverlayColor[:3]
# check if some colors has been directly set by the user
mask = numpy.equal(self._defaultColors, False)
- colors[mask, :3] = self._overlayColors[mask, :3]
+ colors[mask,:3] = self._overlayColors[mask,:3]
# Set alpha
colors[:, -1] = alpha / 2.
diff --git a/silx/gui/plot/actions/control.py b/silx/gui/plot/actions/control.py
index 182ac78..439985e 100755
--- a/silx/gui/plot/actions/control.py
+++ b/silx/gui/plot/actions/control.py
@@ -374,22 +374,7 @@ class ColormapAction(PlotAction):
return
image = self.plot.getActiveImage()
- if isinstance(image, items.ImageComplexData):
- # Specific init for complex images
- colormap = image.getColormap()
-
- mode = image.getComplexMode()
- if mode in (items.ImageComplexData.ComplexMode.AMPLITUDE_PHASE,
- items.ImageComplexData.ComplexMode.LOG10_AMPLITUDE_PHASE):
- data = image.getData(
- copy=False, mode=items.ImageComplexData.ComplexMode.PHASE)
- else:
- data = image.getData(copy=False)
-
- # Set histogram and range if any
- self._dialog.setData(data)
-
- elif isinstance(image, items.ColormapMixIn):
+ if isinstance(image, items.ColormapMixIn):
# Set dialog from active image
colormap = image.getColormap()
# Set histogram and range if any
diff --git a/silx/gui/plot/actions/histogram.py b/silx/gui/plot/actions/histogram.py
index f3e6370..0bba558 100644
--- a/silx/gui/plot/actions/histogram.py
+++ b/silx/gui/plot/actions/histogram.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2021 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
@@ -34,22 +34,238 @@ The following QAction are available:
from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
-__date__ = "10/10/2018"
+__date__ = "01/12/2020"
__license__ = "MIT"
import numpy
import logging
+import typing
import weakref
from .PlotToolAction import PlotToolAction
+
from silx.math.histogram import Histogramnd
from silx.math.combo import min_max
from silx.gui import qt
from silx.gui.plot import items
+from silx.gui.widgets.ElidedLabel import ElidedLabel
+from silx.utils.deprecation import deprecated
_logger = logging.getLogger(__name__)
+class _ElidedLabel(ElidedLabel):
+ """QLabel with a default size larger than what is displayed."""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.setTextInteractionFlags(qt.Qt.TextSelectableByMouse)
+
+ def sizeHint(self):
+ hint = super().sizeHint()
+ nbchar = max(len(self.getText()), 12)
+ width = self.fontMetrics().boundingRect('#' * nbchar).width()
+ return qt.QSize(max(hint.width(), width), hint.height())
+
+
+class _StatWidget(qt.QWidget):
+ """Widget displaying a name and a value
+
+ :param parent:
+ :param name:
+ """
+
+ def __init__(self, parent=None, name: str=''):
+ super().__init__(parent)
+ layout = qt.QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ keyWidget = qt.QLabel(parent=self)
+ keyWidget.setText("<b>" + name.capitalize() + ":<b>")
+ layout.addWidget(keyWidget)
+ self.__valueWidget = _ElidedLabel(parent=self)
+ self.__valueWidget.setText("-")
+ self.__valueWidget.setTextInteractionFlags(
+ qt.Qt.TextSelectableByMouse | qt.Qt.TextSelectableByKeyboard)
+ layout.addWidget(self.__valueWidget)
+
+ def setValue(self, value: typing.Optional[float]):
+ """Set the displayed value
+
+ :param value:
+ """
+ self.__valueWidget.setText(
+ "-" if value is None else "{:.5g}".format(value))
+
+
+class HistogramWidget(qt.QWidget):
+ """Widget displaying a histogram and some statistic indicators"""
+
+ _SUPPORTED_ITEM_CLASS = items.ImageBase, items.Scatter
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.setWindowTitle('Histogram')
+
+ self.__itemRef = None # weakref on the item to track
+
+ layout = qt.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ # Plot
+ # Lazy import to avoid circular dependencies
+ from silx.gui.plot.PlotWindow import Plot1D
+ self.__plot = Plot1D(self)
+ layout.addWidget(self.__plot)
+
+ self.__plot.setDataMargins(0.1, 0.1, 0.1, 0.1)
+ self.__plot.getXAxis().setLabel("Value")
+ self.__plot.getYAxis().setLabel("Count")
+ posInfo = self.__plot.getPositionInfoWidget()
+ posInfo.setSnappingMode(posInfo.SNAPPING_CURVE)
+
+ # Stats display
+ statsWidget = qt.QWidget(self)
+ layout.addWidget(statsWidget)
+ statsLayout = qt.QHBoxLayout(statsWidget)
+ statsLayout.setContentsMargins(4, 4, 4, 4)
+
+ self.__statsWidgets = dict(
+ (name, _StatWidget(parent=statsWidget, name=name))
+ for name in ("min", "max", "mean", "std", "sum"))
+
+ for widget in self.__statsWidgets.values():
+ statsLayout.addWidget(widget)
+ statsLayout.addStretch(1)
+
+ def getPlotWidget(self):
+ """Returns :class:`PlotWidget` use to display the histogram"""
+ return self.__plot
+
+ def resetZoom(self):
+ """Reset PlotWidget zoom"""
+ self.getPlotWidget().resetZoom()
+
+ def reset(self):
+ """Clear displayed information"""
+ self.getPlotWidget().clear()
+ self.setStatistics()
+
+ def getItem(self) -> typing.Optional[items.Item]:
+ """Returns item used to display histogram and statistics."""
+ return None if self.__itemRef is None else self.__itemRef()
+
+ def setItem(self, item: typing.Optional[items.Item]):
+ """Set item from which to display histogram and statistics.
+
+ :param item:
+ """
+ previous = self.getItem()
+ if previous is not None:
+ previous.sigItemChanged.disconnect(self.__itemChanged)
+
+ self.__itemRef = None if item is None else weakref.ref(item)
+ if item is not None:
+ if isinstance(item, self._SUPPORTED_ITEM_CLASS):
+ # Only listen signal for supported items
+ item.sigItemChanged.connect(self.__itemChanged)
+ self._updateFromItem()
+
+ def __itemChanged(self, event):
+ """Handle update of the item"""
+ if event in (items.ItemChangedType.DATA, items.ItemChangedType.MASK):
+ self._updateFromItem()
+
+ def _updateFromItem(self):
+ """Update histogram and stats from the item"""
+ item = self.getItem()
+
+ if item is None:
+ self.reset()
+ return
+
+ if not isinstance(item, self._SUPPORTED_ITEM_CLASS):
+ _logger.error("Unsupported item", item)
+ self.reset()
+ return
+
+ # Compute histogram and stats
+ array = item.getValueData(copy=False)
+
+ if array.size == 0:
+ self.reset()
+ 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)
+ if len(histogram.edges) != 1:
+ _logger.error("Error while computing the histogram")
+ self.reset()
+ return
+
+ self.setHistogram(histogram.histo, histogram.edges[0])
+ self.resetZoom()
+ self.setStatistics(
+ min_=xmin,
+ max_=xmax,
+ mean=numpy.nanmean(array),
+ std=numpy.nanstd(array),
+ sum_=numpy.nansum(array))
+
+ def setHistogram(self, histogram, edges):
+ """Set displayed histogram
+
+ :param histogram: Bin values (N)
+ :param edges: Bin edges (N+1)
+ """
+ self.getPlotWidget().addHistogram(
+ histogram=histogram,
+ edges=edges,
+ legend='histogram',
+ fill=True,
+ color='#66aad7',
+ resetzoom=False)
+
+ def getHistogram(self, copy: bool=True):
+ """Returns currently displayed histogram.
+
+ :param copy: True to get a copy,
+ False to get internal representation (Do not modify!)
+ :return: (histogram, edges) or None
+ """
+ for item in self.getPlotWidget().getItems():
+ if item.getName() == 'histogram':
+ return (item.getValueData(copy=copy),
+ item.getBinEdgesData(copy=copy))
+ else:
+ return None
+
+ def setStatistics(self,
+ min_: typing.Optional[float] = None,
+ max_: typing.Optional[float] = None,
+ mean: typing.Optional[float] = None,
+ std: typing.Optional[float] = None,
+ sum_: typing.Optional[float] = None):
+ """Set displayed statistic indicators."""
+ self.__statsWidgets['min'].setValue(min_)
+ self.__statsWidgets['max'].setValue(max_)
+ self.__statsWidgets['mean'].setValue(mean)
+ self.__statsWidgets['std'].setValue(std)
+ self.__statsWidgets['sum'].setValue(sum_)
+
+
class _LastActiveItem(qt.QObject):
sigActiveItemChanged = qt.Signal(object, object)
@@ -98,20 +314,26 @@ class _LastActiveItem(qt.QObject):
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)
+ if current is None: # Fall-back to active scatter if any
+ self.setActiveItem(plot.getActiveScatter())
else:
- # Do not touch anything, which is consistent with silx v0.12 behavior
- pass
+ 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)
+ if current is None: # Fall-back to active image if any
+ self.setActiveItem(plot.getActiveImage())
+ else:
+ item = plot.getScatter(current)
+ self.setActiveItem(item)
class PixelIntensitiesHistoAction(PlotToolAction):
@@ -129,130 +351,42 @@ class PixelIntensitiesHistoAction(PlotToolAction):
tooltip='Compute image intensity distribution',
parent=parent)
self._lastItemFilter = _LastActiveItem(self, plot)
- self._histo = None
- self._item = None
def _connectPlot(self, window):
self._lastItemFilter.sigActiveItemChanged.connect(self._activeItemChanged)
item = self._lastItemFilter.getActiveItem()
- self._setSelectedItem(item)
+ self.getHistogramWidget().setItem(item)
PlotToolAction._connectPlot(self, window)
def _disconnectPlot(self, window):
self._lastItemFilter.sigActiveItemChanged.disconnect(self._activeItemChanged)
PlotToolAction._disconnectPlot(self, window)
- self._setSelectedItem(None)
-
- def _getSelectedItem(self):
- item = self._item
- if item is None:
- return None
- else:
- return item()
+ self.getHistogramWidget().setItem(None)
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
+ self.getHistogramWidget().setItem(current)
+ @deprecated(since_version='0.15.0')
def computeIntensityDistribution(self):
- """Get the active image and compute the image intensity distribution
- """
- item = self._getSelectedItem()
-
- 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')
- 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
+ self.getHistogramWidget()._updateFromItem()
- 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 getHistogramWidget(self):
+ """Returns the widget displaying the histogram"""
+ return self._getToolWindow()
+ @deprecated(since_version='0.15.0',
+ replacement='getHistogramWidget().getPlotWidget()')
def getHistogramPlotWidget(self):
- """Create the plot histogram if needed, otherwise create it
-
- :return: the PlotWidget showing the histogram of the pixel intensities
- """
- return self._getToolWindow()
+ return self._getToolWindow().getPlotWidget()
def _createToolWindow(self):
- from silx.gui.plot.PlotWindow import Plot1D
- window = Plot1D(parent=self.plot)
- window.setWindowFlags(qt.Qt.Window)
- window.setWindowTitle('Image Intensity Histogram')
- window.setDataMargins(0.1, 0.1, 0.1, 0.1)
- window.getXAxis().setLabel("Value")
- window.getYAxis().setLabel("Count")
- return window
-
- def getHistogram(self):
+ return HistogramWidget(self.plot, qt.Qt.Window)
+
+ def getHistogram(self) -> typing.Optional[numpy.ndarray]:
"""Return the last computed histogram
- :return: the histogram displayed in the HistogramPlotWiget
+ :return: the histogram displayed in the HistogramWidget
"""
- return self._histo
+ histogram = self.getHistogramWidget().getHistogram()
+ return None if histogram is None else histogram[0]
diff --git a/silx/gui/plot/backends/BackendMatplotlib.py b/silx/gui/plot/backends/BackendMatplotlib.py
index 140672f..432b0b0 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-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2021 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
@@ -288,11 +288,17 @@ class _TextWithOffset(Text):
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
+
+ try:
+ invtrans = trans.inverted()
+ except numpy.linalg.LinAlgError:
+ # Cannot inverse transform, fallback: pos without offset
+ self.__cache = None
+ return pos
+
proj = trans.transform_point(pos)
proj = proj + numpy.array((xoffset, yoffset))
pos = invtrans.transform_point(proj)
diff --git a/silx/gui/plot/backends/BackendOpenGL.py b/silx/gui/plot/backends/BackendOpenGL.py
index 909d18a..6fde9df 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-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2021 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
@@ -990,7 +990,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
item.getYAxis() == 'right')
self._plotFrame.isY2Axis = next(y2AxisItems, None) is not None
- self._glGarbageCollector.append(item)
+ if item.isInitialized():
+ self._glGarbageCollector.append(item)
elif isinstance(item, (_MarkerItem, _ShapeItem)):
pass # No-op
diff --git a/silx/gui/plot/backends/glutils/GLPlotCurve.py b/silx/gui/plot/backends/glutils/GLPlotCurve.py
index c4e2c1e..34844c6 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-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2021 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
@@ -235,12 +235,15 @@ class _Fill2D(object):
def discard(self):
"""Release VBOs"""
- if self._xFillVboData is not None:
+ if self.isInitialized():
self._xFillVboData.vbo.discard()
self._xFillVboData = None
self._yFillVboData = None
+ def isInitialized(self):
+ return self._xFillVboData is not None
+
# line ########################################################################
@@ -1061,13 +1064,16 @@ class _ErrorBars(object):
def discard(self):
"""Release VBOs"""
- if self._attribs is not None:
+ if self.isInitialized():
self._lines.xVboData, self._lines.yVboData = None, None
self._xErrPoints.xVboData, self._xErrPoints.yVboData = None, None
self._yErrPoints.xVboData, self._yErrPoints.yVboData = None, None
self._attribs[0].vbo.discard()
self._attribs = None
+ def isInitialized(self):
+ return self._attribs is not None
+
# curves ######################################################################
@@ -1272,6 +1278,11 @@ class GLPlotCurve2D(GLPlotItem):
if self.fill is not None:
self.fill.discard()
+ def isInitialized(self):
+ return (self.xVboData is not None or
+ self._errorBars.isInitialized() or
+ (self.fill is not None and self.fill.isInitialized()))
+
def pick(self, xPickMin, yPickMin, xPickMax, yPickMax):
"""Perform picking on the curve according to its rendering.
diff --git a/silx/gui/plot/backends/glutils/GLPlotImage.py b/silx/gui/plot/backends/glutils/GLPlotImage.py
index f60a159..3ad94b9 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-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2021 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
@@ -286,6 +286,10 @@ class GLPlotColormap(_GLPlotData2D):
self._texture = None
self._textureIsDirty = False
+ def isInitialized(self):
+ return (self._cmap_texture is not None or
+ self._texture is not None)
+
@property
def cmapRange(self):
if self.normalization == 'log':
@@ -622,11 +626,14 @@ class GLPlotRGBAImage(_GLPlotData2D):
return self._alpha
def discard(self):
- if self._texture is not None:
+ if self.isInitialized():
self._texture.discard()
self._texture = None
self._textureIsDirty = False
+ def isInitialized(self):
+ return self._texture is not None
+
def updateData(self, data):
assert data.dtype in self._SUPPORTED_DTYPES
oldData = self.data
diff --git a/silx/gui/plot/backends/glutils/GLPlotItem.py b/silx/gui/plot/backends/glutils/GLPlotItem.py
index 899f38e..ae13091 100644
--- a/silx/gui/plot/backends/glutils/GLPlotItem.py
+++ b/silx/gui/plot/backends/glutils/GLPlotItem.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2020 European Synchrotron Radiation Facility
+# Copyright (c) 2020-2021 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
@@ -92,3 +92,8 @@ class GLPlotItem:
def discard(self):
"""Discards OpenGL resources this item has created."""
pass
+
+ def isInitialized(self) -> bool:
+ """Returns True if resources where initialized and requires `discard`.
+ """
+ return True
diff --git a/silx/gui/plot/backends/glutils/GLPlotTriangles.py b/silx/gui/plot/backends/glutils/GLPlotTriangles.py
index d5ba1a6..fbe9e02 100644
--- a/silx/gui/plot/backends/glutils/GLPlotTriangles.py
+++ b/silx/gui/plot/backends/glutils/GLPlotTriangles.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2019-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2019-2021 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
@@ -144,12 +144,15 @@ class GLPlotTriangles(GLPlotItem):
def discard(self):
"""Release resources on the GPU"""
- if self.__vbos is not None:
+ if self.isInitialized():
self.__vbos[0].vbo.discard()
self.__vbos = None
self.__indicesVbo.discard()
self.__indicesVbo = None
+ def isInitialized(self):
+ return self.__vbos is not None
+
def prepare(self):
"""Allocate resources on the GPU"""
if self.__vbos is None:
diff --git a/silx/gui/plot/items/_arc_roi.py b/silx/gui/plot/items/_arc_roi.py
index a22cc3d..23416ec 100644
--- a/silx/gui/plot/items/_arc_roi.py
+++ b/silx/gui/plot/items/_arc_roi.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2021 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 @@ __authors__ = ["V. Valls"]
__license__ = "MIT"
__date__ = "28/06/2018"
+import logging
import numpy
from ... import utils
@@ -40,6 +41,9 @@ from ._roi_base import InteractionModeMixIn
from ._roi_base import RoiInteractionMode
+logger = logging.getLogger(__name__)
+
+
class _ArcGeometry:
"""
Non-mutable object to store the geometry of the arc ROI.
@@ -779,8 +783,9 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn):
If `startAngle` is smaller than `endAngle` the rotation is clockwise,
else the rotation is anticlockwise.
"""
- assert innerRadius <= outerRadius
- assert numpy.abs(startAngle - endAngle) <= 2 * numpy.pi
+ if innerRadius > outerRadius:
+ logger.error("inner radius larger than outer radius")
+ innerRadius, outerRadius = outerRadius, innerRadius
center = numpy.array(center)
radius = (innerRadius + outerRadius) * 0.5
weight = outerRadius - innerRadius
diff --git a/silx/gui/plot/items/complex.py b/silx/gui/plot/items/complex.py
index 0e492a0..abb64ad 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-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2021 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
@@ -184,18 +184,18 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn):
def setComplexMode(self, mode):
changed = super(ImageComplexData, self).setComplexMode(mode)
if changed:
+ self._valueDataChanged()
+
# Backward compatibility
self._updated(ItemChangedType.VISUALIZATION_MODE)
- # Send data updated as value returned by getData has changed
- self._updated(ItemChangedType.DATA)
-
# Update ColormapMixIn colormap
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)
+ # Send data updated as value returned by getData has changed
+ self._updated(ItemChangedType.DATA)
return changed
def _setAmplitudeRangeInfo(self, max_=None, delta=2):
@@ -263,10 +263,32 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn):
'Image is not complex, converting it to complex to plot it.')
data = numpy.array(data, dtype=numpy.complex64)
- self._dataByModesCache = {}
- self._setColormappedData(self.getData(copy=False), copy=False)
+ # Compute current mode data and set colormap data
+ mode = self.getComplexMode()
+ dataForMode = self.__convertComplexData(data, self.getComplexMode())
+ self._dataByModesCache = {mode: dataForMode}
+
super().setData(data)
+ def _updated(self, event=None, checkVisibility=True):
+ # Synchronizes colormapped data if changed
+ # ItemChangedType.COMPLEX_MODE triggers ItemChangedType.DATA
+ # No need to handle it twice.
+ if event in (ItemChangedType.DATA, ItemChangedType.MASK):
+ # Color-mapped data is NOT the `getValueData` for some modes
+ if self.getComplexMode() in (
+ self.ComplexMode.AMPLITUDE_PHASE,
+ self.ComplexMode.LOG10_AMPLITUDE_PHASE):
+ data = self.getData(copy=False, mode=self.ComplexMode.PHASE)
+ mask = self.getMaskData(copy=False)
+ if mask is not None:
+ data = numpy.copy(data)
+ data[mask != 0] = numpy.nan
+ else:
+ data = self.getValueData(copy=False)
+ self._setColormappedData(data, copy=False)
+ super()._updated(event=event, checkVisibility=checkVisibility)
+
def getComplexData(self, copy=True):
"""Returns the image complex data
@@ -276,6 +298,31 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn):
"""
return super().getData(copy=copy)
+ def __convertComplexData(self, data, mode):
+ """Convert complex data to given mode.
+
+ :param numpy.ndarray data:
+ :param Union[ComplexMode,str] mode:
+ :rtype: numpy.ndarray of float
+ """
+ if mode is self.ComplexMode.PHASE:
+ return numpy.angle(data)
+ elif mode is self.ComplexMode.REAL:
+ return numpy.real(data)
+ elif mode is self.ComplexMode.IMAGINARY:
+ return numpy.imag(data)
+ elif mode in (self.ComplexMode.ABSOLUTE,
+ self.ComplexMode.LOG10_AMPLITUDE_PHASE,
+ self.ComplexMode.AMPLITUDE_PHASE):
+ return numpy.absolute(data)
+ elif mode is self.ComplexMode.SQUARE_AMPLITUDE:
+ return numpy.absolute(data) ** 2
+ else:
+ _logger.error(
+ 'Unsupported conversion mode: %s, fallback to absolute',
+ str(mode))
+ return numpy.absolute(data)
+
def getData(self, copy=True, mode=None):
"""Returns the image data corresponding to (current) mode.
@@ -295,27 +342,8 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn):
mode = self.ComplexMode.from_value(mode)
if mode not in self._dataByModesCache:
- # Compute data for mode and store it in cache
- complexData = self.getComplexData(copy=False)
- if mode is self.ComplexMode.PHASE:
- data = numpy.angle(complexData)
- elif mode is self.ComplexMode.REAL:
- data = numpy.real(complexData)
- elif mode is self.ComplexMode.IMAGINARY:
- data = numpy.imag(complexData)
- elif mode in (self.ComplexMode.ABSOLUTE,
- self.ComplexMode.LOG10_AMPLITUDE_PHASE,
- self.ComplexMode.AMPLITUDE_PHASE):
- data = numpy.absolute(complexData)
- elif mode is self.ComplexMode.SQUARE_AMPLITUDE:
- data = numpy.absolute(complexData) ** 2
- else:
- _logger.error(
- 'Unsupported conversion mode: %s, fallback to absolute',
- str(mode))
- data = numpy.absolute(complexData)
-
- self._dataByModesCache[mode] = data
+ self._dataByModesCache[mode] = self.__convertComplexData(
+ self.getComplexData(copy=False), mode)
return numpy.array(self._dataByModesCache[mode], copy=copy)
diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py
index edc6d89..95a65ad 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-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2021 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
@@ -27,7 +27,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "29/01/2019"
+__date__ = "08/12/2020"
import collections
try:
@@ -110,6 +110,9 @@ class ItemChangedType(enum.Enum):
DATA = 'dataChanged'
"""Item's data changed flag"""
+ MASK = 'maskChanged'
+ """Item's mask changed flag"""
+
HIGHLIGHTED = 'highlightedChanged'
"""Item's highlight state changed flag."""
@@ -315,7 +318,7 @@ class Item(qt.QObject):
info = deepcopy(info)
self._info = info
- def getVisibleBounds(self) -> Optional[Tuple[float,float,float,float]]:
+ def getVisibleBounds(self) -> Optional[Tuple[float, float, float, float]]:
"""Returns visible bounds of the item bounding box in the plot area.
:returns:
@@ -503,9 +506,9 @@ class DataItem(Item):
self._boundsChanged(checkVisibility=False)
super().setVisible(visible)
-
# Mix-in classes ##############################################################
+
class ItemMixInBase(object):
"""Base class for Item mix-in"""
@@ -1232,7 +1235,7 @@ class ScatterVisualizationMixIn(ItemMixInBase):
def __init__(self):
self.__visualization = self.Visualization.POINTS
- self.__parameters = dict( # Init parameters to None
+ self.__parameters = dict(# Init parameters to None
(parameter, None) for parameter in self.VisualizationParameter)
self.__parameters[self.VisualizationParameter.BINNED_STATISTIC_FUNCTION] = 'mean'
@@ -1404,8 +1407,8 @@ class PointsBase(DataItem, SymbolMixIn, AlphaMixIn):
elif error.ndim == 1: # N array
newError = numpy.empty((2, len(value)),
dtype=numpy.float64)
- newError[0, :] = error
- newError[1, :] = error
+ newError[0,:] = error
+ newError[1,:] = error
error = newError
elif error.size == 2 * len(value): # 2xN array
@@ -1610,14 +1613,32 @@ class PointsBase(DataItem, SymbolMixIn, AlphaMixIn):
assert len(x) == len(y)
assert x.ndim == y.ndim == 1
+ # Convert complex data
+ if numpy.iscomplexobj(x):
+ _logger.warning(
+ 'Converting x data to absolute value to plot it.')
+ x = numpy.absolute(x)
+ if numpy.iscomplexobj(y):
+ _logger.warning(
+ 'Converting y data to absolute value to plot it.')
+ y = numpy.absolute(y)
+
if xerror is not None:
if isinstance(xerror, abc.Iterable):
xerror = numpy.array(xerror, copy=copy)
+ if numpy.iscomplexobj(xerror):
+ _logger.warning(
+ 'Converting xerror data to absolute value to plot it.')
+ xerror = numpy.absolute(xerror)
else:
xerror = float(xerror)
if yerror is not None:
if isinstance(yerror, abc.Iterable):
yerror = numpy.array(yerror, copy=copy)
+ if numpy.iscomplexobj(yerror):
+ _logger.warning(
+ 'Converting yerror data to absolute value to plot it.')
+ yerror = numpy.absolute(yerror)
else:
yerror = float(yerror)
# TODO checks on xerror, yerror
@@ -1634,6 +1655,7 @@ class PointsBase(DataItem, SymbolMixIn, AlphaMixIn):
class BaselineMixIn(object):
"""Base class for Baseline mix-in"""
+
def __init__(self, baseline=None):
self._baseline = baseline
diff --git a/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py
index 5941cc6..16bbefa 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-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2021 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
@@ -30,6 +30,7 @@ __license__ = "MIT"
__date__ = "28/08/2018"
import logging
+import typing
import numpy
from collections import OrderedDict, namedtuple
@@ -38,8 +39,10 @@ try:
except ImportError: # Python2 support
import collections as abc
+from ....utils.proxy import docstring
from .core import (DataItem, AlphaMixIn, BaselineMixIn, ColorMixIn, FillMixIn,
- LineMixIn, YAxisMixIn, ItemChangedType)
+ LineMixIn, YAxisMixIn, ItemChangedType, Item)
+from ._pick import PickingResult
_logger = logging.getLogger(__name__)
@@ -219,6 +222,53 @@ class Histogram(DataItem, AlphaMixIn, ColorMixIn, FillMixIn,
min(0, numpy.nanmin(values)),
max(0, numpy.nanmax(values)))
+ def __pickFilledHistogram(self, x: float, y: float) -> typing.Optional[PickingResult]:
+ """Picking implementation for filled histogram
+
+ :param x: X position in pixels
+ :param y: Y position in pixels
+ """
+ if not self.isFill():
+ return None
+
+ plot = self.getPlot()
+ if plot is None:
+ return None
+
+ xData, yData = plot.pixelToData(x, y, axis=self.getYAxis())
+ xmin, xmax, ymin, ymax = self.getBounds()
+ if not xmin < xData < xmax or not ymin < yData < ymax:
+ return None # Outside bounding box
+
+ # Check x
+ edges = self.getBinEdgesData(copy=False)
+ index = numpy.searchsorted(edges, (xData,), side='left')[0] - 1
+ # Safe indexing in histogram values
+ index = numpy.clip(index, 0, len(edges) - 2)
+
+ # Check y
+ baseline = self.getBaseline(copy=False)
+ if baseline is None:
+ baseline = 0 # Default value
+
+ value = self.getValueData(copy=False)[index]
+ if ((baseline <= value and baseline <= yData <= value) or
+ (value < baseline and value <= yData <= baseline)):
+ return PickingResult(self, numpy.array([index]))
+ else:
+ return None
+
+ @docstring(DataItem)
+ def pick(self, x, y):
+ if self.isFill():
+ return self.__pickFilledHistogram(x, y)
+ else:
+ result = super().pick(x, y)
+ if result is None:
+ return None
+ else: # Convert from curve indices to histogram indices
+ return PickingResult(self, numpy.unique(result.getIndices() // 2))
+
def getValueData(self, copy=True):
"""The values of the histogram
diff --git a/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py
index fda4245..0d9c9a4 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-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2021 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
@@ -28,8 +28,7 @@ of the :class:`Plot`.
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "20/10/2017"
-
+__date__ = "08/12/2020"
try:
from collections import abc
@@ -43,7 +42,6 @@ from ....utils.proxy import docstring
from .core import (DataItem, LabelsMixIn, DraggableMixIn, ColormapMixIn,
AlphaMixIn, ItemChangedType)
-
_logger = logging.getLogger(__name__)
@@ -80,8 +78,8 @@ def _convertImageToRgba32(image, copy=True):
if image.shape[-1] == 3:
new_image = numpy.empty((image.shape[0], image.shape[1], 4),
dtype=numpy.uint8)
- new_image[:, :, :3] = image
- new_image[:, :, 3] = 255
+ new_image[:,:,:3] = image
+ new_image[:,:, 3] = 255
return new_image # This is a copy anyway
else:
return numpy.array(image, copy=copy)
@@ -93,7 +91,7 @@ class ImageBase(DataItem, LabelsMixIn, DraggableMixIn, AlphaMixIn):
:param numpy.ndarray data: Initial image data
"""
- def __init__(self, data=None):
+ def __init__(self, data=None, mask=None):
DataItem.__init__(self)
LabelsMixIn.__init__(self)
DraggableMixIn.__init__(self)
@@ -101,7 +99,8 @@ class ImageBase(DataItem, LabelsMixIn, DraggableMixIn, AlphaMixIn):
if data is None:
data = numpy.zeros((0, 0, 4), dtype=numpy.uint8)
self._data = data
-
+ self._mask = mask
+ self.__valueDataCache = None # Store default data
self._origin = (0., 0.)
self._scale = (1., 1.)
@@ -186,13 +185,98 @@ class ImageBase(DataItem, LabelsMixIn, DraggableMixIn, AlphaMixIn):
:param numpy.ndarray data:
"""
+ previousShape = self._data.shape
self._data = data
+ self._valueDataChanged()
self._boundsChanged()
self._updated(ItemChangedType.DATA)
+ if (self.getMaskData(copy=False) is not None and
+ previousShape != self._data.shape):
+ # Data shape changed, so mask shape changes.
+ # Send event, mask is lazily updated in getMaskData
+ self._updated(ItemChangedType.MASK)
+
+ def getMaskData(self, copy=True):
+ """Returns the mask data
+
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :rtype: Union[None,numpy.ndarray]
+ """
+ if self._mask is None:
+ return None
+
+ # Update mask if it does not match data shape
+ shape = self.getData(copy=False).shape[:2]
+ if self._mask.shape != shape:
+ # Clip/extend mask to match data
+ newMask = numpy.zeros(shape, dtype=self._mask.dtype)
+ newMask[:self._mask.shape[0], :self._mask.shape[1]] = self._mask[:shape[0], :shape[1]]
+ self._mask = newMask
+
+ return numpy.array(self._mask, copy=copy)
+
+ def setMaskData(self, mask, copy=True):
+ """Set the image data
+
+ :param numpy.ndarray data:
+ :param bool copy: True (Default) to make a copy,
+ False to use as is (do not modify!)
+ """
+ if mask is not None:
+ mask = numpy.array(mask, copy=copy)
+
+ shape = self.getData(copy=False).shape[:2]
+ if mask.shape != shape:
+ _logger.warning("Inconsistent shape between mask and data %s, %s", mask.shape, shape)
+ # Clip/extent is done lazily in getMaskData
+ elif self._mask is None:
+ return # No update
+
+ self._mask = mask
+ self._valueDataChanged()
+ self._updated(ItemChangedType.MASK)
+
+ def _valueDataChanged(self):
+ """Clear cache of default data array"""
+ self.__valueDataCache = None
+
+ def _getValueData(self, copy=True):
+ """Return data used by :meth:`getValueData`
+
+ :param bool copy:
+ :rtype: numpy.ndarray
+ """
+ return self.getData(copy=copy)
+
+ def getValueData(self, copy=True):
+ """Return data (converted to int or float) with mask applied.
+
+ Masked values are set to Not-A-Number.
+ It returns a 2D array of values (int or float).
+
+ :param bool copy:
+ :rtype: numpy.ndarray
+ """
+ if self.__valueDataCache is None:
+ data = self._getValueData(copy=False)
+ mask = self.getMaskData(copy=False)
+ if mask is not None:
+ if numpy.issubdtype(data.dtype, numpy.floating):
+ dtype = data.dtype
+ else:
+ dtype = numpy.float64
+ data = numpy.array(data, dtype=dtype, copy=True)
+ data[mask != 0] = numpy.NaN
+ self.__valueDataCache = data
+ return numpy.array(self.__valueDataCache, copy=copy)
+
def getRgbaImageData(self, copy=True):
"""Get the displayed RGB(A) image
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
:returns: numpy.ndarray of uint8 of shape (height, width, 4)
"""
raise NotImplementedError('This MUST be implemented in sub-class')
@@ -308,7 +392,7 @@ class ImageData(ImageBase, ColormapMixIn):
alphaImage = self.getAlphaData(copy=False)
if alphaImage is not None:
# Apply transparency
- image[:, :, 3] = image[:, :, 3] * alphaImage
+ image[:,:, 3] = image[:,:, 3] * alphaImage
return image
def getAlternativeImageData(self, copy=True):
@@ -358,7 +442,6 @@ class ImageData(ImageBase, ColormapMixIn):
_logger.warning(
'Converting complex image to absolute value to plot it.')
data = numpy.absolute(data)
- self._setColormappedData(data, copy=False)
if alternative is not None:
alternative = numpy.array(alternative, copy=copy)
@@ -378,6 +461,14 @@ class ImageData(ImageBase, ColormapMixIn):
super().setData(data)
+ def _updated(self, event=None, checkVisibility=True):
+ # Synchronizes colormapped data if changed
+ if event in (ItemChangedType.DATA, ItemChangedType.MASK):
+ self._setColormappedData(
+ self.getValueData(copy=False),
+ copy=False)
+ super()._updated(event=event, checkVisibility=checkVisibility)
+
class ImageRgba(ImageBase):
"""Description of an RGB(A) image"""
@@ -423,6 +514,20 @@ class ImageRgba(ImageBase):
assert data.shape[-1] in (3, 4)
super().setData(data)
+ def _getValueData(self, copy=True):
+ """Compute the intensity of the RGBA image as default data.
+
+ Conversion: https://en.wikipedia.org/wiki/YCbCr#ITU-R_BT.601_conversion
+
+ :param bool copy:
+ """
+ rgba = self.getRgbaImageData(copy=False).astype(numpy.float32)
+ intensity = (rgba[:, :, 0] * 0.299 +
+ rgba[:, :, 1] * 0.587 +
+ rgba[:, :, 2] * 0.114)
+ intensity *= rgba[:, :, 3] / 255.
+ return intensity
+
class MaskImageData(ImageData):
"""Description of an image used as a mask.
diff --git a/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py
index fd7cfae..2d54223 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-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2021 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
@@ -935,6 +935,12 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
assert value.ndim == 1
assert len(x) == len(value)
+ # Convert complex data
+ if numpy.iscomplexobj(value):
+ _logger.warning(
+ 'Converting value data to absolute value to plot it.')
+ value = numpy.absolute(value)
+
# Reset triangulation and interpolator
if self.__delaunayFuture is not None:
self.__delaunayFuture.cancel()
diff --git a/silx/gui/plot/stats/stats.py b/silx/gui/plot/stats/stats.py
index 755b185..a81f7bb 100644
--- a/silx/gui/plot/stats/stats.py
+++ b/silx/gui/plot/stats/stats.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2021 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
@@ -193,19 +193,6 @@ class _StatsContext(object):
self.clipData(item, plot, onlimits, roi=roi)
- def clipData(self, item, plot, onlimits, roi):
- """
- Clip the data to the current mask to have accurate statistics
-
- :param item: item for whiwh we want to clip data
- :param plot: plot containing the item
- :param onlimits: do we want to apply statistic only on
- visible data.
- :param roi: Region of interest for computing the statistics.
- :type roi: Union[None,:class:`_RegionOfInterestBase`]
- """
- raise NotImplementedError()
-
def clear_mask(self):
"""
Remove the mask to force recomputation of it on next iteration
@@ -232,7 +219,8 @@ class _StatsContext(object):
raise NotImplementedError("Base class")
def clipData(self, item, plot, onlimits, roi):
- """
+ """Clip the data to the current mask to have accurate statistics
+
Function called before computing each statistics associated to this
context. It will insure the context for the (item, plot, onlimits, roi)
is created.
@@ -340,9 +328,8 @@ class _CurveContext(_ScatterCurveHistoMixInContext):
mask = self.mask
else:
mask = (minX <= xData) & (xData <= maxX)
- yData = yData[mask]
- xData = xData[mask]
- mask = numpy.zeros_like(yData)
+ mask = mask == 0
+ self._set_mask_validity(onlimits=onlimits, from_=minX, to_=maxX)
elif roi:
minX, maxX = roi.getFrom(), roi.getTo()
if self.is_mask_valid(onlimits=onlimits, from_=minX, to_=maxX):
@@ -350,10 +337,11 @@ class _CurveContext(_ScatterCurveHistoMixInContext):
else:
mask = (minX <= xData) & (xData <= maxX)
mask = mask == 0
- mask = mask.astype(numpy.int32)
+ self._set_mask_validity(onlimits=onlimits, from_=minX, to_=maxX)
else:
mask = numpy.zeros_like(yData)
+ mask = mask.astype(numpy.uint32)
self.xData = xData
self.yData = yData
self.values = numpy.ma.array(yData, mask=mask)
@@ -363,7 +351,6 @@ class _CurveContext(_ScatterCurveHistoMixInContext):
else:
self.min, self.max = None, None
self.data = (xData, yData)
-
self.axes = (xData,)
def _checkContextInputs(self, item, plot, onlimits, roi):
@@ -399,38 +386,33 @@ class _HistogramContext(_ScatterCurveHistoMixInContext):
if onlimits:
minX, maxX = plot.getXAxis().getLimits()
- if self.is_mask_valid(onlimits, from_=minX, to_=maxX):
+ if self.is_mask_valid(onlimits=onlimits, from_=minX, to_=maxX):
mask = self.mask
else:
mask = (minX <= xData) & (xData <= maxX)
- self._set_mask_validity(onlimits=True, from_=minX, to_=maxX)
+ mask = mask == 0
+ self._set_mask_validity(onlimits=onlimits, from_=minX, to_=maxX)
elif roi:
- if self.is_mask_valid(onlimits, from_=roi._fromdata, to_=roi._todata):
+ if self.is_mask_valid(onlimits=onlimits, from_=roi._fromdata, to_=roi._todata):
mask = self.mask
else:
mask = (roi._fromdata <= xData) & (xData <= roi._todata)
mask = mask == 0
- self._set_mask_validity(onlimits=True, from_=roi._fromdata,
+ self._set_mask_validity(onlimits=onlimits, from_=roi._fromdata,
to_=roi._todata)
else:
- mask = numpy.zeros_like(self.data)
-
- if onlimits:
- yData = yData[mask]
- xData = xData[mask]
-
- self.data = (xData, yData)
- self.values = numpy.ma.array(yData, mask=mask)
- self.axes = (xData,)
-
+ mask = numpy.zeros_like(yData)
+ mask = mask.astype(numpy.uint32)
self.xData = xData
self.yData = yData
-
+ self.values = numpy.ma.array(yData, mask=(mask))
unmasked_data = self.values.compressed()
if len(unmasked_data) > 0:
self.min, self.max = min_max(unmasked_data)
else:
self.min, self.max = None, None
+ self.data = (self.xData, self.yData)
+ self.axes = (self.xData,)
def _checkContextInputs(self, item, plot, onlimits, roi):
_StatsContext._checkContextInputs(self, item=item, plot=plot,
diff --git a/silx/gui/plot/test/testMaskToolsWidget.py b/silx/gui/plot/test/testMaskToolsWidget.py
index 2e8db55..c22975f 100644
--- a/silx/gui/plot/test/testMaskToolsWidget.py
+++ b/silx/gui/plot/test/testMaskToolsWidget.py
@@ -136,6 +136,15 @@ class TestMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
self.mouseRelease(
plot, qt.Qt.LeftButton, pos=star[-1])
+ def _isMaskItemSync(self):
+ """Check if masks from item and tools are sync or not"""
+ if self.maskWidget.isItemMaskUpdated():
+ return numpy.all(numpy.equal(
+ self.maskWidget.getSelectionMask(),
+ self.plot.getActiveImage().getMaskData(copy=False)))
+ else:
+ return True
+
def testWithAnImage(self):
"""Plot with an image: test MaskToolsWidget interactions"""
@@ -152,80 +161,91 @@ class TestMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
((0, 0), (-1, -1)),
((1000, 1000), (-1, -1))]
- for origin, scale in tests:
- with self.subTest(origin=origin, scale=scale):
- self.plot.addImage(numpy.arange(1024**2).reshape(1024, 1024),
- legend='test',
- origin=origin,
- scale=scale)
- self.qapp.processEvents()
-
- # Test draw rectangle #
- toolButton = getQToolButtonFromAction(self.maskWidget.rectAction)
- self.assertIsNot(toolButton, None)
- self.mouseClick(toolButton, qt.Qt.LeftButton)
-
- # mask
- self.maskWidget.maskStateGroup.button(1).click()
- self.qapp.processEvents()
- self._drag()
- self.assertFalse(
- numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
-
- # unmask same region
- self.maskWidget.maskStateGroup.button(0).click()
- self.qapp.processEvents()
- self._drag()
- self.assertTrue(
- numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
-
- # Test draw polygon #
- toolButton = getQToolButtonFromAction(self.maskWidget.polygonAction)
- self.assertIsNot(toolButton, None)
- self.mouseClick(toolButton, qt.Qt.LeftButton)
-
- # mask
- self.maskWidget.maskStateGroup.button(1).click()
- self.qapp.processEvents()
- self._drawPolygon()
- self.assertFalse(
- numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
-
- # unmask same region
- self.maskWidget.maskStateGroup.button(0).click()
- self.qapp.processEvents()
- self._drawPolygon()
- self.assertTrue(
- numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
-
- # Test draw pencil #
- toolButton = getQToolButtonFromAction(self.maskWidget.pencilAction)
- self.assertIsNot(toolButton, None)
- self.mouseClick(toolButton, qt.Qt.LeftButton)
-
- self.maskWidget.pencilSpinBox.setValue(30)
- self.qapp.processEvents()
-
- # mask
- self.maskWidget.maskStateGroup.button(1).click()
- self.qapp.processEvents()
- self._drawPencil()
- self.assertFalse(
- numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
-
- # unmask same region
- self.maskWidget.maskStateGroup.button(0).click()
- self.qapp.processEvents()
- self._drawPencil()
- self.assertTrue(
- numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
-
- # Test no draw tool #
- toolButton = getQToolButtonFromAction(self.maskWidget.browseAction)
- self.assertIsNot(toolButton, None)
- self.mouseClick(toolButton, qt.Qt.LeftButton)
-
- self.plot.clear()
+ for itemMaskUpdated in (False, True):
+ for origin, scale in tests:
+ with self.subTest(origin=origin, scale=scale):
+ self.maskWidget.setItemMaskUpdated(itemMaskUpdated)
+ self.plot.addImage(numpy.arange(1024**2).reshape(1024, 1024),
+ legend='test',
+ origin=origin,
+ scale=scale)
+ self.qapp.processEvents()
+
+ self.assertEqual(
+ self.maskWidget.isItemMaskUpdated(), itemMaskUpdated)
+
+ # Test draw rectangle #
+ toolButton = getQToolButtonFromAction(self.maskWidget.rectAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ # mask
+ self.maskWidget.maskStateGroup.button(1).click()
+ self.qapp.processEvents()
+ self._drag()
+ self.assertFalse(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+ self.assertTrue(self._isMaskItemSync())
+
+ # unmask same region
+ self.maskWidget.maskStateGroup.button(0).click()
+ self.qapp.processEvents()
+ self._drag()
+ self.assertTrue(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+ self.assertTrue(self._isMaskItemSync())
+
+ # Test draw polygon #
+ toolButton = getQToolButtonFromAction(self.maskWidget.polygonAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ # mask
+ self.maskWidget.maskStateGroup.button(1).click()
+ self.qapp.processEvents()
+ self._drawPolygon()
+ self.assertFalse(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+ self.assertTrue(self._isMaskItemSync())
+
+ # unmask same region
+ self.maskWidget.maskStateGroup.button(0).click()
+ self.qapp.processEvents()
+ self._drawPolygon()
+ self.assertTrue(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+ self.assertTrue(self._isMaskItemSync())
+
+ # Test draw pencil #
+ toolButton = getQToolButtonFromAction(self.maskWidget.pencilAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ self.maskWidget.pencilSpinBox.setValue(30)
+ self.qapp.processEvents()
+
+ # mask
+ self.maskWidget.maskStateGroup.button(1).click()
+ self.qapp.processEvents()
+ self._drawPencil()
+ self.assertFalse(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+ self.assertTrue(self._isMaskItemSync())
+
+ # unmask same region
+ self.maskWidget.maskStateGroup.button(0).click()
+ self.qapp.processEvents()
+ self._drawPencil()
+ self.assertTrue(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+ self.assertTrue(self._isMaskItemSync())
+
+ # Test no draw tool #
+ toolButton = getQToolButtonFromAction(self.maskWidget.browseAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ self.plot.clear()
def __loadSave(self, file_format):
"""Plot with an image: test MaskToolsWidget operations"""
diff --git a/silx/gui/plot/test/testPixelIntensityHistoAction.py b/silx/gui/plot/test/testPixelIntensityHistoAction.py
index 882f496..ac29952 100644
--- a/silx/gui/plot/test/testPixelIntensityHistoAction.py
+++ b/silx/gui/plot/test/testPixelIntensityHistoAction.py
@@ -65,7 +65,7 @@ class TestPixelIntensitiesHisto(TestCaseQt, ParametricTestCase):
self.mouseMove(button)
self.mouseClick(button, qt.Qt.LeftButton)
self.qapp.processEvents()
- self.assertTrue(histoAction.getHistogramPlotWidget().isVisible())
+ self.assertTrue(histoAction.getHistogramWidget().isVisible())
# test the pixel intensity diagram is hiding
self.qapp.setActiveWindow(self.plotImage)
@@ -73,7 +73,7 @@ class TestPixelIntensitiesHisto(TestCaseQt, ParametricTestCase):
self.mouseMove(button)
self.mouseClick(button, qt.Qt.LeftButton)
self.qapp.processEvents()
- self.assertFalse(histoAction.getHistogramPlotWidget().isVisible())
+ self.assertFalse(histoAction.getHistogramWidget().isVisible())
def testImageFormatInput(self):
"""Test multiple type as image input"""
@@ -108,9 +108,9 @@ class TestPixelIntensitiesHisto(TestCaseQt, ParametricTestCase):
self.mouseClick(button, qt.Qt.LeftButton)
self.qapp.processEvents()
- plot = histoAction.getHistogramPlotWidget()
- self.assertTrue(plot.isVisible())
- items = plot.getItems()
+ widget = histoAction.getHistogramWidget()
+ self.assertTrue(widget.isVisible())
+ items = widget.getPlotWidget().getItems()
self.assertEqual(len(items), 1)
def testChangeItem(self):
@@ -131,9 +131,9 @@ class TestPixelIntensitiesHisto(TestCaseQt, ParametricTestCase):
self.qapp.processEvents()
# Reach histogram from the first item
- plot = histoAction.getHistogramPlotWidget()
- self.assertTrue(plot.isVisible())
- items = plot.getItems()
+ widget = histoAction.getHistogramWidget()
+ self.assertTrue(widget.isVisible())
+ items = widget.getPlotWidget().getItems()
data1 = items[0].getValueData(copy=False)
# Set another item to the plot
diff --git a/silx/gui/plot/test/testPlotWidget.py b/silx/gui/plot/test/testPlotWidget.py
index f9d2281..b55260e 100755
--- a/silx/gui/plot/test/testPlotWidget.py
+++ b/silx/gui/plot/test/testPlotWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2021 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
@@ -197,6 +197,21 @@ class TestPlotWidget(PlotWidgetTestCase, ParametricTestCase):
self.assertTrue(numpy.all(numpy.equal(items[4].getPosition()[0], marker_x)))
self.assertEqual(items[5].getType(), 'rectangle')
+ def testRemoveDiscardItem(self):
+ """Test removeItem and discardItem"""
+ self.plot.addCurve((1, 2, 3), (1, 2, 3))
+ curve = self.plot.getItems()[0]
+ self.plot.removeItem(curve)
+ with self.assertRaises(ValueError):
+ self.plot.removeItem(curve)
+
+ self.plot.addCurve((1, 2, 3), (1, 2, 3))
+ curve = self.plot.getItems()[0]
+ result = self.plot.discardItem(curve)
+ self.assertTrue(result)
+ result = self.plot.discardItem(curve)
+ self.assertFalse(result)
+
def testBackGroundColors(self):
self.plot.setVisible(True)
self.qWaitForWindowExposed(self.plot)
@@ -559,6 +574,11 @@ class TestPlotCurve(PlotWidgetTestCase):
self.plot.addCurve(x=x, y=y, color='grey', legend='curve1', fill=True,
baseline=list(range(0, 100, 1)))
+ def testPlotCurveComplexData(self):
+ """Test curve with complex data"""
+ data = numpy.arange(100.) + 1j
+ self.plot.addCurve(x=data, y=data, xerror=data, yerror=data)
+
class TestPlotHistogram(PlotWidgetTestCase):
"""Basic tests for add Histogram"""
@@ -592,6 +612,13 @@ class TestPlotScatter(PlotWidgetTestCase, ParametricTestCase):
self.plot.addScatter(x, y, value)
self.plot.resetZoom()
+ def testScatterComplexData(self):
+ """Test scatter item with complex data"""
+ data = numpy.arange(100.) + 1j
+ self.plot.addScatter(
+ x=data, y=data, value=data, xerror=data, yerror=data)
+ self.plot.resetZoom()
+
def testScatterVisualization(self):
self.plot.addScatter((0, 1, 0, 1), (0, 0, 2, 2), (0, 1, 2, 3))
self.plot.resetZoom()
@@ -1857,6 +1884,153 @@ class TestPlotWidgetSwitchBackend(PlotWidgetTestCase):
self.assertEqual(self.plot.getItems(), items)
+class TestPlotWidgetSelection(PlotWidgetTestCase):
+ """Test PlotWidget.selection and active items handling"""
+
+ def _checkSelection(self, selection, current=None, selected=()):
+ """Check current item and selected items."""
+ self.assertIs(selection.getCurrentItem(), current)
+ self.assertEqual(selection.getSelectedItems(), selected)
+
+ def testSyncWithActiveItems(self):
+ """Test update of PlotWidgetSelection according to active items"""
+ listener = SignalListener()
+
+ selection = self.plot.selection()
+ selection.sigCurrentItemChanged.connect(listener)
+ self._checkSelection(selection)
+
+ # Active item is current
+ self.plot.addImage(((0, 1), (2, 3)), legend='image')
+ image = self.plot.getActiveImage()
+ self.assertEqual(listener.callCount(), 1)
+ self._checkSelection(selection, image, (image,))
+
+ # No active = no current
+ self.plot.setActiveImage(None)
+ self.assertEqual(listener.callCount(), 2)
+ self._checkSelection(selection)
+
+ # Active item is current
+ self.plot.setActiveImage('image')
+ self.assertEqual(listener.callCount(), 3)
+ self._checkSelection(selection, image, (image,))
+
+ # Mosted recently "actived" item is current
+ self.plot.addScatter((3, 2, 1), (0, 1, 2), (0, 1, 2), legend='scatter')
+ scatter = self.plot.getActiveScatter()
+ self.assertEqual(listener.callCount(), 4)
+ self._checkSelection(selection, scatter, (scatter, image))
+
+ # Previously mosted recently "actived" item is current
+ self.plot.setActiveScatter(None)
+ self.assertEqual(listener.callCount(), 5)
+ self._checkSelection(selection, image, (image,))
+
+ # Mosted recently "actived" item is current
+ self.plot.setActiveScatter('scatter')
+ self.assertEqual(listener.callCount(), 6)
+ self._checkSelection(selection, scatter, (scatter, image))
+
+ # No active = no current
+ self.plot.setActiveImage(None)
+ self.plot.setActiveScatter(None)
+ self.assertEqual(listener.callCount(), 7)
+ self._checkSelection(selection)
+
+ # Mosted recently "actived" item is current
+ self.plot.setActiveScatter('scatter')
+ self.assertEqual(listener.callCount(), 8)
+ self.plot.setActiveImage('image')
+ self.assertEqual(listener.callCount(), 9)
+ self._checkSelection(selection, image, (image, scatter))
+
+ # Add a curve which is not active by default
+ self.plot.addCurve((0, 1, 2), (0, 1, 2), legend='curve')
+ curve = self.plot.getCurve('curve')
+ self.assertEqual(listener.callCount(), 9)
+ self._checkSelection(selection, image, (image, scatter))
+
+ # Mosted recently "actived" item is current
+ self.plot.setActiveCurve('curve')
+ self.assertEqual(listener.callCount(), 10)
+ self._checkSelection(selection, curve, (curve, image, scatter))
+
+ # Add a curve which is not active by default
+ self.plot.addCurve((0, 1, 2), (0, 1, 2), legend='curve2')
+ curve2 = self.plot.getCurve('curve2')
+ self.assertEqual(listener.callCount(), 10)
+ self._checkSelection(selection, curve, (curve, image, scatter))
+
+ # Mosted recently "actived" item is current, previous curve is removed
+ self.plot.setActiveCurve('curve2')
+ self.assertEqual(listener.callCount(), 11)
+ self._checkSelection(selection, curve2, (curve2, image, scatter))
+
+ # No items = no current
+ self.plot.clear()
+ self.assertEqual(listener.callCount(), 12)
+ self._checkSelection(selection)
+
+ def testPlotWidgetWithItems(self):
+ """Test init of selection on a plot with items"""
+ self.plot.addImage(((0, 1), (2, 3)), legend='image')
+ self.plot.addScatter((3, 2, 1), (0, 1, 2), (0, 1, 2), legend='scatter')
+ self.plot.addCurve((0, 1, 2), (0, 1, 2), legend='curve')
+ self.plot.setActiveCurve('curve')
+
+ selection = self.plot.selection()
+ self.assertIsNotNone(selection.getCurrentItem())
+ selected = selection.getSelectedItems()
+ self.assertEqual(len(selected), 3)
+ self.assertIn(self.plot.getActiveCurve(), selected)
+ self.assertIn(self.plot.getActiveImage(), selected)
+ self.assertIn(self.plot.getActiveScatter(), selected)
+
+ def testSetCurrentItem(self):
+ """Test setCurrentItem"""
+ # Add items to the plot
+ self.plot.addImage(((0, 1), (2, 3)), legend='image')
+ image = self.plot.getActiveImage()
+ self.plot.addScatter((3, 2, 1), (0, 1, 2), (0, 1, 2), legend='scatter')
+ scatter = self.plot.getActiveScatter()
+ self.plot.addCurve((0, 1, 2), (0, 1, 2), legend='curve')
+ self.plot.setActiveCurve('curve')
+ curve = self.plot.getActiveCurve()
+
+ selection = self.plot.selection()
+ self.assertIsNotNone(selection.getCurrentItem())
+ self.assertEqual(len(selection.getSelectedItems()), 3)
+
+ # Set current to None reset all active items
+ selection.setCurrentItem(None)
+ self._checkSelection(selection)
+ self.assertIsNone(self.plot.getActiveCurve())
+ self.assertIsNone(self.plot.getActiveImage())
+ self.assertIsNone(self.plot.getActiveScatter())
+
+ # Set current to an item makes it active
+ selection.setCurrentItem(image)
+ self._checkSelection(selection, image, (image,))
+ self.assertIsNone(self.plot.getActiveCurve())
+ self.assertIs(self.plot.getActiveImage(), image)
+ self.assertIsNone(self.plot.getActiveScatter())
+
+ # Set current to an item makes it active and keeps other active
+ selection.setCurrentItem(curve)
+ self._checkSelection(selection, curve, (curve, image))
+ self.assertIs(self.plot.getActiveCurve(), curve)
+ self.assertIs(self.plot.getActiveImage(), image)
+ self.assertIsNone(self.plot.getActiveScatter())
+
+ # Set current to an item makes it active and keeps other active
+ selection.setCurrentItem(scatter)
+ self._checkSelection(selection, scatter, (scatter, curve, image))
+ self.assertIs(self.plot.getActiveCurve(), curve)
+ self.assertIs(self.plot.getActiveImage(), image)
+ self.assertIs(self.plot.getActiveScatter(), scatter)
+
+
def suite():
testClasses = (TestPlotWidget,
TestPlotImage,
@@ -1870,7 +2044,8 @@ def suite():
TestPlotEmptyLog,
TestPlotCurveLog,
TestPlotImageLog,
- TestPlotMarkerLog)
+ TestPlotMarkerLog,
+ TestPlotWidgetSelection)
test_suite = unittest.TestSuite()
diff --git a/silx/gui/plot/tools/PositionInfo.py b/silx/gui/plot/tools/PositionInfo.py
index 4b63cdb..81d312a 100644
--- a/silx/gui/plot/tools/PositionInfo.py
+++ b/silx/gui/plot/tools/PositionInfo.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2021 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
@@ -44,11 +44,25 @@ import numpy
from ....utils.deprecation import deprecated
from ... import qt
from .. import items
+from ...widgets.ElidedLabel import ElidedLabel
_logger = logging.getLogger(__name__)
+class _PositionInfoLabel(ElidedLabel):
+ """QLabel with a default size larger than what is displayed."""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.setTextInteractionFlags(qt.Qt.TextSelectableByMouse)
+
+ def sizeHint(self):
+ hint = super().sizeHint()
+ width = self.fontMetrics().boundingRect('##############').width()
+ return qt.QSize(max(hint.width(), width), hint.height())
+
+
# PositionInfo ################################################################
class PositionInfo(qt.QWidget):
@@ -117,11 +131,8 @@ class PositionInfo(qt.QWidget):
for name, func in converters:
layout.addWidget(qt.QLabel('<b>' + name + ':</b>'))
- contentWidget = qt.QLabel()
+ contentWidget = _PositionInfoLabel(self)
contentWidget.setText('------')
- contentWidget.setTextInteractionFlags(qt.Qt.TextSelectableByMouse)
- contentWidget.setFixedWidth(
- contentWidget.fontMetrics().boundingRect('##############').width())
layout.addWidget(contentWidget)
self._fields.append((contentWidget, name, func))
@@ -213,10 +224,11 @@ class PositionInfo(qt.QWidget):
kinds = []
if snappingMode & self.SNAPPING_CURVE:
kinds.append(items.Curve)
+ kinds.append(items.Histogram)
if snappingMode & self.SNAPPING_SCATTER:
kinds.append(items.Scatter)
selectedItems = [item for item in plot.getItems()
- if isinstance(item, kinds) and item.isVisible()]
+ if isinstance(item, tuple(kinds)) and item.isVisible()]
# Compute distance threshold
if qt.BINDING in ('PyQt5', 'PySide2'):
@@ -233,38 +245,54 @@ class PositionInfo(qt.QWidget):
distInPixels = (self.SNAP_THRESHOLD_DIST * ratio)**2
for item in selectedItems:
- if (snappingMode & self.SNAPPING_SYMBOLS_ONLY and
- not item.getSymbol()):
+ if (snappingMode & self.SNAPPING_SYMBOLS_ONLY and (
+ not isinstance(item, items.SymbolMixIn) or
+ not item.getSymbol())):
# Only handled if item symbols are visible
continue
- xArray = item.getXData(copy=False)
- yArray = item.getYData(copy=False)
- closestIndex = numpy.argmin(
- pow(xArray - x, 2) + pow(yArray - y, 2))
+ if isinstance(item, items.Histogram):
+ result = item.pick(xPixel, yPixel)
+ if result is not None: # Histogram picked
+ index = result.getIndices()[0]
+ edges = item.getBinEdgesData(copy=False)
- xClosest = xArray[closestIndex]
- yClosest = yArray[closestIndex]
+ # Snap to bin center and value
+ xData = 0.5 * (edges[index] + edges[index + 1])
+ yData = item.getValueData(copy=False)[index]
- if isinstance(item, items.YAxisMixIn):
- axis = item.getYAxis()
- else:
- axis = 'left'
-
- closestInPixels = plot.dataToPixel(
- xClosest, yClosest, axis=axis)
- if closestInPixels is not None:
- curveDistInPixels = (
- (closestInPixels[0] - xPixel)**2 +
- (closestInPixels[1] - yPixel)**2)
-
- if curveDistInPixels <= distInPixels:
# Update label style sheet
styleSheet = "color: rgb(0, 0, 0);"
-
- # if close enough, snap to data point coord
- xData, yData = xClosest, yClosest
- distInPixels = curveDistInPixels
+ break
+
+ else: # Curve, Scatter
+ xArray = item.getXData(copy=False)
+ yArray = item.getYData(copy=False)
+ closestIndex = numpy.argmin(
+ pow(xArray - x, 2) + pow(yArray - y, 2))
+
+ xClosest = xArray[closestIndex]
+ yClosest = yArray[closestIndex]
+
+ if isinstance(item, items.YAxisMixIn):
+ axis = item.getYAxis()
+ else:
+ axis = 'left'
+
+ closestInPixels = plot.dataToPixel(
+ xClosest, yClosest, axis=axis)
+ if closestInPixels is not None:
+ curveDistInPixels = (
+ (closestInPixels[0] - xPixel)**2 +
+ (closestInPixels[1] - yPixel)**2)
+
+ if curveDistInPixels <= distInPixels:
+ # Update label style sheet
+ styleSheet = "color: rgb(0, 0, 0);"
+
+ # if close enough, snap to data point coord
+ xData, yData = xClosest, yClosest
+ distInPixels = curveDistInPixels
for label, name, func in self._fields:
label.setStyleSheet(styleSheet)
diff --git a/silx/gui/plot/tools/RadarView.py b/silx/gui/plot/tools/RadarView.py
new file mode 100644
index 0000000..7076835
--- /dev/null
+++ b/silx/gui/plot/tools/RadarView.py
@@ -0,0 +1,361 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2015-2018 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.
+#
+# ###########################################################################*/
+"""QWidget displaying an overview of a 2D plot.
+
+This shows the available range of the data, and the current location of the
+plot view.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "22/02/2021"
+
+import logging
+import weakref
+from ... import qt
+from ...utils import LockReentrant
+
+_logger = logging.getLogger(__name__)
+
+
+class _DraggableRectItem(qt.QGraphicsRectItem):
+ """RectItem which signals its change through visibleRectDragged."""
+ def __init__(self, *args, **kwargs):
+ super(_DraggableRectItem, self).__init__(
+ *args, **kwargs)
+
+ self._previousCursor = None
+ self.setFlag(qt.QGraphicsItem.ItemIsMovable)
+ self.setFlag(qt.QGraphicsItem.ItemSendsGeometryChanges)
+ self.setAcceptHoverEvents(True)
+ self._ignoreChange = False
+ self._constraint = 0, 0, 0, 0
+
+ def setConstraintRect(self, left, top, width, height):
+ """Set the constraint rectangle for dragging.
+
+ The coordinates are in the _DraggableRectItem coordinate system.
+
+ This constraint only applies to modification through interaction
+ (i.e., this constraint is not applied to change through API).
+
+ If the _DraggableRectItem is smaller than the constraint rectangle,
+ the _DraggableRectItem remains within the constraint rectangle.
+ If the _DraggableRectItem is wider than the constraint rectangle,
+ the constraint rectangle remains within the _DraggableRectItem.
+ """
+ self._constraint = left, left + width, top, top + height
+
+ def setPos(self, *args, **kwargs):
+ """Overridden to ignore changes from API in itemChange."""
+ self._ignoreChange = True
+ super(_DraggableRectItem, self).setPos(*args, **kwargs)
+ self._ignoreChange = False
+
+ def moveBy(self, *args, **kwargs):
+ """Overridden to ignore changes from API in itemChange."""
+ self._ignoreChange = True
+ super(_DraggableRectItem, self).moveBy(*args, **kwargs)
+ self._ignoreChange = False
+
+ def itemChange(self, change, value):
+ """Callback called before applying changes to the item."""
+ if (change == qt.QGraphicsItem.ItemPositionChange and
+ not self._ignoreChange):
+ # Makes sure that the visible area is in the data
+ # or that data is in the visible area if area is too wide
+ x, y = value.x(), value.y()
+ xMin, xMax, yMin, yMax = self._constraint
+
+ if self.rect().width() <= (xMax - xMin):
+ if x < xMin:
+ value.setX(xMin)
+ elif x > xMax - self.rect().width():
+ value.setX(xMax - self.rect().width())
+ else:
+ if x > xMin:
+ value.setX(xMin)
+ elif x < xMax - self.rect().width():
+ value.setX(xMax - self.rect().width())
+
+ if self.rect().height() <= (yMax - yMin):
+ if y < yMin:
+ value.setY(yMin)
+ elif y > yMax - self.rect().height():
+ value.setY(yMax - self.rect().height())
+ else:
+ if y > yMin:
+ value.setY(yMin)
+ elif y < yMax - self.rect().height():
+ value.setY(yMax - self.rect().height())
+
+ if self.pos() != value:
+ # Notify change through signal
+ views = self.scene().views()
+ assert len(views) == 1
+ views[0].visibleRectDragged.emit(
+ value.x() + self.rect().left(),
+ value.y() + self.rect().top(),
+ self.rect().width(),
+ self.rect().height())
+
+ return value
+
+ return super(_DraggableRectItem, self).itemChange(
+ change, value)
+
+ def hoverEnterEvent(self, event):
+ """Called when the mouse enters the rectangle area"""
+ self._previousCursor = self.cursor()
+ self.setCursor(qt.Qt.OpenHandCursor)
+
+ def hoverLeaveEvent(self, event):
+ """Called when the mouse leaves the rectangle area"""
+ if self._previousCursor is not None:
+ self.setCursor(self._previousCursor)
+ self._previousCursor = None
+
+
+class RadarView(qt.QGraphicsView):
+ """Widget presenting a synthetic view of a 2D area and
+ the current visible area.
+
+ Coordinates are as in QGraphicsView:
+ x goes from left to right and y goes from top to bottom.
+ This widget preserves the aspect ratio of the areas.
+
+ The 2D area and the visible area can be set with :meth:`setDataRect`
+ and :meth:`setVisibleRect`.
+ When the visible area has been dragged by the user, its new position
+ is signaled by the *visibleRectDragged* signal.
+
+ It is possible to invert the direction of the axes by using the
+ :meth:`scale` method of QGraphicsView.
+ """
+
+ visibleRectDragged = qt.Signal(float, float, float, float)
+ """Signals that the visible rectangle has been dragged.
+
+ It provides: left, top, width, height in data coordinates.
+ """
+
+ _DATA_PEN = qt.QPen(qt.QColor('white'))
+ _DATA_BRUSH = qt.QBrush(qt.QColor('light gray'))
+ _ACTIVEDATA_PEN = qt.QPen(qt.QColor('black'))
+ _ACTIVEDATA_BRUSH = qt.QBrush(qt.QColor('transparent'))
+ _ACTIVEDATA_PEN.setWidth(2)
+ _ACTIVEDATA_PEN.setCosmetic(True)
+ _VISIBLE_PEN = qt.QPen(qt.QColor('blue'))
+ _VISIBLE_PEN.setWidth(2)
+ _VISIBLE_PEN.setCosmetic(True)
+ _VISIBLE_BRUSH = qt.QBrush(qt.QColor(0, 0, 0, 0))
+ _TOOLTIP = 'Radar View:\nRed contour: Visible area\nGray area: The image'
+
+ _PIXMAP_SIZE = 256
+
+ def __init__(self, parent=None):
+ self.__plotRef = None
+ self._scene = qt.QGraphicsScene()
+ self._dataRect = self._scene.addRect(0, 0, 1, 1,
+ self._DATA_PEN,
+ self._DATA_BRUSH)
+ self._imageRect = self._scene.addRect(0, 0, 1, 1,
+ self._ACTIVEDATA_PEN,
+ self._ACTIVEDATA_BRUSH)
+ self._imageRect.setVisible(False)
+ self._scatterRect = self._scene.addRect(0, 0, 1, 1,
+ self._ACTIVEDATA_PEN,
+ self._ACTIVEDATA_BRUSH)
+ self._scatterRect.setVisible(False)
+ self._curveRect = self._scene.addRect(0, 0, 1, 1,
+ self._ACTIVEDATA_PEN,
+ self._ACTIVEDATA_BRUSH)
+ self._curveRect.setVisible(False)
+
+ self._visibleRect = _DraggableRectItem(0, 0, 1, 1)
+ self._visibleRect.setPen(self._VISIBLE_PEN)
+ self._visibleRect.setBrush(self._VISIBLE_BRUSH)
+ self._scene.addItem(self._visibleRect)
+
+ super(RadarView, self).__init__(self._scene, parent)
+ self.setHorizontalScrollBarPolicy(qt.Qt.ScrollBarAlwaysOff)
+ self.setVerticalScrollBarPolicy(qt.Qt.ScrollBarAlwaysOff)
+ self.setFocusPolicy(qt.Qt.NoFocus)
+ self.setStyleSheet('border: 0px')
+ self.setToolTip(self._TOOLTIP)
+
+ self.__reentrant = LockReentrant()
+ self.visibleRectDragged.connect(self._viewRectDragged)
+
+ self.__timer = qt.QTimer(self)
+ self.__timer.timeout.connect(self._updateDataContent)
+
+ def sizeHint(self):
+ # """Overridden to avoid sizeHint to depend on content size."""
+ return self.minimumSizeHint()
+
+ def wheelEvent(self, event):
+ # """Overridden to disable vertical scrolling with wheel."""
+ event.ignore()
+
+ def resizeEvent(self, event):
+ # """Overridden to fit current content to new size."""
+ self.fitInView(self._scene.itemsBoundingRect(), qt.Qt.KeepAspectRatio)
+ super(RadarView, self).resizeEvent(event)
+
+ def setDataRect(self, left, top, width, height):
+ """Set the bounds of the data rectangular area.
+
+ This sets the coordinate system.
+ """
+ self._dataRect.setRect(left, top, width, height)
+ self._visibleRect.setConstraintRect(left, top, width, height)
+ self.fitInView(self._scene.itemsBoundingRect(), qt.Qt.KeepAspectRatio)
+
+ def setVisibleRect(self, left, top, width, height):
+ """Set the visible rectangular area.
+
+ The coordinates are relative to the data rect.
+ """
+ self.__visibleRect = left, top, width, height
+ self._visibleRect.setRect(0, 0, width, height)
+ self._visibleRect.setPos(left, top)
+ self.fitInView(self._scene.itemsBoundingRect(), qt.Qt.KeepAspectRatio)
+
+ def __setVisibleRectFromPlot(self, plot):
+ """Update radar view visible area.
+
+ Takes care of y coordinate conversion.
+ """
+ xMin, xMax = plot.getXAxis().getLimits()
+ yMin, yMax = plot.getYAxis().getLimits()
+ self.setVisibleRect(xMin, yMin, xMax - xMin, yMax - yMin)
+
+ def getPlotWidget(self):
+ """Returns the connected plot
+
+ :rtype: Union[None,PlotWidget]
+ """
+ if self.__plotRef is None:
+ return None
+ plot = self.__plotRef()
+ if plot is None:
+ self.__plotRef = None
+ return plot
+
+ def setPlotWidget(self, plot):
+ """Set the PlotWidget this radar view connects to.
+
+ As result `setDataRect` and `setVisibleRect` will be called
+ automatically.
+
+ :param Union[None,PlotWidget] plot:
+ """
+ previousPlot = self.getPlotWidget()
+ if previousPlot is not None: # Disconnect previous plot
+ plot.getXAxis().sigLimitsChanged.disconnect(self._xLimitChanged)
+ plot.getYAxis().sigLimitsChanged.disconnect(self._yLimitChanged)
+ plot.getYAxis().sigInvertedChanged.disconnect(self._updateYAxisInverted)
+
+ # Reset plot and timer
+ # FIXME: It would be good to clean up the display here
+ self.__plotRef = None
+ self.__timer.stop()
+
+ if plot is not None: # Connect new plot
+ self.__plotRef = weakref.ref(plot)
+ plot.getXAxis().sigLimitsChanged.connect(self._xLimitChanged)
+ plot.getYAxis().sigLimitsChanged.connect(self._yLimitChanged)
+ plot.getYAxis().sigInvertedChanged.connect(self._updateYAxisInverted)
+ self.__setVisibleRectFromPlot(plot)
+ self._updateYAxisInverted()
+ self.__timer.start(500)
+
+ def _xLimitChanged(self, vmin, vmax):
+ plot = self.getPlotWidget()
+ self.__setVisibleRectFromPlot(plot)
+
+ def _yLimitChanged(self, vmin, vmax):
+ plot = self.getPlotWidget()
+ self.__setVisibleRectFromPlot(plot)
+
+ def _updateYAxisInverted(self, inverted=None):
+ """Sync radar view axis orientation."""
+ plot = self.getPlotWidget()
+ if inverted is None:
+ # Do not perform this when called from plot signal
+ inverted = plot.getYAxis().isInverted()
+ # Use scale to invert radarView
+ # RadarView default Y direction is from top to bottom
+ # As opposed to Plot. So invert RadarView when Plot is NOT inverted.
+ self.resetTransform()
+ if not inverted:
+ self.scale(1., -1.)
+ self.update()
+
+ def _viewRectDragged(self, left, top, width, height):
+ """Slot for radar view visible rectangle changes."""
+ plot = self.getPlotWidget()
+ if plot is None:
+ return
+
+ if self.__reentrant.locked():
+ return
+
+ with self.__reentrant:
+ plot.setLimits(left, left + width, top, top + height)
+
+ def _updateDataContent(self):
+ """Update the content to the current data content"""
+ plot = self.getPlotWidget()
+ if plot is None:
+ return
+ ranges = plot.getDataRange()
+ xmin, xmax = ranges.x if ranges.x is not None else (0, 0)
+ ymin, ymax = ranges.y if ranges.y is not None else (0, 0)
+ self.setDataRect(xmin, ymin, xmax - xmin, ymax - ymin)
+
+ self.__updateItem(self._imageRect, plot.getActiveImage())
+ self.__updateItem(self._scatterRect, plot.getActiveScatter())
+ self.__updateItem(self._curveRect, plot.getActiveCurve())
+
+ def __updateItem(self, rect, item):
+ """Sync rect with item bounds
+
+ :param QGraphicsRectItem rect:
+ :param Item item:
+ """
+ if item is None:
+ rect.setVisible(False)
+ return
+ ranges = item._getBounds()
+ if ranges is None:
+ rect.setVisible(False)
+ return
+ xmin, xmax, ymin, ymax = ranges
+ width = xmax - xmin
+ height = ymax - ymin
+ rect.setRect(xmin, ymin, width, height)
+ rect.setVisible(True)
diff --git a/silx/gui/plot/tools/profile/core.py b/silx/gui/plot/tools/profile/core.py
index 1f883dc..200f5cf 100644
--- a/silx/gui/plot/tools/profile/core.py
+++ b/silx/gui/plot/tools/profile/core.py
@@ -167,7 +167,10 @@ class ProfileRoiMixIn:
def __profileWindowAboutToClose(self):
profileManager = self.getProfileManager()
roiManager = profileManager.getRoiManager()
- roiManager.removeRoi(self)
+ try:
+ roiManager.removeRoi(self)
+ except ValueError:
+ pass
def computeProfile(self, item):
"""
diff --git a/silx/gui/plot/tools/profile/manager.py b/silx/gui/plot/tools/profile/manager.py
index 757b741..68db9a6 100644
--- a/silx/gui/plot/tools/profile/manager.py
+++ b/silx/gui/plot/tools/profile/manager.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2021 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
@@ -775,7 +775,8 @@ class ProfileManager(qt.QObject):
window = self._disconnectProfileWindow(profileRoi)
if window is not None:
geometry = window.geometry()
- self._previousWindowGeometry.append(geometry)
+ if not geometry.isEmpty():
+ self._previousWindowGeometry.append(geometry)
self.clearProfileWindow(window)
if profileRoi in self._rois:
self._rois.remove(profileRoi)
@@ -949,6 +950,7 @@ class ProfileManager(qt.QObject):
"""Handle item changes.
"""
if changeType in (items.ItemChangedType.DATA,
+ items.ItemChangedType.MASK,
items.ItemChangedType.POSITION,
items.ItemChangedType.SCALE):
self.requestUpdateAllProfile()
diff --git a/silx/gui/plot/tools/profile/rois.py b/silx/gui/plot/tools/profile/rois.py
index 9e651a7..eb7e975 100644
--- a/silx/gui/plot/tools/profile/rois.py
+++ b/silx/gui/plot/tools/profile/rois.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2021 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,7 +33,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "03/04/2020"
+__date__ = "01/12/2020"
import numpy
import weakref
@@ -137,11 +137,7 @@ class _ImageProfileArea(items.Shape):
if not isinstance(item, items.ImageBase):
raise TypeError("Unexpected class %s" % type(item))
- if isinstance(item, items.ImageRgba):
- rgba = item.getData(copy=False)
- currentData = rgba[..., 0]
- else:
- currentData = item.getData(copy=False)
+ currentData = item.getValueData(copy=False)
roi = self.getParentRoi()
origin = item.getOrigin()
@@ -288,7 +284,7 @@ class _DefaultImageProfileRoiMixIn(core.ProfileRoiMixIn):
roiStart, roiEnd = self.getEndPoints()
else:
assert False
-
+
return roiStart, roiEnd, lineProjectionMode
def computeProfile(self, item):
@@ -310,15 +306,7 @@ class _DefaultImageProfileRoiMixIn(core.ProfileRoiMixIn):
method=method)
return coords, profile, profileName, xLabel
- if isinstance(item, items.ImageRgba):
- rgba = item.getData(copy=False)
- is_uint8 = rgba.dtype.type == numpy.uint8
- # luminosity
- if is_uint8:
- rgba = rgba.astype(numpy.float64)
- currentData = 0.21 * rgba[..., 0] + 0.72 * rgba[..., 1] + 0.07 * rgba[..., 2]
- else:
- currentData = item.getData(copy=False)
+ currentData = item.getValueData(copy=False)
yLabel = "%s" % str(method).capitalize()
coords, profile, title, xLabel = createProfile2(currentData)
@@ -427,7 +415,7 @@ class ProfileImageDirectedLineROI(roi_items.LineROI,
scale = item.getScale()
method = self.getProfileMethod()
lineWidth = self.getProfileLineWidth()
- currentData = item.getData(copy=False)
+ currentData = item.getValueData(copy=False)
roiInfo = self._getRoiInfo()
roiStart, roiEnd, _lineProjectionMode = roiInfo
@@ -448,8 +436,8 @@ class ProfileImageDirectedLineROI(roi_items.LineROI,
method=method)
# Compute the line size
- lineSize = numpy.sqrt((roiEnd[1] - roiStart[1])**2 +
- (roiEnd[0] - roiStart[0])**2)
+ lineSize = numpy.sqrt((roiEnd[1] - roiStart[1]) ** 2 +
+ (roiEnd[0] - roiStart[0]) ** 2)
coords = numpy.linspace(0, lineSize, len(profile),
endpoint=True,
dtype=numpy.float32)
diff --git a/silx/gui/plot3d/items/__init__.py b/silx/gui/plot3d/items/__init__.py
index 5810618..e7c4af1 100644
--- a/silx/gui/plot3d/items/__init__.py
+++ b/silx/gui/plot3d/items/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2021 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 @@ from .core import ItemChangedType, Item3DChangedType # noqa
from .mixins import (ColormapMixIn, ComplexMixIn, InterpolationMixIn, # noqa
PlaneMixIn, SymbolMixIn) # noqa
from .clipplane import ClipPlane # noqa
-from .image import ImageData, ImageRgba # noqa
+from .image import ImageData, ImageRgba, HeightMapData, HeightMapRGBA # noqa
from .mesh import Mesh, ColormapMesh, Box, Cylinder, Hexagon # noqa
from .scatter import Scatter2D, Scatter3D # noqa
from .volume import ComplexField3D, ScalarField3D # noqa
diff --git a/silx/gui/plot3d/items/image.py b/silx/gui/plot3d/items/image.py
index cfd1188..4e2b396 100644
--- a/silx/gui/plot3d/items/image.py
+++ b/silx/gui/plot3d/items/image.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2021 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
@@ -174,3 +174,252 @@ class ImageRgba(_Image, InterpolationMixIn):
:return: The image data
"""
return self._image.getData(copy=copy)
+
+
+class _HeightMap(DataItem3D):
+ """Base class for 2D data array displayed as a height field.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ def __init__(self, parent=None):
+ DataItem3D.__init__(self, parent=parent)
+ self.__data = numpy.zeros((0, 0), dtype=numpy.float32)
+
+ def _pickFull(self, context, threshold=0., sort='depth'):
+ """Perform picking in this item at given widget position.
+
+ :param PickContext context: Current picking context
+ :param float threshold: Picking threshold in pixel.
+ Perform picking in a square of size threshold x threshold.
+ :param str sort: How returned indices are sorted:
+
+ - 'index' (default): sort by the value of the indices
+ - 'depth': Sort by the depth of the points from the current
+ camera point of view.
+ :return: Object holding the results or None
+ :rtype: Union[None,PickingResult]
+ """
+ assert sort in ('index', 'depth')
+
+ rayNdc = context.getPickingSegment(frame='ndc')
+ if rayNdc is None: # No picking outside viewport
+ return None
+
+ # TODO no colormapped or color data
+ # Project data to NDC
+ heightData = self.getData(copy=False)
+ if heightData.size == 0:
+ return # Nothing displayed
+
+ height, width = heightData.shape
+ z = numpy.ravel(heightData)
+ y, x = numpy.mgrid[0:height, 0:width]
+ dataPoints = numpy.transpose((numpy.ravel(x),
+ numpy.ravel(y),
+ z,
+ numpy.ones_like(z)))
+
+ primitive = self._getScenePrimitive()
+
+ pointsNdc = primitive.objectToNDCTransform.transformPoints(
+ dataPoints, perspectiveDivide=True)
+
+ # Perform picking
+ distancesNdc = numpy.abs(pointsNdc[:, :2] - rayNdc[0, :2])
+ # TODO issue with symbol size: using pixel instead of points
+ threshold += 1. # symbol size
+ thresholdNdc = 2. * threshold / numpy.array(primitive.viewport.size)
+ picked = numpy.where(numpy.logical_and(
+ numpy.all(distancesNdc < thresholdNdc, axis=1),
+ numpy.logical_and(rayNdc[0, 2] <= pointsNdc[:, 2],
+ pointsNdc[:, 2] <= rayNdc[1, 2])))[0]
+
+ if sort == 'depth':
+ # Sort picked points from front to back
+ picked = picked[numpy.argsort(pointsNdc[picked, 2])]
+
+ if picked.size > 0:
+ # Convert indices from 1D to 2D
+ return PickingResult(self,
+ positions=dataPoints[picked, :3],
+ indices=(picked // width, picked % width),
+ fetchdata=self.getData)
+ else:
+ return None
+
+ def setData(self, data, copy: bool=True):
+ """Set the height field data.
+
+ :param data:
+ :param copy: True (default) to copy the data,
+ False to use as is (do not modify!).
+ """
+ data = numpy.array(data, copy=copy)
+ assert data.ndim == 2
+
+ self.__data = data
+ self._updated(ItemChangedType.DATA)
+
+ def getData(self, copy: bool=True) -> numpy.ndarray:
+ """Get the height field 2D data.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get internal representation (do not modify!).
+ """
+ return numpy.array(self.__data, copy=copy)
+
+
+class HeightMapData(_HeightMap, ColormapMixIn):
+ """Description of a 2D height field associated to a colormapped dataset.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ def __init__(self, parent=None):
+ _HeightMap.__init__(self, parent=parent)
+ ColormapMixIn.__init__(self)
+
+ self.__data = numpy.zeros((0, 0), dtype=numpy.float32)
+
+ def _updated(self, event=None):
+ if event == ItemChangedType.DATA:
+ self.__updateScene()
+ super()._updated(event=event)
+
+ def __updateScene(self):
+ """Update display primitive to use"""
+ self._getScenePrimitive().children = [] # Remove previous primitives
+ ColormapMixIn._setSceneColormap(self, None)
+
+ if not self.isVisible():
+ return # Update when visible
+
+ data = self.getColormappedData(copy=False)
+ heightData = self.getData(copy=False)
+
+ if data.size == 0 or heightData.size == 0:
+ return # Nothing to display
+
+ # Display as a set of points
+ height, width = heightData.shape
+ # Generates coordinates
+ y, x = numpy.mgrid[0:height, 0:width]
+
+ if data.shape != heightData.shape: # data and height size miss-match
+ # Colormapped data is interpolated (nearest-neighbour) to match the height field
+ data = data[numpy.floor(y * data.shape[0] / height).astype(numpy.int),
+ numpy.floor(x * data.shape[1] / height).astype(numpy.int)]
+
+ x = numpy.ravel(x)
+ y = numpy.ravel(y)
+
+ primitive = primitives.Points(
+ x=x,
+ y=y,
+ z=numpy.ravel(heightData),
+ value=numpy.ravel(data),
+ size=1)
+ primitive.marker = 's'
+ ColormapMixIn._setSceneColormap(self, primitive.colormap)
+ self._getScenePrimitive().children = [primitive]
+
+ def setColormappedData(self, data, copy: bool=True):
+ """Set the 2D data used to compute colors.
+
+ :param data: 2D array of data
+ :param copy: True (default) to copy the data,
+ False to use as is (do not modify!).
+ """
+ data = numpy.array(data, copy=copy)
+ assert data.ndim == 2
+
+ self.__data = data
+ self._updated(ItemChangedType.DATA)
+
+ def getColormappedData(self, copy: bool=True) -> numpy.ndarray:
+ """Returns the 2D data used to compute colors.
+
+ :param copy:
+ True (default) to get a copy,
+ False to get internal representation (do not modify!).
+ """
+ return numpy.array(self.__data, copy=copy)
+
+
+class HeightMapRGBA(_HeightMap):
+ """Description of a 2D height field associated to a RGB(A) image.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ def __init__(self, parent=None):
+ _HeightMap.__init__(self, parent=parent)
+
+ self.__rgba = numpy.zeros((0, 0, 3), dtype=numpy.float32)
+
+ def _updated(self, event=None):
+ if event == ItemChangedType.DATA:
+ self.__updateScene()
+ super()._updated(event=event)
+
+ def __updateScene(self):
+ """Update display primitive to use"""
+ self._getScenePrimitive().children = [] # Remove previous primitives
+
+ if not self.isVisible():
+ return # Update when visible
+
+ rgba = self.getColorData(copy=False)
+ heightData = self.getData(copy=False)
+ if rgba.size == 0 or heightData.size == 0:
+ return # Nothing to display
+
+ # Display as a set of points
+ height, width = heightData.shape
+ # Generates coordinates
+ y, x = numpy.mgrid[0:height, 0:width]
+
+ if rgba.shape[:2] != heightData.shape: # image and height size miss-match
+ # RGBA data is interpolated (nearest-neighbour) to match the height field
+ rgba = rgba[numpy.floor(y * rgba.shape[0] / height).astype(numpy.int),
+ numpy.floor(x * rgba.shape[1] / height).astype(numpy.int)]
+
+ x = numpy.ravel(x)
+ y = numpy.ravel(y)
+
+ primitive = primitives.ColorPoints(
+ x=x,
+ y=y,
+ z=numpy.ravel(heightData),
+ color=rgba.reshape(-1, rgba.shape[-1]),
+ size=1)
+ primitive.marker = 's'
+ self._getScenePrimitive().children = [primitive]
+
+ def setColorData(self, data, copy: bool=True):
+ """Set the RGB(A) image to use.
+
+ Supported array format: float32 in [0, 1], uint8.
+
+ :param data:
+ The RGBA image data as an array of shape (H, W, Channels)
+ :param copy: True (default) to copy the data,
+ False to use as is (do not modify!).
+ """
+ data = numpy.array(data, copy=copy)
+ assert data.ndim == 3
+ assert data.shape[-1] in (3, 4)
+ # TODO check type
+
+ self.__rgba = data
+ self._updated(ItemChangedType.DATA)
+
+ def getColorData(self, copy: bool=True) -> numpy.ndarray:
+ """Get the RGB(A) image data.
+
+ :param copy: True (default) to get a copy,
+ False to get internal representation (do not modify!).
+ """
+ return numpy.array(self.__rgba, copy=copy)
diff --git a/silx/gui/plot3d/scene/primitives.py b/silx/gui/plot3d/scene/primitives.py
index b4c8e26..7f35c3c 100644
--- a/silx/gui/plot3d/scene/primitives.py
+++ b/silx/gui/plot3d/scene/primitives.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2021 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
@@ -1383,7 +1383,7 @@ class ColorPoints(_Points):
"""A set of points with an associated color and size."""
_ATTR_INFO = _Points._ATTR_INFO.copy()
- _ATTR_INFO.update({'value': {'dims': (1, 2), 'lastDim': (4,)}})
+ _ATTR_INFO.update({'value': {'dims': (1, 2), 'lastDim': (3, 4)}})
def __init__(self, x, y, z, color=(1., 1., 1., 1.), size=1.,
indices=None):
@@ -2041,7 +2041,10 @@ class _Image(Geometry):
$scenePreCall(vCameraPosition);
vec4 color = imageColor(data, vTexCoords);
- color.a = alpha;
+ color.a *= alpha;
+ if (color.a == 0.) { /* Discard fully transparent pixels */
+ discard;
+ }
vec3 normal = vec3(0.0, 0.0, 1.0);
gl_FragColor = $lightingCall(color, vPosition, normal);
diff --git a/silx/gui/plot3d/test/testSceneWindow.py b/silx/gui/plot3d/test/testSceneWindow.py
index b2e6ea0..8cf6b81 100644
--- a/silx/gui/plot3d/test/testSceneWindow.py
+++ b/silx/gui/plot3d/test/testSceneWindow.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2019 European Synchrotron Radiation Facility
+# Copyright (c) 2019-2021 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 @@ from silx.gui.utils.testutils import TestCaseQt
from silx.gui import qt
from silx.gui.plot3d.SceneWindow import SceneWindow
-
+from silx.gui.plot3d.items import HeightMapData, HeightMapRGBA
class TestSceneWindow(TestCaseQt, ParametricTestCase):
"""Tests SceneWidget picking feature"""
@@ -114,6 +114,42 @@ class TestSceneWindow(TestCaseQt, ParametricTestCase):
sceneWidget.resetZoom('front')
self.qapp.processEvents()
+ def testHeightMap(self):
+ """Test height map items"""
+ sceneWidget = self.window.getSceneWidget()
+
+ height = numpy.arange(10000).reshape(100, 100) /100.
+
+ for shape in ((100, 100), (4, 5), (150, 20), (110, 110)):
+ with self.subTest(shape=shape):
+ items = []
+
+ # Colormapped data height map
+ data = numpy.arange(numpy.prod(shape)).astype(numpy.float32).reshape(shape)
+
+ heightmap = HeightMapData()
+ heightmap.setData(height)
+ heightmap.setColormappedData(data)
+ heightmap.getColormap().setName('viridis')
+ items.append(heightmap)
+ sceneWidget.addItem(heightmap)
+
+ # RGBA height map
+ colors = numpy.zeros(shape + (3,), dtype=numpy.float32)
+ colors[:, :, 1] = numpy.random.random(shape)
+
+ heightmap = HeightMapRGBA()
+ heightmap.setData(height)
+ heightmap.setColorData(colors)
+ heightmap.setTranslation(100., 0., 0.)
+ items.append(heightmap)
+ sceneWidget.addItem(heightmap)
+
+ self.assertEqual(sceneWidget.getItems(), tuple(items))
+ sceneWidget.resetZoom('front')
+ self.qapp.processEvents()
+ sceneWidget.clearItems()
+
def testChangeContent(self):
"""Test add/remove/clear items"""
sceneWidget = self.window.getSceneWidget()
diff --git a/silx/gui/plot3d/tools/PositionInfoWidget.py b/silx/gui/plot3d/tools/PositionInfoWidget.py
index fc86a7f..78f2959 100644
--- a/silx/gui/plot3d/tools/PositionInfoWidget.py
+++ b/silx/gui/plot3d/tools/PositionInfoWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2021 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
@@ -144,6 +144,8 @@ class PositionInfoWidget(qt.QWidget):
items.Scatter2D,
items.ImageData,
items.ImageRgba,
+ items.HeightMapData,
+ items.HeightMapRGBA,
items.Mesh,
items.Box,
items.Cylinder,
diff --git a/silx/gui/qt/__init__.py b/silx/gui/qt/__init__.py
index b8c6cdd..ace2841 100644
--- a/silx/gui/qt/__init__.py
+++ b/silx/gui/qt/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2021 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
@@ -46,7 +46,7 @@ Example of using :mod:`silx.gui.qt` module:
>>> widget = qt.QWidget()
For an alternative solution providing a structured namespace,
-see `qtpy <https://pypi.python.org/pypi/QtPy/>`_ which
+see `qtpy <https://pypi.org/project/QtPy/>`_ which
provides the namespace of PyQt5 over PyQt4, PySide and PySide2.
"""
diff --git a/silx/gui/setup.py b/silx/gui/setup.py
index 6eb87ae..04a2bac 100644
--- a/silx/gui/setup.py
+++ b/silx/gui/setup.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2021 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 @@ def configuration(parent_package='', top_path=None):
config.add_subpackage('data')
config.add_subpackage('dialog')
config.add_subpackage('utils')
+ config.add_subpackage('utils.glutils')
config.add_subpackage('utils.test')
return config
diff --git a/silx/gui/utils/glutils.py b/silx/gui/utils/glutils/__init__.py
index 83cfd89..c90f029 100644
--- a/silx/gui/utils/glutils.py
+++ b/silx/gui/utils/glutils/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2020 European Synchrotron Radiation Facility
+# Copyright (c) 2020-2021 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
@@ -27,13 +27,6 @@
import os
import sys
-
-if __name__ == "__main__":
- # When run as a script, remove directory from sys.path
- # This avoids other script in same directory to override Python modules
- if os.path.abspath(sys.path[0]) == os.path.abspath(os.path.dirname(__file__)):
- sys.path.pop(0)
-
import subprocess
from silx.gui import qt
@@ -77,7 +70,7 @@ def _runtimeOpenGLCheck(version):
try:
error = subprocess.check_output(
- [sys.executable, __file__, major, minor],
+ [sys.executable, '-s', '-S', __file__, major, minor],
env=env,
timeout=2)
except subprocess.TimeoutExpired:
@@ -190,7 +183,7 @@ if __name__ == "__main__":
app = qt.QApplication([])
window = qt.QMainWindow(flags=
- qt.Qt.Window |
+ qt.Qt.Popup |
qt.Qt.FramelessWindowHint |
qt.Qt.NoDropShadowWindowHint |
qt.Qt.WindowStaysOnTopHint)
diff --git a/silx/gui/widgets/FlowLayout.py b/silx/gui/widgets/FlowLayout.py
index 14c8ab2..3c4c9dd 100644
--- a/silx/gui/widgets/FlowLayout.py
+++ b/silx/gui/widgets/FlowLayout.py
@@ -71,7 +71,7 @@ class FlowLayout(qt.QLayout):
return None
def expandingDirections(self):
- return 0
+ return qt.Qt.Orientations()
def hasHeightForWidth(self):
return True
diff --git a/silx/gui/widgets/UrlSelectionTable.py b/silx/gui/widgets/UrlSelectionTable.py
index 27ea363..fb15edd 100644
--- a/silx/gui/widgets/UrlSelectionTable.py
+++ b/silx/gui/widgets/UrlSelectionTable.py
@@ -1,5 +1,5 @@
# /*##########################################################################
-# Copyright (C) 2017 European Synchrotron Radiation Facility
+# Copyright (C) 2017-2021 European Synchrotron Radiation Facility
#
# This file is part of the PyMca X-ray Fluorescence Toolkit developed at
# the ESRF by the Software group.
@@ -37,7 +37,7 @@ import functools
import logging
import os
-logger = logging.getLogger(__file__)
+logger = logging.getLogger(__name__)
class UrlSelectionTable(TableWidget):