Search code examples
pyqtpyqt5handlepyqtgraphroi

Pyqtgraph ROI Mirrors the Displayed Text


I want to display some text close to the handles of crosshair ROI. The text is mirrored and I don't know why or how to fix it.


The following code runs, where the class CrossHair is a slight modification of the CrosshairROI given at https://pyqtgraph.readthedocs.io/en/latest/_modules/pyqtgraph/graphicsItems/ROI.html#ROI. More precisely, all I did was setting lock aspect to be False and making another handle to deal with another direction.

import pyqtgraph as pg
from PyQt5.QtWidgets import*
from PyQt5.QtCore import*
from PyQt5.QtGui import*

class MainWindow(pg.GraphicsLayoutWidget):
    def __init__(self):
        super().__init__()
        
        layout = self.addLayout()
        self.viewbox = layout.addViewBox(lockAspect=True)
        self.viewbox.setLimits(minXRange = 200, minYRange = 200,maxXRange = 200,maxYRange = 200)
        
        self.crosshair = CrossHair()
        self.crosshair.setPen(pg.mkPen("w", width=5))
        self.viewbox.addItem(self.crosshair)

class CrossHair(pg.graphicsItems.ROI.ROI):
    def __init__(self, pos=None, size=None, **kargs):
        if size is None:
            size=[50,50]
        if pos is None:
            pos = [0,0]
        self._shape = None
        pg.graphicsItems.ROI.ROI.__init__(self, pos, size, **kargs)
        
        self.sigRegionChanged.connect(self.invalidate)      
        self.addScaleRotateHandle(pos =  pg.Point(1,0), center = pg.Point(0, 0))
        self.addScaleRotateHandle(pos = pg.Point(0,1), center = pg.Point(0,0))

    def invalidate(self):
        self._shape = None
        self.prepareGeometryChange()
        
    def boundingRect(self):
        return self.shape().boundingRect()
    
    def shape(self):
        if self._shape is None:
            x_radius, y_radius = self.getState()['size'][0],self.getState()['size'][1]
            p = QPainterPath()
            p.moveTo(pg.Point(-x_radius, 0))
            p.lineTo(pg.Point(x_radius, 0))
            p.moveTo(pg.Point(0, -y_radius))
            p.lineTo(pg.Point(0, y_radius))
            p = self.mapToDevice(p)
            stroker = QPainterPathStroker()
            stroker.setWidth(10)
            outline = stroker.createStroke(p)
            self._shape = self.mapFromDevice(outline)
        return self._shape
    
    def paint(self, p, *args):
        x_radius, y_radius = self.getState()['size'][0],self.getState()['size'][1]
        p.setRenderHint(QPainter.RenderHint.Antialiasing)
        p.setPen(self.currentPen)
        
        p.drawLine(pg.Point(0, -y_radius), pg.Point(0, y_radius))
        p.drawLine(pg.Point(-x_radius, 0), pg.Point(x_radius, 0))
        
        x_pos, y_pos = self.handles[0]['item'].pos(), self.handles[1]['item'].pos()
        x_length, y_length = 2*x_radius, 2*y_radius
        x_text, y_text = str(round(x_length,2)) + "TEXT",str(round(y_length,2)) + "TEXT"
            
        p.drawText(QRectF(x_pos.x()-50, x_pos.y()-50, 100, 100), Qt.AlignmentFlag.AlignLeft, x_text)
        p.drawText(QRectF(y_pos.x()-50, y_pos.y()-50, 100, 100), Qt.AlignmentFlag.AlignBottom, y_text)
        
if __name__ == '__main__':
    import sys
    app = QApplication(sys.argv)
    main = MainWindow()
    main.show()
    
    app.exec()

We see that:

enter image description here

The objective is to fix the above code such that:

  1. It displays texts dependent on the length of the line (2*radius) close to each handle without reflecting.
  2. The text is aligned close to the handle such that no matter how the user rotates the handle the text is readable (i.e. not upside down).

I am having great deal of trouble with the first part. The second part can probably be fixed by changing aligning policies but I don't know which one to choose .


