From cafe0799107f64f0da94925300df5b35b78075ca Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Tue, 4 May 2021 21:25:42 -0700 Subject: [PATCH] Support horizontal HistogramLUT{Item,Widget} (#1757) * Improve HistogramLUTItem docs, a few cosmetic changes * Initial implementation of horizontally-oriented HistogramLUTItem - Also adds support in HistogramLUTWidget - Fixes AxisItem orientation bug for vertical orientation - Make use of GradientEditorItem orientation (fixes another bug for vertical orientation) - Use horizontal orientation in an example for demonstration * Remove unused HistogramLUTItem option * A few more minor fixups * Copy paste bug * Use f-strings * Update from review and a couple more minor updates * Woops * Add doc for orientation arg * Add top/bottom orientation to doc. Expand on levelMode doc a bit --- examples/NonUniformImage.py | 3 +- pyqtgraph/graphicsItems/HistogramLUTItem.py | 332 +++++++++++++------- pyqtgraph/widgets/HistogramLUTWidget.py | 34 +- 3 files changed, 244 insertions(+), 125 deletions(-) diff --git a/examples/NonUniformImage.py b/examples/NonUniformImage.py index 2048c900..5c7f974e 100644 --- a/examples/NonUniformImage.py +++ b/examples/NonUniformImage.py @@ -52,10 +52,11 @@ win.setWindowTitle('pyqtgraph example: Non-uniform Image') p = cw.addPlot(title="Power Losses [W]", row=0, col=0) -lut = pg.HistogramLUTItem() +lut = pg.HistogramLUTItem(orientation="horizontal") p.setMouseEnabled(x=False, y=False) +cw.nextRow() cw.addItem(lut) # load the gradient diff --git a/pyqtgraph/graphicsItems/HistogramLUTItem.py b/pyqtgraph/graphicsItems/HistogramLUTItem.py index 20ed2d6f..7306ebec 100644 --- a/pyqtgraph/graphicsItems/HistogramLUTItem.py +++ b/pyqtgraph/graphicsItems/HistogramLUTItem.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """ -GraphicsWidget displaying an image histogram along with gradient editor. Can be used to adjust the appearance of images. +GraphicsWidget displaying an image histogram along with gradient editor. Can be used to +adjust the appearance of images. """ @@ -25,65 +26,115 @@ __all__ = ['HistogramLUTItem'] class HistogramLUTItem(GraphicsWidget): """ - This is a graphicsWidget which provides controls for adjusting the display of an image. - + :class:`~pyqtgraph.GraphicsWidget` with controls for adjusting the display of an + :class:`~pyqtgraph.ImageItem`. + Includes: - - Image histogram - - Movable region over histogram to select black/white levels + - Image histogram + - Movable region over the histogram to select black/white levels - Gradient editor to define color lookup table for single-channel images - - ================ =========================================================== - image (:class:`~pyqtgraph.ImageItem` or ``None``) If *image* is - provided, then the control will be automatically linked to - the image and changes to the control will be immediately - reflected in the image's appearance. - fillHistogram (bool) By default, the histogram is rendered with a fill. - For performance, set ``fillHistogram=False`` - rgbHistogram (bool) Sets whether the histogram is computed once over all - channels of the image, or once per channel. - levelMode 'mono' or 'rgba'. If 'mono', then only a single set of - black/white level lines is drawn, and the levels apply to - all channels in the image. If 'rgba', then one set of - levels is drawn for each channel. - gradientPosition 'right' (default) OR 'left'. Which side of the histogram to - put the LUT gradient. - ================ =========================================================== + + Parameters + ---------- + image : pyqtgraph.ImageItem, optional + If provided, control will be automatically linked to the image and changes to + the control will be reflected in the image's appearance. This may also be set + via :meth:`setImageItem`. + fillHistogram : bool, optional + By default, the histogram is rendered with a fill. Performance may be improved + by disabling the fill. Additional control over the fill is provided by + :meth:`fillHistogram`. + levelMode : str, optional + 'mono' (default) + One histogram with a :class:`~pyqtgraph.LinearRegionItem` is displayed to + control the black/white levels of the image. This option may be used for + color images, in which case the histogram and levels correspond to all + channels of the image. + 'rgba' + A histogram and level control pair is provided for each image channel. The + alpha channel histogram and level control are only shown if the image + contains an alpha channel. + gradientPosition : str, optional + Position of the gradient editor relative to the histogram. Must be one of + {'right', 'left', 'top', 'bottom'}. 'right' and 'left' options should be used + with a 'vertical' orientation; 'top' and 'bottom' options are for 'horizontal' + orientation. + orientation : str, optional + The orientation of the axis along which the histogram is displayed. Either + 'vertical' (default) or 'horizontal'. + + Attributes + ---------- + sigLookupTableChanged : signal + Emits the HistogramLUTItem itself when the gradient changes + sigLevelsChanged : signal + Emits the HistogramLUTItem itself while the movable region is changing + sigLevelChangeFinished : signal + Emits the HistogramLUTItem itself when the movable region is finished changing + + See Also + -------- + :class:`~pyqtgraph.ImageItem` + HistogramLUTItem is most useful when paired with an ImageItem. + :class:`~pyqtgraph.ImageView` + Widget containing a paired ImageItem and HistogramLUTItem. + :class:`~pyqtgraph.HistogramLUTWidget` + QWidget containing a HistogramLUTItem for widget-based layouts. """ - + sigLookupTableChanged = QtCore.Signal(object) sigLevelsChanged = QtCore.Signal(object) sigLevelChangeFinished = QtCore.Signal(object) - - def __init__(self, image=None, fillHistogram=True, rgbHistogram=False, levelMode='mono', gradientPosition='right'): + + def __init__(self, image=None, fillHistogram=True, levelMode='mono', + gradientPosition='right', orientation='vertical'): GraphicsWidget.__init__(self) self.lut = None self.imageItem = lambda: None # fake a dead weakref self.levelMode = levelMode - self.rgbHistogram = rgbHistogram + self.orientation = orientation self.gradientPosition = gradientPosition - + + if orientation == 'vertical' and gradientPosition not in {'right', 'left'}: + self.gradientPosition = 'right' + elif orientation == 'horizontal' and gradientPosition not in {'top', 'bottom'}: + self.gradientPosition = 'bottom' + self.layout = QtGui.QGraphicsGridLayout() self.setLayout(self.layout) - self.layout.setContentsMargins(1,1,1,1) + self.layout.setContentsMargins(1, 1, 1, 1) self.layout.setSpacing(0) + self.vb = ViewBox(parent=self) - self.vb.setMaximumWidth(152) - self.vb.setMinimumWidth(45) - self.vb.setMouseEnabled(x=False, y=True) - self.gradient = GradientEditorItem() - self.gradient.setOrientation(gradientPosition) + if self.orientation == 'vertical': + self.vb.setMaximumWidth(152) + self.vb.setMinimumWidth(45) + self.vb.setMouseEnabled(x=False, y=True) + else: + self.vb.setMaximumHeight(152) + self.vb.setMinimumHeight(45) + self.vb.setMouseEnabled(x=True, y=False) + + self.gradient = GradientEditorItem(orientation=self.gradientPosition) self.gradient.loadPreset('grey') + + # LinearRegionItem orientation refers to the bounding lines + regionOrientation = 'horizontal' if self.orientation == 'vertical' else 'vertical' self.regions = [ - LinearRegionItem([0, 1], 'horizontal', swapMode='block'), - LinearRegionItem([0, 1], 'horizontal', swapMode='block', pen='r', + # single region for mono levelMode + LinearRegionItem([0, 1], regionOrientation, swapMode='block'), + # r/g/b/a regions for rgba levelMode + LinearRegionItem([0, 1], regionOrientation, swapMode='block', pen='r', brush=fn.mkBrush((255, 50, 50, 50)), span=(0., 1/3.)), - LinearRegionItem([0, 1], 'horizontal', swapMode='block', pen='g', + LinearRegionItem([0, 1], regionOrientation, swapMode='block', pen='g', brush=fn.mkBrush((50, 255, 50, 50)), span=(1/3., 2/3.)), - LinearRegionItem([0, 1], 'horizontal', swapMode='block', pen='b', + LinearRegionItem([0, 1], regionOrientation, swapMode='block', pen='b', brush=fn.mkBrush((50, 50, 255, 80)), span=(2/3., 1.)), - LinearRegionItem([0, 1], 'horizontal', swapMode='block', pen='w', - brush=fn.mkBrush((255, 255, 255, 50)), span=(2/3., 1.))] + LinearRegionItem([0, 1], regionOrientation, swapMode='block', pen='w', + brush=fn.mkBrush((255, 255, 255, 50)), span=(2/3., 1.)) + ] + self.region = self.regions[0] # for backward compatibility. for region in self.regions: region.setZValue(1000) self.vb.addItem(region) @@ -91,111 +142,160 @@ class HistogramLUTItem(GraphicsWidget): region.lines[1].addMarker('|>', 0.5) region.sigRegionChanged.connect(self.regionChanging) region.sigRegionChangeFinished.connect(self.regionChanged) - - self.region = self.regions[0] # for backward compatibility. - - self.axis = AxisItem('left', linkView=self.vb, maxTickLength=-10, parent=self) - self.layout.addItem(self.axis, 0, 0) - self.layout.addItem(self.vb, 0, 1) - pos = (0, 2) if gradientPosition == 'right' else (2, 0) - self.layout.addItem(self.axis, 0, pos[0]) - self.layout.addItem(self.gradient, 0, pos[1]) - self.range = None + + # gradient position to axis orientation + ax = {'left': 'right', 'right': 'left', + 'top': 'bottom', 'bottom': 'top'}[self.gradientPosition] + self.axis = AxisItem(ax, linkView=self.vb, maxTickLength=-10, parent=self) + + # axis / viewbox / gradient order in the grid + avg = (0, 1, 2) if self.gradientPosition in {'right', 'bottom'} else (2, 1, 0) + if self.orientation == 'vertical': + self.layout.addItem(self.axis, 0, avg[0]) + self.layout.addItem(self.vb, 0, avg[1]) + self.layout.addItem(self.gradient, 0, avg[2]) + else: + self.layout.addItem(self.axis, avg[0], 0) + self.layout.addItem(self.vb, avg[1], 0) + self.layout.addItem(self.gradient, avg[2], 0) + self.gradient.setFlag(self.gradient.ItemStacksBehindParent) self.vb.setFlag(self.gradient.ItemStacksBehindParent) - + self.gradient.sigGradientChanged.connect(self.gradientChanged) self.vb.sigRangeChanged.connect(self.viewRangeChanged) - add = QtGui.QPainter.CompositionMode_Plus + + comp = QtGui.QPainter.CompositionMode_Plus self.plots = [ PlotCurveItem(pen=(200, 200, 200, 100)), # mono - PlotCurveItem(pen=(255, 0, 0, 100), compositionMode=add), # r - PlotCurveItem(pen=(0, 255, 0, 100), compositionMode=add), # g - PlotCurveItem(pen=(0, 0, 255, 100), compositionMode=add), # b - PlotCurveItem(pen=(200, 200, 200, 100), compositionMode=add), # a - ] - + PlotCurveItem(pen=(255, 0, 0, 100), compositionMode=comp), # r + PlotCurveItem(pen=(0, 255, 0, 100), compositionMode=comp), # g + PlotCurveItem(pen=(0, 0, 255, 100), compositionMode=comp), # b + PlotCurveItem(pen=(200, 200, 200, 100), compositionMode=comp), # a + ] self.plot = self.plots[0] # for backward compatibility. for plot in self.plots: - plot.setRotation(90) + if self.orientation == 'vertical': + plot.setRotation(90) self.vb.addItem(plot) - + self.fillHistogram(fillHistogram) self._showRegions() - - self.vb.addItem(self.plot) + self.autoHistogramRange() - + if image is not None: self.setImageItem(image) - + def fillHistogram(self, fill=True, level=0.0, color=(100, 100, 200)): + """Control fill of the histogram curve(s). + + Parameters + ---------- + fill : bool, optional + Set whether or not the histogram should be filled. + level : float, optional + Set the fill level. See :meth:`PlotCurveItem.setFillLevel + `. Only used if ``fill`` is True. + color : color, optional + Color to use for the fill when the histogram ``levelMode == "mono"``. See + :meth:`PlotCurveItem.setBrush `. + """ colors = [color, (255, 0, 0, 50), (0, 255, 0, 50), (0, 0, 255, 50), (255, 255, 255, 50)] - for i,plot in enumerate(self.plots): + for color, plot in zip(colors, self.plots): if fill: plot.setFillLevel(level) - plot.setBrush(colors[i]) + plot.setBrush(color) else: plot.setFillLevel(None) - + def paint(self, p, *args): + # paint the bounding edges of the region item and gradient item with lines + # connecting them if self.levelMode != 'mono' or not self.region.isVisible(): return - + pen = self.region.lines[0].pen - rgn = self.getLevels() - p1 = self.vb.mapFromViewToItem(self, Point(self.vb.viewRect().center().x(), rgn[0])) - p2 = self.vb.mapFromViewToItem(self, Point(self.vb.viewRect().center().x(), rgn[1])) + + mn, mx = self.getLevels() + vbc = self.vb.viewRect().center() gradRect = self.gradient.mapRectToParent(self.gradient.gradRect.rect()) + if self.orientation == 'vertical': + p1mn = self.vb.mapFromViewToItem(self, Point(vbc.x(), mn)) + Point(0, 5) + p1mx = self.vb.mapFromViewToItem(self, Point(vbc.x(), mx)) - Point(0, 5) + if self.gradientPosition == 'right': + p2mn = gradRect.bottomLeft() + p2mx = gradRect.topLeft() + else: + p2mn = gradRect.bottomRight() + p2mx = gradRect.topRight() + else: + p1mn = self.vb.mapFromViewToItem(self, Point(mn, vbc.y())) - Point(5, 0) + p1mx = self.vb.mapFromViewToItem(self, Point(mx, vbc.y())) + Point(5, 0) + if self.gradientPosition == 'bottom': + p2mn = gradRect.topLeft() + p2mx = gradRect.topRight() + else: + p2mn = gradRect.bottomLeft() + p2mx = gradRect.bottomRight() + p.setRenderHint(QtGui.QPainter.Antialiasing) for pen in [fn.mkPen((0, 0, 0, 100), width=3), pen]: p.setPen(pen) - if self.gradientPosition == 'right': - p.drawLine(p1 + Point(0, 5), gradRect.bottomLeft()) - p.drawLine(p2 - Point(0, 5), gradRect.topLeft()) + + # lines from the linear region item bounds to the gradient item bounds + p.drawLine(p1mn, p2mn) + p.drawLine(p1mx, p2mx) + + # lines bounding the edges of the gradient item + if self.orientation == 'vertical': + p.drawLine(gradRect.topLeft(), gradRect.topRight()) + p.drawLine(gradRect.bottomLeft(), gradRect.bottomRight()) else: - p.drawLine(p1 + Point(0, 5), gradRect.bottomRight()) - p.drawLine(p2 - Point(0, 5), gradRect.topRight()) - p.drawLine(gradRect.topLeft(), gradRect.topRight()) - p.drawLine(gradRect.bottomLeft(), gradRect.bottomRight()) + p.drawLine(gradRect.topLeft(), gradRect.bottomLeft()) + p.drawLine(gradRect.topRight(), gradRect.bottomRight()) def setHistogramRange(self, mn, mx, padding=0.1): """Set the Y range on the histogram plot. This disables auto-scaling.""" - self.vb.enableAutoRange(self.vb.YAxis, False) - self.vb.setYRange(mn, mx, padding) - + if self.orientation == 'vertical': + self.vb.enableAutoRange(self.vb.YAxis, False) + self.vb.setYRange(mn, mx, padding) + else: + self.vb.enableAutoRange(self.vb.XAxis, False) + self.vb.setXRange(mn, mx, padding) + def autoHistogramRange(self): """Enable auto-scaling on the histogram plot.""" self.vb.enableAutoRange(self.vb.XYAxes) def setImageItem(self, img): - """Set an ImageItem to have its levels and LUT automatically controlled - by this HistogramLUTItem. + """Set an ImageItem to have its levels and LUT automatically controlled by this + HistogramLUTItem. """ self.imageItem = weakref.ref(img) img.sigImageChanged.connect(self.imageChanged) self._setImageLookupTable() self.regionChanged() self.imageChanged(autoLevel=True) - + def viewRangeChanged(self): self.update() - + def gradientChanged(self): if self.imageItem() is not None: self._setImageLookupTable() - + self.lut = None self.sigLookupTableChanged.emit(self) def _setImageLookupTable(self): if self.gradient.isLookupTrivial(): - self.imageItem().setLookupTable(None) #lambda x: x.astype(np.uint8)) + self.imageItem().setLookupTable(None) else: - self.imageItem().setLookupTable(self.getLookupTable) ## send function pointer, not the result + self.imageItem().setLookupTable(self.getLookupTable) def getLookupTable(self, img=None, n=None, alpha=None): - """Return a lookup table from the color gradient defined by this + """Return a lookup table from the color gradient defined by this HistogramLUTItem. """ if self.levelMode != 'mono': @@ -223,7 +323,7 @@ class HistogramLUTItem(GraphicsWidget): def imageChanged(self, autoLevel=False, autoRange=False): if self.imageItem() is None: return - + if self.levelMode == 'mono': for plt in self.plots[1:]: plt.setVisible(False) @@ -264,10 +364,10 @@ class HistogramLUTItem(GraphicsWidget): self.plots[i].setVisible(False) # make sure we are displaying the correct number of channels self._showRegions() - + def getLevels(self): """Return the min and max levels. - + For rgba mode, this returns a list of the levels for each channel. """ if self.levelMode == 'mono': @@ -277,37 +377,47 @@ class HistogramLUTItem(GraphicsWidget): if nch is None: nch = 3 return [r.getRegion() for r in self.regions[1:nch+1]] - + def setLevels(self, min=None, max=None, rgba=None): """Set the min/max (bright and dark) levels. - - Arguments may be *min* and *max* for single-channel data, or - *rgba* = [(rmin, rmax), ...] for multi-channel data. + + Parameters + ---------- + min : float, optional + Minimum level. + max : float, optional + Maximum level. + rgba : list, optional + Sequence of (min, max) pairs for each channel for 'rgba' mode. """ + if None in {min, max} and (rgba is None or None in rgba[0]): + raise ValueError("Must specify min and max levels") + if self.levelMode == 'mono': if min is None: min, max = rgba[0] - assert None not in (min, max) self.region.setRegion((min, max)) else: if rgba is None: - raise TypeError("Must specify rgba argument when levelMode != 'mono'.") - for i, levels in enumerate(rgba): - self.regions[i+1].setRegion(levels) - + rgba = 4*[(min, max)] + for levels, region in zip(rgba, self.regions[1:]): + region.setRegion(levels) + def setLevelMode(self, mode): - """ Set the method of controlling the image levels offered to the user. + """Set the method of controlling the image levels offered to the user. + Options are 'mono' or 'rgba'. """ - assert mode in ('mono', 'rgba') - + if mode not in {'mono', 'rgba'}: + raise ValueError(f"Level mode must be one of {{'mono', 'rgba'}}, got {mode}") + if mode == self.levelMode: return - + oldLevels = self.getLevels() self.levelMode = mode self._showRegions() - + # do our best to preserve old levels if mode == 'mono': levels = np.array(oldLevels).mean(axis=0) @@ -315,18 +425,18 @@ class HistogramLUTItem(GraphicsWidget): else: levels = [oldLevels] * 4 self.setLevels(rgba=levels) - + # force this because calling self.setLevels might not set the imageItem # levels if there was no change to the region item self.imageItem().setLevels(self.getLevels()) - + self.imageChanged() self.update() def _showRegions(self): for i in range(len(self.regions)): self.regions[i].setVisible(False) - + if self.levelMode == 'rgba': imax = 4 if self.imageItem() is not None: @@ -343,15 +453,15 @@ class HistogramLUTItem(GraphicsWidget): self.regions[0].setVisible(True) self.gradient.show() else: - raise ValueError("Unknown level mode %r" % self.levelMode) - + raise ValueError(f"Unknown level mode {self.levelMode}") + def saveState(self): return { 'gradient': self.gradient.saveState(), 'levels': self.getLevels(), 'mode': self.levelMode, } - + def restoreState(self, state): if 'mode' in state: self.setLevelMode(state['mode']) diff --git a/pyqtgraph/widgets/HistogramLUTWidget.py b/pyqtgraph/widgets/HistogramLUTWidget.py index 5259900c..1788bd2b 100644 --- a/pyqtgraph/widgets/HistogramLUTWidget.py +++ b/pyqtgraph/widgets/HistogramLUTWidget.py @@ -1,6 +1,7 @@ +# -*- coding: utf-8 -*- """ -Widget displaying an image histogram along with gradient editor. Can be used to adjust the appearance of images. -This is a wrapper around HistogramLUTItem +Widget displaying an image histogram along with gradient editor. Can be used to adjust +the appearance of images. This is a wrapper around HistogramLUTItem """ from ..Qt import QtGui, QtCore @@ -11,23 +12,30 @@ __all__ = ['HistogramLUTWidget'] class HistogramLUTWidget(GraphicsView): - - def __init__(self, parent=None, *args, **kargs): + """QWidget wrapper for :class:`~pyqtgraph.HistogramLUTItem`. + + All parameters are passed along in creating the HistogramLUTItem. + """ + + def __init__(self, parent=None, *args, **kargs): background = kargs.pop('background', 'default') GraphicsView.__init__(self, parent, useOpenGL=False, background=background) self.item = HistogramLUTItem(*args, **kargs) self.setCentralItem(self.item) - self.setSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Expanding) - self.setMinimumWidth(95) - + + self.orientation = kargs.get('orientation', 'vertical') + if self.orientation == 'vertical': + self.setSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Expanding) + self.setMinimumWidth(95) + else: + self.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred) + self.setMinimumHeight(95) def sizeHint(self): - return QtCore.QSize(115, 200) - - + if self.orientation == 'vertical': + return QtCore.QSize(115, 200) + else: + return QtCore.QSize(200, 115) def __getattr__(self, attr): return getattr(self.item, attr) - - -