ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

H5游戏图集拆解实战:plist/atlas解析与Python还原脚本

H5游戏图集拆解实战:plist/atlas解析与Python还原脚本

简介:面向Cocos、白鹭(Egret)与Laya等2D游戏引擎开发者的图集/合图分解实用工具,用于将精灵表按plist元数据还原为单张图片,便于资源编辑、版本更新与多设备适配。压缩包共6个文件、约227KB,以可执行程序为主,含splitImage主程序及调试宿主程序,辅以Newtonsoft.Json动态库与XML文档用于解析图集配置数据,另有清单文件与调试符号文件支撑运行与排错。已有1286人学习下载,说明其在资源拆分场景中具备一定实用参考价值。工具围绕导入合图与plist、解析子图坐标尺寸、裁剪分割、输出单图并生成新plist的流程设计,读者可借此理解精灵表结构与元数据组织方式,掌握图集拆分思路,并迁移到字体图集等资源的整理与优化工作中,提升游戏资源管理效率。

1. 图集拆解这件事,为什么值得单独拎出来讲

接手一个二手 H5 小游戏项目,美术资源全被打进一张 2048×2048 的大图里,配一个 plist 描述文件,代码里按帧名取图。现在要换皮、要改某个按钮、要把其中几张图挪到新项目里复用——你手里只有这张大图和一份 XML,没有原始 PSD,也没有分层的散图。这时候能救命的,就是一个能把图集还原成单张 PNG 的分解工具。cocos、白鹭(Egret)、laya 这几个国内 H5 引擎生态里,图集打包是标配流程,TexturePacker、引擎自带的打包插件都会产出「大图 + plist/atlas/json」的组合,而分解工具就是这条流水线的逆向出口。它适合三类人:接手遗留项目要改资源的、做资源迁移要拆包复用的、以及想搞清楚 plist 里那些 frame、offset、rotated 字段到底在说什么的。下面按「先看懂格式、再动手拆、最后避坑」的顺序走一遍。

2. 先读懂 plist 与 atlas:图集描述文件到底存了什么

2.1 plist 的两种形态与关键字段

cocos 系图集最常见的描述文件是 plist,本质是一个 XML(也有二进制 plist,工具一般会先转成 XML 再解析)。它的结构分两层:frames字典描述每一张小图,metadata描述整张图集。真正决定「怎么切」的字段就那么几个,看懂了就不会被工具的黑匣子行为搞懵。

字段含义分解时的作用
frame小图在大图中的矩形(x/y/w/h)决定裁剪区域,注意 y 轴方向
rotated是否旋转 90 度存储为 true 时必须反向旋转回来
sourceSize小图原始尺寸还原画布大小,处理透明边
sourceColorRect原始有效像素在 sourceSize 中的位置决定贴回画布时的偏移
offset相对中心点的偏移部分引擎用它做锚点对齐

frame里的 y 坐标是最容易翻车的地方。plist 沿用 Cocos2d 的坐标系,原点在左上角、y 向下;而很多图像库(比如 Python 的 Pillow)裁剪时原点在左上、y 也向下,看起来一致,但一旦你手动换算成「左下原点」就会整体上下颠倒。我的习惯是:解析出来先不做任何坐标变换,直接按frame的 x/y/w/h 裁,裁完再对照sourceSize检查尺寸对不对,对不上再怀疑坐标系。

2.2 rotated 与 offset:两个最容易被忽略的还原参数

rotated=true表示这张小图在打包时被顺时针旋转了 90 度塞进大图,目的是提高 packing 利用率。分解时必须逆时针转回来,否则你拿到的图是躺着的。判断方向不能靠猜,标准做法是:裁出w×h的块后,如果 rotated 为真,先把块逆时针旋转 90 度,得到的尺寸应该等于sourceSize(在无 trim 的情况下)。如果转完尺寸反而对不上,说明旋转方向搞反了,换顺时针再试一次——这是我在处理白鹭导出的图集时踩过的坑,不同打包器对 rotated 的方向约定并不完全统一。

offset和sourceColorRect处理的是「透明边裁剪」。打包器为了省空间,会把小图四周全透明的像素裁掉,只存有效区域,原始尺寸记在sourceSize里。分解时如果只按frame裁,你会得到一张被裁瘦的图,贴回 UI 就会错位。正确做法是新建一张sourceSize大小的透明画布,把裁出的有效像素按sourceColorRect的偏移贴回去。很多一键分解工具默认不做这步,导出的图看着没问题,一进引擎就偏,血泪经验就是先确认工具有没有「还原原始尺寸」的选项。

