summaryrefslogtreecommitdiff
path: root/src/silx/gui/plot/ImageView.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/silx/gui/plot/ImageView.py')
-rw-r--r--src/silx/gui/plot/ImageView.py1057
1 files changed, 1057 insertions, 0 deletions
diff --git a/src/silx/gui/plot/ImageView.py b/src/silx/gui/plot/ImageView.py
new file mode 100644
index 0000000..f8b830a
--- /dev/null
+++ b/src/silx/gui/plot/ImageView.py
@@ -0,0 +1,1057 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# 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
+# 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 a 2D image with histograms on its sides.
+
+The :class:`ImageView` implements this widget, and
+:class:`ImageViewMainWindow` provides a main window with additional toolbar
+and status bar.
+
+Basic usage of :class:`ImageView` is through the following methods:
+
+- :meth:`ImageView.getColormap`, :meth:`ImageView.setColormap` to update the
+ default colormap to use and update the currently displayed image.
+- :meth:`ImageView.setImage` to update the displayed image.
+
+For an example of use, see `imageview.py` in :ref:`sample-code`.
+"""
+
+from __future__ import division
+
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "26/04/2018"
+
+
+import logging
+import numpy
+import collections
+from typing import Union
+
+import silx
+from .. import qt
+from .. import colors
+from .. import icons
+
+from . import items, PlotWindow, PlotWidget, actions
+from ..colors import Colormap
+from ..colors import cursorColorForColormap
+from .tools import LimitsToolBar
+from .Profile import ProfileToolBar
+from ...utils.proxy import docstring
+from ...utils.deprecation import deprecated
+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
+from .actions import PlotAction
+
+_logger = logging.getLogger(__name__)
+
+
+ProfileSumResult = collections.namedtuple("ProfileResult",
+ ["dataXRange", "dataYRange",
+ 'histoH', 'histoHRange',
+ 'histoV', 'histoVRange',
+ "xCoords", "xData",
+ "yCoords", "yData"])
+
+
+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.
+
+ Optionally takes a previous computed result to be able to skip the
+ computation.
+
+ :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.
+
+ Implement ProfileWindow
+ """
+
+ 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
+ """
+ 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 __drawProfile(self):
+ """Only draw the profile on the plot.
+
+ Other elements are removed
+ """
+ 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)
+
+
+class ShowSideHistogramsAction(PlotAction):
+ """QAction to change visibility of side histogram of a :class:`.ImageView`.
+
+ :param plot: :class:`.ImageView` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(ShowSideHistogramsAction, self).__init__(
+ plot, icon='side-histograms', text='Show/hide side histograms',
+ tooltip='Show/hide side histogram',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+
+ def _actionTriggered(self, checked=False):
+ if self.plot.isSideHistogramDisplayed() != checked:
+ self.plot.setSideHistogramDisplayed(checked)
+
+
+class AggregationModeAction(qt.QWidgetAction):
+ """Action providing few filters to the image"""
+
+ sigAggregationModeChanged = qt.Signal()
+
+ def __init__(self, parent):
+ qt.QWidgetAction.__init__(self, parent)
+
+ toolButton = qt.QToolButton(parent)
+
+ filterAction = qt.QAction(self)
+ filterAction.setText("No filter")
+ filterAction.setCheckable(True)
+ filterAction.setChecked(True)
+ filterAction.setProperty("aggregation", items.ImageDataAggregated.Aggregation.NONE)
+ densityNoFilterAction = filterAction
+
+ filterAction = qt.QAction(self)
+ filterAction.setText("Max filter")
+ filterAction.setCheckable(True)
+ filterAction.setProperty("aggregation", items.ImageDataAggregated.Aggregation.MAX)
+ densityMaxFilterAction = filterAction
+
+ filterAction = qt.QAction(self)
+ filterAction.setText("Mean filter")
+ filterAction.setCheckable(True)
+ filterAction.setProperty("aggregation", items.ImageDataAggregated.Aggregation.MEAN)
+ densityMeanFilterAction = filterAction
+
+ filterAction = qt.QAction(self)
+ filterAction.setText("Min filter")
+ filterAction.setCheckable(True)
+ filterAction.setProperty("aggregation", items.ImageDataAggregated.Aggregation.MIN)
+ densityMinFilterAction = filterAction
+
+ densityGroup = qt.QActionGroup(self)
+ densityGroup.setExclusive(True)
+ densityGroup.addAction(densityNoFilterAction)
+ densityGroup.addAction(densityMaxFilterAction)
+ densityGroup.addAction(densityMeanFilterAction)
+ densityGroup.addAction(densityMinFilterAction)
+ densityGroup.triggered.connect(self._aggregationModeChanged)
+ self.__densityGroup = densityGroup
+
+ filterMenu = qt.QMenu(toolButton)
+ filterMenu.addAction(densityNoFilterAction)
+ filterMenu.addAction(densityMaxFilterAction)
+ filterMenu.addAction(densityMeanFilterAction)
+ filterMenu.addAction(densityMinFilterAction)
+
+ toolButton.setPopupMode(qt.QToolButton.InstantPopup)
+ toolButton.setMenu(filterMenu)
+ toolButton.setText("Data filters")
+ toolButton.setToolTip("Enable/disable filter on the image")
+ icon = icons.getQIcon("aggregation-mode")
+ toolButton.setIcon(icon)
+ toolButton.setText("Pixel aggregation filter")
+
+ self.setDefaultWidget(toolButton)
+
+ def _aggregationModeChanged(self):
+ self.sigAggregationModeChanged.emit()
+
+ def setAggregationMode(self, mode):
+ """Set an Aggregated enum from ImageDataAggregated"""
+ for a in self.__densityGroup.actions():
+ if a.property("aggregation") is mode:
+ a.setChecked(True)
+
+ def getAggregationMode(self):
+ """Returns an Aggregated enum from ImageDataAggregated"""
+ densityAction = self.__densityGroup.checkedAction()
+ if densityAction is None:
+ return items.ImageDataAggregated.Aggregation.NONE
+ return densityAction.property("aggregation")
+
+
+class ImageView(PlotWindow):
+ """Display a single image with horizontal and vertical histograms.
+
+ Use :meth:`setImage` to control the displayed image.
+ This class also provides the :class:`silx.gui.plot.Plot` API.
+
+ The :class:`ImageView` inherits from :class:`.PlotWindow` (which provides
+ the toolbars) and also exposes :class:`.PlotWidget` API for further
+ plot control (plot title, axes labels, aspect ratio, ...).
+
+ :param parent: The parent of this widget or None.
+ :param backend: The backend to use for the plot (default: matplotlib).
+ See :class:`.PlotWidget` for the list of supported backend.
+ :type backend: str or :class:`BackendBase.BackendBase`
+ """
+
+ HISTOGRAMS_COLOR = 'blue'
+ """Color to use for the side histograms."""
+
+ HISTOGRAMS_HEIGHT = 200
+ """Height in pixels of the side histograms."""
+
+ IMAGE_MIN_SIZE = 200
+ """Minimum size in pixels of the image area."""
+
+ # Qt signals
+ valueChanged = qt.Signal(float, float, object)
+ """Signals that the data value under the cursor has changed.
+
+ It provides: row, column, data value.
+
+ When the cursor is over an histogram, either row or column is Nan
+ and the provided data value is the histogram value
+ (i.e., the sum along the corresponding row/column).
+ 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
+
+ super(ImageView, self).__init__(parent=parent, backend=backend,
+ resetzoom=True, autoScale=False,
+ logScale=False, grid=False,
+ curveStyle=False, colormap=True,
+ aspectRatio=True, yInverted=True,
+ 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)
+
+ self.__showSideHistogramsAction = ShowSideHistogramsAction(self, self)
+ self.__showSideHistogramsAction.setChecked(True)
+
+ self.__aggregationModeAction = AggregationModeAction(self)
+ self.__aggregationModeAction.sigAggregationModeChanged.connect(self._aggregationModeChanged)
+
+ if parent is None:
+ self.setWindowTitle('ImageView')
+
+ if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward':
+ self.getYAxis().setInverted(True)
+
+ self._initWidgets(backend)
+
+ toolBar = self.toolBar()
+ toolBar.addAction(self.__showSideHistogramsAction)
+ toolBar.addAction(self.__aggregationModeAction)
+
+ 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 = _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.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.sigActiveImageChanged.connect(self._activeImageChangedSlot)
+
+ self._radarView = RadarView(parent=self)
+ self._radarView.setPlotWidget(self)
+
+ self.__syncXAxis = SyncAxes([self.getXAxis(), self._histoHPlot.getXAxis()])
+ self.__syncYAxis = SyncAxes([self.getYAxis(), self._histoVPlot.getYAxis()])
+
+ self.__setCentralWidget()
+
+ def __setCentralWidget(self):
+ """Set central widget with all its content"""
+ layout = qt.QGridLayout()
+ layout.addWidget(self.getWidgetHandle(), 0, 0)
+ layout.addWidget(self._histoVPlot, 0, 1)
+ layout.addWidget(self._histoHPlot, 1, 0)
+ layout.addWidget(self._radarView, 1, 1, 1, 2)
+ layout.addWidget(self.getColorBarWidget(), 0, 2)
+
+ self._radarView.setMinimumWidth(self.IMAGE_MIN_SIZE)
+ self._radarView.setMinimumHeight(self.HISTOGRAMS_HEIGHT)
+ self._histoHPlot.setMinimumWidth(self.IMAGE_MIN_SIZE)
+ self._histoVPlot.setMinimumHeight(self.HISTOGRAMS_HEIGHT)
+
+ layout.setColumnStretch(0, 1)
+ layout.setColumnStretch(1, 0)
+ layout.setRowStretch(0, 1)
+ layout.setRowStretch(1, 0)
+
+ layout.setSpacing(0)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ centralWidget = qt.QWidget(self)
+ centralWidget.setLayout(layout)
+ self.setCentralWidget(centralWidget)
+
+ @docstring(PlotWidget)
+ def setBackend(self, backend):
+ # Use PlotWidget here since we override PlotWindow behavior
+ PlotWidget.setBackend(self, backend)
+ self.__setCentralWidget()
+
+ def _dirtyCache(self):
+ self._cache = None
+
+ def getAggregationModeAction(self):
+ return self.__aggregationModeAction
+
+ def _aggregationModeChanged(self):
+ item = self._getItem("image", self._imageLegend)
+ if item is None:
+ return
+ aggregationMode = self.__aggregationModeAction.getAggregationMode()
+ if aggregationMode is not None and isinstance(item, items.ImageDataAggregated):
+ item.setAggregationMode(aggregationMode)
+ else:
+ # It means the item type have to be changed
+ self.removeImage(self._imageLegend)
+ image = item.getData(copy=False)
+ if image is None:
+ return
+ origin = item.getOrigin()
+ scale = item.getScale()
+ self.setImage(image, origin, scale, copy=False, resetzoom=False)
+
+ def getShowSideHistogramsAction(self):
+ return self.__showSideHistogramsAction
+
+ def setSideHistogramDisplayed(self, show):
+ """Display or not the side histograms"""
+ if self.isSideHistogramDisplayed() == show:
+ return
+ self._histoHPlot.setVisible(show)
+ self._histoVPlot.setVisible(show)
+ self._radarView.setVisible(show)
+ self.__showSideHistogramsAction.setChecked(show)
+ if show:
+ # Probably have to be computed
+ self._updateHistograms()
+
+ def isSideHistogramDisplayed(self):
+ """True if the side histograms are displayed"""
+ return self._histoHPlot.isVisible()
+
+ def _updateHistograms(self):
+ """Update histograms content using current active image."""
+ if not self.isSideHistogramDisplayed():
+ # The histogram computation can be skipped
+ return
+
+ activeImage = self.getActiveImage()
+ if activeImage is not None:
+ 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
+
+ def _imagePlotCB(self, eventDict):
+ """Callback for imageView plot events."""
+ if eventDict['event'] == 'mouseMoved':
+ activeImage = self.getActiveImage()
+ if activeImage is not None:
+ data = activeImage.getData(copy=False)
+ height, width = data.shape[0:2]
+
+ # Get corresponding coordinate in image
+ origin = activeImage.getOrigin()
+ scale = activeImage.getScale()
+ if (eventDict['x'] >= origin[0] and
+ eventDict['y'] >= origin[1]):
+ x = int((eventDict['x'] - origin[0]) / scale[0])
+ y = int((eventDict['y'] - origin[1]) / scale[1])
+
+ if x >= 0 and x < width and y >= 0 and y < height:
+ self.valueChanged.emit(float(x), float(y),
+ data[y][x])
+
+ elif eventDict['event'] == 'limitsChanged':
+ self._updateHistograms()
+
+ def _mouseMovedOnHistoH(self, x, y):
+ if self._cache is None:
+ return
+ activeImage = self.getActiveImage()
+ if activeImage is None:
+ return
+
+ xOrigin = activeImage.getOrigin()[0]
+ xScale = activeImage.getScale()[0]
+
+ minValue = xOrigin + xScale * self._cache.dataXRange[0]
+
+ 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])
+
+ def _mouseMovedOnHistoV(self, x, y):
+ if self._cache is None:
+ return
+ activeImage = self.getActiveImage()
+ if activeImage is None:
+ return
+
+ yOrigin = activeImage.getOrigin()[1]
+ yScale = activeImage.getScale()[1]
+
+ minValue = yOrigin + yScale * self._cache.dataYRange[0]
+
+ 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.
+
+ Resets side histograms cache
+ """
+ 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
+ @deprecated(replacement="getProfileToolBar()")
+ def profile(self):
+ return self.getProfileToolBar()
+
+ def getHistogram(self, axis):
+ """Return the histogram and corresponding row or column extent.
+
+ The returned value when an histogram is available is a dict with keys:
+
+ - 'data': numpy array of the histogram values.
+ - 'extent': (start, end) row or column index.
+ end index is not included in the histogram.
+
+ :param str axis: 'x' for horizontal, 'y' for vertical
+ :return: The histogram and its extent as a dict or None.
+ :rtype: dict
+ """
+ assert axis in ('x', 'y')
+ if self._cache is None:
+ return None
+ else:
+ if axis == 'x':
+ return dict(
+ 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.dataYRange))
+
+ def radarView(self):
+ """Get the lower right radarView widget."""
+ return self._radarView
+
+ def setRadarView(self, radarView):
+ """Change the lower right radarView widget.
+
+ :param RadarView radarView: Widget subclassing RadarView to replace
+ the lower right corner widget.
+ """
+ self._radarView = radarView
+ self._radarView.setPlotWidget(self)
+ self.centralWidget().layout().addWidget(self._radarView, 1, 1)
+
+ # High-level API
+
+ def getColormap(self):
+ """Get the default colormap description.
+
+ :return: A description of the current colormap.
+ See :meth:`setColormap` for details.
+ :rtype: dict
+ """
+ return self.getDefaultColormap()
+
+ def setColormap(self, colormap=None, normalization=None,
+ autoscale=None, vmin=None, vmax=None, colors=None):
+ """Set the default colormap and update active image.
+
+ Parameters that are not provided are taken from the current colormap.
+
+ The colormap parameter can also be a dict with the following keys:
+
+ - *name*: string. The colormap to use:
+ 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue'.
+ - *normalization*: string. The mapping to use for the colormap:
+ either 'linear' or 'log'.
+ - *autoscale*: bool. Whether to use autoscale (True)
+ or range provided by keys 'vmin' and 'vmax' (False).
+ - *vmin*: float. The minimum value of the range to use if 'autoscale'
+ is False.
+ - *vmax*: float. The maximum value of the range to use if 'autoscale'
+ is False.
+ - *colors*: optional. Nx3 or Nx4 array of float in [0, 1] or uint8.
+ List of RGB or RGBA colors to use (only if name is None)
+
+ :param colormap: Name of the colormap in
+ 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue'.
+ Or the description of the colormap as a dict.
+ :type colormap: dict or str.
+ :param str normalization: Colormap mapping: 'linear' or 'log'.
+ :param bool autoscale: Whether to use autoscale (True)
+ or [vmin, vmax] range (False).
+ :param float vmin: The minimum value of the range to use if
+ 'autoscale' is False.
+ :param float vmax: The maximum value of the range to use if
+ 'autoscale' is False.
+ :param numpy.ndarray colors: Only used if name is None.
+ Custom colormap colors as Nx3 or Nx4 RGB or RGBA arrays
+ """
+ cmap = self.getDefaultColormap()
+
+ if isinstance(colormap, Colormap):
+ # Replace colormap
+ cmap = colormap
+
+ self.setDefaultColormap(cmap)
+
+ # Update active image colormap
+ activeImage = self.getActiveImage()
+ if isinstance(activeImage, items.ColormapMixIn):
+ activeImage.setColormap(cmap)
+
+ elif isinstance(colormap, dict):
+ # Support colormap parameter as a dict
+ assert normalization is None
+ assert autoscale is None
+ assert vmin is None
+ assert vmax is None
+ assert colors is None
+ cmap._setFromDict(colormap)
+
+ else:
+ if colormap is not None:
+ cmap.setName(colormap)
+ if normalization is not None:
+ cmap.setNormalization(normalization)
+ if autoscale:
+ cmap.setVRange(None, None)
+ else:
+ if vmin is not None:
+ cmap.setVMin(vmin)
+ if vmax is not None:
+ cmap.setVMax(vmax)
+ if colors is not None:
+ cmap.setColormapLUT(colors)
+
+ cursorColor = cursorColorForColormap(cmap.getName())
+ self.setInteractiveMode('zoom', color=cursorColor)
+
+ def setImage(self, image, origin=(0, 0), scale=(1., 1.),
+ copy=True, reset=None, resetzoom=True):
+ """Set the image to display.
+
+ :param image: A 2D array representing the image or None to empty plot.
+ :type image: numpy.ndarray-like with 2 dimensions or None.
+ :param origin: The (x, y) position of the origin of the image.
+ Default: (0, 0).
+ The origin is the lower left corner of the image when
+ the Y axis is not inverted.
+ :type origin: Tuple of 2 floats: (origin x, origin y).
+ :param scale: The scale factor to apply to the image on X and Y axes.
+ Default: (1, 1).
+ It is the size of a pixel in the coordinates of the axes.
+ Scales must be positive numbers.
+ :type scale: Tuple of 2 floats: (scale x, scale y).
+ :param bool copy: Whether to copy image data (default) or not.
+ :param bool reset: Deprecated. Alias for `resetzoom`.
+ :param bool resetzoom: Whether to reset zoom and ROI (default) or not.
+ """
+ self._dirtyCache()
+
+ if reset is not None:
+ resetzoom = reset
+
+ assert len(origin) == 2
+ assert len(scale) == 2
+ assert scale[0] > 0
+ assert scale[1] > 0
+
+ if image is None:
+ self.remove(self._imageLegend, kind='image')
+ return
+
+ data = numpy.array(image, order='C', copy=copy)
+ if data.size == 0:
+ self.remove(self._imageLegend, kind='image')
+ return
+
+ assert data.ndim == 2 or (data.ndim == 3 and data.shape[2] in (3, 4))
+
+ aggregation = self.getAggregationModeAction().getAggregationMode()
+ if data.ndim != 2 and aggregation is not None:
+ # RGB/A with aggregation is not supported
+ aggregation = items.ImageDataAggregated.Aggregation.NONE
+
+ if aggregation is items.ImageDataAggregated.Aggregation.NONE:
+ self.addImage(data,
+ legend=self._imageLegend,
+ origin=origin, scale=scale,
+ colormap=self.getColormap(),
+ resetzoom=False)
+ else:
+ item = self._getItem("image", self._imageLegend)
+ if isinstance(item, items.ImageDataAggregated):
+ item.setData(data)
+ item.setOrigin(origin)
+ item.setScale(scale)
+ else:
+ if isinstance(item, items.ImageDataAggregated):
+ imageItem = item
+ wasCreated = False
+ else:
+ if item is not None:
+ self.removeImage(self._imageLegend)
+ imageItem = items.ImageDataAggregated()
+ imageItem.setName(self._imageLegend)
+ imageItem.setColormap(self.getColormap())
+ wasCreated = True
+ imageItem.setData(data)
+ imageItem.setOrigin(origin)
+ imageItem.setScale(scale)
+ imageItem.setAggregationMode(aggregation)
+ if wasCreated:
+ self.addItem(imageItem)
+
+ self.setActiveImage(self._imageLegend)
+ self._updateHistograms()
+ if resetzoom:
+ self.resetZoom()
+
+
+# ImageViewMainWindow #########################################################
+
+class ImageViewMainWindow(ImageView):
+ """:class:`ImageView` with additional toolbars
+
+ Adds extra toolbar and a status bar to :class:`ImageView`.
+ """
+ def __init__(self, parent=None, backend=None):
+ self._dataInfo = None
+ super(ImageViewMainWindow, self).__init__(parent, backend)
+ self.setWindowFlags(qt.Qt.Window)
+
+ self.getXAxis().setLabel('X')
+ self.getYAxis().setLabel('Y')
+ self.setGraphTitle('Image')
+
+ # Add toolbars and status bar
+ self.addToolBar(qt.Qt.BottomToolBarArea, LimitsToolBar(plot=self))
+
+ menu = self.menuBar().addMenu('File')
+ menu.addAction(self.getOutputToolBar().getSaveAction())
+ menu.addAction(self.getOutputToolBar().getPrintAction())
+ menu.addSeparator()
+ action = menu.addAction('Quit')
+ action.triggered[bool].connect(qt.QApplication.instance().quit)
+
+ menu = self.menuBar().addMenu('Edit')
+ menu.addAction(self.getOutputToolBar().getCopyAction())
+ menu.addSeparator()
+ menu.addAction(self.getResetZoomAction())
+ menu.addAction(self.getColormapAction())
+ menu.addAction(actions.control.KeepAspectRatioAction(self, self))
+ menu.addAction(actions.control.YAxisInvertedAction(self, self))
+ menu.addAction(self.getShowSideHistogramsAction())
+
+ 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 _formatValueToString(self, value):
+ try:
+ if isinstance(value, numpy.ndarray):
+ if len(value) == 4:
+ return "RGBA: %.3g, %.3g, %.3g, %.3g" % (value[0], value[1], value[2], value[3])
+ elif len(value) == 3:
+ return "RGB: %.3g, %.3g, %.3g" % (value[0], value[1], value[2])
+ else:
+ return "Value: %g" % value
+ except Exception:
+ _logger.error("Error while formatting pixel value", exc_info=True)
+ pass
+ return "Value: %s" % value
+
+ def _statusBarSlot(self, row, column, value):
+ """Update status bar with coordinates/value from plots."""
+ if numpy.isnan(row):
+ msg = 'Column: %d, Sum: %g' % (int(column), value)
+ elif numpy.isnan(column):
+ msg = 'Row: %d, Sum: %g' % (int(row), value)
+ else:
+ msg_value = self._formatValueToString(value)
+ msg = 'Position: (%d, %d), %s' % (int(row), int(column), msg_value)
+ if self._dataInfo is not None:
+ msg = self._dataInfo + ', ' + msg
+
+ self.statusBar().showMessage(msg)
+
+ @docstring(ImageView)
+ def setProfileWindowBehavior(self, behavior: str):
+ super().setProfileWindowBehavior(behavior)
+ self.__updateProfileMenu()
+
+ @docstring(ImageView)
+ def setImage(self, image, *args, **kwargs):
+ if hasattr(image, 'dtype') and hasattr(image, 'shape'):
+ assert image.ndim == 2 or (image.ndim == 3 and image.shape[2] in (3, 4))
+ height, width = image.shape[0:2]
+ dataInfo = 'Data: %dx%d (%s)' % (width, height, str(image.dtype))
+ else:
+ dataInfo = None
+
+ if self._dataInfo != dataInfo:
+ self._dataInfo = dataInfo
+ self.statusBar().showMessage(self._dataInfo)
+
+ # Set the new image in ImageView widget
+ super(ImageViewMainWindow, self).setImage(image, *args, **kwargs)