PyQt5桌面应用系列

PyQt5 与艺术

AI时代,计算创作艺术已经不是什么新闻。在前AI时代,为了讨好女朋友,秃子们也还是经常努力一下,比如用Turbo
C编个程序,显示一些闪来闪去一亮一亮的文字,效果不怎么样!别问我为什么知道。当时作为大一生的C语言老师,我指导了很多份这样的手工作品,但是班里成双结对的都是哪些Hello world!勉强能打印出来的帅小伙……

而今秃不秃什么的已经毫无心理波动,初心还是没有改变。那就来一次艺术与PyQt5的碰撞。

先看作品:

PyQt5桌面应用开发(13):QGraphicsView框架-LMLPHP

这个充满后现代主义、透着一种小清新、又带有亿点点叛逆的作品就是这次的快200多行代码的成果。当数量增加到20000后后,更有一种残酷感觉,一种欲言又止的感觉。Oh man, 那就是我的青春啊……

PyQt5桌面应用开发(13):QGraphicsView框架-LMLPHP

code

下面就是代码。

import random
import sys

from PyQt5.QtCore import Qt, QThread, pyqtSignal, pyqtSlot, QRectF
from PyQt5.QtGui import QColor, QFont, QPen, QFontDatabase, QPainter, QTransform
from PyQt5.QtGui import QImage, QKeyEvent, QKeySequence, QResizeEvent
from PyQt5.QtWidgets import QApplication, QGraphicsView, QGraphicsScene, QFileDialog, QMessageBox, QMainWindow
from PyQt5.QtWidgets import QInputDialog, qApp, QProgressBar, QGraphicsTextItem, QLabel


# def transform2str(tt: QTransform):
#     return f"""[{tt.m11():5.2f} {tt.m12():5.2f} {tt.m13():5.2f}\n{tt.m21():5.2f} {tt.m22():5.2f} {tt.m23():5.2f}\n{tt.m31():5.2f} {tt.m32():5.2f} {tt.m33():5.2f}]"""


class Worker(QThread):
    finished = pyqtSignal()
    tick_progress = pyqtSignal(int, tuple)
    start_tick = pyqtSignal(int)

    def __init__(self, n: int, r: QRectF):
        super(Worker, self).__init__()
        self.n = n
        self.r = r

    def run(self) -> None:
        self.start_tick.emit(self.n)
        for i in range(self.n):
            x, y = random.uniform(self.r.x(), self.r.x() + self.r.width()), \
                random.uniform(self.r.y(), self.r.y() + self.r.height())

            self.tick_progress.emit(i + 1, (
                random.randint(8, 28),  # fontSize
                chr(random.randint(ord('A'), ord('z'))),  # character
                random.randint(0, 255),  # red
                random.randint(0, 255),  # green
                random.randint(0, 255),  # blue
                random.randint(0, 255),  # alpha
                random.randint(0, 360),  # rotation
                x, y
            ))
        self.finished.emit()


class TextArtScene(QGraphicsScene):
    progress = pyqtSignal(int)

    def __init__(self, parent=None):
        super(TextArtScene, self).__init__(parent)
        self.fonts = None
        self.text_count = None
        self.fonts_choice = None

        self.setSceneRect(-200, -200, 400, 400)

    @pyqtSlot(int)
    def start_fill_text(self, n: int):
        self.clear()
        self.progress.emit(0)
        self.text_count = n
        self.fonts = [QFont(f) for f in QFontDatabase().families()]
        self.fonts_choice = random.choices(self.fonts, k=n)

    @pyqtSlot(int, tuple)
    def fill_text(self, i: int, text_property: tuple):
        font = self.fonts_choice[i - 1]
        progress = (i * 100) // self.text_count

        ps, text, red, green, blue, alpha, angle, x, y = text_property

        font.setPointSize(ps)
        text: QGraphicsTextItem = self.addText(text, font)
        text.setDefaultTextColor(QColor(red,
                                        green,
                                        blue,
                                        alpha))
        text.setRotation(angle)
        text.moveBy(x, y)

        # print(transform2str(text.sceneTransform()))

        self.progress.emit(progress)

        [current_view.viewport().update() for current_view in self.views()]

        if i == self.text_count:
            self.addRect(self.sceneRect(), QPen(QColor(255, 0, 0, 150), 4))
            self.progress.emit(100)