Solution

  • The reason of the inversion is because the coordinate system of pyqtgraph is always vertically inverted: similarly to the standard convention of computer coordinates, the reference point in Qt is always considered at the top left of positive coordinates, with y > 0 going down instead of up.

    While, for general computer based imaging this is fine, it clearly doesn't work well for data imaging that is commonly based on standard Cartesian references (positive values of y are always "above"). And that's what pyqtgraph does by default.

    The result is that, for obvious reasons, basic drawing that is directly done on an active QPainter will always be vertically inverted ("mirrored"). What you show in the image is the result of a composition of vertical mirroring and rotation, which is exactly the same as horizontal mirroring.
    To simplify: when p is vertically mirrored, it becomes b, which, when rotated by 180°, results in q.

    There's also another issue: all pyqtgraph items are actually QGraphicsItem subclasses, and one of the most important aspects of QGraphicsItems is that their painting is and shall always be restricted by its boundingRect():

    [...] all painting must be restricted to inside an item's bounding rect. QGraphicsView uses this to determine whether the item requires redrawing.

    If you try to move the handles very fast, you'll probably see some drawing artifacts ("ghosts") in the text caused by the painting buffer that is used to improve drawing performance, and that's because you didn't consider those elements in the boundingRect() override: the painting engine didn't know that the bounding rect was actually bigger, and didn't consider that the previously drawn regions required repainting in order to "clear up" the previous content.

    Now, since those are text displaying objects, I doubt that you're actually interested in having them always aligned to their respective axis (which is not impossible, but much more difficult). You will probably want to always display the values of those handles to the user in an easy, readable way: horizontally.

    Considering the above, the preferred solution is to use child items for the text instead of manually drawing it. While, at first sight, it might seem a risk for performance and further complication, it actually ensures 2 aspects:

    • the text items will always be properly repainted, and without any "ghost residue" caused by the wrong bounding rect;
    • the performance loss is practically little to none, since item management (including painting) is completely done on the C++ side;

    For that, I'd suggest the pg.TextItem class, which will also completely ignore any kind of transformation, ensuring that the text will always be visible no matter of the scale factor.

    Note that "mirroring" is actually the result of a transformation matrix that uses negative scaling: a scaling of (0, -1) means that the coordinates are vertically mirrored. If you think about it, it's quite obvious: if you have a positive y value in a cartesian system (shown "above" the horizontal axis) and multiply it by -1, that result is then shown "below".

    Given the above, what you need to do is to add the two "labels" as children of the handle items, and just worry about painting the two crosshair lines.

    Finally, due to the general performance requirements of pyqtgraph (and QGraphicsView in general), in the following example I took the liberty to make some modifications to the original code in order to improve responsiveness:

    class CrossHair(pg.graphicsItems.ROI.ROI):
        _shape = None
        def __init__(self, pos=None, size=None, **kargs):
            if size is None:
                size = [50, 50]
            if pos is None:
                pos = [0, 0]
            super().__init__(pos, size, **kargs)
            
            self.sigRegionChanged.connect(self.invalidate)
    
            font = QFont()
            font.setPointSize(font.pointSize() * 2)
            self.handleLabels = []
            for refPoint in (QPoint(1, 0), QPoint(0, 1)):
                handle = self.addScaleRotateHandle(pos=refPoint, center=pg.Point())
                handle.xChanged.connect(self.updateHandleLabels)
                handle.yChanged.connect(self.updateHandleLabels)
    
                handleLabel = pg.TextItem(color=self.currentPen.color())
                handleLabel.setParentItem(handle)
                handleLabel.setFont(font)
                self.handleLabels.append(handleLabel)
    
            self.updateHandleLabels()
    
        def updateHandleLabels(self):
            for label, value in zip(self.handleLabels, self.state['size']):
                label.setText(format(value * 2, '.2f'))
    
        def invalidate(self):
            self._shape = None
            self.prepareGeometryChange()
            
        def boundingRect(self):
            return self.shape().boundingRect()
        
        def shape(self):
            if self._shape is None:
                x_radius, y_radius = self.state['size']
                p = QPainterPath(QPointF(-x_radius, 0))
                p.lineTo(QPointF(x_radius, 0))
                p.moveTo(QPointF(0, -y_radius))
                p.lineTo(QPointF(0, y_radius))
                p = self.mapToDevice(p)
                stroker = QPainterPathStroker()
                stroker.setWidth(10)
                outline = stroker.createStroke(p)
                self._shape = self.mapFromDevice(outline)
            return self._shape
        
        def paint(self, p, *args):
            p.setRenderHint(QPainter.Antialiasing)
            p.setPen(self.currentPen)
            x_radius, y_radius = self.state['size']
            p.drawLine(QPointF(0, -y_radius), QPointF(0, y_radius))
            p.drawLine(QPointF(-x_radius, 0), QPointF(x_radius, 0))
    

    Notes:

    • pg.Point is actually a subclass of QPointF; unlike helper functions like mkColor() that can be actually necessary for pg objects and are effective in their simplicity/readability, there is really no point (pun intended) to use those subclasses for basic Qt functions, like you're doing for paintEvent(); just use the basic class;
    • considering the point above, always try to leave object conversion on the C++ side; QPainterPath's moveTo and lineTo always accept floating point values (they are overloaded functions that internally transform the values to QPointF objects); on the other hand, QPainter functions like drawLine only accept individual numeric values as integers (that's why I used QPointF in paintEvent()), so in that case you cannot directly use the coordinate values; always look for the C++ implementation and the accepted value types;
    • self.getState()['size'] is already a two-item tuple (width and height), retrieving it twice is unnecessary; also, since getState() actually recalls the internal self.state dict, you can avoid the function call (as I did above) as long as getState() is not overridden by a custom subclass;