Repository: pyqtgraph/pyqtgraph

Issue:
HistogramLUTItem should support multiple orientations
Right now, `HistogramLUTItem` really only makes sense to put on the right edge of a layout. It would be really nice if `HistogramLUTItem.__init__()` took an orientation parameter (i.e., `'left'`, `'right'`, `'top'`, `'bottom'`; default to `'right'`) that made it have a layout appropriate for docking to that side of an image.

My use case is that my images are short and wide; I'd really like to put the histogram beneath them to
more efficiently use screen space.



Discussion:
I am running into this need as well.

Resolving patch:
diff --git a/examples/NonUniformImage.py b/examples/NonUniformImage.py
--- a/examples/NonUniformImage.py
+++ b/examples/NonUniformImage.py
@@ -52,10 +52,11 @@
 
 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
--- 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 @@
 
 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 @@ def __init__(self, image=None, fillHistogram=True, rgbHistogram=False, levelMode
             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
+            <pyqtgraph.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 <pyqtgraph.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 @@ def regionChanging(self):
     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 @@ def imageChanged(self, autoLevel=False, autoRange=False):
                     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 @@ def getLevels(self):
             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 @@ def setLevelMode(self, mode):
         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 @@ def _showRegions(self):
             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
--- 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 @@
 
 
 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)
-
-
-


Test patch: