summaryrefslogtreecommitdiff
path: root/src/silx/gui/dialog/ColormapDialog.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/silx/gui/dialog/ColormapDialog.py')
-rw-r--r--src/silx/gui/dialog/ColormapDialog.py1918
1 files changed, 1918 insertions, 0 deletions
diff --git a/src/silx/gui/dialog/ColormapDialog.py b/src/silx/gui/dialog/ColormapDialog.py
new file mode 100644
index 0000000..75ab39e
--- /dev/null
+++ b/src/silx/gui/dialog/ColormapDialog.py
@@ -0,0 +1,1918 @@
+# /*##########################################################################
+#
+# Copyright (c) 2004-2023 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.
+#
+# ###########################################################################*/
+"""A QDialog widget to set-up the colormap.
+
+It uses a description of colormaps as dict compatible with :class:`Plot`.
+
+To run the following sample code, a QApplication must be initialized.
+
+Create the colormap dialog and set the colormap description and data range:
+
+>>> from silx.gui.dialog.ColormapDialog import ColormapDialog
+>>> from silx.gui.colors import Colormap
+
+>>> dialog = ColormapDialog()
+>>> colormap = Colormap(name='red', normalization='log',
+... vmin=1., vmax=2.)
+
+>>> dialog.setColormap(colormap)
+>>> colormap.setVRange(1., 100.) # This scale the width of the plot area
+>>> dialog.show()
+
+Get the colormap description (compatible with :class:`Plot`) from the dialog:
+
+>>> cmap = dialog.getColormap()
+>>> cmap.getName()
+'red'
+
+It is also possible to display an histogram of the image in the dialog.
+This updates the data range with the range of the bins.
+
+>>> import numpy
+>>> image = numpy.random.normal(size=512 * 512).reshape(512, -1)
+>>> hist, bin_edges = numpy.histogram(image, bins=10)
+>>> dialog.setHistogram(hist, bin_edges)
+
+The updates of the colormap description are also available through the signal:
+:attr:`ColormapDialog.sigColormapChanged`.
+""" # noqa
+
+from __future__ import annotations
+
+__authors__ = ["V.A. Sole", "T. Vincent", "H. Payno"]
+__license__ = "MIT"
+__date__ = "08/12/2020"
+
+import enum
+import logging
+
+import numpy
+
+from .. import qt
+from .. import utils
+from ..colors import Colormap, cursorColorForColormap
+from ..plot import PlotWidget
+from ..plot.items.axis import Axis
+from ..plot.items import BoundingRect
+from silx.gui.widgets.FloatEdit import FloatEdit
+import weakref
+from silx.math.combo import min_max
+from silx.gui.plot import items
+from silx.gui import icons
+from silx.gui.qt import inspect as qtinspect
+from silx.gui.widgets.ColormapNameComboBox import ColormapNameComboBox
+from silx.gui.widgets.FormGridLayout import FormGridLayout
+from silx.math.histogram import Histogramnd
+from silx.gui.plot.items.roi import RectangleROI
+from silx.gui.plot.tools.roi import RegionOfInterestManager
+from silx.utils.enum import Enum as _Enum
+
+_logger = logging.getLogger(__name__)
+
+_colormapIconPreview = {}
+
+
+class _DataRefHolder(items.Item, items.ColormapMixIn):
+ """Holder for a weakref of a numpy array.
+
+ It provides features from `ColormapMixIn`.
+ """
+
+ def __init__(self, dataRef):
+ items.Item.__init__(self)
+ items.ColormapMixIn.__init__(self)
+ self.__dataRef = dataRef
+ self._updated(items.ItemChangedType.DATA)
+
+ def getColormappedData(self, copy=True):
+ return self.__dataRef()
+
+
+class _BoundaryWidget(qt.QWidget):
+ """Widget to edit a boundary of the colormap (vmin or vmax)"""
+
+ sigAutoScaleChanged = qt.Signal(object)
+ """Signal emitted when the autoscale was changed
+
+ True is sent as an argument if autoscale is set to true.
+ """
+
+ sigValueChanged = qt.Signal(object)
+ """Signal emitted when value is changed
+
+ The new value is sent as an argument.
+ """
+
+ def __init__(self, parent=None, value=0.0):
+ qt.QWidget.__init__(self, parent=parent)
+ self.setLayout(qt.QHBoxLayout())
+ self.layout().setContentsMargins(0, 0, 0, 0)
+ self._numVal = FloatEdit(parent=self, value=value)
+
+ self._iconAuto = icons.getQIcon("scale-auto")
+ self._iconFixed = icons.getQIcon("scale-fixed")
+
+ self._autoToggleAction = qt.QAction(self)
+ self._autoToggleAction.setText("Auto scale")
+ self._autoToggleAction.setToolTip("Toggle auto scale")
+ self._autoToggleAction.setCheckable(True)
+ self._autoToggleAction.setIcon(self._iconFixed)
+ self._autoToggleAction.setChecked(False)
+ self._autoToggleAction.toggled.connect(self._autoToggled)
+
+ self._numVal.addAction(self._autoToggleAction, qt.QLineEdit.LeadingPosition)
+
+ self.layout().addWidget(self._numVal)
+ self._autoCB = qt.QCheckBox("auto", parent=self)
+ self.layout().addWidget(self._autoCB)
+ self._autoCB.setChecked(False)
+ self._autoCB.setVisible(False)
+
+ self._autoCB.toggled.connect(self._autoToggled)
+ self._numVal.textEdited.connect(self.__textEdited)
+ self._numVal.editingFinished.connect(self.__editingFinished)
+ self.setFocusProxy(self._numVal)
+
+ self.__textWasEdited = False
+ """True if the text was edited, in order to send an event
+ at the end of the user interaction"""
+
+ self.__realValue = None
+ """Store the real value set by setValue, to avoid
+ rounding of the widget"""
+
+ def __textEdited(self):
+ self.__textWasEdited = True
+
+ def __editingFinished(self):
+ if self.__textWasEdited:
+ value = self._numVal.value()
+ self.__realValue = value
+ with utils.blockSignals(self._numVal):
+ # Fix the formatting
+ self._numVal.setValue(self.__realValue)
+ self.sigValueChanged.emit(value)
+ self.__textWasEdited = False
+
+ def isAutoChecked(self):
+ return self._autoCB.isChecked()
+
+ def getValue(self):
+ """Returns the stored range. If autoscale is
+ enabled, this returns None.
+ """
+ if self._autoCB.isChecked():
+ return None
+ if self.__realValue is not None:
+ return self.__realValue
+ return self._numVal.value()
+
+ def _autoToggled(self, enabled):
+ self._updateAutoScaleState(enabled)
+ self._updateDisplayedText()
+ self.sigAutoScaleChanged.emit(enabled)
+
+ def _updateDisplayedText(self):
+ self.__textWasEdited = False
+ if self._autoCB.isChecked() and self.__realValue is not None:
+ with utils.blockSignals(self._numVal):
+ self._numVal.setValue(self.__realValue)
+
+ def setValue(self, value, isAuto=False):
+ """Set the value of the boundary.
+
+ :param float value: A finite value for the boundary
+ :param bool isAuto: If true, the finite value was automatically computed
+ from the data, else it is a fixed custom value.
+ """
+ assert value is not None
+ self._autoCB.setChecked(isAuto)
+ with utils.blockSignals(self._numVal):
+ if isAuto or self.__realValue != value:
+ if not self.__textWasEdited:
+ self._numVal.setValue(value)
+ self.__realValue = value
+ self._updateAutoScaleState(isAuto)
+
+ def _updateAutoScaleState(self, isAutoScale):
+ self._numVal.setReadOnly(isAutoScale)
+ palette = qt.QPalette()
+ if isAutoScale:
+ color = palette.color(qt.QPalette.Disabled, qt.QPalette.Base)
+ icon = self._iconAuto
+ else:
+ color = palette.color(qt.QPalette.Active, qt.QPalette.Base)
+ icon = self._iconFixed
+ palette.setColor(qt.QPalette.Base, color)
+ self._numVal.setPalette(palette)
+ self._autoToggleAction.setIcon(icon)
+
+
+class _AutoscaleModeComboBox(qt.QComboBox):
+ DATA = {
+ Colormap.MINMAX: ("Min/max", "Use the data min/max"),
+ Colormap.STDDEV3: ("Mean±3std", "Use the data mean ± 3 × standard deviation"),
+ }
+
+ def __init__(self, parent: qt.QWidget):
+ super(_AutoscaleModeComboBox, self).__init__(parent=parent)
+ self.currentIndexChanged.connect(self.__updateTooltip)
+ self._init()
+
+ def _init(self):
+ for mode in Colormap.AUTOSCALE_MODES:
+ label, tooltip = self.DATA.get(mode, (mode, None))
+ self.addItem(label, mode)
+ if tooltip is not None:
+ self.setItemData(self.count() - 1, tooltip, qt.Qt.ToolTipRole)
+
+ def setCurrentIndex(self, index):
+ self.__updateTooltip(index)
+ super(_AutoscaleModeComboBox, self).setCurrentIndex(index)
+
+ def __updateTooltip(self, index):
+ if index > -1:
+ tooltip = self.itemData(index, qt.Qt.ToolTipRole)
+ else:
+ tooltip = ""
+ self.setToolTip(tooltip)
+
+ def currentMode(self):
+ index = self.currentIndex()
+ return self.itemData(index)
+
+ def setCurrentMode(self, mode):
+ for index in range(self.count()):
+ if mode == self.itemData(index):
+ self.setCurrentIndex(index)
+ return
+ if mode is None:
+ # If None was not a value
+ self.setCurrentIndex(-1)
+ return
+ self.addItem(mode, mode)
+ self.setCurrentIndex(self.count() - 1)
+
+
+class _AutoScaleButton(qt.QPushButton):
+ autoRangeChanged = qt.Signal(object)
+
+ def __init__(self, parent=None):
+ qt.QPushButton.__init__(self, parent=parent)
+ self.setText("Autoscale")
+ self.setToolTip("Enable/disable the autoscale for both min and max")
+ self.setCheckable(True)
+ self.toggled[bool].connect(self.__toggled)
+ self.setFocusPolicy(qt.Qt.TabFocus)
+
+ def __toggled(self, checked):
+ autoRange = checked, checked
+ self.setAutoRange(autoRange)
+ self.autoRangeChanged.emit(autoRange)
+
+ def setAutoRangeFromColormap(self, colormap):
+ vRange = colormap.getVRange()
+ autoRange = vRange[0] is None, vRange[1] is None
+ self.setAutoRange(autoRange)
+
+ def setAutoRange(self, autoRange):
+ with utils.blockSignals(self):
+ self.setChecked(autoRange[0] if autoRange[0] == autoRange[1] else False)
+
+
+@enum.unique
+class DisplayMode(_Enum):
+ """Enum for each mode of display of the data in the plot."""
+
+ RANGE = "range"
+ HISTOGRAM = "histogram"
+
+
+class _ColormapHistogram(qt.QWidget):
+ """Display the colormap and the data as a plot."""
+
+ sigRangeMoving = qt.Signal(object, object, object)
+ """Emitted when a mouse interaction moves the location
+ of the colormap range in the plot.
+
+ This signal contains 2 elements:
+
+ - vmin: A float value if this range was moved, else None
+ - vmax: A float value if this range was moved, else None
+ - gammaPos: A float value if this range was moved, else None
+ """
+
+ sigRangeMoved = qt.Signal(object, object, object)
+ """Emitted when a mouse interaction stop.
+
+ This signal contains 2 elements:
+
+ - vmin: A float value if this range was moved, else None
+ - vmax: A float value if this range was moved, else None
+ - gammaPos: A float value if this range was moved, else None
+ """
+
+ def __init__(self, parent):
+ qt.QWidget.__init__(self, parent=parent)
+ self._displayMode = DisplayMode.RANGE
+ self._finiteRange = None, None
+ self._initPlot()
+
+ self._histogramData = {}
+ """Histogram displayed in the plot"""
+
+ self._dragging = False, False, False
+ """True, if the min or the max handle is dragging"""
+
+ self._dataRange = {}
+ """Histogram displayed in the plot"""
+
+ self._invalidated = False
+
+ def paintEvent(self, event):
+ if self._invalidated:
+ self._updateDisplayMode()
+ self._invalidated = False
+ self._updateMarkerPosition()
+ return super(_ColormapHistogram, self).paintEvent(event)
+
+ def getFiniteRange(self):
+ """Returns the colormap range as displayed in the plot."""
+ return self._finiteRange
+
+ def setFiniteRange(self, vRange):
+ """Set the colormap range to use in the plot.
+
+ Here there is no concept of auto. The values should
+ not be None, except if there is no range or marker
+ to display.
+ """
+ # Do not reset the limit for handle about to be dragged
+ if self._dragging[0]:
+ vRange = self._finiteRange[0], vRange[1]
+ if self._dragging[1]:
+ vRange = vRange[0], self._finiteRange[1]
+
+ if vRange == self._finiteRange:
+ return
+
+ self._finiteRange = vRange
+ self.update()
+
+ def getColormap(self):
+ return self.parent().getColormap()
+
+ def _getNormalizedHistogram(self):
+ """Return an histogram already normalized according to the colormap
+ normalization.
+
+ Returns a tuple edges, counts
+ """
+ norm = self._getNorm()
+ histogram = self._histogramData.get(norm, None)
+ if histogram is None:
+ histogram = self._computeNormalizedHistogram()
+ self._histogramData[norm] = histogram
+ return histogram
+
+ def _computeNormalizedHistogram(self):
+ colormap = self.getColormap()
+ if colormap is None:
+ norm = Colormap.LINEAR
+ else:
+ norm = colormap.getNormalization()
+
+ # Try to use the histogram defined in the dialog
+ histo = self.parent()._getHistogram()
+ if histo is not None:
+ counts, edges = histo
+ normalizer = Colormap(normalization=norm)._getNormalizer()
+ mask = normalizer.is_valid(edges[:-1]) # Check lower bin edges only
+ firstValid = numpy.argmax(mask) # edges increases monotonically
+ if firstValid == 0: # Mask is all False or all True
+ return (counts, edges) if mask[0] else (None, None)
+ else: # Clip to valid values
+ return counts[firstValid:], edges[firstValid:]
+
+ data = self.parent()._getArray()
+ if data is None:
+ return None, None
+ dataRange = self._getNormalizedDataRange()
+ if dataRange[0] is None or dataRange[1] is None:
+ return None, None
+ counts, edges = self.parent().computeHistogram(
+ data, scale=norm, dataRange=dataRange
+ )
+ return counts, edges
+
+ def _getNormalizedDataRange(self):
+ """Return a data range already normalized according to the colormap
+ normalization.
+
+ Returns a tuple with min and max
+ """
+ norm = self._getNorm()
+ dataRange = self._dataRange.get(norm, None)
+ if dataRange is None:
+ dataRange = self._computeNormalizedDataRange()
+ self._dataRange[norm] = dataRange
+ return dataRange
+
+ def _computeNormalizedDataRange(self):
+ colormap = self.getColormap()
+ if colormap is None:
+ norm = Colormap.LINEAR
+ else:
+ norm = colormap.getNormalization()
+
+ # Try to use the one defined in the dialog
+ dataRange = self.parent()._getDataRange()
+ if dataRange is not None:
+ if norm in (Colormap.LINEAR, Colormap.GAMMA, Colormap.ARCSINH):
+ return dataRange[0], dataRange[2]
+ elif norm == Colormap.LOGARITHM:
+ return dataRange[1], dataRange[2]
+ elif norm == Colormap.SQRT:
+ return dataRange[1], dataRange[2]
+ else:
+ _logger.error("Undefined %s normalization", norm)
+
+ # Try to use the histogram defined in the dialog
+ histo = self.parent()._getHistogram()
+ if histo is not None:
+ _histo, edges = histo
+ normalizer = Colormap(normalization=norm)._getNormalizer()
+ edges = edges[normalizer.is_valid(edges)]
+ if edges.size == 0:
+ return None, None
+ else:
+ dataRange = min_max(edges, finite=True)
+ return dataRange.minimum, dataRange.maximum
+
+ item = self.parent()._getItem()
+ if item is not None:
+ # Trick to reach data range using colormap cache
+ cm = Colormap()
+ cm.setVRange(None, None)
+ cm.setNormalization(norm)
+ dataRange = item._getColormapAutoscaleRange(cm)
+ return dataRange
+
+ # If there is no item, there is no data
+ return None, None
+
+ def _getDisplayableRange(self):
+ """Returns the selected min/max range to apply to the data,
+ according to the used scale.
+
+ One or both limits can be None in case it is not displayable in the
+ current axes scale.
+
+ :returns: Tuple{float, float}
+ """
+ scale = self._plot.getXAxis().getScale()
+
+ def isDisplayable(pos):
+ if pos is None:
+ return False
+ if scale == Axis.LOGARITHMIC:
+ return pos > 0.0
+ return True
+
+ posMin, posMax = self.getFiniteRange()
+ if not isDisplayable(posMin):
+ posMin = None
+ if not isDisplayable(posMax):
+ posMax = None
+
+ return posMin, posMax
+
+ def _initPlot(self):
+ """Init the plot to display the range and the values"""
+ self._plot = PlotWidget(self)
+ self._plot.setAxesDisplayed(False)
+ self._plot.setDataMargins(0.125, 0.125, 0.01, 0.01)
+ self._plot.getXAxis().setLabel("Data Values")
+ self._plot.getYAxis().setLabel("")
+ self._plot.setInteractiveMode("select", zoomOnWheel=False)
+ self._plot.setActiveCurveHandling(False)
+ self._plot.setMinimumSize(qt.QSize(250, 200))
+ self._plot.sigPlotSignal.connect(self._plotEventReceived)
+ palette = self.palette()
+ color = palette.color(qt.QPalette.Active, qt.QPalette.Window)
+ self._plot.setBackgroundColor(color)
+ self._plot.setDataBackgroundColor("white")
+
+ lut = numpy.arange(256)
+ lut.shape = 1, -1
+ self._plot.addImage(lut, legend="lut")
+ self._lutItem = self._plot._getItem("image", "lut")
+ self._lutItem.setVisible(False)
+
+ self._plot.addScatter(x=[], y=[], value=[], legend="lut2")
+ self._lutItem2 = self._plot._getItem("scatter", "lut2")
+ self._lutItem2.setVisible(False)
+ self.__lutY = numpy.array([-0.05] * 256)
+ self.__lutV = numpy.arange(256)
+
+ self._bound = BoundingRect()
+ self._plot.addItem(self._bound)
+ self._bound.setVisible(True)
+
+ # Add plot for histogram
+ self._plotToolbar = qt.QToolBar(self)
+ self._plotToolbar.setFloatable(False)
+ self._plotToolbar.setMovable(False)
+ self._plotToolbar.setIconSize(qt.QSize(8, 8))
+ self._plotToolbar.setStyleSheet("QToolBar { border: 0px }")
+ self._plotToolbar.setOrientation(qt.Qt.Vertical)
+
+ group = qt.QActionGroup(self._plotToolbar)
+ group.setExclusive(True)
+ # data range mode
+ action = qt.QAction("Data range", self)
+ action.setToolTip(
+ "Display the data range within the colormap range. A fast data processing have to be done."
+ )
+ action.setIcon(icons.getQIcon("colormap-range"))
+ action.setCheckable(True)
+ action.setData(DisplayMode.RANGE)
+ action.setChecked(action.data() == self._displayMode)
+ self._plotToolbar.addAction(action)
+ group.addAction(action)
+ self._dataRangeAction = action
+ # histogram mode
+ action = qt.QAction("Histogram", self)
+ action.setToolTip(
+ "Display the data histogram within the colormap range. A slow data processing have to be done. "
+ )
+ action.setIcon(icons.getQIcon("colormap-histogram"))
+ action.setCheckable(True)
+ action.setData(DisplayMode.HISTOGRAM)
+ action.setChecked(action.data() == self._displayMode)
+ self._plotToolbar.addAction(action)
+ group.addAction(action)
+ self._dataHistogramAction = action
+ group.setExclusive(True)
+ group.triggered.connect(self._displayModeChanged)
+
+ plotBoxLayout = qt.QHBoxLayout()
+ plotBoxLayout.setContentsMargins(0, 0, 0, 0)
+ plotBoxLayout.setSpacing(2)
+ plotBoxLayout.addWidget(self._plotToolbar)
+ plotBoxLayout.addWidget(self._plot)
+ plotBoxLayout.setSizeConstraint(qt.QLayout.SetMinimumSize)
+ self.setLayout(plotBoxLayout)
+
+ def _plotEventReceived(self, event):
+ """Handle events from the plot"""
+ kind = event["event"]
+
+ if kind == "markerMoving":
+ value = event["xdata"]
+ if event["label"] == "Min":
+ self._dragging = True, False, False
+ self._finiteRange = value, self._finiteRange[1]
+ self._last = value, None, None
+ self._updateGammaPosition()
+ self.sigRangeMoving.emit(*self._last)
+ elif event["label"] == "Max":
+ self._dragging = False, True, False
+ self._finiteRange = self._finiteRange[0], value
+ self._last = None, value, None
+ self._updateGammaPosition()
+ self.sigRangeMoving.emit(*self._last)
+ elif event["label"] == "Gamma":
+ self._dragging = False, False, True
+ self._last = None, None, value
+ self.sigRangeMoving.emit(*self._last)
+ self._updateLutItem(self._finiteRange)
+ elif kind == "markerMoved":
+ self.sigRangeMoved.emit(*self._last)
+ self._plot.resetZoom()
+ self._dragging = False, False, False
+ else:
+ pass
+
+ def _updateMarkerPosition(self):
+ colormap = self.getColormap()
+ posMin, posMax = self._getDisplayableRange()
+
+ if colormap is None:
+ isDraggable = False
+ else:
+ isDraggable = colormap.isEditable()
+
+ with utils.blockSignals(self):
+ if posMin is not None and not self._dragging[0]:
+ self._plot.addXMarker(
+ posMin,
+ legend="Min",
+ text="Min",
+ draggable=isDraggable,
+ color="blue",
+ constraint=self._plotMinMarkerConstraint,
+ )
+ self._updateGammaPosition()
+ if posMax is not None and not self._dragging[1]:
+ self._plot.addXMarker(
+ posMax,
+ legend="Max",
+ text="\n\nMax",
+ draggable=isDraggable,
+ color="blue",
+ constraint=self._plotMaxMarkerConstraint,
+ )
+
+ self._updateLutItem((posMin, posMax))
+ self._plot.resetZoom()
+
+ def _updateGammaPosition(self):
+ colormap = self.getColormap()
+ posMin, posMax = self._getDisplayableRange()
+
+ if colormap is None:
+ gamma = None
+ else:
+ if colormap.getNormalization() == Colormap.GAMMA:
+ gamma = colormap.getGammaNormalizationParameter()
+ else:
+ gamma = None
+
+ if gamma is not None:
+ if not self._dragging[2]:
+ posRange = posMax - posMin
+ if posRange > 0:
+ gammaPos = posMin + posRange * 0.5 ** (1 / gamma)
+ else:
+ gammaPos = posMin
+ marker = self._plot._getMarker(
+ self._plot.addXMarker(
+ gammaPos,
+ legend="Gamma",
+ text="\nGamma",
+ draggable=True,
+ color="blue",
+ constraint=self._plotGammaMarkerConstraint,
+ )
+ )
+ marker.setZValue(2)
+ else:
+ try:
+ self._plot.removeMarker("Gamma")
+ except Exception:
+ pass
+
+ def _updateLutItem(self, vRange):
+ colormap = self.getColormap()
+ if colormap is None:
+ return
+
+ if vRange is None:
+ posMin, posMax = self._getDisplayableRange()
+ else:
+ posMin, posMax = vRange
+ if posMin is None or posMax is None:
+ self._lutItem.setVisible(False)
+ pos = posMax if posMin is None else posMin
+ if pos is not None:
+ self._bound.setBounds((pos, pos, -0.1, 0))
+ else:
+ self._bound.setBounds((0, 0, -0.1, 0))
+ else:
+ norm = colormap.getNormalization()
+ normColormap = colormap.copy()
+ normColormap.setEditable(True)
+ normColormap.setVRange(0, 255)
+ normColormap.setNormalization(Colormap.LINEAR)
+ if norm == Colormap.LINEAR:
+ scale = (posMax - posMin) / 256
+ self._lutItem.setColormap(normColormap)
+ self._lutItem.setOrigin((posMin, -0.09))
+ self._lutItem.setScale((scale, 0.08))
+ self._lutItem.setVisible(True)
+ self._lutItem2.setVisible(False)
+ elif norm == Colormap.LOGARITHM:
+ self._lutItem2.setVisible(False)
+ self._lutItem2.setColormap(normColormap)
+ xx = numpy.geomspace(posMin, posMax, 256)
+ self._lutItem2.setData(
+ x=xx, y=self.__lutY, value=self.__lutV, copy=False
+ )
+ self._lutItem2.setSymbol("|")
+ self._lutItem2.setVisible(True)
+ self._lutItem.setVisible(False)
+ else:
+ # Fallback: Display with linear axis and applied normalization
+ self._lutItem2.setVisible(False)
+ normColormap.setNormalization(norm)
+ self._lutItem2.setColormap(normColormap)
+ xx = numpy.linspace(posMin, posMax, 256, endpoint=True)
+ self._lutItem2.setData(
+ x=xx, y=self.__lutY, value=self.__lutV, copy=False
+ )
+ self._lutItem2.setSymbol("|")
+ self._lutItem2.setVisible(True)
+ self._lutItem.setVisible(False)
+
+ self._bound.setBounds((posMin, posMax, -0.1, 1))
+
+ def _plotMinMarkerConstraint(self, x, y):
+ """Constraint of the min marker"""
+ _vmin, vmax = self.getFiniteRange()
+ if vmax is None:
+ return x, y
+ return min(x, vmax), y
+
+ def _plotMaxMarkerConstraint(self, x, y):
+ """Constraint of the max marker"""
+ vmin, _vmax = self.getFiniteRange()
+ if vmin is None:
+ return x, y
+ return max(x, vmin), y
+
+ def _plotGammaMarkerConstraint(self, x, y):
+ """Constraint of the gamma marker"""
+ vmin, vmax = self.getFiniteRange()
+ if vmin is not None:
+ x = max(x, vmin)
+ if vmax is not None:
+ x = min(x, vmax)
+ return x, y
+
+ def setDisplayMode(self, mode: str | DisplayMode):
+ mode = DisplayMode.from_value(mode)
+ if mode is DisplayMode.HISTOGRAM:
+ action = self._dataHistogramAction
+ elif mode is DisplayMode.RANGE:
+ action = self._dataRangeAction
+ else:
+ raise ValueError("Mode not supported")
+ action.setChecked(True)
+ self._displayModeChanged(action)
+
+ def _setDisplayMode(self, mode):
+ if self._displayMode == mode:
+ return
+ self._displayMode = mode
+ self._updateDisplayMode()
+
+ def getDsiplayMode(self) -> DisplayMode:
+ return self._displayMode
+
+ def _displayModeChanged(self, action):
+ mode = action.data()
+ self._setDisplayMode(mode)
+
+ def invalidateData(self):
+ self._histogramData = {}
+ self._dataRange = {}
+ self._invalidated = True
+ self.update()
+
+ def _updateDisplayMode(self):
+ mode = self._displayMode
+
+ norm = self._getNorm()
+ if norm == Colormap.LINEAR:
+ scale = Axis.LINEAR
+ elif norm == Colormap.LOGARITHM:
+ scale = Axis.LOGARITHMIC
+ else:
+ scale = Axis.LINEAR
+
+ axis = self._plot.getXAxis()
+ axis.setScale(scale)
+
+ if mode == DisplayMode.RANGE:
+ dataRange = self._getNormalizedDataRange()
+ xmin, xmax = dataRange
+ if xmax is None or xmin is None:
+ self._plot.remove(legend="Data", kind="histogram")
+ else:
+ histogram = numpy.array([1])
+ bin_edges = numpy.array([xmin, xmax])
+ self._plot.addHistogram(
+ histogram,
+ bin_edges,
+ legend="Data",
+ color="gray",
+ align="center",
+ fill=True,
+ z=1,
+ )
+
+ elif mode == DisplayMode.HISTOGRAM:
+ histogram, bin_edges = self._getNormalizedHistogram()
+ if histogram is None or bin_edges is None:
+ self._plot.remove(legend="Data", kind="histogram")
+ else:
+ histogram = numpy.array(histogram, copy=True)
+ bin_edges = numpy.array(bin_edges, copy=True)
+ with numpy.errstate(invalid="ignore"):
+ norm_histogram = histogram / numpy.nanmax(histogram)
+ self._plot.addHistogram(
+ norm_histogram,
+ bin_edges,
+ legend="Data",
+ color="gray",
+ align="center",
+ fill=True,
+ z=1,
+ )
+ else:
+ _logger.error("Mode unsupported")
+
+ def sizeHint(self):
+ return self.layout().minimumSize()
+
+ def updateLut(self):
+ self._updateLutItem(None)
+
+ def _getNorm(self):
+ colormap = self.getColormap()
+ if colormap is None:
+ return Axis.LINEAR
+ else:
+ norm = colormap.getNormalization()
+ return norm
+
+ def updateNormalization(self):
+ self._updateDisplayMode()
+ self.update()
+
+
+class ColormapDialog(qt.QDialog):
+ """A QDialog widget to set the colormap.
+
+ :param parent: See :class:`QDialog`
+ :param str title: The QDialog title
+ """
+
+ visibleChanged = qt.Signal(bool)
+ """This event is sent when the dialog visibility change"""
+
+ def __init__(self, parent=None, title="Colormap Dialog"):
+ qt.QDialog.__init__(self, parent)
+ self.setWindowTitle(title)
+
+ self.__aboutToDelete = False
+ self._colormap = None
+
+ self._data = None
+ """Weak ref to an external numpy array
+ """
+ self._itemHolder = None
+ """Hard ref to a private item (used as holder to the data)
+ This allow to reuse the item cache
+ """
+ self._item = None
+ """Weak ref to an external item"""
+
+ self._colormapped = None
+ """Weak ref to reduce data update"""
+
+ self._colormapChange = utils.LockReentrant()
+ """Used as a semaphore to avoid editing the colormap object when we are
+ only attempt to display it.
+ Used instead of n connect and disconnect of the sigChanged. The
+ disconnection to sigChanged was also limiting when this colormapdialog
+ is used in the colormapaction and associated to the activeImageChanged.
+ (because the activeImageChanged is send when the colormap changed and
+ the self.setcolormap is a callback)
+ """
+
+ self.__colormapInvalidated = False
+ self.__dataInvalidated = False
+
+ self._histogramData = None
+
+ self._dataRange = None
+ """If defined 3-tuple containing information from a data:
+ minimum, positive minimum, maximum"""
+
+ self._colormapStoredState = None
+
+ # Colormap row
+ self._comboBoxColormap = ColormapNameComboBox(parent=self)
+ self._comboBoxColormap.currentIndexChanged[int].connect(
+ self._comboBoxColormapUpdated
+ )
+
+ # Normalization row
+ self._comboBoxNormalization = qt.QComboBox(parent=self)
+ normalizations = [
+ ("Linear", Colormap.LINEAR),
+ ("Gamma correction", Colormap.GAMMA),
+ ("Arcsinh", Colormap.ARCSINH),
+ ("Logarithmic", Colormap.LOGARITHM),
+ ("Square root", Colormap.SQRT),
+ ]
+ for name, userData in normalizations:
+ try:
+ icon = icons.getQIcon("colormap-norm-%s" % userData)
+ except:
+ icon = qt.QIcon()
+ self._comboBoxNormalization.addItem(icon, name, userData)
+ self._comboBoxNormalization.currentIndexChanged[int].connect(
+ self._normalizationUpdated
+ )
+
+ self._gammaSpinBox = qt.QDoubleSpinBox(parent=self)
+ self._gammaSpinBox.setEnabled(False)
+ self._gammaSpinBox.setRange(0.01, 100.0)
+ self._gammaSpinBox.setDecimals(4)
+ if hasattr(qt.QDoubleSpinBox, "setStepType"):
+ # Introduced in Qt 5.12
+ self._gammaSpinBox.setStepType(qt.QDoubleSpinBox.AdaptiveDecimalStepType)
+ else:
+ self._gammaSpinBox.setSingleStep(0.1)
+ self._gammaSpinBox.valueChanged.connect(self._gammaUpdated)
+ self._gammaSpinBox.setValue(2.0)
+
+ autoScaleCombo = _AutoscaleModeComboBox(self)
+ autoScaleCombo.currentIndexChanged.connect(self._autoscaleModeUpdated)
+ self._autoScaleCombo = autoScaleCombo
+
+ # Min row
+ self._minValue = _BoundaryWidget(parent=self, value=1.0)
+ self._minValue.sigAutoScaleChanged.connect(self._minAutoscaleUpdated)
+ self._minValue.sigValueChanged.connect(self._minValueUpdated)
+ self._minValue.setMinimumWidth(140)
+
+ # Max row
+ self._maxValue = _BoundaryWidget(parent=self, value=10.0)
+ self._maxValue.sigAutoScaleChanged.connect(self._maxAutoscaleUpdated)
+ self._maxValue.sigValueChanged.connect(self._maxValueUpdated)
+ self._maxValue.setMinimumWidth(140)
+
+ self._autoButtons = _AutoScaleButton(self)
+ self._autoButtons.autoRangeChanged.connect(self._autoRangeButtonsUpdated)
+
+ rangeLayout = qt.QGridLayout()
+ miniFont = qt.QFont(self.font())
+ miniFont.setPixelSize(8)
+ labelMin = qt.QLabel("Min", self)
+ labelMin.setFont(miniFont)
+ labelMin.setAlignment(qt.Qt.AlignHCenter)
+ labelMax = qt.QLabel("Max", self)
+ labelMax.setAlignment(qt.Qt.AlignHCenter)
+ labelMax.setFont(miniFont)
+ rangeLayout.addWidget(labelMin, 0, 1)
+ rangeLayout.addWidget(labelMax, 0, 3)
+ rangeLayout.addWidget(self._minValue, 1, 1)
+ rangeLayout.addWidget(self._maxValue, 1, 3)
+ rangeLayout.setColumnStretch(0, 1)
+ rangeLayout.setColumnStretch(1, 2)
+ rangeLayout.setColumnStretch(2, 1)
+ rangeLayout.setColumnStretch(3, 2)
+ rangeLayout.setColumnStretch(4, 1)
+
+ self._histoWidget = _ColormapHistogram(self)
+ self._histoWidget.sigRangeMoving.connect(self._histogramRangeMoving)
+ self._histoWidget.sigRangeMoved.connect(self._histogramRangeMoved)
+ self._histoWidget.setSizePolicy(
+ qt.QSizePolicy.Expanding, qt.QSizePolicy.Expanding
+ )
+
+ # Scale to buttons
+ self._visibleAreaButton = qt.QPushButton(self)
+ self._visibleAreaButton.setEnabled(False)
+ self._visibleAreaButton.setText("Visible Area")
+ self._visibleAreaButton.clicked.connect(
+ self._handleScaleToVisibleAreaClicked, type=qt.Qt.QueuedConnection
+ )
+
+ # Place-holder for selected area ROI manager
+ self._roiForColormapManager = None
+
+ self._selectedAreaButton = qt.QPushButton(self)
+ self._selectedAreaButton.setCheckable(True)
+ self._selectedAreaButton.setEnabled(False)
+ self._selectedAreaButton.setText("Selection")
+ self._selectedAreaButton.setIcon(icons.getQIcon("add-shape-rectangle"))
+ self._selectedAreaButton.setCheckable(True)
+ self._selectedAreaButton.toggled.connect(
+ self._handleScaleToSelectionToggled, type=qt.Qt.QueuedConnection
+ )
+
+ # define modal buttons
+ types = qt.QDialogButtonBox.Ok | qt.QDialogButtonBox.Cancel
+ self._buttonsModal = qt.QDialogButtonBox(parent=self)
+ self._buttonsModal.setStandardButtons(types)
+ self._buttonsModal.accepted.connect(self.accept)
+ self._buttonsModal.rejected.connect(self.reject)
+
+ # define non modal buttons
+ types = qt.QDialogButtonBox.Close | qt.QDialogButtonBox.Reset
+ self._buttonsNonModal = qt.QDialogButtonBox(parent=self)
+ self._buttonsNonModal.setStandardButtons(types)
+ button = self._buttonsNonModal.button(qt.QDialogButtonBox.Close)
+ button.clicked.connect(self.accept)
+ button.setDefault(True)
+ button = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset)
+ button.clicked.connect(self.resetColormap)
+
+ self._buttonsModal.setFocus(qt.Qt.OtherFocusReason)
+ self._buttonsNonModal.setFocus(qt.Qt.OtherFocusReason)
+
+ # Set the colormap to default values
+ self.setColormap(
+ Colormap(name="gray", normalization="linear", vmin=None, vmax=None)
+ )
+
+ self.setModal(self.isModal())
+
+ layout = qt.QHBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self._visibleAreaButton)
+ layout.addWidget(self._selectedAreaButton)
+ layout.addStretch()
+ self._scaleToAreaGroup = qt.QWidget(self)
+ self._scaleToAreaGroup.setLayout(layout)
+ self._scaleToAreaGroup.setVisible(False)
+
+ layoutScale = qt.QHBoxLayout()
+ layoutScale.setContentsMargins(0, 0, 0, 0)
+ layoutScale.addWidget(self._autoButtons)
+ layoutScale.addWidget(self._autoScaleCombo)
+ layoutScale.addStretch()
+
+ formLayout = FormGridLayout(self)
+ formLayout.setContentsMargins(10, 10, 10, 10)
+
+ formLayout.addRow("Colormap:", self._comboBoxColormap)
+ formLayout.addRow("Normalization:", self._comboBoxNormalization)
+ formLayout.addRow("Gamma:", self._gammaSpinBox)
+
+ formLayout.addItem(
+ qt.QSpacerItem(1, 1, qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed)
+ )
+ formLayout.addRow(self._histoWidget)
+ formLayout.setRowStretch(formLayout.rowCount() - 1, 1)
+ formLayout.addRow(rangeLayout)
+ formLayout.addItem(
+ qt.QSpacerItem(1, 1, qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed)
+ )
+ formLayout.addRow("Scale:", layoutScale)
+ formLayout.addRow("Fixed scale on:", self._scaleToAreaGroup)
+ formLayout.addRow(self._buttonsModal)
+ formLayout.addRow(self._buttonsNonModal)
+ formLayout.setSizeConstraint(qt.QLayout.SetMinAndMaxSize)
+
+ self.setTabOrder(self._comboBoxColormap, self._comboBoxNormalization)
+ self.setTabOrder(self._comboBoxNormalization, self._gammaSpinBox)
+ self.setTabOrder(self._gammaSpinBox, self._minValue)
+ self.setTabOrder(self._minValue, self._maxValue)
+ self.setTabOrder(self._maxValue, self._autoButtons)
+ self.setTabOrder(self._autoButtons, self._autoScaleCombo)
+ self.setTabOrder(self._autoScaleCombo, self._visibleAreaButton)
+ self.setTabOrder(self._visibleAreaButton, self._selectedAreaButton)
+ self.setTabOrder(self._selectedAreaButton, self._buttonsModal)
+ self.setTabOrder(self._buttonsModal, self._buttonsNonModal)
+
+ self._applyColormap()
+
+ def getHistogramWidget(self):
+ return self._histoWidget
+
+ def _invalidateColormap(self):
+ if self.isVisible():
+ self._applyColormap()
+ else:
+ self.__colormapInvalidated = True
+
+ def _invalidateData(self):
+ if self.isVisible():
+ self._updateWidgetRange()
+ self._histoWidget.invalidateData()
+ else:
+ self.__dataInvalidated = True
+
+ def _validate(self):
+ if self.__colormapInvalidated:
+ self._applyColormap()
+ if self.__dataInvalidated:
+ self._histoWidget.invalidateData()
+ if self.__dataInvalidated or self.__colormapInvalidated:
+ self._updateWidgetRange()
+ self.__dataInvalidated = False
+ self.__colormapInvalidated = False
+
+ def showEvent(self, event):
+ self.visibleChanged.emit(True)
+ super(ColormapDialog, self).showEvent(event)
+ if self.isVisible():
+ self._validate()
+
+ def closeEvent(self, event):
+ if not self.isModal():
+ self.accept()
+ super(ColormapDialog, self).closeEvent(event)
+
+ def hideEvent(self, event):
+ if self._selectedAreaButton.isChecked():
+ self._selectedAreaButton.setChecked(False)
+ self.visibleChanged.emit(False)
+ super(ColormapDialog, self).hideEvent(event)
+
+ def close(self):
+ if self._selectedAreaButton.isChecked():
+ self._selectedAreaButton.setChecked(False)
+ self.accept()
+ qt.QDialog.close(self)
+
+ def setModal(self, modal):
+ assert type(modal) is bool
+ self._buttonsNonModal.setVisible(not modal)
+ self._buttonsModal.setVisible(modal)
+ qt.QDialog.setModal(self, modal)
+
+ def event(self, event):
+ if event.type() == qt.QEvent.DeferredDelete:
+ self.__aboutToDelete = True
+ return super(ColormapDialog, self).event(event)
+
+ def exec(self):
+ wasModal = self.isModal()
+ self.setModal(True)
+ result = super(ColormapDialog, self).exec()
+ if not self.__aboutToDelete:
+ self.setModal(wasModal)
+ return result
+
+ def exec_(self): # Qt5 compatibility wrapper
+ return self.exec()
+
+ def _getFiniteColormapRange(self):
+ """Return a colormap range where auto ranges are fixed
+ according to the available data.
+ """
+ colormap = self.getColormap()
+ if colormap is None:
+ return 1, 10
+
+ item = self._getItem()
+ if item is not None:
+ return colormap.getColormapRange(item)
+ # If there is not item, there is no data
+ return colormap.getColormapRange(None)
+
+ @staticmethod
+ def computeDataRange(data):
+ """Compute the data range as used by :meth:`setDataRange`.
+
+ :param data: The data to process
+ :rtype: List[Union[None,float]]
+ """
+ if data is None or len(data) == 0:
+ return None, None, None
+
+ dataRange = min_max(data, min_positive=True, finite=True)
+ if dataRange.minimum is None:
+ # Only non-finite data
+ dataRange = None
+
+ if dataRange is not None:
+ dataRange = dataRange.minimum, dataRange.min_positive, dataRange.maximum
+
+ if dataRange is None or len(dataRange) != 3:
+ qt.QMessageBox.warning(
+ None, "No Data", "Image data does not contain any real value"
+ )
+ dataRange = 1.0, 1.0, 10.0
+
+ return dataRange
+
+ @staticmethod
+ def computeHistogram(data, scale=Axis.LINEAR, dataRange=None):
+ """Compute the data histogram as used by :meth:`setHistogram`.
+
+ :param data: The data to process
+ :param dataRange: Optional range to compute the histogram, which is a
+ tuple of min, max
+ :rtype: Tuple(List(float),List(float)
+ """
+ # For compatibility
+ if scale == Axis.LOGARITHMIC:
+ scale = Colormap.LOGARITHM
+
+ if data is None:
+ return None, None
+
+ if len(data) == 0:
+ return None, None
+
+ if data.ndim == 3: # RGB(A) images
+ _logger.info(
+ "Converting current image from RGB(A) to grayscale\
+ in order to compute the intensity distribution"
+ )
+ data = data[:, :, 0] * 0.299 + data[:, :, 1] * 0.587 + data[:, :, 2] * 0.114
+
+ # bad hack: get 256 continuous bins in the case we have a B&W
+ normalizeData = True
+ if numpy.issubdtype(data.dtype, numpy.ubyte):
+ normalizeData = False
+ elif numpy.issubdtype(data.dtype, numpy.integer):
+ if dataRange is not None:
+ xmin, xmax = dataRange
+ if xmin is not None and xmax is not None:
+ normalizeData = (xmax - xmin) > 255
+
+ if normalizeData:
+ if scale == Colormap.LOGARITHM:
+ with numpy.errstate(divide="ignore", invalid="ignore"):
+ data = numpy.log10(data)
+
+ if dataRange is not None:
+ xmin, xmax = dataRange
+ if xmin is None:
+ return None, None
+ if normalizeData:
+ if scale == Colormap.LOGARITHM:
+ xmin, xmax = numpy.log10(xmin), numpy.log10(xmax)
+ else:
+ xmin, xmax = min_max(data, min_positive=False, finite=True)
+
+ if xmin is None:
+ return None, None
+
+ nbins = min(256, int(numpy.sqrt(data.size)))
+ data_range = xmin, xmax
+
+ # bad hack: get 256 bins in the case we have a B&W
+ if numpy.issubdtype(data.dtype, numpy.integer):
+ if nbins > xmax - xmin:
+ nbins = int(xmax - xmin)
+
+ nbins = max(2, nbins)
+ data = data.ravel().astype(numpy.float32)
+
+ histogram = Histogramnd(data, n_bins=nbins, histo_range=data_range)
+ bins = histogram.edges[0]
+ if normalizeData:
+ if scale == Colormap.LOGARITHM:
+ bins = 10**bins
+ return histogram.histo, bins
+
+ def _getItem(self):
+ if self._itemHolder is not None:
+ return self._itemHolder
+ if self._item is None:
+ return None
+ return self._item()
+
+ def setItem(self, item):
+ """Store the plot item.
+
+ According to the state of the dialog, the item will be used to display
+ the data range or the histogram of the data using :meth:`setDataRange`
+ and :meth:`setHistogram`
+ """
+ old = self._getItem()
+ if old is item:
+ # While event from items are not supported, we can't ignore dup items
+ if item is not None:
+ array = item.getColormappedData(copy=False)
+ else:
+ array = None
+ colormapped = self._colormapped
+ if colormapped is not None:
+ oldArray = colormapped()
+ else:
+ oldArray = None
+ if oldArray is array:
+ return
+
+ self.__resetItem()
+ try:
+ if item is None:
+ self._item = None
+ else:
+ if not isinstance(item, items.ColormapMixIn):
+ self._item = None
+ raise ValueError("Item %s is not supported" % item)
+ item.sigItemChanged.connect(self.__itemChanged)
+ self._item = weakref.ref(item, self._itemAboutToFinalize)
+ finally:
+ self._syncScaleToButtonsEnabled()
+ self._dataRange = None
+ self._histogramData = None
+ self._invalidateData()
+
+ def __resetItem(self):
+ """Reset item and data used by the dialog"""
+ self._data = None
+ self._itemHolder = None
+ if self._item is not None:
+ item = self._item()
+ self._item = None
+ if item is not None:
+ item.sigItemChanged.disconnect(self.__itemChanged)
+
+ def __itemChanged(self, event):
+ if event == items.ItemChangedType.DATA:
+ self._invalidateData()
+
+ def _getData(self):
+ if self._data is None:
+ return None
+ return self._data()
+
+ def setData(self, data):
+ """Store the data
+
+ According to the state of the dialog, the data will be used to display
+ the data range or the histogram of the data using :meth:`setDataRange`
+ and :meth:`setHistogram`
+ """
+ oldData = self._getData()
+ if oldData is data:
+ return
+
+ self.__resetItem()
+ self._syncScaleToButtonsEnabled()
+ if data is not None:
+ self._data = weakref.ref(data, self._dataAboutToFinalize)
+ self._itemHolder = _DataRefHolder(self._data)
+
+ self._dataRange = None
+ self._histogramData = None
+
+ self._invalidateData()
+
+ def _getArray(self):
+ data = self._getData()
+ if data is not None:
+ return data
+ item = self._getItem()
+ if item is not None:
+ colormapped = item.getColormappedData(copy=False)
+ if colormapped is not None:
+ self._colormapped = weakref.ref(colormapped)
+ else:
+ self._colormapped = None
+ return colormapped
+ return None
+
+ def _colormapAboutToFinalize(self, weakrefColormap):
+ """Callback when the data weakref is about to be finalized."""
+ if self._colormap is weakrefColormap and qtinspect.isValid(self):
+ self.setColormap(None)
+
+ def _dataAboutToFinalize(self, weakrefData):
+ """Callback when the data weakref is about to be finalized."""
+ if self._data is weakrefData and qtinspect.isValid(self):
+ self.setData(None)
+
+ def _itemAboutToFinalize(self, weakref):
+ """Callback when the data weakref is about to be finalized."""
+ if self._item is weakref and qtinspect.isValid(self):
+ self.setItem(None)
+
+ def _getHistogram(self):
+ """Returns the histogram defined by the dialog as metadata
+ to describe the data in order to speed up the dialog.
+
+ :return: (hist, bin_edges)
+ :rtype: 2-tuple of numpy arrays"""
+ return self._histogramData
+
+ def setHistogram(self, hist=None, bin_edges=None):
+ """Set the histogram to display.
+
+ This update the data range with the bounds of the bins.
+
+ :param hist: array-like of counts or None to hide histogram
+ :param bin_edges: array-like of bins edges or None to hide histogram
+ """
+ if hist is None or bin_edges is None:
+ self._histogramData = None
+ else:
+ self._histogramData = numpy.array(hist), numpy.array(bin_edges)
+
+ self._invalidateData()
+
+ def getColormap(self):
+ """Return the colormap description.
+
+ :rtype: ~silx.gui.colors.Colormap
+ """
+ if self._colormap is None:
+ return None
+ return self._colormap()
+
+ def resetColormap(self):
+ """
+ Reset the colormap state before modification.
+
+ ..note :: the colormap reference state is the state when set or the
+ state when validated
+ """
+ colormap = self.getColormap()
+ if colormap is not None and self._colormapStoredState is not None:
+ if colormap != self._colormapStoredState:
+ with self._colormapChange:
+ colormap.setFromColormap(self._colormapStoredState)
+ self._applyColormap()
+
+ def _getDataRange(self):
+ """Returns the data range defined by the dialog as metadata
+ to describe the data in order to speed up the dialog.
+
+ :return: (minimum, positiveMin, maximum)
+ :rtype: 3-tuple of floats or None"""
+ return self._dataRange
+
+ def setDataRange(self, minimum=None, positiveMin=None, maximum=None):
+ """Set the range of data to use for the range of the histogram area.
+
+ :param float minimum: The minimum of the data
+ :param float positiveMin: The positive minimum of the data
+ :param float maximum: The maximum of the data
+ """
+ self._dataRange = minimum, positiveMin, maximum
+ self._invalidateData()
+
+ def _setColormapRange(self, xmin, xmax):
+ """Set a new range to the held colormap and update the
+ widget."""
+ colormap = self.getColormap()
+ if colormap is not None:
+ with self._colormapChange:
+ colormap.setVRange(xmin, xmax)
+ self._updateWidgetRange()
+
+ def setColormapRangeFromDataBounds(self, bounds):
+ """Set the range of the colormap from current item and rect.
+
+ If there is no ColormapMixIn item attached to the ColormapDialog,
+ nothing is done.
+
+ :param Union[List[float],None] bounds:
+ (xmin, xmax, ymin, ymax) Rectangular region in data space
+ """
+ if bounds is None:
+ return # no-op
+
+ colormap = self.getColormap()
+ if colormap is None:
+ return # no-op
+
+ item = self._getItem()
+ if not isinstance(item, items.ColormapMixIn):
+ return # no-op
+
+ data = item.getColormappedData(copy=False)
+ xmin, xmax, ymin, ymax = bounds
+
+ if isinstance(item, items.ImageBase):
+ if data.ndim != 2:
+ return # no-op
+
+ ox, oy = item.getOrigin()
+ sx, sy = item.getScale()
+
+ ystart = max(0, int((ymin - oy) / sy))
+ ystop = max(0, int(numpy.ceil((ymax - oy) / sy)))
+ xstart = max(0, int((xmin - ox) / sx))
+ xstop = max(0, int(numpy.ceil((xmax - ox) / sx)))
+
+ subset = data[ystart:ystop, xstart:xstop]
+
+ elif isinstance(item, items.Scatter):
+ x = item.getXData(copy=False)
+ y = item.getYData(copy=False)
+ subset = data[
+ numpy.logical_and(
+ numpy.logical_and(xmin <= x, x <= xmax),
+ numpy.logical_and(ymin <= y, y <= ymax),
+ )
+ ]
+
+ if subset.size == 0:
+ return # no-op
+
+ vmin, vmax = colormap._computeAutoscaleRange(subset)
+ self._setColormapRange(vmin, vmax)
+
+ def _updateWidgetRange(self):
+ """Update the colormap range displayed into the widget."""
+ xmin, xmax = self._getFiniteColormapRange()
+ colormap = self.getColormap()
+ if colormap is not None:
+ vRange = colormap.getVRange()
+ autoMin, autoMax = (r is None for r in vRange)
+ else:
+ autoMin, autoMax = False, False
+
+ with utils.blockSignals(self._minValue):
+ self._minValue.setValue(xmin, autoMin)
+ with utils.blockSignals(self._maxValue):
+ self._maxValue.setValue(xmax, autoMax)
+ with utils.blockSignals(self._histoWidget):
+ self._histoWidget.setFiniteRange((xmin, xmax))
+ with utils.blockSignals(self._autoButtons):
+ self._autoButtons.setAutoRange((autoMin, autoMax))
+
+ def accept(self):
+ self.storeCurrentState()
+ qt.QDialog.accept(self)
+
+ def storeCurrentState(self):
+ """
+ save the current value sof the colormap if the user want to undo is
+ modifications
+ """
+ colormap = self.getColormap()
+ if colormap is not None:
+ self._colormapStoredState = colormap.copy()
+ else:
+ self._colormapStoredState = None
+
+ def reject(self):
+ self.resetColormap()
+ qt.QDialog.reject(self)
+
+ def setColormap(self, colormap):
+ """Set the colormap description
+
+ :param ~silx.gui.colors.Colormap colormap: the colormap to edit
+ """
+ assert colormap is None or isinstance(colormap, Colormap)
+ if self._colormapChange.locked():
+ return
+
+ oldColormap = self.getColormap()
+ if oldColormap is colormap:
+ return
+ if oldColormap is not None:
+ oldColormap.sigChanged.disconnect(self._applyColormap)
+
+ if colormap is not None:
+ colormap.sigChanged.connect(self._applyColormap)
+ colormap = weakref.ref(colormap, self._colormapAboutToFinalize)
+
+ self._colormap = colormap
+ self.storeCurrentState()
+ self._invalidateColormap()
+
+ def _updateResetButton(self):
+ resetButton = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset)
+ rStateEnabled = False
+ colormap = self.getColormap()
+ if colormap is not None and colormap.isEditable():
+ # can reset only in the case the colormap changed
+ rStateEnabled = colormap != self._colormapStoredState
+ resetButton.setEnabled(rStateEnabled)
+
+ def _applyColormap(self):
+ self._updateResetButton()
+ if self._colormapChange.locked():
+ return
+
+ self._syncScaleToButtonsEnabled()
+
+ colormap = self.getColormap()
+ if colormap is None:
+ self._comboBoxColormap.setEnabled(False)
+ self._comboBoxNormalization.setEnabled(False)
+ self._gammaSpinBox.setEnabled(False)
+ self._autoScaleCombo.setEnabled(False)
+ self._minValue.setEnabled(False)
+ self._maxValue.setEnabled(False)
+ self._autoButtons.setEnabled(False)
+ self._histoWidget.setVisible(False)
+ self._histoWidget.setFiniteRange((None, None))
+ else:
+ assert colormap.getNormalization() in Colormap.NORMALIZATIONS
+ with utils.blockSignals(self._comboBoxColormap):
+ self._comboBoxColormap.setCurrentLut(colormap)
+ self._comboBoxColormap.setEnabled(colormap.isEditable())
+ with utils.blockSignals(self._comboBoxNormalization):
+ index = self._comboBoxNormalization.findData(
+ colormap.getNormalization()
+ )
+ if index < 0:
+ _logger.error(
+ "Unsupported normalization: %s" % colormap.getNormalization()
+ )
+ else:
+ self._comboBoxNormalization.setCurrentIndex(index)
+ self._comboBoxNormalization.setEnabled(colormap.isEditable())
+ with utils.blockSignals(self._gammaSpinBox):
+ self._gammaSpinBox.setValue(colormap.getGammaNormalizationParameter())
+ self._gammaSpinBox.setEnabled(
+ colormap.getNormalization() == Colormap.GAMMA
+ and colormap.isEditable()
+ )
+ with utils.blockSignals(self._autoScaleCombo):
+ self._autoScaleCombo.setCurrentMode(colormap.getAutoscaleMode())
+ self._autoScaleCombo.setEnabled(colormap.isEditable())
+ with utils.blockSignals(self._autoButtons):
+ self._autoButtons.setEnabled(colormap.isEditable())
+ self._autoButtons.setAutoRangeFromColormap(colormap)
+
+ vmin, vmax = colormap.getVRange()
+ if vmin is None or vmax is None:
+ # Compute it only if needed
+ dataRange = self._getFiniteColormapRange()
+ else:
+ dataRange = vmin, vmax
+
+ with utils.blockSignals(self._minValue):
+ self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None)
+ self._minValue.setEnabled(colormap.isEditable())
+ with utils.blockSignals(self._maxValue):
+ self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None)
+ self._maxValue.setEnabled(colormap.isEditable())
+
+ with utils.blockSignals(self._histoWidget):
+ self._histoWidget.setVisible(True)
+ self._histoWidget.setFiniteRange(dataRange)
+ self._histoWidget.updateNormalization()
+
+ def _comboBoxColormapUpdated(self):
+ """Callback executed when the combo box with the colormap LUT
+ is updated by user input.
+ """
+ colormap = self.getColormap()
+ if colormap is not None:
+ with self._colormapChange:
+ name = self._comboBoxColormap.getCurrentName()
+ if name is not None:
+ colormap.setName(name)
+ else:
+ lut = self._comboBoxColormap.getCurrentColors()
+ colormap.setColormapLUT(lut)
+ self._histoWidget.updateLut()
+
+ def _autoRangeButtonsUpdated(self, autoRange):
+ """Callback executed when the autoscale buttons widget
+ is updated by user input.
+ """
+ dataRange = self._getFiniteColormapRange()
+
+ # Final colormap range
+ vmin = dataRange[0] if not autoRange[0] else None
+ vmax = dataRange[1] if not autoRange[1] else None
+
+ with self._colormapChange:
+ colormap = self.getColormap()
+ colormap.setVRange(vmin, vmax)
+
+ with utils.blockSignals(self._minValue):
+ self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None)
+ with utils.blockSignals(self._maxValue):
+ self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None)
+
+ self._updateWidgetRange()
+
+ def _normalizationUpdated(self, index):
+ """Callback executed when the normalization widget
+ is updated by user input.
+ """
+ colormap = self.getColormap()
+ if colormap is not None:
+ normalization = self._comboBoxNormalization.itemData(index)
+ self._gammaSpinBox.setEnabled(normalization == "gamma")
+
+ with self._colormapChange:
+ colormap.setNormalization(normalization)
+ self._histoWidget.updateNormalization()
+
+ self._updateWidgetRange()
+
+ def _gammaUpdated(self, value):
+ """Callback used to update the gamma normalization parameter"""
+ colormap = self.getColormap()
+ if colormap is not None:
+ colormap.setGammaNormalizationParameter(value)
+
+ def _autoscaleModeUpdated(self):
+ """Callback executed when the autoscale mode widget
+ is updated by user input.
+ """
+ mode = self._autoScaleCombo.currentMode()
+
+ colormap = self.getColormap()
+ if colormap is not None:
+ with self._colormapChange:
+ colormap.setAutoscaleMode(mode)
+
+ self._updateWidgetRange()
+
+ def _minAutoscaleUpdated(self, autoEnabled):
+ """Callback executed when the min autoscale from
+ the lineedit is updated by user input"""
+ colormap = self.getColormap()
+ xmin, xmax = colormap.getVRange()
+ if autoEnabled:
+ xmin = None
+ else:
+ xmin, _xmax = self._getFiniteColormapRange()
+ self._setColormapRange(xmin, xmax)
+
+ def _maxAutoscaleUpdated(self, autoEnabled):
+ """Callback executed when the max autoscale from
+ the lineedit is updated by user input"""
+ colormap = self.getColormap()
+ xmin, xmax = colormap.getVRange()
+ if autoEnabled:
+ xmax = None
+ else:
+ _xmin, xmax = self._getFiniteColormapRange()
+ self._setColormapRange(xmin, xmax)
+
+ def _minValueUpdated(self, value):
+ """Callback executed when the lineedit min value is
+ updated by user input"""
+ xmin = value
+ xmax = self._maxValue.getValue()
+ if xmax is not None and xmin > xmax:
+ # FIXME: This should be done in the widget itself
+ xmin = xmax
+ with utils.blockSignals(self._minValue):
+ self._minValue.setValue(xmin)
+ self._setColormapRange(xmin, xmax)
+
+ def _maxValueUpdated(self, value):
+ """Callback executed when the lineedit max value is
+ updated by user input"""
+ xmin = self._minValue.getValue()
+ xmax = value
+ if xmin is not None and xmin > xmax:
+ # FIXME: This should be done in the widget itself
+ xmax = xmin
+ with utils.blockSignals(self._maxValue):
+ self._maxValue.setValue(xmax)
+ self._setColormapRange(xmin, xmax)
+
+ def _histogramRangeMoving(self, vmin, vmax, gammaPos):
+ """Callback executed when for colormap range displayed in
+ the histogram widget is moving.
+
+ :param vmin: Update of the minimum range, else None
+ :param vmax: Update of the maximum range, else None
+ :param gammaPos: Update of the gamma location, else None
+ """
+ colormap = self.getColormap()
+ if vmin is not None:
+ with self._colormapChange:
+ colormap.setVMin(vmin)
+ self._minValue.setValue(vmin)
+ if vmax is not None:
+ with self._colormapChange:
+ colormap.setVMax(vmax)
+ self._maxValue.setValue(vmax)
+ if gammaPos is not None:
+ vmin, vmax = self._histoWidget.getFiniteRange()
+ if vmax < vmin:
+ gamma = 1
+ elif gammaPos >= vmax:
+ gamma = self._gammaSpinBox.maximum()
+ elif gammaPos <= vmin:
+ gamma = self._gammaSpinBox.minimum()
+ else:
+ gamma = numpy.clip(
+ numpy.log(0.5) / numpy.log((gammaPos - vmin) / (vmax - vmin)),
+ self._gammaSpinBox.minimum(),
+ self._gammaSpinBox.maximum(),
+ )
+ with self._colormapChange:
+ colormap.setGammaNormalizationParameter(gamma)
+ with utils.blockSignals(self._gammaSpinBox):
+ self._gammaSpinBox.setValue(gamma)
+
+ def _histogramRangeMoved(self, vmin, vmax, gammaPos):
+ """Callback executed when for colormap range displayed in
+ the histogram widget has finished to move
+ """
+ if vmin is None and vmax is None:
+ return
+ xmin = self._minValue.getValue()
+ xmax = self._maxValue.getValue()
+ if vmin is None:
+ vmin = xmin
+ if vmax is None:
+ vmax = xmax
+ self._setColormapRange(vmin, vmax)
+
+ def _syncScaleToButtonsEnabled(self):
+ """Set the state of scale to buttons according to current item and colormap"""
+ colormap = self.getColormap()
+ enabled = (
+ self._item is not None and colormap is not None and colormap.isEditable()
+ )
+ self._scaleToAreaGroup.setVisible(enabled)
+ self._visibleAreaButton.setEnabled(enabled)
+ if not enabled:
+ self._selectedAreaButton.setChecked(False)
+ self._selectedAreaButton.setEnabled(enabled)
+
+ def _handleScaleToVisibleAreaClicked(self):
+ """Set colormap range from current item's visible area"""
+ item = self._getItem()
+ if item is None:
+ return # no-op
+
+ bounds = item.getVisibleBounds()
+ if bounds is None:
+ return # no-op
+
+ self.setColormapRangeFromDataBounds(bounds)
+
+ def _handleScaleToSelectionToggled(self, checked=False):
+ """Handle toggle of scale to selected are button"""
+ # Reset any previous ROI manager
+ if self._roiForColormapManager is not None:
+ self._roiForColormapManager.clear()
+ self._roiForColormapManager.stop()
+ self._roiForColormapManager = None
+
+ if not checked: # Reset button status
+ self._selectedAreaButton.setText("Selection")
+ return
+
+ item = self._getItem()
+ if item is None:
+ self._selectedAreaButton.setChecked(False)
+ return # no-op
+
+ plotWidget = item.getPlot()
+ if plotWidget is None:
+ self._selectedAreaButton.setChecked(False)
+ return # no-op
+
+ self._selectedAreaButton.setText("Draw Area...")
+
+ self._roiForColormapManager = RegionOfInterestManager(parent=plotWidget)
+ cmap = self.getColormap()
+ self._roiForColormapManager.setColor(
+ "black" if cmap is None else cursorColorForColormap(cmap.getName())
+ )
+ self._roiForColormapManager.sigInteractiveModeFinished.connect(
+ self.__roiInteractiveModeFinished
+ )
+ self._roiForColormapManager.sigInteractiveRoiFinalized.connect(
+ self.__roiFinalized
+ )
+ self._roiForColormapManager.start(RectangleROI)
+
+ def __roiInteractiveModeFinished(self):
+ self._selectedAreaButton.setChecked(False)
+
+ def __roiFinalized(self, roi):
+ if roi is not None:
+ ox, oy = roi.getOrigin()
+ width, height = roi.getSize()
+ self.setColormapRangeFromDataBounds((ox, ox + width, oy, oy + height))
+ # clear ROI
+ self._roiForColormapManager.removeRoi(roi)
+
+ def keyPressEvent(self, event):
+ """Override key handling.
+
+ It disables leaving the dialog when editing a text field.
+
+ But several press of Return key can be use to validate and close the
+ dialog.
+ """
+ if event.key() in (qt.Qt.Key_Enter, qt.Qt.Key_Return):
+ # Bypass QDialog keyPressEvent
+ # To avoid leaving the dialog when pressing enter on a text field
+ if self._minValue.hasFocus():
+ nextFocus = self._maxValue
+ elif self._maxValue.hasFocus():
+ if self.isModal():
+ nextFocus = self._buttonsModal.button(qt.QDialogButtonBox.Apply)
+ else:
+ nextFocus = self._buttonsNonModal.button(qt.QDialogButtonBox.Close)
+ else:
+ nextFocus = None
+ if nextFocus is not None:
+ nextFocus.setFocus(qt.Qt.OtherFocusReason)
+ else:
+ super(ColormapDialog, self).keyPressEvent(event)