2.3 atlas 与 json 格式的差异

laya 和部分白鹭项目用的是.atlas文本格式,结构比 plist 扁平,一行图片名、一行x,y,w,h、一行offsetX,offsetY、一行origW,origH,没有 XML 的嵌套。cocos creator 新版本则可能导出.json,字段名是frames数组加spriteSourceSize、sourceSize、rotated。三种格式语义相通,字段名不同,写解析脚本时建议先做一个「统一中间结构」,把三种格式都归一成{name, x, y, w, h, rotated, sourceW, sourceH, offsetX, offsetY},后面的裁剪逻辑就只写一份。这个归一化层是整套工具能不能同时吃三种引擎资源的关键,也是我建议自己写而不是纯依赖现成 GUI 的原因——GUI 工具往往只认一种格式。

3. 动手拆一张图集:从解析到落盘的完整脚本

3.1 环境与依赖

用 Python 写分解脚本是最省事的,Pillow 负责图像裁剪和旋转,plistlib是标准库自带,解析 XML plist 不用装额外包。装依赖就一行:

pip install pillow

如果你的 plist 是二进制格式,plistlib.load也能直接读,不用先转 XML。atlas 和 json 则用普通文本解析即可。建议单独建一个工作目录,把大图和描述文件放进去,输出目录也建好,避免脚本跑完散落一地。

3.2 解析 plist 并归一化

import plistlib import os def load_plist(path): with open(path, 'rb') as f: data = plistlib.load(f) frames = data.get('frames', {}) result = [] for name, info in frames.items(): fr = info['frame'] # 字符串 "{x,y},{w,h}" # plist 里 frame 是字符串,需要手动拆 xy, wh = fr.split('},{') x, y = [int(float(v)) for v in xy.strip('{').split(',')] w, h = [int(float(v)) for v in wh.strip('}').split(',')] ss = info.get('sourceSize', f'{{{w},{h}}}') sw, sh = [int(float(v)) for v in ss.strip('{}').split(',')] scr = info.get('sourceColorRect', f'{{{{0,0}},{{{w},{h}}}}}') # sourceColorRect 形如 "{{x,y},{w,h}}" inner = scr.strip('{}') pos, size = inner.split('},{') ox, oy = [int(float(v)) for v in pos.strip('{').split(',')] result.append({ 'name': name, 'x': x, 'y': y, 'w': w, 'h': h, 'rotated': bool(info.get('rotated', False)), 'sourceW': sw, 'sourceH': sh, 'offsetX': ox, 'offsetY': oy, }) return result

这段的关键在frame和sourceColorRect都是字符串而非结构化数据,必须手动拆。int(float(v))是为了兼容"12.0"这种带小数点的写法,直接int("12.0")会抛异常。归一化后的列表就是后面裁剪的唯一输入,格式无关的解析到此结束。

3.3 裁剪、反旋转、还原透明边

from PIL import Image def split_atlas(img_path, frames, out_dir): os.makedirs(out_dir, exist_ok=True) atlas = Image.open(img_path).convert('RGBA') for f in frames: # 1. 按 frame 裁出存储块 block = atlas.crop((f['x'], f['y'], f['x'] + f['w'], f['y'] + f['h'])) # 2. rotated 为真则逆时针转回 if f['rotated']: block = block.rotate(90, expand=True) # 3. 还原到 sourceSize 画布,按 offset 贴回 canvas = Image.new('RGBA', (f['sourceW'], f['sourceH']), (0, 0, 0, 0)) canvas.paste(block, (f['offsetX'], f['offsetY'])) # 4. 文件名去掉扩展名,避免路径问题 safe = f['name'].replace('/', '_') canvas.save(os.path.join(out_dir, safe))

逻辑说明:第 1 步裁剪用的是左上原点、y 向下的坐标,和 plist 的frame一致,不做任何翻转。第 2 步rotate(90, expand=True)是逆时针,expand=True保证旋转后画布跟着变,否则会被裁掉一角。第 3 步是还原透明边的核心——sourceW/sourceH是原始尺寸,offsetX/offsetY是有效像素在原始画布里的左上角位置,贴回去就得到和打包前一模一样的图。第 4 步把名字里的斜杠替换掉,因为有些图集用ui/button/ok.png这种带路径的帧名,直接当文件名会创建失败。

