Search code examples
pythonqtpyqt5qtstylesheets

How to make a beautiful neon effect in Qt?


I want to make a beautiful and juicy neon effect with the ability to control the power of light. For this, I built such code

import sys
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *


class Window(QWidget):
    def __init__(self):
        super().__init__()

        self.resize(800, 800)

        self.setStyleSheet('background:black;')


        mainLayout = QVBoxLayout(self)
        mainLayout.setContentsMargins(0, 0, 0, 0)

        color_1 = '162, 162, 162,'
        color_2 = '255, 255, 255,'
        color_3 = '0, 255, 255,'

        d_ = 1

        power = int(255/100*d_)

        for x in range(6):
            label = QLabel(self)


            color_L = color_1
            glass_L = 255
            size_L = 60
            blut_L = 0


            label.raise_()

            if x < 1 :
                color_L = color_1
            elif x < 2 :
                color_L = color_3
                glass_L = power
            elif x < 3 :
                color_L = color_2
                blut_L = 6
                glass_L = power
            elif x < 4:
                color_L = color_2
                blut_L = 40
                glass_L = power
            elif x < 5 :
                label.lower()
                color_L = color_3
                blut_L = 40
                size_L = 70
                glass_L = power
            elif x < 6 :
                label.lower()
                color_L = color_3
                blut_L = 150
                size_L = 70
                glass_L = power

            label.setText('test')
            label.setStyleSheet('background:rgba(0, 0, 0, 0);color:rgba({} {}); font-size:{}px;'.format(color_L, glass_L,size_L))
            label.resize(self.width(), self.height())
            label.setAlignment(Qt.AlignCenter)

            self.effect = QGraphicsBlurEffect(blurRadius=blut_L)
            label.setGraphicsEffect(self.effect)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    w = Window()
    w.show()
    sys.exit(app.exec_())

but the code is too cumbersome. And the light turned out to be too unnaturalistic,

enter image description here

it looks especially bad if you indicate a weak light intensity.

Are there any better options to make a neon effect? or why does he look so bad?


