PyQt drawing a QPixmap along QPainterPath

499 views Asked by At

I want to draw patterns (preferable as QPixmap) along a QGraphicsPathItem inside my QGraphicsView. This should work similar to filling a QBrush with a texture by using QBrush.setTexture("example.png"). Is there a way to do this on a QPen? I couldn't find anything related to this in the PyQt documentation.

The texture should be placed similar to this example picture

It doesn't matter if the texture keeps it's own width or is scaled to QPen's width.

Is there some form of workaround to implement this? I was thinking about using QTransform in a way to transform QPixmap to the shape of the desired QGraphicsPathItem.

2

There are 2 answers

1
bravestarr On

I still haven't found a statisfying solution. But for people that dont require the pattern to be aligned along the path, you can use this piece of code for your QPen:

pen = QPen()
patternBrush = QBrush(QPixmap('patter.png'))
pen.setBrush(patternBrush)
pen.setWidth(5)

It fills your QGraphicsPathItem like this

I hope this is helpful for others.

0
musicamante On

Long story short, there is no easy way to achieve this, especially if the requirement is to use a QPixmap.

This doesn't mean that it is completely impossible, but doing it using the common Qt capabilities is quite hard.

What you want to achieve is fundamentally a "texture mapping". Even if it's done on a two-dimensional surface (which can be easily done by using simple transformations), doing it for curved paths requires much more complex computations. While simple curves (like circles and ellipses) may be easily achieved with relatively simple matrix transforms, those curves are actually simplifications of extremely complex math problems that will eventually rise in case of quadratic or cubic curves.

See the related question How can I project a rectangular texture into a region of curved shape?, in which the OP explains that this can be achieved by using thin-plate spline transformations. Qt doesn't provide means to do this, so you'd need to rely on external libraries: it seems that the scikit-image module may introduce such feature (see issue 2429, but this is still done from Python, which could result in a very slow processing for complex paths, since each pattern of each curve needs to be properly mapped.

Still, there is a possibility, as long as the pattern is simple as the one in the question, all that can be simplified into a mono-dimensional structure (a linear sequence of colors with specified widths): the given pattern can be then interpreted as a dashed line that alternates two colors, each one being 15 pixels wide.

In this case, the trick is to properly use QPen capabilities to show "dash patterns", which makes the whole approach much easier, even if not completely foolproof. Just remember that dash patterns are based on the pen width, so their actual extent needs to be divided by it.

The concept is based on a main QGraphicsPathItem that uses a dash pattern to draw its main segments (the "first color" of the pattern) and overrides its paint() method in order to draw the remaining ones by setting their relative dash pattern and an offset based on the previously drawn dashes.

Note that this approach is far from perfect, since drawing is done in layers and complex paths may create graphical artifacts since the overimposed "dash lines" are always drawn one above the other.

Yet, it can provide acceptable results for simple cases, and some level of partial improvement is still possible.

The following example class takes a few possible arguments: other than the default ones QGraphicsPathItem requires, it also accepts an iterable for the "pattern" and a "pen width", which is fundamental to keep drawing consistent.

The "pattern" is made of pairs of colors and extents, indicating the "size" of each color within the drawing result.

Then, whenever more than a color/size pair is set, it draws further "dashed paths" above the main one.

class PatternPath(QGraphicsPathItem):
    _pattern = None
    def __init__(self, path=None, penWidth=5, pattern=None, parent=None):
        super().__init__(path, parent)
        self.penWidth = max(1, penWidth)
        self.otherPens = []
        self.setPattern(pattern)

    def setPattern(self, pattern):
        if not pattern:
            pattern = [(Qt.black, 1)]
        if self._pattern != pattern:
            self._pattern = pattern
            if len(pattern) == 1:
                self.setPen(QPen(pattern[0], self.penWidth, cap=Qt.FlatCap))
                return

            patternSize = 0
            for _, size in pattern:
                patternSize += size
            patternSize /= self.penWidth

            self.otherPens.clear()
            color, size = pattern[0]
            size /= self.penWidth
            basePen = QPen(color, self.penWidth, cap=Qt.FlatCap)
            basePen.setDashPattern((size, patternSize - size))
            self.setPen(basePen)

            delta = size
            for color, size in pattern[1:]:
                size /= self.penWidth
                pen = QPen(color, self.penWidth, cap=Qt.FlatCap)
                pen.setDashPattern((size, patternSize - size))
                pen.setDashOffset(delta)
                delta += size
                self.otherPens.append(pen)

    def paint(self, qp, opt, widget=None):
        super().paint(qp, opt, widget)
        if self.otherPens:
            path = self.path()
            for pen in self.otherPens:
                qp.setPen(pen)
                qp.drawPath(path)

Here is a simple example that randomly creates paths and patterns in order to show the result:

from random import randrange, choice
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *

pathFuncs = (
    (QPainterPath.lineTo, 1), 
    (QPainterPath.quadTo, 2), 
    (QPainterPath.cubicTo, 3)
)


def createPath(count=5, rect=QRectF(0, 0, 500, 500)):
    xRange = (rect.x(), rect.width())
    yRange = (rect.y(), rect.height())
    randPoint = lambda: QPointF(randrange(*xRange), randrange(*yRange))
    path = QPainterPath(randPoint())
    for i in range(count):
        func, pointCount = choice(pathFuncs)
        func(path, *(randPoint() for _ in range(pointCount)))
    return path

def createPattern(count=2, colorSize=5):
    colorData = []
    for i in range(max(1, count)):
        colorData.append((
            QColor(randrange(255), randrange(255), randrange(255)),
            colorSize
        ))
    return colorData


class PatternPath(QGraphicsPathItem):
    # as above


if __name__ == '__main__':
    import sys
    app = QApplication(sys.argv)
    scene = QGraphicsScene()
    view = QGraphicsView(scene)
    view.setRenderHint(QPainter.Antialiasing)
    pathItem = PatternPath(createPath(2), penWidth=25, 
        pattern=createPattern(4, colorSize=15))
    scene.addItem(pathItem)
    view.resize(600, 600)
    view.show()
    sys.exit(app.exec())

Here is a possible result:

screenshot of a test

As you can see, there are some issues near the bottom right corner, caused by the superimposed drawing explained above. This is clearer for a more complex path:

screenshot of another test

The above image clearly shows the issues related to "layered" painting.

Still, if the paths you're drawing are not that complex, the solution is quite acceptable considering its speed and results.

It may be possible to partially improve the result by splitting the overall path in smaller ones, but you need to consider partial dash offsets and potential issues with alpha channels or pen styles/caps.

About the last point, as you can see, I always used the FlatCap mode, which is required for proper "multi-line" approaches like this. This obviously means that using SquareCap or RoundCap styles will also create drawing issues, so these aspects must be taken into account.