-
Notifications
You must be signed in to change notification settings - Fork 80
Draft: Add 'dynamic colormap' mode #4271
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a08e507
46ce9e5
2ebee34
3d7e541
fbc7b08
c4974f7
27949b1
90be74d
caade84
ef931e2
f37f2ba
bd73b36
41ec4ee
0b9db3a
cbfc436
d7176f4
9a79361
c385029
bc10794
2fe6fca
eccb4a1
2846eea
3309167
1ad842a
308b49d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
|
Comment on lines
+42
to
+60
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is nothing specific about dynamic colormap in this code sample, I would remove it. |
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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): | ||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK. I understand that this interaction only aims 2d plots. Then, its place could be in the ImageToolbar. In the same time, it is really an interactive mode.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it is an interaction mode so independent of the |
||||||||||||
| """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 <<radius>> | ||||||||||||
| 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]) | ||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn't take care of origin and scale of the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. True. But it is not incompatible. In the worst case, it does not improve the local contrast. The user can choose either a specific color scale or to use that feature.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The image can have an offset from origin and a scaling so the coordinates of the plot This should be handled and it should not be an issue to do so
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You retrieve the |
||||||||||||
| 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 | ||||||||||||
|
Comment on lines
+1680
to
+1681
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the typical use case of this exception ? |
||||||||||||
|
|
||||||||||||
| # Get data | ||||||||||||
| result = self.plot._pickTopMost(x, y, lambda i: isinstance(i, items.ImageBase)) | ||||||||||||
| if result is None: | ||||||||||||
| return | ||||||||||||
| else: | ||||||||||||
| item = result.getItem() | ||||||||||||
|
Comment on lines
+1687
to
+1688
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||
| 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, | ||||||||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is a part where we will clearly need @t20100 expertise. Is there is convention here to display those kind of information ? |
||||||||||||
| ) | ||||||||||||
|
|
||||||||||||
| # Set new min and max | ||||||||||||
| colormap.setVRange(vmin, vmax) | ||||||||||||
| item.setColormap(colormap) | ||||||||||||
|
Comment on lines
+1710
to
+1712
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I misslead you sorry, indeed the update will be applied even if 'item.setColormap(colormap)' is not called. I had the old object in mind my bad 🙏
Suggested change
|
||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| # 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"} | ||||||||||||
|
|
||||||||||||
|
Comment on lines
+1867
to
+1869
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's avoid to add a "generic" interaction for a specific case
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure I get what is behind the 'generic' interaction. Do you suggest that we have some kind of interaction subset / categories ? |
||||||||||||
| 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() | ||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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`. | ||||||||||||||
| # """ | ||||||||||||||
|
|
||||||||||||||
lesaintjerome marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+335
to
+341
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||
| 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") | ||||||||||||||
|
Comment on lines
+3795
to
+3800
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. keyboard shortcut could be added to the PR description. It could be useful to get them back quickly... and to discuss them.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure we want to add this to
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I understand the point, but it's really a key feature to me: to make it light... : in that respect, adding the icon in the colormap widget would not make much sense IMO. But maybe it is independent where the icon is and how to activate the interaction mode...
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yes!
Sure, but this doesn't mean it is a key feature for other usage of |
||||||||||||||
| else: | ||||||||||||||
| # Only call base class implementation when key is not handled. | ||||||||||||||
| # See QWidget.keyPressEvent for details. | ||||||||||||||
|
|
||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||
|
Comment on lines
+143
to
+144
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||
| 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) | ||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
|
||
|
Comment on lines
+56
to
+60
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will add it to all kind of plots (curves, scatters) while it only works with images, so it's not the place.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same remark as before: could be in ImageToolbar but is truly an interactive mode. On the top of that, this ImageToolbar does not seem to be used on the PlotWindow/ImageView that displays images. I can add the action in the Plot2D toolbar. But don't really know how to handle the interaction mode (hence the initial positioning in the PlotWidget/InteractionToolbar. |
||
| 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 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.