Solution

  • Well, I eventually decided it was fun to do it :-)

    IMPORTANT: Consider that this is some sort of a hack, because it uses a private and undocumented function of Qt (which is the same used by QGraphicsBlurEffect) and it's not guaranteed it will work everywhere.
    I've been able to achieve it by borrowing some code from a Live tv viewer known as Atropine, the interesting parts is in the effects.py source.

    Note that a similar effect can probably be achieved by using "abstract" drawing of a private QGraphicsScene within the QGraphicsEffect itself, but it would be much more slower (since you'll have to create new QGraphicsPixmapItems each time the draw() method of the effect is called) and would probably have some side effects.

    The trick was to get the function name through ctypes, I've only been able to find the exported function names in Linux and Windows (but I wasn't able to test it there):

        # Linux:
        $ nm -D /usr/lib/libQt5Widgets.so |grep qt_blurImage
        004adc30 T _Z12qt_blurImageP8QPainterR6QImagedbbi
        004ae0e0 T _Z12qt_blurImageR6QImagedbi
    
        # Windows (through Mingw):
        > objdump -p /QtGui4.dll |grep blurImage
            [8695] ?qt_blurImage@@YAXAAVQImage@@N_NH@Z
            [8696] ?qt_blurImage@@YAXPAVQPainter@@AAVQImage@@N_N2H@Z
    

    I cannot do testing for MacOs, but I think that it should be the same command line as it's on linux.

    If you don't have GNU binutils on Windows, use the dumpbin utility.

    Note that these names seem to be somehow consistent along the same operating system and Qt versions: I run the same nn command on the old libQtGui.so file for Qt4 and it gave the same result.

    Yet, on Windows, the string has slightly changed since some version of Qt5 (I'm not sure which one, but probably after 5.8) and seems still valid in Qt6:

    ?qt_blurImage@@YAXPEAVQPainter@@AEAVQImage@@N_N2H@Z


    So, here's your beautiful neon effect...

    a wonderful neon effect!

    And here's the code, I've added an example program to test it:

    import sip
    import ctypes
    from PyQt5.QtCore import *
    from PyQt5.QtGui import *
    from PyQt5.QtWidgets import *
    
    if sys.platform == 'win32':
        # the exported function name has illegal characters on Windows, let's use
        # getattr to access it
        _qt_blurImage  = getattr(ctypes.CDLL('Qt5Widgets.dll'), 
            '?qt_blurImage@@YAXPAVQPainter@@AAVQImage@@N_N2H@Z')
    else:
        try:
            qtgui = ctypes.CDLL('libQt5Widgets.so')
        except:
            qtgui = ctypes.CDLL('libQt5Widgets.so.5')
        _qt_blurImage = qtgui._Z12qt_blurImageP8QPainterR6QImagedbbi
    
    
    class NeonEffect(QGraphicsColorizeEffect):
        _blurRadius = 5.
        _glow = 2
    
        def glow(self):
            return self._glow
    
        @pyqtSlot(int)
        def setGlow(self, glow):
            if glow == self._glow:
                return
            self._glow = max(1, min(glow, 10))
            self.update()
    
        def blurRadius(self):
            return self._blurRadius
    
        @pyqtSlot(int)
        @pyqtSlot(float)
        def setBlurRadius(self, radius):
            if radius == self._blurRadius:
                return
            self._blurRadius = max(1., float(radius))
            self.update()
    
        def applyBlurEffect(self, blurImage, radius, quality, alphaOnly, transposed=0, qp=None):
            blurImage = ctypes.c_void_p(sip.unwrapinstance(blurImage))
            radius = ctypes.c_double(radius)
            quality = ctypes.c_bool(quality)
            alphaOnly = ctypes.c_bool(alphaOnly)
            transposed = ctypes.c_int(transposed)
            if qp:
                qp = ctypes.c_void_p(sip.unwrapinstance(qp))
            _qt_blurImage(qp, blurImage, radius, quality, alphaOnly, transposed)
    
        def draw(self, qp):
            pm, offset = self.sourcePixmap(Qt.LogicalCoordinates, self.PadToEffectiveBoundingRect)
            if pm.isNull():
                return
    
            # use a double sized image to increase the blur factor
            scaledSize = QSize(pm.width() * 2, pm.height() * 2)
            blurImage = QImage(scaledSize, QImage.Format_ARGB32_Premultiplied)
            blurImage.fill(0)
            blurPainter = QPainter(blurImage)
            blurPainter.drawPixmap(0, 0, pm.scaled(scaledSize, 
                Qt.KeepAspectRatio, Qt.SmoothTransformation))
            blurPainter.end()
            
            # apply the blurred effect on the image
            self.applyBlurEffect(blurImage, 1 * self._blurRadius, True, False)
    
            # start the painter that will use the previous image as alpha
            tmpPainter = QPainter(blurImage)
            # using SourceIn composition mode we use the existing alpha values
            # to paint over
            tmpPainter.setCompositionMode(tmpPainter.CompositionMode_SourceIn)
            color = QColor(self.color())
            color.setAlpha(color.alpha() * self.strength())
            # fill using the color
            tmpPainter.fillRect(pm.rect(), color)
            tmpPainter.end()
            
            # repeat the effect which will make it more "glowing"
            for g in range(self._glow):
                qp.drawImage(0, 0, blurImage.scaled(pm.size(), 
                    Qt.KeepAspectRatio, Qt.SmoothTransformation))
    
            super().draw(qp)
    
    
    class NeonTest(QWidget):
        def __init__(self):
            super().__init__()
            layout = QGridLayout(self)
    
            palette = self.palette()
            palette.setColor(palette.Window, Qt.black)
            palette.setColor(palette.WindowText, Qt.white)
            self.setPalette(palette)
    
            
            self.label = QLabel('NEON EFFECT')
            layout.addWidget(self.label, 0, 0, 1, 2)
            self.label.setPalette(QApplication.palette())
            self.label.setContentsMargins(20, 20, 20, 20)
            f = self.font()
            f.setPointSizeF(48)
            f.setBold(True)
            self.label.setFont(f)
            self.effect = NeonEffect(color=QColor(152, 255, 250))
            self.label.setGraphicsEffect(self.effect)
            self.effect.setBlurRadius(40)
    
            layout.addWidget(QLabel('blur radius'))
            radiusSpin = QDoubleSpinBox(minimum=1, maximum=100, singleStep=5)
            layout.addWidget(radiusSpin, 1, 1)
            radiusSpin.setValue(self.effect.blurRadius())
            radiusSpin.valueChanged.connect(self.effect.setBlurRadius)
    
            layout.addWidget(QLabel('glow factor'))
            glowSpin = QSpinBox(minimum=1, maximum=10)
            layout.addWidget(glowSpin, 2, 1)
            glowSpin.setValue(self.effect.glow())
            glowSpin.valueChanged.connect(self.effect.setGlow)
    
            layout.addWidget(QLabel('color strength'))
            strengthSpin = QDoubleSpinBox(minimum=0, maximum=1, singleStep=.05)
            strengthSpin.setValue(1)
            layout.addWidget(strengthSpin, 3, 1)
            strengthSpin.valueChanged.connect(self.effect.setStrength)
    
            colorBtn = QPushButton('color')
            layout.addWidget(colorBtn, 4, 0)
            colorBtn.clicked.connect(self.setColor)
    
            self.aniBtn = QPushButton('play animation')
            layout.addWidget(self.aniBtn, 4, 1)
            self.aniBtn.setCheckable(True)
    
            self.glowAni = QVariantAnimation(duration=1000)
            self.glowAni.setStartValue(1)
            self.glowAni.setEndValue(10)
            self.glowAni.setEasingCurve(QEasingCurve.InQuad)
            self.glowAni.valueChanged.connect(glowSpin.setValue)
            self.glowAni.finished.connect(self.animationFinished)
    
            self.aniBtn.toggled.connect(self.glowAni.start)
    
        def animationFinished(self):
            if self.aniBtn.isChecked():
                self.glowAni.setDirection(not self.glowAni.direction())
                self.glowAni.start()
    
        def setColor(self):
            color = QColorDialog.getColor(self.effect.color(), self)
            if color.isValid():
                self.effect.setColor(color)
    
    
    if __name__ == '__main__':
        import sys
        app = QApplication(sys.argv)
        test = NeonTest()
        test.show()
        sys.exit(app.exec())
    

    Remember that this answer was provided for Qt5: since Qt6, PyQt (and, later, PySide) switched to actual Python types for flags and enums, so you shall use full namespaces for all of them (for instance, Qt.GlobalColor.black, etc.).

    Note that, based on my tests, using a glow factor higher than 1 with a blur radius less than 4 might result in some painting artifacts.

    Also, some care has to be taken with the palette. If you want, for example, apply the effect to a QLineEdit, you'll probably also want to set the QPalette.Base to transparent:

    a glowing line edit!

    I've been able to test it successfully on two Linux machines (with Qt 5.7 and 5.12), if anyone would care to comment about testing on other platforms I'll be glad to update this answer accordingly.