PyQt5桌面应用开发(16):定制化控件-QPainter绘图
PyQt5桌面应用系列
- PyQt5桌面应用开发(1):需求分析
- PyQt5桌面应用开发(2):事件循环
- PyQt5桌面应用开发(3):并行设计
- PyQt5桌面应用开发(4):界面设计
- PyQt5桌面应用开发(5):对话框
- PyQt5桌面应用开发(6):文件对话框
- PyQt5桌面应用开发(7):文本编辑+语法高亮与行号
- PyQt5桌面应用开发(8):从QInputDialog转进到函数参数传递
- PyQt5桌面应用开发(9):经典布局QMainWindow
- PyQt5桌面应用开发(10):界面布局基本支持
- PyQt5桌面应用开发(11):摸鱼也要讲基本法,两个字,16
- PyQt5桌面应用开发(12):QFile与线程安全
- PyQt5桌面应用开发(13):QGraphicsView框架
- PyQt5桌面应用开发(14):数据库+ModelView+QCharts
- PyQt5桌面应用开发(15):界面动画
- PyQt5桌面应用开发(16):定制化控件-QPainter绘图
画画图,喝喝茶
程序员的日子,还是挺悠闲。只要快捷键设置得好,摸鱼不要太简单。话说这天,本人文青病又犯了,就想着整点艺术。虽然艺术细胞不多……
先上艺术。
不过跟我儿子比,还是差点:
QPainter和QPixmap
前面都是胡扯,这里其是想写的是PyQt5中自定义控件的技术。对于PyQt5而言,所有的控件,都把自己作为一种bitmap来绘制,只有理解了这一点,才能说掌握了自定义控件。
QPixmap
计算机图像学里所有的图形都是像素点阵列,每个像素点都有自己的颜色,这种点阵列就是bitmap。bitmap也可以序列化成图像文件,不同的编码格式对应不同格式的文件,常见的有bmp、jpg、png等。
PyQt5中,所有的控件都是继承自QWidget,而QWidget继承自QPaintDevice,QPaintDevice是一个抽象类,它定义了一些绘制的接口。QPixmap和QImage都继承自QPaintDevice,所以QPixmap和QImage都可以作为绘制的目标。在Device之上,又有QPainter,QPainter是一个绘制的工具,它可以把图形绘制到QPaintDevice上。
控件的pixmap()函数都返回一个QPixmap对象,这个对象可以用来绘制控件的内容。
QPainter
QPainter是一个绘制工具,它可以把图形绘制到QPaintDevice上。QPainter的绘制函数有很多,这里只介绍几个常用的:
- drawPoint(x, y):绘制一个点
- drawLine(x1, y1, x2, y2):绘制一条直线
- drawRect(x, y, width, height):绘制一个矩形
- drawEllipse(x, y, width, height):绘制一个椭圆
- drawArc(x, y, width, height, startAngle, spanAngle):绘制一个圆弧
- drawPie(x, y, width, height, startAngle, spanAngle):绘制一个扇形
- drawPolygon(points):绘制一个多边形
- drawText(x, y, text):绘制一段文本
- drawPixmap(x, y, pixmap):绘制一个图像
- drawImage(x, y, image):绘制一个图像
- fillRect(x, y, width, height, color):用color颜色填充一个矩形
此外,QPainter还提供了函数设置画图的笔触。
- pen():获取当前的画笔
- setPen(pen):设置画笔
这些函数,提供了多种不同的重载形式,具体查看文档就行。
绘制事件
QWidget的paintEvent()函数是绘制事件,当控件需要绘制的时候,就会调用这个函数。paintEvent()
函数的参数是QPaintEvent,它包含了绘制的区域,可以通过rect()函数获取。
paintEvent()函数的实现,一般是先创建一个QPainter对象,然后调用QPainter的绘制函数,最后销毁QPainter对象。
def paintEvent(self, event):
painter = QPainter(self)
painter.drawPixmap(self.rect(), self.pixmap)
painter.end()
QPainter提供了一对函数begin()和end()来管理绘制的生命周期,这两个函数是成对出现的,一般都是在begin()
函数中创建QPainter对象,然后在end()
函数中销毁QPainter对象。但是,QPainter的构造函数也可以直接传入一个QPaintDevice对象,这样就不需要调用begin()。但是end()
函数一般是需要自行调用的。
基本的内容就是这些,比较简单,接下来增加一点点细节。
一个魔改的QLabel
我们选择一个QLabel控件作为父类,定义一个Canvas类。在Canvas中,提供鼠标绘图的功能,所有的图形都是花在QLabel的pixmap()上。
希望提供的功能如下:
- 鼠标绘制图形
- 设置绘制的颜色
- 设置线条粗细
- 鼠标喷涂
- 喷涂的过程增加一个框(框有不同的形状)
- 保存绘制的图形
- 清楚绘图区
界面的设计如下:
- 下方工具栏:颜色选择,包括增加自定义颜色
- 上方工具栏:颜色、保存之外其它所有交互功能
Canvas类
Canvas类继承自QLabel,它的构造函数中,设置了一些属性,包括:
- setMouseTracking(False):设置鼠标跟踪,如果设置为True,那么鼠标移动事件就会被触发,否则只有鼠标按下和释放事件才会被触发。
- last_x, last_y:记录鼠标的上一个位置,用于绘制直线。
- pen_color:记录当前的画笔颜色。
- is_spray:记录是否是喷涂模式。
- gauss_size:喷涂的大小。
- shape_type:记录当前的绘制形状。
- is_rotate:形状是否旋转
- is_gauss_sum_up:是否用高斯叠加函数形成一个特殊笔刷
类中间定义了一个enum,用于记录绘制的形状,包括:
- NONE:无
- RECT:矩形
- CIRC:圆形
- TRI:三角形
- HEX:六角形
相应地还定义了一系列槽函数。重要的功能在重载的几个函数中实现。
- def resizeEvent(self, a0: QResizeEvent) -> None:
- def mouseReleaseEvent(self, ev: QMouseEvent) -> None:
- def mousePressEvent(self, ev: QMouseEvent) -> None:
- def mouseMoveEvent(self, e: QMouseEvent):
resizeEvent()
函数在控件大小改变的时候被调用,这里我们在这个函数中重新设置pixmap的大小,保证pixmap的大小和控件大小一致。mousePressEvent和mouseReleaseEvent处理鼠标按下和释放事件,mouseMoveEvent处理鼠标移动事件。
最后是一个功能函数把QPixamp保存为图片和内部函数绘制喷涂。
class Canvas(QLabel):
class ShapeType:
NONE = 0
RECT = 1
CIRC = 2
TRI = 3
HEX = 4
def __init__(self, parent=None):
super(Canvas, self).__init__(parent)
self.last_x, self.last_y = None, None
self.pen_color = QColor(Qt.black)
self.setMouseTracking(False)
self.is_spray = False
self.gauss_size = 10
self.gauss_density = 100
self.shape_type = self.ShapeType.NONE
self.is_rotate = False
self.angle = None
self.line_width = 4
self.gauss_sum_up = False
@pyqtSlot(bool)
def set_gauss_sum_up(self, b: bool):
self.gauss_sum_up = b
@pyqtSlot(int)
def set_line_width(self, w):
self.line_width = w
@pyqtSlot(int)
def set_gauss_size(self, s):
self.gauss_size = s
@pyqtSlot(int)
def set_gauss_density(self, d):
self.gauss_density = d
@pyqtSlot(bool)
def set_rotate(self, b: bool):
self.is_rotate = b
@pyqtSlot(int)
def set_shape_type(self, shape_type: ShapeType):
self.shape_type = shape_type
@pyqtSlot(QColor)
def set_pen_color(self, c):
self.pen_color = QColor(c)
@pyqtSlot(bool)
def toggle_spray(self, b: bool):
self.is_spray = b
@pyqtSlot()
def clear(self) -> None:
self.pixmap().fill(Qt.white)
self.update()
def resizeEvent(self, a0: QResizeEvent) -> None:
pixmap = self.pixmap()
if pixmap:
self.setPixmap(pixmap.scaled(a0.size()))
else:
pixmap = QPixmap(a0.size())
pixmap.fill(Qt.white)
self.setPixmap(pixmap)
def mouseReleaseEvent(self, ev: QMouseEvent) -> None:
self.last_x, self.last_y = None, None
def mousePressEvent(self, ev: QMouseEvent) -> None:
self.last_x, self.last_y = ev.x(), ev.y()
if self.is_spray:
self._spray(ev.x(), ev.y(), QPainter(self.pixmap()))
self.update()
def mouseMoveEvent(self, e: QMouseEvent):
painter = QPainter(self.pixmap())
p = painter.pen()
p.setColor(self.pen_color)
p.setWidth(self.line_width)
painter.setPen(p)
if self.is_spray:
self._spray(e.x(), e.y(), painter)
else:
painter.drawLine(self.last_x, self.last_y, e.x(), e.y())
painter.end()
self.update()
self.last_x, self.last_y = e.x(), e.y()
def save_png(self):
fid, _ = QFileDialog.getSaveFileName(self, 'Save PNG', 'canvas.png', 'PNG (*.png)')
if fid:
self.pixmap().save(fid, 'PNG', 100)
def _spray(self, x, y, painter):
p = painter.pen()
p.setWidth(1)
p.setColor(self.pen_color)
painter.setPen(p)
painter.translate(x, y)
# draw shape in local coordinates
if self.is_rotate:
self.angle = random.randrange(0, 360)
painter.rotate(self.angle)
if self.shape_type == self.ShapeType.RECT:
angle = random.randrange(0, 360)
painter.drawRect(QRectF(- self.gauss_size, - self.gauss_size, 2 * self.gauss_size, 2 * self.gauss_size))
if self.shape_type == self.ShapeType.CIRC:
painter.drawEllipse(QPointF(0, 0), self.gauss_size, self.gauss_size)
if self.shape_type == self.ShapeType.TRI or self.shape_type == self.ShapeType.HEX:
painter.drawPolygon(QPolygonF([
QPointF(- math.sqrt(3) * self.gauss_size, self.gauss_size),
QPointF(+ math.sqrt(3) * self.gauss_size, self.gauss_size),
QPointF(0, - self.gauss_size * 2.0)
]))
if self.shape_type == self.ShapeType.HEX:
painter.drawPolygon(QPolygonF([
QPointF(- math.sqrt(3) * self.gauss_size, - self.gauss_size),
QPointF(+ math.sqrt(3) * self.gauss_size, - self.gauss_size),
QPointF(0, self.gauss_size * 2.0)
]))
if self.is_rotate:
painter.rotate(-self.angle)
xi, yi = 0, 0
for i in range(self.gauss_density):
if self.gauss_sum_up:
xi += random.gauss(0, self.gauss_size / 3.0)
yi += random.gauss(0, self.gauss_size / 3.0)
else:
xi, yi = random.gauss(0, self.gauss_size / 3.0), random.gauss(0, self.gauss_size / 3.0)
painter.drawPoint(QPointF(xi, yi))
# back to global coordinates
painter.translate(-x, -y)
主窗口
主窗口要构造所有的界面元素,并提供保存按键组合的响应。
class MainWindow(QMainWindow):
def __init__(self):
super(MainWindow, self).__init__()
self.toolbar_button_width = 30
self.canvas = Canvas()
self.setCentralWidget(self.canvas)
self.palette_widget = QToolBar(self)
self.palette_widget.setWindowTitle('Palette')
self.current_color = QLabel()
self.current_color.setFixedWidth(self.toolbar_button_width)
self.current_color.setStyleSheet('background-color: #000000')
self.palette_widget.addWidget(self.current_color)
add_color = QPushButton("新")
add_color.setFixedWidth(self.toolbar_button_width)
add_color.clicked.connect(self.pick_color)
self.palette_widget.addWidget(add_color)
self.palette_widget.addSeparator()
for color in COLORS:
button = QPaletteButton(color, self.palette_widget, self.toolbar_button_width)
self.palette_widget.addWidget(button)
button.clicked.connect(partial(self.canvas.set_pen_color, QColor(color)))
button.clicked.connect(partial(self.current_color.setStyleSheet, f'background-color: {color}'))
self.addToolBar(Qt.BottomToolBarArea, self.palette_widget)
self.palette_widget.setMovable(False)
self.pen_style = QToolBar(self)
self.pen_style.setWindowTitle('Pen Style')
checkbox = QCheckBox('喷涂', self.pen_style)
checkbox.clicked.connect(self.canvas.toggle_spray)
sumup = QCheckBox('高斯叠加', self.pen_style)
sumup.clicked.connect(self.canvas.set_gauss_sum_up)
size_tunner = QSpinBox(self.pen_style)
size_tunner.setMinimum(1)
size_tunner.setMaximum(200)
size_tunner.setValue(50)
size_tunner.valueChanged.connect(self.canvas.set_gauss_size)
size_tunner.setSingleStep(1)
size_tunner.setFixedWidth(100)
density_tunner = QSpinBox(self.pen_style)
density_tunner.setMinimum(0)
density_tunner.setMaximum(5000)
density_tunner.setValue(500)
density_tunner.valueChanged.connect(self.canvas.set_gauss_density)
density_tunner.setSingleStep(10)
density_tunner.setFixedWidth(100)
clear_button = QPushButton('Clear', self.pen_style)
clear_button.clicked.connect(self.canvas.clear)
clear_button.setStyleSheet('background-color: #ff0000; color: #ffffff;')
self.pen_style.addWidget(clear_button)
self.pen_style.addSeparator()
self.pen_style.addWidget(checkbox)
self.pen_style.addWidget(sumup)
self.pen_style.addSeparator()
self.pen_style.addWidget(QLabel('喷涂大小:'))
self.pen_style.addWidget(size_tunner)
self.pen_style.addSeparator()
self.pen_style.addWidget(QLabel('喷涂数目:'))
self.pen_style.addWidget(density_tunner)
self.outline = QComboBox(self.pen_style)
self.outline.addItem("无")
self.outline.addItem("方形")
self.outline.addItem("圆形")
self.outline.addItem("三角形")
self.outline.addItem("六角形")
self.outline.currentIndexChanged.connect(self.canvas.set_shape_type)
self.pen_style.addSeparator()
self.pen_style.addWidget(QLabel('边框:'))
self.pen_style.addWidget(self.outline)
is_rotate = QCheckBox('旋转', self.pen_style)
is_rotate.clicked.connect(self.canvas.set_rotate)
is_rotate.setChecked(False)
self.pen_style.addWidget(is_rotate)
self.pen_style.addSeparator()
self.pen_style.addWidget(QLabel('线条宽度:'))
self.pen_style.addSeparator()
line_width = QSpinBox(self.pen_style)
line_width.setMinimum(1)
line_width.setMaximum(10)
line_width.setValue(4)
line_width.valueChanged.connect(self.canvas.set_line_width)
line_width.setSingleStep(1)
self.pen_style.addWidget(line_width)
self.pen_style.setMovable(False)
self.addToolBar(Qt.TopToolBarArea, self.pen_style)
@pyqtSlot()
def pick_color(self):
color = QColorDialog.getColor()
if color.isValid():
button = QPaletteButton(color.name(), self.palette_widget, self.toolbar_button_width)
self.palette_widget.addWidget(button)
button.clicked.connect(partial(self.canvas.set_pen_color, color))
button.clicked.connect(partial(self.current_color.setStyleSheet, f'background-color: {color.name()}'))
button.clicked.emit()
# resize the palette widget to fit all the buttons
self.palette_widget.resize(self.palette_widget.sizeHint())
def keyPressEvent(self, ev: QKeyEvent) -> None:
if ev.matches(QKeySequence.Save):
self.canvas.save_png()
这里的代码乏善可陈,唯一好玩的是那个颜色选择界面。
COLORS = sorted(
{'#000000', '#808080', '#800000', '#808000', '#008000', '#008080', '#000080', '#800080', '#808040', '#004040',
'#004080', '#0000ff', '#004080', '#0080ff', '#0040ff', '#0080c0', '#00ffff', '#00ff00', '#00ff80', '#00ffbf',
'#00ffff', '#00bfff', '#00bfbf', '#00b080', '#00bf80', '#00b040', '#00bf40', '#00bf00', '#00ff40', '#00ffbf',
'#00ffff', '#80ff00', '#80ff80', '#80ffbf', '#80ffff', '#80bfff', '#80bfbf', '#80b080', '#80bf80', '#80b040',
'#80bf40', '#80bf00', '#80ff40', '#80ffbf', '#80ffff', '#bf0000', '#bf0080', '#bf0080', '#bf8000', '#bf8040',
'#bf8080', '#bf80c0', '#bf80ff', '#bf40ff', '#bf00ff', '#bf00bf', '#bf0080', '#bf0040', '#bf0000', '#bf4000',
'#bf4040', '#bf4080', '#bf40c0', '#bf40ff', '#bf00ff', '#bf00bf', '#bf0080', '#bf0040', '#bf0000', '#bf4000',
'#bf4040', '#bf4080', '#bf40c0', '#bf40ff', '#bf00ff', '#bf00bf', '#bf0080', '#bf0040', '#bf0000', '#bf4000',
'#bf4040', '#bf4080', '#bf40c0', '#bf40ff', '#bf00ff', '#bf00bf', '#bf0080', '#bf0040', '#bf0000', '#bf4000',
'#bf4040', '#bf4080', '#bf40c0', '#bf40ff', '#bf00ff', '#bf00bf', '#bf0080', '#bf0040', '#bf0000', '#bf4000',
'#bf4040', '#bf4080', '#bf40c0', '#bf40ff', '#bf00ff', '#bf00bf', '#bf0080', '#bf0040', '#bf0000', '#bf4000',
'#bf4040', '#bf4080', '#00ff00', '#00ff80', '#00ffbf', '#00ffff', '#00bfff', '#00bfbf', '#00b080', '#00bf80',
'#00b040', '#00bf40', '#00bf00', '#00ff40', '#00ffbf', '#00ffff', '#80ff00', '#80ff80', '#80ffbf', '#80ffff',
'#80bfff', '#80bfbf', '#80b080', '#80bf80', '#80b040', '#80bf40', '#80bf00', '#80ff40', '#80ffbf', '#80ffff',
'#bf0000', '#bf0080', '#bf0080', '#bf8000', '#bf8040', '#bf8080', '#bf80c0', '#bf80ff', '#bf40ff', '#bf00ff',
'#bf00bf', '#bf0080', '#ffff00', '#ffff80', '#ffffbf', '#ffffff', '#bfffff', '#bfbfbf', '#bf8080', '#bfbf80',
'#bf4040', '#bfbf40', '#bf0000', '#bfbf00', '#bf00bf', '#bfbfbf', '#bf8080', '#bfbf80'})
class QPaletteButton(QPushButton):
def __init__(self, color, parent=None, size=24):
super(QPaletteButton, self).__init__(parent)
self.setFixedSize(size, size)
self.setStyleSheet(f'background-color: {color}')
这里就是随便写了一堆颜色,然后用一个按钮的子类来实现,这样就可以直接用按钮的clicked信号来实现颜色的选择了。
主程序:
if __name__ == '__main__':
app = QApplication(sys.argv)
window = MainWindow()
window.setWindowTitle("Painter")
window.resize(1400, 800)
window.show()
sys.exit(app.exec_())
总结
这里面需要注意的技术要素包括:
- 信号与槽的使用
- 事件处理,特别是鼠标事件,按下、释放、移动等
- 画图的基本原理,包括画笔、画刷、画布等
- 画图中,我们用了一个移动的技术,把画布移动到一个位置,以那个位置为0点画图,可以转动,然后移动回去。
Canvas的resizeEvent实际上承担了两个功能:初始创建的时候,会调用resizeEvent,然后在resize的时候也会调用resizeEvent,所以我们需要在resizeEvent里面做一些初始化的工作,比如创建画布,然后在resize的时候,我们需要重新创建画布,然后把画布的内容复制到新的画布上面,然后删除旧的画布。