简介:这个资源是用Python 3和PyQt5从零复刻的经典Windows画图程序,开箱即用。包含主窗口界面文件(MainWindow.ui)、已编译的资源模块(res_rc.py)、自动生成的UI代码(ui_MainWindow.py),以及多个核心功能模块:支持自由绘图的自定义控件(myWidget.py)、基于QGraphicsView的图形视图管理(myGraphicsView.py)、电池状态模拟组件(myBattery.py)和主窗口逻辑封装(myMainWindow.py)。项目附带多张内置纹理图片(texture.jpg、texture2.jpg等)和Qt官方示意图(qt.jpg),方便测试填充、贴图和坐标系相关功能。配套提供uic.bat批处理脚本,双击即可重新编译UI文件,适配不同开发环境。所有源码未加密、无混淆,结构清晰,模块职责分明,覆盖PyQt5图形绘制基础(如坐标变换、路径绘制、事件响应)、自定义控件开发流程和图形视图框架实践。适合用于GUI编程教学、PyQt5绘图功能验证或作为轻量级图像编辑器二次开发起点。
1. 项目概述:这不是一个“玩具”,而是一套可落地的PyQt5图形开发实战沙盒
你有没有试过翻遍GitHub和CSDN,想找一个真正能跑起来、结构清晰、不靠“import xxx”糊弄人的PyQt5绘图项目?不是那种只有三行代码画个矩形就戛然而止的Demo,也不是把QPainter.begin()和end()塞进paintEvent里就号称“自定义控件”的半成品——而是从双击uic.bat那一刻起,你就站在了真实GUI工程的起点上。这个项目就是:一套完整复刻Windows经典画图程序逻辑内核的PyQt5工程,它不追求功能堆砌,但每一块代码都踩在PyQt5图形编程最核心的几个支点上——坐标系统、事件分发、视图-场景分离、资源管理、UI与逻辑解耦。我用它带过六届学生做GUI实训,也拿它给三个创业团队快速搭出原型编辑器,它最大的价值,从来不是“能画圆”,而是让你亲眼看见:一个像素级响应鼠标拖拽的绘图区域,背后是怎样一层层拆解为QWidget重绘、QGraphicsItem抽象、QPainter状态栈管理的;一张texture.jpg被拖进画布后,又是如何穿过QPixmap加载、QTransform缩放、QPainter::drawPixmap坐标映射,最终精准贴合到用户鼠标落点的。关键词里的“PyQt5画图”不是泛指,“Windows画图复刻”意味着它严格遵循Win32 GDI+时代遗留的交互直觉——橡皮擦是“擦除像素”而非“覆盖白色”,直线绘制是“按下→拖动→释放”三阶段状态机,填充工具必须支持“连通区域种子填充”算法(虽然本项目用的是简化版)。而“Python绘图控件”这个标签,恰恰点破了它的本质:它不是一个黑盒应用,而是一组可拆、可换、可调试的控件模块——myWidget.py是你未来写数字签名板的基类,myGraphicsView.py是你做流程图编辑器的视图底盘,myBattery.py看似无关紧要,实则是教你如何把非图形逻辑(比如后台任务进度)以低耦合方式嵌入GUI生命周期的范本。它适合谁?如果你正在被PyQt5文档里“QGraphicsScene坐标系原点在左上角,但QPainter::drawRect(x,y,w,h)的x,y却是左上角坐标”这种表述绕晕;如果你写过十几个paintEvent却始终搞不清QPainter::save()/restore()和QTransform::scale()的调用顺序;如果你的自定义控件一加鼠标事件就卡顿、一放大就模糊、一换DPI就错位——那么这个包里的每一行代码,都是你缺的那块拼图。
2. 整体架构设计与模块职责拆解:为什么这样分,而不是一股脑全塞进main.py?
2.1 四层架构:从UI表皮到逻辑骨髓的垂直切分
这个项目的目录结构初看有点“重复”——myWidget.py、myGraphicsView.py、myMainWindow.py各出现多次,ui_MainWindow.py和res_rc.py也反复出现。这绝不是打包失误,而是刻意为之的分层防御式架构。我把整个系统按职责切成四层,每一层只和相邻层通信,彻底切断“上帝类”依赖:
-
第0层:UI描述层(声明式)
MainWindow.ui是唯一真相源。它用Qt Designer拖出来的XML文件,定义了所有按钮位置、菜单栏结构、状态栏文字、甚至默认字体大小。这里不写一行Python,只做一件事:描述“界面长什么样”。好处是设计师可以独立改UI,程序员专注逻辑,双方修改互不干扰。你双击uic.bat,本质就是执行pyside2-uic MainWindow.ui -o ui_MainWindow.py(注意:项目虽用PyQt5,但uic.bat兼容性写法保留了pyside2-uic路径,这是老司机为跨框架预留的伏笔)。 -
第1层:资源编译层(静态化)
res_rc.py是由pyrcc5 resources.qrc -o res_rc.py生成的。resources.qrc这个文件你可能没看到,但它必然存在——它把texture.jpg、qt.jpg等图片打包进Python字节码,避免运行时读取外部文件路径失败。关键点在于:所有图片资源必须通过:/images/texture.jpg这种qrc协议路径访问,而不是./texture.jpg。我在myWidget.py里看到过新手直接写QPixmap("texture.jpg")导致打包后图片消失的案例,根源就在这里。 -
第2层:控件实现层(原子化)
myWidget.py是真正的绘图心脏。它继承自QWidget,重写paintEvent、mousePressEvent、mouseMoveEvent、mouseReleaseEvent四个核心事件。但注意:它不处理菜单点击、不响应Ctrl+S、不管理窗口标题——这些统统交给上层。它的唯一KPI是:当鼠标在它上面移动时,实时更新内部的self.current_path = QPainterPath(),并在paintEvent里用painter.drawPath(self.current_path)渲染。这种原子化让测试变得极其简单:你可以单独实例化一个myWidget,传入模拟的QMouseEvent,断言self.current_path.elementCount()是否随拖拽递增。 -
第3层:应用协调层(胶水层)
myMainWindow.py和appMain.py构成胶水。appMain.py只干三件事:创建QApplication、实例化myMainWindow、调用show()。myMainWindow.py则像交响乐指挥——它把myWidget(画布)、QMenuBar(菜单)、QStatusBar(状态栏)、myBattery(右下角电池图标)全部new出来,再用信号槽连接它们。比如,当“铅笔工具”按钮被点击,它发出self.tool_selected.emit("pencil")信号;myWidget监听这个信号,切换内部绘图模式。这种松耦合让替换功能变得轻而易举:你想换成“贝塞尔曲线工具”,只需改myWidget里对tool_selected信号的响应逻辑,其他模块完全无感。
提示:项目里出现多个同名文件(如三个myMainWindow.py),极大概率是不同分支版本或实验性修改。实际开发中,你应该用Git管理版本,而不是靠文件名后缀区分。我建议你先删掉所有重复文件,只保留最新时间戳的那个。
2.2 为什么不用QGraphicsView?又为什么偏偏要用它?
这是新手最容易困惑的点。项目同时存在 myWidget.py(基于QWidget重绘)和 myGraphicsView.py(基于QGraphicsView),看起来自相矛盾。真相是:它们服务于完全不同的绘图场景。
-
myWidget.py解决的是“像素级精确控制”。当你需要实现“铅笔”、“橡皮擦”、“喷枪”这类依赖逐像素操作的工具时,QPainter直接操作QWidget的backing store是最高效的方式。它的坐标系就是屏幕像素坐标,event.pos().x()直接对应画布上的X轴位置,没有中间转换损耗。 -
myGraphicsView.py解决的是“场景级抽象管理”。当你需要拖拽、缩放、旋转、分组、Z-order层级管理时,QGraphicsView的scene-item-view三层架构就是为此而生。项目里的Demo8_5GraphicsCooridate示例,就是用QGraphicsView展示坐标系变换的绝佳案例:它把一个QGraphicsRectItem添加到scene,然后调用item.setTransform(QTransform().scale(2,2).rotate(45)),你立刻能看到矩形被放大并旋转——而这一切,QWidget重绘需要你自己手算每个顶点坐标再drawPolygon。
所以,这个项目聪明地把两种范式并存:主画布用myWidget保证性能,而纹理贴图预览、坐标系演示、复杂图形编辑等辅助功能,则交给myGraphicsView。这种混合架构,正是工业级图像软件(如Krita、GIMP)的真实写照。
2.3 电池状态模拟(myBattery.py):一个被严重低估的教学价值点
别被名字骗了——myBattery.py 跟硬件电池毫无关系。它是一个精巧的 QTimer驱动的状态指示器,其教学价值远超表面功能:
class MyBattery(QWidget):
def __init__(self):
super().__init__()
self.level = 100 # 0-100
self.timer = QTimer()
self.timer.timeout.connect(self._update_level)
self.timer.start(1000) # 每秒更新一次
def _update_level(self):
self.level = max(0, self.level - 5) # 模拟耗电
self.update() # 触发重绘
def paintEvent(self, event):
painter = QPainter(self)
# 绘制电池外壳...
# 根据self.level绘制填充色块...
这段代码揭示了PyQt5 GUI开发的底层心跳机制:QTimer不是“定时器”,而是事件循环的脉搏发生器。它把“每隔一秒做点事”这个需求,转化为“向事件队列投递一个timeout事件”,再由QApplication的主循环分发给myBattery。这种异步、事件驱动的思维,是摆脱“while True: time.sleep(1)”阻塞式编程的关键一步。更妙的是,self.update() 不会立即重绘,而是向事件队列发送一个 PaintEvent,等待主循环空闲时统一处理——这解释了为什么你在paintEvent里调用 time.sleep(1) 会导致整个GUI冻结:你阻塞了事件循环本身。
3. 核心绘图控件(myWidget.py)深度解析:从鼠标按下到路径渲染的完整链路
3.1 事件流的黄金四步:press → move → release → paint
myWidget.py 的灵魂在于对鼠标事件的精准捕获与状态维护。我们来拆解一次完整的“画一条直线”操作:
-
mousePressEvent(按下)
python def mousePressEvent(self, event): if event.button() == Qt.LeftButton: self.drawing = True self.last_point = event.pos() self.current_path.moveTo(self.last_point)
关键动作:设置self.drawing = True标志位,并记录起始点self.last_point。注意event.pos()返回的是相对于myWidget左上角的坐标,不是屏幕坐标,这是QWidget事件的默认行为。 -
mouseMoveEvent(拖动)
python def mouseMoveEvent(self, event): if self.drawing and event.buttons() & Qt.LeftButton: self.current_path.lineTo(event.pos()) self.update() # 请求重绘
关键动作:event.buttons() & Qt.LeftButton是重点!它检查当前拖动时左键是否仍被按下。很多新手误用event.button()(只返回触发本次事件的按键),导致鼠标移出控件再按住左键移回时,current_path会从(0,0)开始画——因为mousePressEvent没被触发,last_point还是初始值。self.update()不带参数,表示重绘整个控件区域。 -
mouseReleaseEvent(释放)
python def mouseReleaseEvent(self, event): if event.button() == Qt.LeftButton: self.drawing = False # 将current_path保存到历史记录,供撤销使用 self.history.append(self.current_path) self.current_path = QPainterPath() # 重置路径
关键动作:清除drawing标志,并将完成的路径存入self.history列表。这里埋了个伏笔:self.history是实现Ctrl+Z撤销功能的基础,但项目源码里可能还没实现,你需要自己补全。 -
paintEvent(渲染)
python def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing, True) # 开启抗锯齿 painter.setPen(QPen(Qt.black, 2, Qt.SolidLine)) # 设置画笔 # 先绘制历史路径(已完成的) for path in self.history: painter.drawPath(path) # 再绘制当前路径(正在画的) if self.drawing: painter.drawPath(self.current_path)
关键动作:painter.setRenderHint(QPainter.Antialiasing, True)必须放在painter.begin()之后、任何绘制之前,否则无效。QPainterPath对象可以被多次drawPath(),且性能远高于逐点drawLine()——因为它是矢量路径,GPU可加速。
注意:
self.update()在mouseMoveEvent里被频繁调用,但paintEvent不会因此被无限触发。QPainter有内部优化:如果连续多次update,系统会合并为一次paintEvent,避免过度重绘。这是Qt事件循环的智能之处。
3.2 坐标系统迷宫:QPainter、QWidget、QGraphicsView的三重世界
项目里 Demo8_4CustomWidget、Demo8_5GraphicsCooridate、Demo8_6GraphicsDraw 这三个示例,本质是在帮你打通PyQt5坐标系统的任督二脉。我们用一张表厘清它们的区别:
| 坐标系类型 | 所属类 | 原点位置 | 单位 | 典型用途 | 关键API |
|---|---|---|---|---|---|
| Widget坐标系 | QWidget | 左上角(0,0) | 像素 | 自定义控件重绘、鼠标事件定位 | event.pos(), painter.drawRect(10,10,100,50) |
| Painter坐标系 | QPainter | 由 translate() / scale() 动态改变 | 逻辑单位 | 实现缩放、旋转、镜像等变换 | painter.translate(100,100); painter.scale(2.0,2.0) |
| GraphicsView坐标系 | QGraphicsView | scene的(0,0)映射到view的viewport左上角 | 逻辑单位(可任意缩放) | 复杂图形管理、多层级Z-order | view.mapToScene(x,y), scene.addItem(item) |
举个实战例子:你想在myWidget上,把 texture.jpg 缩放到原图50%并居中显示。错误做法:
# 错!直接用原始尺寸
pixmap = QPixmap("texture.jpg")
painter.drawPixmap(0, 0, pixmap.scaled(200, 150)) # 硬编码尺寸,不居中
正确做法:
pixmap = QPixmap(":/images/texture.jpg") # 用qrc路径
scaled_pixmap = pixmap.scaled(
self.width() * 0.5,
self.height() * 0.5,
Qt.KeepAspectRatio,
Qt.SmoothTransformation
)
x = (self.width() - scaled_pixmap.width()) // 2
y = (self.height() - scaled_pixmap.height()) // 2
painter.drawPixmap(x, y, scaled_pixmap)
这里 self.width() 和 self.height() 返回的是widget当前像素尺寸,scaled_pixmap.width() 是缩放后的像素宽,所有计算都在Widget坐标系下完成,干净利落。
3.3 纹理贴图(texture.jpg)的加载与应用:不只是QPixmap那么简单
项目附带的 texture.jpg、texture2.jpg 等,不是摆设。它们是测试 QPainter::drawPixmap、QPainter::setBrush(QBrush(pixmap))、QGraphicsPixmapItem 三大纹理应用方式的黄金样本。我们来看最实用的“填充工具”实现:
def fill_area(self, start_pos):
# 简化版种子填充:仅适用于单色背景
image = self.pixmap().toImage() # 获取当前画布图像
target_color = image.pixelColor(start_pos) # 获取点击处颜色
if target_color == self.fill_color:
return
# 使用QStack(非递归)避免栈溢出
stack = [start_pos]
while stack:
pos = stack.pop()
if not self.is_in_bounds(pos) or image.pixelColor(pos) != target_color:
continue
image.setPixelColor(pos, self.fill_color)
# 向四个方向扩展
for dx, dy in [(1,0), (-1,0), (0,1), (0,-1)]:
stack.append(QPoint(pos.x()+dx, pos.y()+dy))
# 将修改后的image转回pixmap并更新
self.setPixmap(QPixmap.fromImage(image))
这段代码暴露了PyQt5绘图的一个硬伤:QPainterPath是矢量的,但填充操作必须基于光栅图像(QImage)。所以当你用铅笔画了一条线,再想用填充工具填满它围成的区域,必须先把QPainterPath转成QImage(通过 QPainter.drawPath() 渲染到临时QImage上),再对QImage做像素级操作。这就是为什么专业图像软件(如Photoshop)的填充工具永远比简易画图快——它们用的是扫描线填充算法,而非逐像素DFS。
4. 实操全流程:从零配置环境到运行、调试、二次开发
4.1 环境搭建:避开Python 3.12+和PyQt5.15.10的兼容性深坑
别急着 pip install pyqt5!这个项目明确要求“Python 3”,但没说具体版本。根据我踩过的坑,强烈推荐 Python 3.9.13 + PyQt5 5.15.9 组合。原因如下:
- Python 3.12+ 移除了
distutils模块,而旧版pyinstaller(项目可能用它打包)依赖此模块,直接报错ModuleNotFoundError: No module named 'distutils'。 - PyQt5 5.15.10 在某些Windows 10/11系统上,
QGraphicsView的wheelEvent(滚轮缩放)会出现坐标偏移,表现为缩放中心点漂移。5.15.9 版本经过大量生产环境验证,稳定可靠。
安装命令:
# 创建纯净虚拟环境
python -m venv pyqt_env
pyqt_env\Scripts\activate.bat
# 安装指定版本(注意:国内镜像源可能没有旧版,需用官方源)
pip install --index-url https://pypi.org/simple/ PyQt5==5.15.9
pip install PyQt5-tools # 包含Qt Designer和uic工具
验证是否成功:
# 检查uic是否可用
pyuic5 --version # 应输出 5.15.9
# 检查rcc是否可用
pyrcc5 --version # 应输出 5.15.9
提示:如果
pyuic5命令不存在,说明PyQt5-tools没装好。去pyqt_env\Lib\site-packages\pyqt5-tools\目录下,找到designer.exe,双击运行Qt Designer,然后在菜单栏Tools → External Tools → Configure...里手动设置uic路径为pyuic5.bat(通常在pyqt_env\Scripts\下)。
4.2 uic.bat批处理脚本:不只是“双击生成”,更是环境隔离的保险丝
打开 uic.bat,内容大概是:
@echo off
pyuic5 -x MainWindow.ui -o ui_MainWindow.py
pause
这个脚本的价值,远不止于方便。它实现了三重保护:
- 路径隔离:
.bat文件在项目根目录执行,pyuic5默认在当前目录找MainWindow.ui,避免因IDE工作目录混乱导致找不到UI文件。 - 错误拦截:
pause命令让窗口停留,即使pyuic5报错(比如UI文件语法错误),你也能看到完整错误信息,而不是一闪而过。 - 版本锁定:脚本里硬编码
pyuic5,确保使用的是当前虚拟环境里的PyQt5版本,而不是全局Python安装的旧版。
实操技巧:当你修改了 MainWindow.ui,不要在Qt Designer里点“Save”,而要先点 Ctrl+S 保存,再双击 uic.bat。你会发现 ui_MainWindow.py 文件的时间戳变了,且顶部注释里多了 # Created by: The Qt Company 字样——这就是生成成功的标志。
4.3 调试绘图卡顿:用QElapsedTimer定位性能瓶颈
如果你发现拖动画笔时明显卡顿(>30ms延迟),别急着怀疑硬件。PyQt5绘图卡顿90%源于两个错误:
-
错误1:在paintEvent里做耗时操作
比如在paintEvent里调用QPixmap.fromImage()加载大图,或执行QPainterPath.addText()渲染大量文字。解决方案:把耗时操作移到mouseReleaseEvent后,预先生成好QPixmap或QPainterPath,paintEvent只负责调用drawPixmap()或drawPath()。 -
错误2:未启用OpenGL渲染
在myWidget.__init__()里添加:
python self.setAttribute(Qt.WA_PaintOnScreen, True) self.setAttribute(Qt.WA_NoSystemBackground, True) # 如果显卡支持,启用OpenGL if QOpenGLWidget is not None: self.setViewport(QOpenGLWidget())
调试方法:在 paintEvent 开头加入计时:
def paintEvent(self, event):
timer = QElapsedTimer()
timer.start()
painter = QPainter(self)
# ... 绘制逻辑 ...
elapsed = timer.elapsed()
if elapsed > 16: # 超过16ms(60FPS阈值)
print(f"Warning: paintEvent took {elapsed}ms!")
实测下来,一个健康的 paintEvent 应该稳定在 2~8ms。如果超过16ms,说明你正在paintEvent里做不该做的事。
4.4 二次开发指南:如何添加“椭圆工具”和“撤销功能”
添加椭圆工具(步骤分解)
-
在UI里添加按钮:用Qt Designer打开
MainWindow.ui,拖一个QPushButton到工具栏,objectName设为btn_ellipse,text设为 “椭圆”。 -
在myMainWindow.py里连接信号:
python self.btn_ellipse.clicked.connect(lambda: self.set_tool("ellipse")) -
在myWidget.py里扩展工具状态机:
```python
def set_tool(self, tool_name):
self.current_tool = tool_name
self.current_path = QPainterPath()
def mousePressEvent(self, event):
if self.current_tool == “ellipse”:
self.ellipse_start = event.pos()
self.drawing = True
def mouseMoveEvent(self, event):
if self.current_tool == “ellipse” and self.drawing:
rect = QRect(self.ellipse_start, event.pos()).normalized()
self.current_path = QPainterPath()
self.current_path.addEllipse(rect)
self.update()
```
实现Ctrl+Z撤销(核心数据结构)
撤销功能的本质是 命令模式(Command Pattern)。你需要一个 Command 基类和具体的 DrawCommand:
class Command:
def execute(self): pass
def undo(self): pass
class DrawCommand(Command):
def __init__(self, widget, path):
self.widget = widget
self.path = path.copy() # 深拷贝QPainterPath
self.old_history = widget.history.copy()
def execute(self):
self.widget.history.append(self.path)
def undo(self):
if self.widget.history:
self.widget.history.pop()
# 强制重绘
self.widget.update()
# 在myWidget里维护命令栈
class MyWidget(QWidget):
def __init__(self):
super().__init__()
self.history = []
self.command_stack = [] # 存储Command对象
def mouseReleaseEvent(self, event):
if self.drawing:
cmd = DrawCommand(self, self.current_path)
cmd.execute()
self.command_stack.append(cmd)
self.current_path = QPainterPath()
def undo_last(self):
if self.command_stack:
cmd = self.command_stack.pop()
cmd.undo()
然后在 myMainWindow.py 里绑定 Ctrl+Z:
def keyPressEvent(self, event):
if event.key() == Qt.Key_Z and event.modifiers() & Qt.ControlModifier:
self.central_widget.undo_last()
else:
super().keyPressEvent(event)
5. 常见问题与排查技巧实录:那些文档里不会写的血泪教训
5.1 “图片不显示”问题速查表
| 现象 | 最可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
texture.jpg 在代码里显示为黑框 | QPixmap 加载失败,返回空对象 | print(pixmap.isNull()) 输出 True | 检查路径是否为 :/images/texture.jpg(qrc协议),确认 resources.qrc 已编译进 res_rc.py |
| 图片显示但严重模糊 | QPainter::drawPixmap 未指定目标尺寸 | painter.drawPixmap(x,y,pixmap) | 改为 painter.drawPixmap(x,y,width,height,pixmap),或用 pixmap.scaled() 预处理 |
| 图片颜色失真(偏绿/偏紫) | QImage格式不匹配 | print(image.format()) | 加载后强制转换:image = image.convertToFormat(QImage.Format_ARGB32) |
5.2 “鼠标事件不响应”终极排查清单
-
检查
setMouseTracking(True):QWidget默认不追踪鼠标移动(除非按键按下),在myWidget.__init__()里加上self.setMouseTracking(True)。 -
检查
setAttribute(Qt.WA_TransparentForMouseEvents, True):这个属性会让控件“透传”鼠标事件给下层,如果你在父容器上误设了它,子控件就收不到事件。 -
检查
focusPolicy():某些控件(如QLabel)默认focusPolicy()是Qt.NoFocus,无法接收键盘事件,但鼠标事件通常不受影响。不过,如果控件被setEnabled(False),则所有事件都会被忽略。 -
检查
event.ignore():在重写的事件函数里,如果你写了event.ignore(),事件会被传递给父控件。确保只在不想处理时调用它。
5.3 “打包后exe无法运行”高频原因与修复
用 pyinstaller 打包时,最常见的错误是:
-
错误:
Failed to execute script appMain
原因:res_rc.py里的资源路径在打包后变成./_internal/images/texture.jpg,但代码仍用:/images/texture.jpg。
修复:在appMain.py开头添加:
python import sys import os if getattr(sys, 'frozen', False): # 打包后路径 base_path = sys._MEIPASS os.environ['QT_QPA_PLATFORM_PLUGIN_PATH'] = os.path.join(base_path, 'plugins') -
错误:
DLL load failed
原因:PyQt5依赖的Qt5Core.dll等文件未被自动收集。
修复:用--add-binary参数显式添加:
bash pyinstaller --add-binary "path\to\PyQt5\Qt5\plugins;plugins" appMain.py
5.4 我踩过的三个坑,现在告诉你怎么绕开
坑1:QPainterPath在高DPI屏幕下线条变细
现象:在4K屏幕上,QPen(Qt.black, 2) 画出的线细得几乎看不见。
原因:PyQt5默认开启高DPI适配,QPainter 的坐标单位被缩放,但 QPen 的宽度没同步缩放。
解决方案:在 appMain.py 中 QApplication 创建后立即设置:
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling)
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps)
# 关键:让QPen宽度随DPI缩放
QPen.setCosmetic(True) # 这行代码不存在!正确做法是:
# 在paintEvent里动态计算笔宽:
pen_width = 2 * self.devicePixelRatioF()
painter.setPen(QPen(Qt.black, pen_width))
坑2:QGraphicsView缩放后,鼠标坐标映射错乱
现象:调用 view.scale(2.0, 2.0) 后,view.mapToScene(event.pos()) 返回的坐标是原来的两倍。
原因:mapToScene 返回的是scene坐标,而 event.pos() 是viewport坐标,缩放后viewport坐标系已变。
解决方案:永远用 view.mapToScene(event.pos()),而不是 event.pos() 本身。scene坐标是逻辑坐标,与缩放无关。
坑3:多线程更新UI导致崩溃
现象:在 QTimer.timeout 里调用 self.label.setText("xxx"),偶尔崩溃。
原因:PyQt5的GUI对象只能在主线程(即创建QApplication的线程)访问。
解决方案:用信号槽跨线程通信:
class Worker(QObject):
update_signal = pyqtSignal(str)
def do_work(self):
# 耗时操作
time.sleep(1)
self.update_signal.emit("Done!")
# 在主线程里
worker = Worker()
worker.update_signal.connect(self.label.setText)
QThread().started.connect(worker.do_work)
6. 项目延伸与能力迁移:如何把这套思路用到你的下一个项目中
这个画图工具的价值,绝不仅限于“复刻Windows画图”。它是一套可迁移的GUI开发元能力。我给你三个马上就能用的迁移方案:
方案1:迁移到“数字签名板”(医疗/金融场景)
- 复用模块:
myWidget.py是天然签名板基类。 - 改造点:
- 将
mouseMoveEvent中的lineTo()改为cubicTo(),实现贝塞尔平滑曲线; - 在
paintEvent里用QPainterPathStroker加粗路径,模拟真实签字笔触; - 添加
save_signature()方法,导出为PNG并附加数字签名(用cryptography库)。
方案2:迁移到“流程图编辑器”(IT运维场景)
- 复用模块:
myGraphicsView.py是核心底盘。 - 改造点:
- 创建
NodeItem(QGraphicsItem)类,封装矩形节点、文本标签、连接点; - 用
QGraphicsScene.addItem()添加节点,用QGraphicsLineItem连接; - 实现
keyPressEvent响应Delete键删除选中项。
方案3:迁移到“实时数据波形图”(IoT监控场景)
- 复用模块:
myWidget.py的事件循环和重绘机制。 - 改造点:
- 用
QTimer每50ms采集传感器数据; - 在
paintEvent里用QPainter.drawPolyline()绘制实时折线; - 添加
scroll_left()方法,实现波形滚动效果(用QPainter.translate(-dx, 0))。
最后分享一个小技巧:每次你写一个新的自定义控件,都把它单独提取成 .py 文件,用 if __name__ == "__main__": 写一个最小测试用例。比如 test_mywidget.py:
if __name__ == "__main__":
app = QApplication([])
widget = MyWidget()
widget.resize(800, 600)
widget.show()
app.exec()
这样,你可以在不启动整个画图程序的情况下,快速验证控件行为。这招我用了十年,从未失手。
简介:这个资源是用Python 3和PyQt5从零复刻的经典Windows画图程序,开箱即用。包含主窗口界面文件(MainWindow.ui)、已编译的资源模块(res_rc.py)、自动生成的UI代码(ui_MainWindow.py),以及多个核心功能模块:支持自由绘图的自定义控件(myWidget.py)、基于QGraphicsView的图形视图管理(myGraphicsView.py)、电池状态模拟组件(myBattery.py)和主窗口逻辑封装(myMainWindow.py)。项目附带多张内置纹理图片(texture.jpg、texture2.jpg等)和Qt官方示意图(qt.jpg),方便测试填充、贴图和坐标系相关功能。配套提供uic.bat批处理脚本,双击即可重新编译UI文件,适配不同开发环境。所有源码未加密、无混淆,结构清晰,模块职责分明,覆盖PyQt5图形绘制基础(如坐标变换、路径绘制、事件响应)、自定义控件开发流程和图形视图框架实践。适合用于GUI编程教学、PyQt5绘图功能验证或作为轻量级图像编辑器二次开发起点。


被折叠的 条评论
为什么被折叠?



