diff options
Diffstat (limited to 'silx/gui/plot')
33 files changed, 2177 insertions, 862 deletions
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) |