参数上要留意:offsetX/offsetY在部分 plist 里是相对中心点的偏移而非左上角,如果你发现贴出来的图整体偏了半个身位,就把paste的位置改成(sourceW - w) // 2 + offsetX试一下。这个差异没有统一标准,只能靠对照原图验证。

3.4 批量处理与结果校验

单张跑通后,把目录下所有图集批量过一遍:

import glob for plist_path in glob.glob('input/*.plist'): base = os.path.splitext(plist_path)[0] png_path = base + '.png' if not os.path.exists(png_path): print('缺少大图:', png_path) continue frames = load_plist(plist_path) split_atlas(png_path, frames, os.path.join('output', os.path.basename(base))) print(f'{base}: 拆出 {len(frames)} 张')

校验方法很朴素但有效:拆完后统计输出图片数量是否等于frames数量,再随机抽几张用图片查看器打开,看有没有躺着的、有没有被裁掉边的。更严格一点,可以写个断言比对每张输出图的尺寸是否等于sourceSize,对不上就打印帧名,通常能立刻定位到 rotated 方向或 offset 语义的问题。

4. 避坑与排查:拆图集时最容易翻车的五件事

4.1 拆出来的图上下颠倒

现象:所有小图内容正确但整体上下翻转。原因:解析时把 plist 的 y 坐标当成了左下原点,做了height - y - h的换算。解决:plist 的frame就是左上原点、y 向下,和 Pillow 的crop一致,不要做任何 y 翻转,直接裁。

4.2 rotated 图转完尺寸对不上

现象:rotated=true的图旋转后宽高和sourceSize差一位或完全不符。原因:旋转方向搞反,或者旋转前裁的矩形宽高没交换。解决:先确认裁出块的尺寸,逆时针转 90 度后宽高应互换;若仍不对,改顺时针再试。个别打包器在 rotated 时frame的 w/h 已经是旋转后的值,需要先交换再裁。

4.3 图片边缘出现半透明杂色

现象:拆出的图边缘有一圈不属于原图的颜色。原因:大图是带预乘 alpha 的 PNG,直接裁剪再保存导致边缘混色。解决:读取时用convert('RGBA')并在保存前不做额外缩放;若源图确实是预乘 alpha,需要先反预乘再输出,否则贴到引擎里边缘会发黑。

4.4 帧名带路径导致保存失败

现象:脚本报No such file or directory。原因:帧名形如ui/icon/close.png,被当成相对路径。解决:保存前把/和\替换成_,或者按路径建子目录。前者更省事,后者保留目录结构,看你的复用需求。

4.5 二进制 plist 读出来是乱码

现象:用文本编辑器打开 plist 全是不可读字符,脚本解析报错。原因:这是二进制 plist,不是 XML。解决:用plistlib.load(f)以二进制模式打开即可,它会自动识别格式;不要用xml.etree去解析。

5. 进阶:把分解结果直接喂回引擎与自动化校验

拆完只是第一步,真正省时间的是让输出直接能被引擎认。cocos 和 laya 都支持「散图 + 自动打图集」的工作流,所以分解出的 PNG 只要命名和原帧名一致,丢进项目的 assets 目录,引擎重新打包时会按原逻辑取图,代码一行不用改。这里有个细节:如果原图集用了 trim(裁透明边),分解时已经还原成sourceSize,重新打包时引擎会再裁一次,最终渲染位置不变,但如果你的代码里写死了某张图的像素尺寸做碰撞检测,就要注意还原后的尺寸才是逻辑尺寸。

自动化校验我一般会加一段尺寸断言,跑完批量分解后统一报告异常帧:

from PIL import Image def verify(out_dir, frames): bad = [] for f in frames: p = os.path.join(out_dir, f['name'].replace('/', '_')) if not os.path.exists(p): bad.append((f['name'], 'missing')); continue w, h = Image.open(p).size if (w, h) != (f['sourceW'], f['sourceH']): bad.append((f['name'], f'size {w}x{h} != {f["sourceW"]}x{f["sourceH"]}')) return bad

这段断言能一次性把 rotated 方向错、offset 语义错、trim 没还原这三类问题全暴露出来,比肉眼抽查靠谱得多。跑通一次之后,我习惯把它固化成流水线的第一步:任何新接手的图集,先分解、再断言、全绿了才进改图环节。从那以后我每次拆图集都强制先跑一遍尺寸校验再动美术资源,省下的返工时间远比写这十几行脚本多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进