class TextArtGraphicsView(QGraphicsView):
    file_saved = pyqtSignal(str)

    def __init__(self, parent=None):
        super(TextArtGraphicsView, self).__init__(parent)

        self.setBackgroundBrush(QColor(125, 200, 0, 100))
        self.is_generating_in_progress = False

        n = int(400 * 1.618)
        self.setMinimumSize(n, n)
        self.count_of_text = 250

        self.worker = None
        self.progress_bar = QProgressBar(self)
        self.progress_bar.setRange(0, 100)
        self.progress_bar.setValue(0)
        self.progress_bar.setAlignment(Qt.AlignCenter)
        self.progress_bar.hide()

        self.text_scene = TextArtScene(self)
        self.setScene(self.text_scene)

        self.help_info = QLabel(self.info, self)
        self.help_info.setStyleSheet("color: darkgray;")

        self.file_saved.connect(self.show_saved_file)

    @property
    def info(self):
        return f"当前数目:{self.count_of_text}。 q: 退出;c/Space: 设置数目;r/F5: 生成;s/C-S:存储图片, ijkl↑↓←→h:自行探索。"

    @property
    def available(self):
        return not self.is_generating_in_progress

    @property
    def available(self):
        return not self.is_generating_in_progress

    def _request_textart_regeneration(self):
        if self.available:
            self.is_generating_in_progress = True

            self.worker = Worker(self.count_of_text, self.text_scene.sceneRect())
            self.worker.finished.connect(self.finish_textart_generation)
            self.worker.start_tick.connect(self.text_scene.start_fill_text)
            self.worker.tick_progress.connect(self.text_scene.fill_text)
            self.worker.start()

            self.text_scene.progress.connect(self.progress_bar.setValue)
            self.progress_bar.show()
            self.resetTransform()

    @pyqtSlot()
    def finish_textart_generation(self):
        self.is_generating_in_progress = False
        self.progress_bar.hide()

    @pyqtSlot(str)
    def show_saved_file(self, fn: str):
        QMessageBox.information(self, "Saved", f"<a href='{fn}'>{fn}</a>\nsaved.")

    def resizeEvent(self, event: QResizeEvent):
        self.progress_bar.setFixedWidth(event.size().width() + 2)
        self.help_info.setGeometry(10, self.height() - self.help_info.height(), self.width(), self.help_info.height())
        super(TextArtGraphicsView, self).resizeEvent(event)

    def keyPressEvent(self, event: QKeyEvent) -> None:
        if event.key() == Qt.Key_C or event.key() == Qt.Key_Space:
            count, flag = QInputDialog.getInt(self, "Count of QTextItem", "Please set an integer", self.count_of_text)
            if flag:
                self.count_of_text = count
                self.help_info.setText(self.info)

        elif event.matches(QKeySequence.Cancel) or event.key() == Qt.Key_Q:
            qApp.closeAllWindows()

        elif event.matches(QKeySequence.Refresh) or event.key() == Qt.Key_R:
            self._request_textart_regeneration()

        elif event.matches(QKeySequence.Save) or event.key() == Qt.Key_S:
            self.export_png()
        elif event.key() == Qt.Key_Left:
            self.rotate(-4)
            self.centerOn(0.0, 0.0)
        elif event.key() == Qt.Key_Right:
            self.rotate(4)
            self.centerOn(0.0, 0.0)
        elif event.key() == Qt.Key_Up:
            self.scale(1.1, 1.1)
            self.centerOn(0.0, 0.0)
        elif event.key() == Qt.Key_Down:
            self.scale(0.909, 0.909)
            self.centerOn(0.0, 0.0)
        elif event.key() == Qt.Key_J:
            self.shear(0.1, 0.0)
            self.centerOn(0.0, 0.0)
        elif event.key() == Qt.Key_L:
            self.shear(-0.1, 0.0)
            self.centerOn(0.0, 0.0)
        elif event.key() == Qt.Key_I:
            self.shear(0.0, 0.1)
            self.centerOn(0.0, 0.0)
        elif event.key() == Qt.Key_K:
            self.shear(0.0, -0.1)
            self.centerOn(0.0, 0.0)
        elif event.key() == Qt.Key_H:
            self.resetTransform()
        else:         
            super(TextArtGraphicsView, self).keyPressEvent(event)


    def export_png(self):
        fn, _ = QFileDialog.getSaveFileName(self,
                                            "Save Image", "./untitled.png",
                                            "PNG(*.png)")
        if fn:
            self.scene().clearSelection()

            image = QImage(self.size(), QImage.Format_RGBA64)
            painter = QPainter()

            painter.begin(image)
            painter.setRenderHint(QPainter.Antialiasing)
            self.render(painter)
            painter.end()  # must call it manually, or, an error will occur

            image.save(fn, "png", 100)
            self.file_saved.emit(fn)


