diff --git a/examples/dynamic_colormap.py b/examples/dynamic_colormap.py new file mode 100644 index 0000000000..04e3096a8c --- /dev/null +++ b/examples/dynamic_colormap.py @@ -0,0 +1,60 @@ +# /*########################################################################## +# +# 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 +# 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. +# +# ###########################################################################*/ +"""This script shows how to dyamically adjust the colormap to a small region +around the cursor position. The DynamicColormapMode can be activated either by +the icon in the widget toolbar or by simply pressing the w-key. +The image has 4 regions with different contrasts (but same levels =0). When activated, the +DynamicColormap mode will adjust the colormap to enhance the contrast in the +region close to the cursor. +More precissely: it computes the min and max in the region surrounded by the blue +rectangle and applies these values to the current colormap. + +The pan and zoom modes (the two other interaction modes) can be activated either +by their respective icon or by pressing the P- and Z-key respectively. +""" + +import numpy +from silx.gui import qt +from silx.gui.plot import Plot2D + + +def main(): + app = qt.QApplication([]) + + # Create the ad hoc plot widget and change its default colormap + x = numpy.zeros((100, 100),dtype=numpy.float32) + x[:50,:50] = numpy.random.randn(50,50) + x[:50,50:] = 10 * numpy.random.randn(50,50) + x[50:,:50] = 100 * numpy.random.randn(50,50) + x[50:,50:] = 5 * numpy.random.randn(50,50) + + example = Plot2D() + example.addImage(x) + example.show() + + app.exec() + + +if __name__ == "__main__": + main() diff --git a/src/silx/gui/plot/PlotInteraction.py b/src/silx/gui/plot/PlotInteraction.py index 1c4bb94513..80aac62b21 100644 --- a/src/silx/gui/plot/PlotInteraction.py +++ b/src/silx/gui/plot/PlotInteraction.py @@ -37,6 +37,8 @@ from typing import NamedTuple from silx.gui import qt +from silx.math.combo import min_max + from .. import colors from . import items from .Interaction import ( @@ -1643,6 +1645,73 @@ def endDrag(self, startPos, endPos, btn): return super().endDrag(startPos, endPos, btn) +class DynamicColormapMode(ItemsInteraction): + """This mode automatically adjusts the colormap range of the image + based on a NxM ROI centered on the current cursor position. N and M are defined in the ROI_SIZE class variable. + + :param plot: The Plot to which this interaction is attached + """ + + ROI_SIZE = (10, 10) # (y,x). The ROI <> + COLOR = "blue" + LINESTYLE = "--" + + @staticmethod + def _compute_vmin_vmax(data: numpy.ndarray, dataPos: tuple[float, float]): + """Compute the min and max values of the data in a ROI centered on (x,y)""" + roi_size = DynamicColormapMode.ROI_SIZE + idx_x, idx_y = int(dataPos[0]), int(dataPos[1]) + x_start = max((0, idx_x - roi_size[1])) + x_end = min((idx_x + roi_size[1], data.shape[1])) + y_start = max((0, idx_y - roi_size[0])) + y_end = min((idx_y + roi_size[0], data.shape[0])) + + data_values = data[y_start:y_end, x_start:x_end] + vmin, vmax = min_max(data_values) + bb_x = (x_start, x_start, x_end, x_end) + bb_y = (y_start, y_end, y_end, y_start) + return vmin, vmax, bb_x, bb_y + + def handleEvent(self, eventName, *args, **kwargs): + super().handleEvent(eventName, *args, **kwargs) + + try: + x, y = args[:2] + except ValueError: + return + + # Get data + result = self.plot._pickTopMost(x, y, lambda i: isinstance(i, items.ImageBase)) + if result is None: + return + else: + item = result.getItem() + colormap = item.getColormap() + dataPos = self.plot.pixelToData(x, y) + data = item.getData() + + # Extract ROI min and max + vmin, vmax, bb_x, bb_y = self._compute_vmin_vmax(data, dataPos) + + # Add a blue rectangle that shows the ROI + self.plot.addShape( + bb_x, + bb_y, + legend="ColorMap reference", + replace=False, + fill=False, + color=self.COLOR, + gapcolor=None, + linestyle=self.LINESTYLE, + overlay=True, + z=1, + ) + + # Set new min and max + colormap.setVRange(vmin, vmax) + item.setColormap(colormap) + + # Interaction mode control #################################################### # Mapping of draw modes: event handler @@ -1795,6 +1864,9 @@ def _getInteractiveMode(self): elif isinstance(self._eventHandler, PanAndSelect): return {"mode": "pan"} + elif isinstance(self._eventHandler, DynamicColormapMode): + return {"mode": "dynamic_colormap"} + else: return {"mode": "select"} @@ -1824,8 +1896,14 @@ def _setInteractiveMode( :param str label: Only for 'draw' mode. :param float width: Width of the pencil. Only for draw pencil mode. """ - assert mode in ("draw", "pan", "select", "select-draw", "zoom") - + assert mode in ( + "draw", + "pan", + "select", + "select-draw", + "zoom", + "dynamic_colormap", + ) plotWidget = self.parent() assert plotWidget is not None @@ -1848,6 +1926,10 @@ def _setInteractiveMode( self._eventHandler = ZoomAndSelect(plotWidget, color) self._eventHandler.zoomEnabledAxes = self.getZoomEnabledAxes() + elif mode == "dynamic_colormap": + self._eventHandler.cancel() + self._eventHandler = DynamicColormapMode(plotWidget) + else: # Default mode: interaction with plot objects # Ignores color, shape and label self._eventHandler.cancel() diff --git a/src/silx/gui/plot/PlotWidget.py b/src/silx/gui/plot/PlotWidget.py index 16b2881c8e..dc33e9a97c 100755 --- a/src/silx/gui/plot/PlotWidget.py +++ b/src/silx/gui/plot/PlotWidget.py @@ -332,6 +332,13 @@ class PlotWidget(qt.QMainWindow): It provides the source as passed to :meth:`setInteractiveMode`. """ + # sigDynamicColormapModeChanged = qt.Signal(object) + # """ + # Signal emitted when the dynamic colormap changed + + # It provides the source as passed to :meth:`setInteractiveMode`. + # """ + sigItemAdded = qt.Signal(items.Item) """Signal emitted when an item was just added to the plot @@ -3667,7 +3674,7 @@ def getInteractiveMode(self): """Returns the current interactive mode as a dict. The returned dict contains at least the key 'mode'. - Mode can be: 'draw', 'pan', 'select', 'select-draw', 'zoom'. + Mode can be: 'draw', 'pan', 'select', 'select-draw', 'zoom', 'dynamic_colormap'. It can also contains extra keys (e.g., 'color') specific to a mode as provided to :meth:`setInteractiveMode`. """ @@ -3695,7 +3702,7 @@ def setInteractiveMode( """Switch the interactive mode. :param mode: The name of the interactive mode. - In 'draw', 'pan', 'select', 'select-draw', 'zoom'. + In 'draw', 'pan', 'select', 'select-draw', 'zoom', 'dynamic_colormap'. :param color: Only for 'draw' and 'zoom' modes. Color to use for drawing selection area. Default black. :type color: Color description: The name as a str or @@ -3719,7 +3726,7 @@ def setInteractiveMode( finally: self.__isInteractionSignalForwarded = True - if mode in ["pan", "zoom"]: + if mode in ["pan", "zoom", "dynamic_colormap"]: self._previousDefaultMode = mode, zoomOnWheel self.notify("interactiveModeChanged", source=source) @@ -3785,6 +3792,12 @@ def keyPressEvent(self, event): # that even if mouse didn't move on the screen, it moved relative # to the plotted data. self.__simulateMouseMove() + elif key == qt.Qt.Key_W: + self.setInteractiveMode("dynamic_colormap") + elif key == qt.Qt.Key_P: + self.setInteractiveMode("pan") + elif key == qt.Qt.Key_Z: + self.setInteractiveMode("zoom") else: # Only call base class implementation when key is not handled. # See QWidget.keyPressEvent for details. diff --git a/src/silx/gui/plot/actions/mode.py b/src/silx/gui/plot/actions/mode.py index 85c6aaf9f2..8f41bdd8b3 100644 --- a/src/silx/gui/plot/actions/mode.py +++ b/src/silx/gui/plot/actions/mode.py @@ -137,3 +137,39 @@ def _actionTriggered(self, checked=False): plot = self.plot if plot is not None: plot.setInteractiveMode("pan", source=self) + + +class DynamicColormapAction(PlotAction): + """QAction controlling the colormap mode of a :class:`.PlotWidget`. + This mode adjusts the colormap based on a small region around the + mouse position in the plot. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + super().__init__( + plot, + icon="dynamic_colormap", # TODO: add a dedicated icon + text="Dynamic Colormap mode", + tooltip="Update the colormap according to the mouse position in the plot", + triggered=self._actionTriggered, + checkable=True, + parent=parent, + ) + # Listen to mode change + self.plot.sigInteractiveModeChanged.connect(self._modeChanged) + # Init the state + self._modeChanged(None) + + def _modeChanged(self, source): + modeDict = self.plot.getInteractiveMode() + old = self.blockSignals(True) + self.setChecked(modeDict["mode"] == "dynamic_colormap") + self.blockSignals(old) + + def _actionTriggered(self, checked=False): + plot = self.plot + if plot is not None: + plot.setInteractiveMode("dynamic_colormap", source=self) diff --git a/src/silx/gui/plot/test/test_plotinteraction.py b/src/silx/gui/plot/test/test_plotinteraction.py index b889c89b15..fe19367979 100644 --- a/src/silx/gui/plot/test/test_plotinteraction.py +++ b/src/silx/gui/plot/test/test_plotinteraction.py @@ -28,10 +28,12 @@ __date__ = "01/09/2017" import pytest - +import numpy from silx.gui import qt from silx.gui.plot import PlotWidget from .utils import PlotWidgetTestCase +from silx.gui.plot.PlotInteraction import DynamicColormapMode + class _SignalDump: @@ -49,6 +51,33 @@ def received(self): return list(self._received) +class TestSelectDynamicColormap(): + + def test_dynamic_colormap_interaction(self): + """Test correct interaction mode.""" + plot = PlotWidget() + plot.setInteractiveMode("dynamic_colormap", shape="rectangle", label="test") + + interaction = plot.getInteractiveMode() + assert interaction["mode"] == "dynamic_colormap" + assert isinstance(plot.interaction()._eventHandler, DynamicColormapMode) + + def test_dynamic_colormap_vmin_vmax_calculation(self): + """Test vmin/vmax calculation for dynamic colormap""" + # Test with a rectangle + roi = numpy.arange(484).reshape((22,22)) + vmin, vmax, bb_x, bb_y = DynamicColormapMode._compute_vmin_vmax(roi,(11,11)) + assert vmin == 23 and vmax == 460 and bb_x == (1,1,21,21) and bb_y == (1,21,21,1) + + roi = numpy.arange(484).reshape((22,22)) + vmin, vmax, bb_x, bb_y = DynamicColormapMode._compute_vmin_vmax(roi,(0,0)) + assert vmin == 0 and vmax == 207 and bb_x == (0,0,10,10) and bb_y == (0,10,10,0) + + roi = numpy.arange(484).reshape((22,22)) + vmin, vmax, bb_x, bb_y = DynamicColormapMode._compute_vmin_vmax(roi,(21,21)) + assert vmin == 253 and vmax == 483 and bb_x == (11,11,22,22) and bb_y == (11,22,22,11) + + class TestSelectPolygon(PlotWidgetTestCase): """Test polygon selection interaction""" diff --git a/src/silx/gui/plot/tools/toolbars.py b/src/silx/gui/plot/tools/toolbars.py index 5e762c763f..2e41411c46 100644 --- a/src/silx/gui/plot/tools/toolbars.py +++ b/src/silx/gui/plot/tools/toolbars.py @@ -53,6 +53,11 @@ def __init__(self, parent=None, plot=None, title="Plot Interaction"): self._panModeAction = actions.mode.PanModeAction(parent=self, plot=plot) self.addAction(self._panModeAction) + self._dynamicColormapAction = actions.mode.DynamicColormapAction( + parent=self, plot=plot + ) + self.addAction(self._dynamicColormapAction) + def getZoomModeAction(self): """Returns the zoom mode QAction. @@ -67,6 +72,9 @@ def getPanModeAction(self): """ return self._panModeAction + def getDynamicColormapAction(self): + return self._dynamicColormapAction + class OutputToolBar(qt.QToolBar): """Toolbar providing icons to copy, save and print a PlotWidget diff --git a/src/silx/resources/gui/icons/dynamic_colormap.svg b/src/silx/resources/gui/icons/dynamic_colormap.svg new file mode 100644 index 0000000000..64bf9b549e --- /dev/null +++ b/src/silx/resources/gui/icons/dynamic_colormap.svg @@ -0,0 +1,71 @@ + + + + + + + +