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:

    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: