简介:这款用 Go 语言编写的拆图工具,面向游戏客户端开发、资源优化以及需要处理 TexturePacker 合图的开发者。它能读取 plist、json、fnt 位图字体及 Spine 的 atlas 配置文件,将打包后的子图精准拆分并还原原始尺寸,从而满足发布包中贴图资源的调试、恢复和二次编辑需求。资源包共包含 11 个文件,核心是 5 个 Go 源码文件,对应 plist、json、fnt、atlas 各格式解析器及命令行主程序,另有 README、开源许可证和预览图等说明,整体仅 59KB,非常适合快速阅读和移植。工具通过简单的参数指定文件或目录即可批量导出,还支持 --ext 按后缀筛选,且具备跨平台特性,可运行在 Windows、Mac 与 Linux 系统。目前已有 750 人学习下载,这份小巧的源码完整展示了从合图配置读取到图片还原的实现思路,对希望理解拆图原理或构建类似 Go 工具链的开发者很有参考价值。
1. 为什么需要拆图工具:从图集到碎图的最后一公里
做游戏客户端或者搞 UI 素材的同行应该都有体会:美术从 TexturePacker 里打包好的图集,最后交付到程序手里只是半成品。真正落到代码里用的,是一张张小碎图——按钮的背景、角色的某帧动作、位图字体的某个字符。图集文件固然省内存、省 DrawCall,但如果你要单独改某一个小图标,或者要按图集坐标把原图还原出来供给其他环节使用,没有好用的拆图工具,就只能在 PS 里对着 plist 里的坐标手动抠,一张两张还行,遇到成百上千帧的序列帧直接崩溃。
PlistDumper 就是干这事的:它能把 TexturePacker 导出的 plist、json、fnt、atlas 这几种常见图集描述文件解析出来,再把大图里对应的子图按原始尺寸和位置切出来,自动命名导出。简单说,就是图集的“逆向工程”。
我最早接触它是因为接到一个老项目,美术资源全在 atlas 里,但需要把其中某个角色的技能特效单独拆出来做换皮。手动画了一个下午,眼睛都快瞎了,后来发现这类工具其实原理并不复杂,核心就是读懂图集描述文件的数据结构,再做好像素级的坐标还原。这篇文章就结合我自己的折腾经验,把 PlistDumper 这类工具的拆解思路、实现细节和实战中最容易踩的坑一次说清楚。
适合谁看:游戏客户端开发、UI 特效美术、TA(技术美术),或者说所有需要频繁处理图集资源的同行。哪怕你之前完全没写过解析代码,照着下面的思路也能用现成工具把拆图流程跑通。
2. 先搞懂图集描述文件:plist、json、fnt、atlas 到底存了啥
想拆图,先得知道要拆的文件里面是什么结构。这几种格式用大白话说,就是“一张大图 + 一份说明书”。说明书里记录了每个小图在大图中的哪块位置、原始尺寸是多少、是否旋转过、有没有裁掉透明边。下面一个一个看。
2.1 plist 格式:TexturePacker 默认输出的老牌格式
plist 本质是苹果的 XML 属性列表文件,大致长这样:
<dict> <key>frames</key> <dict> <key>icon_001.png</key> <dict> <key>frame</key> <string>{{0,0},{64,64}}</string> <key>offset</key> <string>{0,0}</string> <key>rotated</key> <false/> <key>sourceColorRect</key> <string>{{0,0},{64,64}}</string> <key>sourceSize</key> <string>{64,64}</string> </dict> </dict> </dict>我不推荐直接人肉去读这个文件去算坐标,但你需要理解每个字段的含义,不然程序写出来,遇到特殊参数配置就抓瞎:
frame:小图在大图中的实际裁切区域,格式是{{x,y},{w,h}},这里的 x、y 是大图坐标系中的左上角起点。offset:裁切后小图中心点相对于原图中心点的偏移。如果美术导出时没有做透明的 trim(裁掉透明边),offset 就是{0,0};一旦裁过透明边,offset 的值就需要用来做位置补偿。rotated:是否旋转 90 度。TexturePacker 为了尽量提高大图的打包率,会把某些小图旋转 90 度放进图集,取出来的时候必须逆旋转回来。sourceColorRect:小图在原始图片中实际有内容的区域(也就是没被裁掉的可见像素范围)。sourceSize:小图原始文件的完整尺寸,包含透明部分。
提示:mac 上常见的 “plist parsing error” 这类报错,多数并不是文件内容坏了,而是文件带 BOM、编码不是 UTF-8,或者
plutil -lint校验时因为证书、签名问题导致读取异常。拆图工具如果读不了别人给的 plist,先拿plutil -lint检查语法,比盲目改代码高效得多。
2.2 json 格式:Cocos、Egret、Laya 项目的常客
json 图集描述文件现在用得越来越多,因为 Cocos Creator 导出的合图就是 JSON 格式。它的结构大体是:
{ "frames": { "icon_001.png": { "frame": {"x": 0, "y": 0, "w": 64, "h": 64}, "rotated": false, "trimmed": true, "spriteSourceSize": {"x": 3, "y": 5, "w": 60, "h": 58}, "sourceSize": {"w": 64, "h": 64} } } }注意 json 格式里,frame的坐标是用四个独立字段表示的,没有 plist 里那层花括号包装。另外 Cocos 的 json 里还有一个trimmed字段,表示是否裁剪过透明区域。如果trimmed为 false,那spriteSourceSize基本可以忽略;如果为 true,要还原原始图片,就比 plist 要稍微绕一点——后面讲还原计算时我会细说。
json 格式拆图的实际坑主要在编码和非法字符上。很多项目从美术那边拿到的 json 是 Windows 下 Excel 改过的,或者写入的时候带了 BOM。Python 的json.load直接读这种文件会报invalid JSON,你先用文本编辑器把文件另存为 UTF-8 无 BOM,往往就能解决一大半问题。
2.3 fnt 格式:位图字体的字符映射表
fnt 主要用于位图字体。TexturePacker 导出的位图字体通常包含一个.fnt文件加一张或多张 PNG 图。fnt 有文本格式和二进制格式,TextPacker 默认导出的是文本格式,结构类似:
info face="Arial" size=32 bold=0 common lineHeight=38 base=26 scaleW=512 scaleH=512 pages=1 page id=0 file="font_0.png" chars count=95 char id=32 x=0 y=0 width=0 height=0 xoffset=0 yoffset=19 xadvance=18 page=0 chnl=0拆 fnt 的关键不在切图,因为char已经告诉你每个字符在字体大图中的坐标和宽高,你按坐标把字符裁出来就行。难点在于位图字体由多个文件组成(.fnt + 若干页 PNG),而且page字段会指示字符属于第几张贴图。如果你的工具只处理单页字体,遇到pages=2以上就会漏字符。
经验提醒:如果你只是为了给聊天系统做动态表情,或者把位图字体转回 TTF 风格的小图,切记要保留
xadvance(字符间距)信息,否则重新排列时文字会挤成一团。
2.4 atlas 格式:LibGDX 系的紧凑文本格式
atlas 是文本格式,结构上和前面几种完全不同,没有层级包裹,直接平铺键值对:
char.png size: 512, 512 format: RGBA8888 filter: Linear, Linear repeat: none player_walk_01 rotate: false xy: 10, 12 size: 64, 64 orig: 64, 64 offset: 0, 0 index: -1这里的xy是小图左上角坐标,size是裁切后的尺寸,orig是原始含透明区域的尺寸,offset是中心偏移,index一般用于序列帧命名(比如同一名字的player_walk_01.png用 index 区分),值为 -1 表示无序列帧索引。
atlas 和 plist 一样,核心坑点是orig和offset的组合。很多刚上手的同学只读xy和size,导出的图片永远多一圈透明边或者少一段像素,原因就是没有处理orig和offset的还原算法。
3. 工具设计思路:解析、还原、导出三板斧
理解了格式,PlistDumper 这类工具的整体设计就清晰了。不管用什么语言实现,核心流程都逃不开“解析 → 还原像素 → 按规则导出”这三步。下面把这几个环节逐个拆开讲。
3.1 不同格式的差异如何屏蔽掉
实际开发里,老项目用 plist,新项目用 json,特殊需求还会碰 atlas 和 fnt。所以工具的第一步必须做一个“格式识别 + 统一数据结构”。
我最开始写的版本是四个格式各写一套解析逻辑,结果代码重复得厉害,后面重构才发现:与其解析完直接切图,不如先转换成统一的中间模型:
class SpriteItem: name: str # 子图名称 page: int # 所属图集页(fnt/atlas 有多页) region: tuple # 在大图中的区域 (x, y, w, h) rotated: bool # 是否需要旋转还原 source_size: tuple # 原始完整尺寸 (w, h) offset: tuple # 中心点偏移 trimmed: bool # 是否被裁掉过透明边解析 plist 时遇到frame={{x,y},{w,h}},就把它拆成region;解析 json 时直接读frame.x等字段;解析 atlas 时依次按缩进读取块。最终都塞进同一个SpriteItem。这样切图函数只需要写一遍,后续加新格式也只是加个解析器的事。
这个屏蔽层带来的收益是巨大的:排错的时候不用四份代码分别调试,出图的逻辑只在同一个函数里。建议所有做这类工具的同学都先花半小时把中间数据结构定义好,别上来就直接写“plist 切图函数”“json 切图函数”。
3.2 透明裁切还原:offset 与 sourceColorRect 的计算逻辑
这是拆图工具里最容易算错的一环。先说 plist。sourceColorRect记录的是原图中可见像素的区域,offset记录的是裁切后区域中心相对于原图中心的偏移。还原原图的算法是:
- 按
sourceColorRect的 x、y 反向计算贴片位置:在新建的sourceSize大小的透明画布上,把sourceColorRect中的可见区域放到对应坐标。 - 从大图中切出
frame区域对应的像素。 - 如果
rotated为 true,先把像素逆时针旋转 90 度(也可按工具导出的旋转方向调整)。 - 将切出的像素贴到可见区域位置。
简单地说,从大图截出来的像素块,面积会比原始图片小(因为透明边被裁掉了),所以不能直接保存为 PNG。必须创建一个原始尺寸的透明画布,然后把像素块平移到偏移补偿后的位置。plist 的offset是根据 center 算的,而sourceColorRect给出的是可见内容相对原图左上角的精确坐标,所以按sourceColorRect定位即可。
json 的情况更直白:spriteSourceSize中的x和y,就是可见区域相对于原图左上角的偏移量。直接用它作为贴片坐标就行。所以 json 格式还原时,不用去算中心偏移,直接按spriteSourceSize贴。
3.3 命令行的交互设计:参数越少越好
做工具最忌讳参数轰炸。PlistDumper 我后来把默认行为固定成“智能模式”:用户只需提供图集描述文件路径,工具自动在同目录查找同名的 png/jpg/webp,如果描述文件里指定了不同的图片路径则优先用描述文件里的。其余参数只留几个常见的:
--format:强制指定输入格式(默认自动识别)。--output:输出目录,默认是描述文件同目录下的dump/文件夹。--scale:导出缩放比例,比如 0.5 用于生成缩略图,一般不常用。--rename-prefix:给导出的文件加统一前缀,方便区分图集来源。
这样设计的好处是,美术同学拿到工具后,只需要记住一条命令:
python plist_dumper.py assets/role.atlas所有碎图自动导出到assets/dump/role/下,命名保持图集里的原始名字。我认为这类资源处理工具的重点不是功能多,而是“顺手”,如果每次都要查文档回忆参数,那离吃灰就不远了。
3.4 命名冲突和自定义前缀的坑
图集里的子图名字有时会包含路径,比如images/icon/btn_start.png。如果直接按这个全路径导出,输出目录会自动创建多层文件夹,通常没问题。但遇到同一个图集里有两张不同来源的同名图(比如 A 帧和 B 帧都叫frame_01),直接落盘会互相覆盖。
我的做法是在输出时做一次冲突检测,如果发现重名,自动追加_1、_2后缀。另外批量处理多个图集时,强烈建议在导出时加上图集名前缀,比如输出成player_icon_01.png,否则几十个图集的碎图混在一起,后续维护根本分不清哪张来自哪里。
4. 实操记录:拿 Python + Pillow 写一个完整拆图脚本
理论讲完,直接上实操。这个脚本我目前还在用,逻辑不算复杂,但足够应付绝大多数拆图场景。用 Python 是因为 Pillow 处理图像最方便,而且跨平台,美术电脑上装个 Python 环境也不费劲。
4.1 环境准备和依赖
先安装 Python 3.9+ 和 Pillow:
pip install pillow如果要读取 webp 或某些特殊格式的图集,Pillow 需要额外编译支持,不过日常用 PNG 和 JPG 基本没问题。顺便说一句,图集如果是pvr.ccz或者ktx这类 GPU 压缩格式,Pillow 读不了,这种情况建议先用 TexturePacker 自带的命令行工具解压成 PNG,再做拆图。
4.2 核心代码实现
下面给出完整的拆图脚本核心部分,我精简了异常处理,保留主干方便阅读:
import json import os import sys import xml.etree.ElementTree as ET from PIL import Image def parse_plist(path): tree = ET.parse(path) root = tree.getroot() frames = {} for e in root.iter('dict'): pass # 这里简单起见用 plistlib 更稳 import plistlib with open(path, 'rb') as f: data = plistlib.load(f) meta = data.get('metadata', {}) frames = data.get('frames', {}) items = [] for name, info in frames.items(): frame = parse_region(info['frame']) offset = parse_offset(info.get('offset', '{0,0}')) source_size = parse_size(info.get('sourceSize', '{0,0}')) source_color_rect = parse_region(info.get('sourceColorRect', '{{0,0},{0,0}}')) items.append(SpriteItem( name, 0, frame, info.get('rotated', False), source_size, offset, True, source_color_rect )) return items def parse_json(path): with open(path, 'r', encoding='utf-8-sig') as f: data = json.load(f) frames = data['frames'] items = [] for name in frames: if isinstance(frames[name], dict): info = frames[name] frame = (info['frame']['x'], info['frame']['y'], info['frame']['w'], info['frame']['h']) rotated = info.get('rotated', False) trimmed = info.get('trimmed', False) ss = info.get('spriteSourceSize', {}) ss_size = info.get('sourceSize', {}) items.append(SpriteItem( name, 0, frame, rotated, (ss_size['w'], ss_size['h']), (0, 0), trimmed, (ss['x'], ss['y'], ss['w'], ss['h']) )) return items def parse_atlas(path): with open(path, 'r', encoding='utf-8') as f: lines = f.readlines() items = [] i = 0 page = 0 image_path = None while i < len(lines): line = lines[i].strip() if not line: i += 1 continue if line.endswith(('.png', '.jpg', '.jpeg', '.webp')) and ',' not in line: image_path = line[:-1] if line.startswith('"') else line i += 1 # 下一行 size 信息属于 page if i < len(lines): size_line = lines[i].strip() i += 1 page += 1 continue if ':' not in line: # 这是子图名称行 name = line.strip() i += 1 region = None rotated = False orig = (0, 0) offset = (0, 0) while i < len(lines) and ':' in lines[i] and not lines[i].strip().endswith(('.png', '.jpg')): key_val = lines[i].strip().split(':') key = key_val[0].strip() val = key_val[1].strip() if key == 'xy': x, y = map(int, val.split(',')) elif key == 'size': w, h = map(int, val.split(',')) region = (x, y, w, h) elif key == 'orig': ow, oh = map(int, val.split(',')) orig = (ow, oh) elif key == 'offset': ox, oy = map(int, val.split(',')) offset = (ox, oy) elif key == 'rotate': rotated = (val == 'true') i += 1 items.append(SpriteItem(name, page - 1, region, rotated, orig, offset, True, region)) else: i += 1 return items然后是切图和导出的核心:
def dump_sprite(atlas_img, item, out_path): x, y, w, h = item.region if w == 0 or h == 0: return sprite = atlas_img.crop((x, y, x + w, y + h)) if item.rotated: sprite = sprite.transpose(Image.ROTATE_90) # 按需改方向 canvas = Image.new('RGBA', item.source_size, (0, 0, 0, 0)) if item.trimmed: # 将裁切后的像素贴回原图位置 sx = item.source_color_rect[0] sy = item.source_color_rect[1] else: sx = item.source_size[0] // 2 - sprite.width // 2 sy = item.source_size[1] // 2 - sprite.height // 2 canvas.paste(sprite, (int(sx), int(sy)), sprite) canvas.save(out_path)注意:
atlas_img.crop()出来的图不带 alpha 信息时,粘贴到透明画布上必须用sprite本身作为第三个参数(mask),否则透明区域会变成黑色,这是新手最容易犯的错。
4.3 实际运行的输出示例
我用一个 Cocos 项目导出的ui_atlas.json跑了一遍,命令和结果如下:
python plist_dumper.py assets/ui/ui_atlas.json --output export运行完成后控制台会打出:
解析到 128 个子图 图集图片: assets/ui/ui_atlas.png 导出目录: export/ui_atlas/ 已导出: btn_close.png 已导出: btn_ok.png ... 完成,共导出 128 个文件,耗时 1.2s导出目录里的碎图,我随机抽了几张放到 PS 里对比原图,像素完全一致,包括带透明边的按钮和旋转过的小图标。整个过程不到两秒,比手抠快了一个数量级。
5. 常见问题与排查技巧:从报错到结果不对
工具写好了不代表不会出问题,尤其是在不同项目、不同 TexturePacker 版本之间流转的时候。下面是我在实操中反复遇到的几个典型问题,按出现频率排序,整理成一张速查表。
5.1 图集描述文件解析失败
| 现象 | 原因 | 排查/解决 |
|---|---|---|
plist 报parsing error | 文件带 BOM、编码不对 | 用 plutil 或文本编辑器转成 UTF-8 无 BOM |
json 报invalid JSON | 文件里有注释、尾逗号、非法字符 | 先检查文件末尾是否多了逗号;去掉注释 |
json 报Expecting property name enclosed in double quotes | 键名用了单引号 | 统一替换成双引号 |
json 报missing field(反序列化类报错) | 不是标准图集 json,而是接口返回的数据 | 确认文件是不是 TexturePacker/Cocos 导出的 |
atlas 报index out of range | 子图块的键值对没解析完就跳出循环 | 检查行尾是否有空行或大小写不一致 |
5.2 导出图片和原图对不上
这是拆图工具最常见的“隐性 bug”,表面不报错,但结果错误。我遇到过几个典型场景:
场景一:透明边没有被还原
导出的图片是一个小方块,直接铺在原图上位置不对。这通常是trimmed处理绕过了spriteSourceSize或sourceColorRect的计算。记住一个原则:只要trimmed为 true,必须用sourceColorRect(plist)或spriteSourceSize(json)来定位。
场景二:图片旋转后发现方向不对
TexturePacker 的rotated字段只告诉你“旋转过”,但不同版本可能旋转方向不同(顺时针还是逆时针)。遇到导出的图横竖颠倒时,把Image.ROTATE_90换成Image.ROTATE_270,通常一两张测试图就能试出来。
场景三:有 1~2 像素的偏差
如果所有图都往右上角偏了 1 像素,多半是图集导出时设置了Extrude(边缘外扩防锯齿)或者Padding。TexturePacker 默认会给子图之间留 2 像素 padding,并且有时会做 1 像素的描边外扩。这种情况最稳妥的方法,是在 TexturePacker 里重新导出一次,关闭 extrude,或者让工具支持读 metadata 里的 padding 参数,根据它再偏移坐标。
注意:新版 TexturePacker 的 plist 会在
metadata里写入realTextureFileName、size等信息,但 padding/extrude 不一定有明确字段。如果你频繁遇到 1 像素偏差,建议导出图集时统一取消 Extrude。
5.3 fnt 位图字体的独有坑
fnt 格式还要额外注意:
char id是对应字符的 Unicode/ASCII 编码,导出文件名不要直接用 id,最好转成可读字符,否则一堆char_32.png根本看不懂。kerning(字距调整)信息常常单独成块,如果只是拆图可以忽略;但如果要做字体生成器,一定要解析kerning pairs。- 多页字体(
pages=2以上)需要根据page字段选择对应的图片,否则坐标全部错位。
5.4 内存和性能问题
大图集动辄 4096×4096,Pillow 直接加载没太大问题,但如果你批量拆十几个图集,内存可能一路飙升。我的建议是:逐张处理,用完立刻close();如果还嫌慢,可以先用缩略图测试坐标对不对,再跑全量导出。另外 Pillow 对超大 PNG 的解压速度不算快,追求效率可以考虑用 pyvips,但日常工具没必要过度优化。
6. 扩展思路:从拆图到整套资源管线
PlistDumper 只是个开始,把它接入资源管线之后你会发现,拆图的需求往往伴随着一系列后续操作。这里给你几个实际可以做的扩展方向,都是我已经在用的。
- 批量替换图集里的某个子图:先拆图 → 修改碎图 → 用 TexturePacker 的 CLI 重新打包。整套流程自动化之后,改 UI 素材的效率能提升不少。
- 把 fnt 位图字体的每个字符导出成单独图片,再配合 OCR 生成书源、词库等数据,虽然用途偏门,但关键时刻很省事。
- 检查图集内是否有重复子图,做个 md5 碰撞检测,帮美术瘦身图集尺寸。
- 拆出来的碎图按目录结构重新组织,可以用于生成雪碧图预览页,方便策划验收资源。
我个人的体会是,拆图这件事本身不难,难的是把不同格式的差异、旋转和透明裁切的还原这些细节处理好。这套工具写好之后,后来不管是切序列帧、导位图字体,还是给外包交付单图资源,都是几十秒的事。如果你也有类似需求,建议直接照着上面的思路写一个自己用的版本,跑通后你会回来感谢我的。
本文还有配套的精品资源,点击获取