if __name__ == "__main__":
    app = QApplication([])
    window = QMainWindow()
    window.setWindowTitle("Text art")
    view = TextArtGraphicsView(window)
    window.setCentralWidget(view)
    window.show()
    sys.exit(app.exec_())

QGraphicsView Framework

Qt5在画图这一块,QGraphicsView框架已经是比较成熟,在Qt6里面也还是原封不动。这个系统主要是由QGraphicsView、QGraphicsScene和QGraphicsItem三个部分构成的。

QGraphicsScene主要是是管理QGraphicsItem,不涉及实际的绘制部分,主要是处理View、Scene和Item的数学关系,其中View的坐标系以左上角为原点,x轴向右增长,y轴向下增长;Scene以其中心为原点坐标轴与View相同,所有的Item在被加到上面是,默认的位置也就是这个原点。Item的原点其中心,坐标方向同前。这里主要的数学变换设计两个,一个是Item的旋转和平移,最终体现为一个转换矩阵(2D的转移矩阵是 3 × 3 3\times3 3×3数组,直接加到Scene中间的Item,其转换矩阵用函数sceneTransform来获得。

QGraphicsScene可以连接到多个QGraphicsView,每个View和Scenne之间的关系,也是用同样的数学结构进行描述。

总结一句话:QGraphicsScene是一个数学模型,QGraphicsView是一个视图,QGraphicsItem是一个实体。数学模型恒久不变,提供一个空间;视图可以有多个,可以有不同的显示方式;实体可以有多个,可以有不同的形状,在数学模型中的位置、姿态也可以不同。

可以简单的说几句几何,但是下面的一小节无须关心。

几何

变化的计算大概是把旋转、平移、缩放、仿射集合成如下的矩阵 M M M

M = [ m 11 m 12 m 13 m 21 m 22 m 23 m 31 m 32 m 33 ] M = \begin{bmatrix} m_{11} & m_{12} & m_{13} \\ m_{21} & m_{22} & m_{23} \\ m_{31} & m_{32} & m_{33} \\ \end{bmatrix} M=m11m21m31m12m22m32m13m23m33

转换分为两步,首先,
[ x ′ y ′ w ] = [ x y 1 ] × M \left[ \begin{matrix} x' & y' & w \end{matrix} \right] = \left[ \begin{matrix} x & y & 1 \end{matrix} \right] \times M [xyw]=[xy1]×M

接下来, [ x ′ , y ′ , w ] [x', y', w] [x,y,w]再用 w w w归一化为, [ x t , y t , 1 ] [x_t, y_t, 1] [xt,yt,1]

程序伪码大概是:

x' = m11*x + m21*y + dx
y' = m22*y + m12*x + dy
if (!isAffine()) {
    w' = m13*x + m23*y + m33
    x' /= w'
    y' /= w'
}

这部分内容基本上就是线性代数、仿射几何的内容。不需要知道这些也没有关系。只需要能够对QGraphicsView和QGraphicsItem调用相应的函数就可以了。

QGraphicsView应用

QGraphicsView提供对QGraphicsScene的一个视图,这个视图可以缩放、可以拖动,可以旋转,还能够剪切。分别对应函数:

  • scale
  • move
  • rotate
  • shear

这所有函数是加上去的最终效果与之前的效果叠加。这些函数都是对QGraphicsView的,对QGraphicsScene没有影响。最终的效果是,QGraphicsView的坐标系发生了变化,但是QGraphicsScene的坐标系没有变化。对应的变换矩阵可以通过函数transform()获得。并且利用resetTransform()可以将QGraphicsView的变换矩阵恢复到初始状态。

其实,运行一下程序,就可以很容易理解上面的概念。下面是一些截图。

PyQt5桌面应用开发(13):QGraphicsView框架-LMLPHP

QGraphicsItem应用

前面大量演示了把一个TextItem移动、旋转的过程。对应的函数:

  • setPos
  • setRotation
  • setTransform
  • setTransformOriginPoint
  • setScale
  • setShear
  • setTransformations

具体的使用可以参考文档。

这里两个地方的用词就能看出区别,Item是本身的属性;而View是一个动作。

keyPressEvent

这个程序的操作基本上通过键盘来完成,没有涉及到鼠标点击、缩放。键盘事件在重载的函数keyPressEvent中处理。这个函数是在QGraphicsView中重载的。值得注意的式,QGraphicsView的事件处理函数,与QGraphicsScene没有关系。

这里有两种判断事件的方式:

  • event.matches(QKeySequence.Save)
  • event.key() == Qt.Key_S

前面这种方式对应操作系统中的键盘组合的概念,后面这种就是检测按键。这两种方式都可以,但是前面的方式更加灵活,可以检测到Ctrl+S和Ctrl+Shift+S,后面的方式只能检测到S。

QObject cross QThread/thread

这个问题的提出是在实现的早期版本中,发现QGraphicsScene中增加QGraphicsItem是一个很耗时的操作,所以就想着来个多线程,创建一个更新一个。但是发现,这样做不行,QGraphicsScene中的QGraphicsItem就不能显示了。而且,程序还一直报错:QObject::startTimer: Timers cannot be started from another thread,比如在终端里运行才能看到。

这个问题是QObject的线程再入特性导致的。这个特性的描述在文档中是这样的:

QObject reentry

结论一句话:

那像这种情况,又要UI有响应,又要动态做一堆事情,怎么办?

整一个线程,模拟一个心跳,不停把这个事情放到主线程中去做。也就是,做事情的那个被当作事件加到主线程的事件循环中。那么,这里有没有更加直接的办法?就像是addEvent之类的方法?这样会节省很多时间,但在主线程中还需要有一个循环,如果这个循环有20000次,怎么办?就算是addEvent时间很短,主线程一样会卡顿。

上面程序的流程就是:

  1. 建立一个线程;
  2. 启动循环之前的准备工作;
  3. 启动循环,每次循环都是一个事件,这个事件会被加入到主线程的事件循环中;
  4. 结束循环之后的收尾工作。

这个做法不会增加程序的计算负载能力,但是用户的响应基本上在n比较小的时候还是存在的,我家的瓜娃子直接设成123123123,程序马上卡死。

总计

  1. QGraphicsView框架比较成熟,数学概念清晰。
  2. QGraphicsItem的使用比较简单,但是对于复杂的图形,很方便支持在局部坐标系中实现。
  3. QObject和线程的关系,通过事件机制实现。
05-12 21:52