diff options
Diffstat (limited to 'silx/gui/plot/actions/histogram.py')
-rw-r--r-- | silx/gui/plot/actions/histogram.py | 366 |
1 files changed, 250 insertions, 116 deletions
diff --git a/silx/gui/plot/actions/histogram.py b/silx/gui/plot/actions/histogram.py index f3e6370..0bba558 100644 --- a/silx/gui/plot/actions/histogram.py +++ b/silx/gui/plot/actions/histogram.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# Copyright (c) 2004-2021 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -34,22 +34,238 @@ The following QAction are available: from __future__ import division __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] -__date__ = "10/10/2018" +__date__ = "01/12/2020" __license__ = "MIT" import numpy import logging +import typing import weakref from .PlotToolAction import PlotToolAction + from silx.math.histogram import Histogramnd from silx.math.combo import min_max from silx.gui import qt from silx.gui.plot import items +from silx.gui.widgets.ElidedLabel import ElidedLabel +from silx.utils.deprecation import deprecated _logger = logging.getLogger(__name__) +class _ElidedLabel(ElidedLabel): + """QLabel with a default size larger than what is displayed.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setTextInteractionFlags(qt.Qt.TextSelectableByMouse) + + def sizeHint(self): + hint = super().sizeHint() + nbchar = max(len(self.getText()), 12) + width = self.fontMetrics().boundingRect('#' * nbchar).width() + return qt.QSize(max(hint.width(), width), hint.height()) + + +class _StatWidget(qt.QWidget): + """Widget displaying a name and a value + + :param parent: + :param name: + """ + + def __init__(self, parent=None, name: str=''): + super().__init__(parent) + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + keyWidget = qt.QLabel(parent=self) + keyWidget.setText("<b>" + name.capitalize() + ":<b>") + layout.addWidget(keyWidget) + self.__valueWidget = _ElidedLabel(parent=self) + self.__valueWidget.setText("-") + self.__valueWidget.setTextInteractionFlags( + qt.Qt.TextSelectableByMouse | qt.Qt.TextSelectableByKeyboard) + layout.addWidget(self.__valueWidget) + + def setValue(self, value: typing.Optional[float]): + """Set the displayed value + + :param value: + """ + self.__valueWidget.setText( + "-" if value is None else "{:.5g}".format(value)) + + +class HistogramWidget(qt.QWidget): + """Widget displaying a histogram and some statistic indicators""" + + _SUPPORTED_ITEM_CLASS = items.ImageBase, items.Scatter + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setWindowTitle('Histogram') + + self.__itemRef = None # weakref on the item to track + + layout = qt.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Plot + # Lazy import to avoid circular dependencies + from silx.gui.plot.PlotWindow import Plot1D + self.__plot = Plot1D(self) + layout.addWidget(self.__plot) + + self.__plot.setDataMargins(0.1, 0.1, 0.1, 0.1) + self.__plot.getXAxis().setLabel("Value") + self.__plot.getYAxis().setLabel("Count") + posInfo = self.__plot.getPositionInfoWidget() + posInfo.setSnappingMode(posInfo.SNAPPING_CURVE) + + # Stats display + statsWidget = qt.QWidget(self) + layout.addWidget(statsWidget) + statsLayout = qt.QHBoxLayout(statsWidget) + statsLayout.setContentsMargins(4, 4, 4, 4) + + self.__statsWidgets = dict( + (name, _StatWidget(parent=statsWidget, name=name)) + for name in ("min", "max", "mean", "std", "sum")) + + for widget in self.__statsWidgets.values(): + statsLayout.addWidget(widget) + statsLayout.addStretch(1) + + def getPlotWidget(self): + """Returns :class:`PlotWidget` use to display the histogram""" + return self.__plot + + def resetZoom(self): + """Reset PlotWidget zoom""" + self.getPlotWidget().resetZoom() + + def reset(self): + """Clear displayed information""" + self.getPlotWidget().clear() + self.setStatistics() + + def getItem(self) -> typing.Optional[items.Item]: + """Returns item used to display histogram and statistics.""" + return None if self.__itemRef is None else self.__itemRef() + + def setItem(self, item: typing.Optional[items.Item]): + """Set item from which to display histogram and statistics. + + :param item: + """ + previous = self.getItem() + if previous is not None: + previous.sigItemChanged.disconnect(self.__itemChanged) + + self.__itemRef = None if item is None else weakref.ref(item) + if item is not None: + if isinstance(item, self._SUPPORTED_ITEM_CLASS): + # Only listen signal for supported items + item.sigItemChanged.connect(self.__itemChanged) + self._updateFromItem() + + def __itemChanged(self, event): + """Handle update of the item""" + if event in (items.ItemChangedType.DATA, items.ItemChangedType.MASK): + self._updateFromItem() + + def _updateFromItem(self): + """Update histogram and stats from the item""" + item = self.getItem() + + if item is None: + self.reset() + return + + if not isinstance(item, self._SUPPORTED_ITEM_CLASS): + _logger.error("Unsupported item", item) + self.reset() + return + + # Compute histogram and stats + array = item.getValueData(copy=False) + + if array.size == 0: + self.reset() + return + + xmin, xmax = min_max(array, min_positive=False, finite=True) + nbins = min(1024, int(numpy.sqrt(array.size))) + data_range = xmin, xmax + + # bad hack: get 256 bins in the case we have a B&W + if numpy.issubdtype(array.dtype, numpy.integer): + if nbins > xmax - xmin: + nbins = xmax - xmin + + nbins = max(2, nbins) + + data = array.ravel().astype(numpy.float32) + histogram = Histogramnd(data, n_bins=nbins, histo_range=data_range) + if len(histogram.edges) != 1: + _logger.error("Error while computing the histogram") + self.reset() + return + + self.setHistogram(histogram.histo, histogram.edges[0]) + self.resetZoom() + self.setStatistics( + min_=xmin, + max_=xmax, + mean=numpy.nanmean(array), + std=numpy.nanstd(array), + sum_=numpy.nansum(array)) + + def setHistogram(self, histogram, edges): + """Set displayed histogram + + :param histogram: Bin values (N) + :param edges: Bin edges (N+1) + """ + self.getPlotWidget().addHistogram( + histogram=histogram, + edges=edges, + legend='histogram', + fill=True, + color='#66aad7', + resetzoom=False) + + def getHistogram(self, copy: bool=True): + """Returns currently displayed histogram. + + :param copy: True to get a copy, + False to get internal representation (Do not modify!) + :return: (histogram, edges) or None + """ + for item in self.getPlotWidget().getItems(): + if item.getName() == 'histogram': + return (item.getValueData(copy=copy), + item.getBinEdgesData(copy=copy)) + else: + return None + + def setStatistics(self, + min_: typing.Optional[float] = None, + max_: typing.Optional[float] = None, + mean: typing.Optional[float] = None, + std: typing.Optional[float] = None, + sum_: typing.Optional[float] = None): + """Set displayed statistic indicators.""" + self.__statsWidgets['min'].setValue(min_) + self.__statsWidgets['max'].setValue(max_) + self.__statsWidgets['mean'].setValue(mean) + self.__statsWidgets['std'].setValue(std) + self.__statsWidgets['sum'].setValue(sum_) + + class _LastActiveItem(qt.QObject): sigActiveItemChanged = qt.Signal(object, object) @@ -98,20 +314,26 @@ class _LastActiveItem(qt.QObject): def _activeImageChanged(self, previous, current): """Handle active image change""" plot = self.getPlotWidget() - item = plot.getImage(current) - if item is None: - self.setActiveItem(None) - elif isinstance(item, items.ImageBase): - self.setActiveItem(item) + if current is None: # Fall-back to active scatter if any + self.setActiveItem(plot.getActiveScatter()) else: - # Do not touch anything, which is consistent with silx v0.12 behavior - pass + item = plot.getImage(current) + if item is None: + self.setActiveItem(None) + elif isinstance(item, items.ImageBase): + self.setActiveItem(item) + else: + # Do not touch anything, which is consistent with silx v0.12 behavior + pass def _activeScatterChanged(self, previous, current): """Handle active scatter change""" plot = self.getPlotWidget() - item = plot.getScatter(current) - self.setActiveItem(item) + if current is None: # Fall-back to active image if any + self.setActiveItem(plot.getActiveImage()) + else: + item = plot.getScatter(current) + self.setActiveItem(item) class PixelIntensitiesHistoAction(PlotToolAction): @@ -129,130 +351,42 @@ class PixelIntensitiesHistoAction(PlotToolAction): tooltip='Compute image intensity distribution', parent=parent) self._lastItemFilter = _LastActiveItem(self, plot) - self._histo = None - self._item = None def _connectPlot(self, window): self._lastItemFilter.sigActiveItemChanged.connect(self._activeItemChanged) item = self._lastItemFilter.getActiveItem() - self._setSelectedItem(item) + self.getHistogramWidget().setItem(item) PlotToolAction._connectPlot(self, window) def _disconnectPlot(self, window): self._lastItemFilter.sigActiveItemChanged.disconnect(self._activeItemChanged) PlotToolAction._disconnectPlot(self, window) - self._setSelectedItem(None) - - def _getSelectedItem(self): - item = self._item - if item is None: - return None - else: - return item() + self.getHistogramWidget().setItem(None) def _activeItemChanged(self, previous, current): if self._isWindowInUse(): - self._setSelectedItem(current) - - def _setSelectedItem(self, item): - if item is not None: - if not isinstance(item, (items.ImageBase, items.Scatter)): - # Filter out other things - return - - old = self._getSelectedItem() - if item is old: - return - if old is not None: - old.sigItemChanged.disconnect(self._itemUpdated) - if item is None: - self._item = None - else: - self._item = weakref.ref(item) - item.sigItemChanged.connect(self._itemUpdated) - self.computeIntensityDistribution() - - def _itemUpdated(self, event): - if event == items.ItemChangedType.DATA: - self.computeIntensityDistribution() - - def _cleanUp(self): - plot = self.getHistogramPlotWidget() - try: - plot.remove('pixel intensity', kind='item') - except Exception: - pass + self.getHistogramWidget().setItem(current) + @deprecated(since_version='0.15.0') def computeIntensityDistribution(self): - """Get the active image and compute the image intensity distribution - """ - item = self._getSelectedItem() - - if item is None: - self._cleanUp() - return - - if isinstance(item, items.ImageBase): - array = item.getData(copy=False) - if array.ndim == 3: # RGB(A) images - _logger.info('Converting current image from RGB(A) to grayscale\ - in order to compute the intensity distribution') - array = (array[:, :, 0] * 0.299 + - array[:, :, 1] * 0.587 + - array[:, :, 2] * 0.114) - elif isinstance(item, items.Scatter): - array = item.getValueData(copy=False) - else: - assert(False) - - if array.size == 0: - self._cleanUp() - return - - xmin, xmax = min_max(array, min_positive=False, finite=True) - nbins = min(1024, int(numpy.sqrt(array.size))) - data_range = xmin, xmax - - # bad hack: get 256 bins in the case we have a B&W - if numpy.issubdtype(array.dtype, numpy.integer): - if nbins > xmax - xmin: - nbins = xmax - xmin + self.getHistogramWidget()._updateFromItem() - nbins = max(2, nbins) - - data = array.ravel().astype(numpy.float32) - histogram = Histogramnd(data, n_bins=nbins, histo_range=data_range) - assert len(histogram.edges) == 1 - self._histo = histogram.histo - edges = histogram.edges[0] - plot = self.getHistogramPlotWidget() - plot.addHistogram(histogram=self._histo, - edges=edges, - legend='pixel intensity', - fill=True, - color='#66aad7') - plot.resetZoom() + def getHistogramWidget(self): + """Returns the widget displaying the histogram""" + return self._getToolWindow() + @deprecated(since_version='0.15.0', + replacement='getHistogramWidget().getPlotWidget()') def getHistogramPlotWidget(self): - """Create the plot histogram if needed, otherwise create it - - :return: the PlotWidget showing the histogram of the pixel intensities - """ - return self._getToolWindow() + return self._getToolWindow().getPlotWidget() def _createToolWindow(self): - from silx.gui.plot.PlotWindow import Plot1D - window = Plot1D(parent=self.plot) - window.setWindowFlags(qt.Qt.Window) - window.setWindowTitle('Image Intensity Histogram') - window.setDataMargins(0.1, 0.1, 0.1, 0.1) - window.getXAxis().setLabel("Value") - window.getYAxis().setLabel("Count") - return window - - def getHistogram(self): + return HistogramWidget(self.plot, qt.Qt.Window) + + def getHistogram(self) -> typing.Optional[numpy.ndarray]: """Return the last computed histogram - :return: the histogram displayed in the HistogramPlotWiget + :return: the histogram displayed in the HistogramWidget """ - return self._histo + histogram = self.getHistogramWidget().getHistogram() + return None if histogram is None else histogram[0] |