How to mask an image with a smooth circle in PyQt5

I am currently working on a PyQt5 based user manager and want to display user images as circle.

User image can have any size and aspect ratio, so I also need to crop them before applying the mask:

Crop image and apply a mask to make it a circle

You can easily set a QPixmap to a QLabel and show it in your widget. However, it took me hours to find out how to mask an image with a smooth, anti-aliased circle.

If you search for this problem, you’ll find some (relatively old) StackOverflow questions and topics in Qt5 forums, but none of the proposed solutions worked for me.

The following snipped does:

  • crop the original image to a square
  • mask it with a smooth, anti-aliased circle
  • rescale it to the required size
  • take care of Retina / high DPI displays
from PyQt5.QtCore import Qt, QRect
from PyQt5.QtGui import QBrush, QImage, QPainter, QPixmap, QWindow
from PyQt5.QtWidgets import QLabel, QVBoxLayout, QWidget


def mask_image(imgdata, imgtype='jpg', size=64):
    """Return a ``QPixmap`` from *imgdata* masked with a smooth circle.

    *imgdata* are the raw image bytes, *imgtype* denotes the image type.

    The returned image will have a size of *size* × *size* pixels.

    """
    # Load image and convert to 32-bit ARGB (adds an alpha channel):
    image = QImage.fromData(imgdata, imgtype)
    image.convertToFormat(QImage.Format_ARGB32)

    # Crop image to a square:
    imgsize = min(image.width(), image.height())
    rect = QRect(
        (image.width() - imgsize) / 2,
        (image.height() - imgsize) / 2,
        imgsize,
        imgsize,
    )
    image = image.copy(rect)

    # Create the output image with the same dimensions and an alpha channel
    # and make it completely transparent:
    out_img = QImage(imgsize, imgsize, QImage.Format_ARGB32)
    out_img.fill(Qt.transparent)

    # Create a texture brush and paint a circle with the original image onto
    # the output image:
    brush = QBrush(image)        # Create texture brush
    painter = QPainter(out_img)  # Paint the output image
    painter.setBrush(brush)      # Use the image texture brush
    painter.setPen(Qt.NoPen)     # Don't draw an outline
    painter.setRenderHint(QPainter.Antialiasing, True)  # Use AA
    painter.drawEllipse(0, 0, imgsize, imgsize)  # Actually draw the circle
    painter.end()                # We are done (segfault if you forget this)

    # Convert the image to a pixmap and rescale it.  Take pixel ratio into
    # account to get a sharp image on retina displays:
    pr = QWindow().devicePixelRatio()
    pm = QPixmap.fromImage(out_img)
    pm.setDevicePixelRatio(pr)
    size *= pr
    pm = pm.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)

    return pm


class Window(QWidget):
    """Simple window that shows our masked image and text label."""
    def __init__(self, imgpath):
        super().__init__()

        imgdata = open(imgpath, 'rb').read()
        pixmap = mask_image(imgdata)
        ilabel = QLabel()
        ilabel.setPixmap(pixmap)
        tlabel = QLabel('Hello, world!')

        layout = QVBoxLayout()
        layout.addWidget(ilabel, 0, Qt.AlignCenter)
        layout.addWidget(tlabel, 0, Qt.AlignCenter)
        self.setLayout(layout)


if __name__ == '__main__':
    import sys
    from PyQt5.QtWidgets import QApplication

    app = QApplication(sys.argv)
    w = Window(imgpath=sys.argv[1])
    w.show()
    sys.exit(app.exec_())

And this is how the result looks like:

Screenshot of simple PyQt5 app showing our masked image

I also toyed around with QPainter.setCompositionMode() and QPixmap.setMask() but both approaches did not lead to the desired results.

If you have found a better solution for this problem, please let me know via Mastodon or Twitter.