title: 手把手教你用 Python + PyQt5 做一个可视化图片切图工具
date: 2026-07-03
categories: Python PyQt5 工具开发
tags: Python, PyQt5, Pillow, 图片处理, GUI
手把手教你用 Python + PyQt5 做一个可视化图片切图工具
一、写在前面
日常游戏开发、UI 设计、精灵表(Sprite Sheet)处理中,我们经常需要把一张大图按网格切分成若干小图。市面上虽然有 PhotoShop、TexturePacker 等工具,但要么太重,要么需要付费,要么操作繁琐。
这篇文章将带你从零实现一个轻量级桌面切图工具------用鼠标框选区域,设置行列数,一键导出。最终效果如下:
(此处可插入工具运行截图:左侧大图显示区 + 右侧控制面板 + 黄色选区网格)

二、技术选型
| 需求 | 选型 | 理由 |
|---|---|---|
| GUI 框架 | PyQt5 | 功能丰富,跨平台,社区成熟 |
| 图片处理 | Pillow (PIL) | 轻量、读写格式全、裁剪接口简洁 |
| 语言 | Python 3.8+ | 开发效率高,生态完善 |
PyQt5 负责界面交互、图片显示、鼠标事件处理;Pillow 负责最终的像素级裁剪与文件输出。
三、整体架构设计
工具分为两层:
┌─────────────────────────────────────────┐
│ SlicerWindow │ ← 主窗口:布局 + 控制逻辑
│ ┌──────────────────┐ ┌──────────────┐ │
│ │ │ │ 控制面板 │ │
│ │ ImageLabel │ │ ├ 行数/列数 │ │
│ │ (QScrollArea) │ │ ├ 输出目录 │ │
│ │ │ │ └ 切图按钮 │ │
│ └──────────────────┘ └──────────────┘ │
└─────────────────────────────────────────┘
- ImageLabel :继承
QLabel,负责图片渲染、选区绘制、鼠标交互,是整个工具的核心组件。 - SlicerWindow :继承
QMainWindow,组装布局、管理文件对话框、执行切图导出。
四、核心难点与解决方案
4.1 坐标映射------两个坐标系的换算
图片加载后按比例缩放并居中显示在控件中。鼠标在控件上点击的位置,需要转换成图片的原始像素坐标,才能做精确裁剪。
图片原始坐标 (x_img, y_img) ←→ 控件坐标 (x_widget, y_widget)
换算公式:
python
x_img = (x_widget - offset_x) / scale_factor
y_img = (y_widget - offset_y) / scale_factor
代码实现:
python
def _to_img(self, wx, wy):
return QPoint(
int((wx - self.offset_x) / self.scale_factor),
int((wy - self.offset_y) / self.scale_factor)
)
def _from_img(self, ix, iy):
return QPoint(
int(ix * self.scale_factor + self.offset_x),
int(iy * self.scale_factor + self.offset_y)
)
图片的
offset_x/y是实现居中显示的关键------(控件宽 - 缩放后图片宽) // 2。
4.2 选区持久化------窗口缩放后不跑偏
所有选区数据(select_rect)统一存储在图片原始坐标系 中。每次绘制时通过 _widget_rect() 实时转换到控件坐标系:
python
def _widget_rect(self, img_rect):
tl = self._from_img(img_rect.x(), img_rect.y())
br = self._from_img(
img_rect.x() + img_rect.width(),
img_rect.y() + img_rect.height()
)
return QRect(tl, br)
这样一来,无论窗口如何缩放、图片如何重绘,选区在原图上的位置始终不变。
4.3 手柄系统------8 个方向的自由调整
选区确认后显示 8 个黄色拖拽手柄(4 个角 + 4 条边的中点),每个手柄对应不同的拖拽行为:
python
def _handle_rects(self, wr):
hs = self.HANDLE_SIZE # 8px
hh = hs // 2
x, y, w, h = wr.x(), wr.y(), wr.width(), wr.height()
return {
'tl': QRect(x - hh, y - hh, hs, hs), # 左上角
'tr': QRect(x + w - hh, y - hh, hs, hs), # 右上角
'bl': QRect(x - hh, y + h - hh, hs, hs), # 左下角
'br': QRect(x + w - hh, y + h - hh, hs, hs), # 右下角
'top': QRect(x + w//4, y - hh, w//2, hs), # 上边
'bottom': QRect(x + w//4, y + h - hh, w//2, hs),
'left': QRect(x - hh, y + h//4, hs, h//2),
'right': QRect(x + w - hh, y + h//4, hs, h//2),
}
命中检测 + 光标反馈:
python
def _hit_handle(self, pos):
for name, hr in self._handle_rects(wr).items():
if hr.contains(pos):
return name
return None
def _cursor_for_handle(self, handle):
return {
'tl': Qt.SizeFDiagCursor, 'br': Qt.SizeFDiagCursor,
'tr': Qt.SizeBDiagCursor, 'bl': Qt.SizeBDiagCursor,
'top': Qt.SizeVerCursor, 'bottom': Qt.SizeVerCursor,
'left': Qt.SizeHorCursor, 'right': Qt.SizeHorCursor,
}.get(handle)
4.4 拖拽状态机------三种操作模式
鼠标交互分为三种模式,通过状态变量 _drag_mode 区分:
mousePressEvent
├─ 点击手柄 → _drag_mode = 'resize'
├─ 点击内部 → _drag_mode = 'move'
└─ 点击外部 → _drag_mode = 'new'(清除旧选区,创建新选区)
mouseMoveEvent
├─ resize: 根据手柄名称只修改对应的边/角
├─ move: 整体偏移选区
└─ new: 从起点拉出新矩形
mouseReleaseEvent → 重置状态,过滤过小选区(<5px)
Resize 模式的核心逻辑------根据拖拽的手柄决定修改哪些边:
python
if h in ('tl', 'left', 'bl'):
r.setX(min(r.right() - 10, r.x() + dix))
if h in ('tl', 'top', 'tr'):
r.setY(min(r.bottom() - 10, r.y() + diy))
if h in ('tr', 'right', 'br'):
r.setWidth(max(10, r.width() + dix))
if h in ('bl', 'bottom', 'br'):
r.setHeight(max(10, r.height() + diy))
边界保护:min(r.right() - 10, ...) 和 max(10, ...) 防止选区被拖拽到反转或消失。
五、切图算法详解
选区 + 网格参数确定后,切图逻辑非常简单:
python
# 1. 在原图上裁取选区
crop = pil_image.crop((x, y, x + w, y + h))
# 2. 计算每个格子尺寸
cell_w = w // cols
cell_h = h // rows
# 3. 逐格裁剪并保存
for r in range(rows):
for c in range(cols):
left = c * cell_w
top = r * cell_h
right = left + cell_w if c < cols - 1 else w
bottom = top + cell_h if r < rows - 1 else h
tile = crop.crop((left, top, right, bottom))
tile.save(f"slice_{r+1:02d}_{c+1:02d}.png", "PNG")
关键细节 :最后一行/列不直接使用 cell_w * (c+1),而是直接用选区的宽/高边界 w / h。这是因为 w 可能不能被 cols 整除,直接截断会丢失像素。用边界值兜底可以确保覆盖全部选区,不会出现缝隙或丢失。
六、完整代码结构
slicer.py (约 487 行) ← 单文件,无外部资源依赖
├── ImageLabel(QtWidgets.QLabel) ← 核心画板
│ ├── 属性 (origin_pixmap, select_rect, rows/cols, _drag_*)
│ ├── 坐标转换 (_to_img, _from_img, _widget_rect)
│ ├── 手柄系统 (_handle_rects, _hit_handle, _cursor_for_handle)
│ ├── 事件 (mousePress/Move/Release, resizeEvent, paintEvent)
│ └── 信号 rect_changed(QRect)
└── SlicerWindow(QMainWindow) ← 主窗口
├── 左侧 QScrollArea + ImageLabel
├── 右侧控制面板 (打开、行列、目录、切图)
└── 导出方法 export_slices()
6.1 ImageLabel 核心属性
python
origin_pixmap: QPixmap # 原始图片
scale_factor: float # 缩放比例
offset_x, offset_y: int # 居中偏移(像素)
select_rect: QRect | None # 选区(图片坐标系)
rows, cols: int # 网格行列数
_dragging: bool # 是否正在拖拽
_drag_mode: str | None # 'new' / 'move' / 'resize'
_drag_handle: str | None # 当前拖拽的手柄名称
_drag_start_widget: QPoint # 拖拽起始点(控件坐标)
_drag_start_rect: QRect # 拖拽起始选区快照
6.2 SlicerWindow 主窗口布局

七、使用演示
7.1 基础流程
- 启动程序:
python slicer.py - 点击「打开图片」,选择一张大图
- 在图片上按住鼠标左键拖拽,松开后出现黄色选区
- 拖拽选区边角的手柄微调大小,或拖动内部移动位置
- 右侧设置行数 = 4,列数 = 4,选区上实时显示 4×4 网格
- 点击「开始切图」,输出目录下生成 16 个 PNG 文件
7.2 命名规范
slice_01_01.png ← 第1行第1列
slice_01_02.png ← 第1行第2列
...
slice_04_04.png ← 第4行第4列
八、完整源码
python
# 完整源码见同目录 slicer.py(约 487 行)
# 或访问:https://github.com/HuangHunterPlus/python_image_slicer_tools
核心代码已在文章中分段解析,完整源码在文末附带的 slicer.py 文件中。你也可以直接复制各章节的代码片段自行组装。
这里再贴一下启动入口供参考:
python
def main():
app = QApplication(sys.argv)
app.setStyle("Fusion") # 跨平台统一外观
window = SlicerWindow()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
九、最后
本文实现了一个完整的 PyQt5 图片切图工具,核心要点包括:
- 坐标映射:在控件坐标系和图片坐标系之间做精确换算,确保选区不随缩放漂移
- 手柄系统:8 个方向拖拽手柄 + 光标反馈 + 边界保护,提供和 PhotoShop 类似的交互体验
- 拖拽状态机 :通过
_drag_mode区分新建/移动/调整三种操作,逻辑清晰且易于扩展 - Pillow 裁剪:最后一行/列自动吸收余数,保证无像素丢失
整个工具单文件、零外部依赖(除 PyQt5 和 Pillow),非常适合作为 Python GUI 编程的练手项目,也可以直接集成到游戏开发、UI 切图等实际工作流中。