ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Cocos/白鹭/Laya图集拆解:plist帧描述解析与Python批量还原实战

Cocos/白鹭/Laya图集拆解:plist帧描述解析与Python批量还原实战

简介:面向Cocos、白鹭(Egret)与Laya等2D/3D游戏开发框架使用者的图集/合图分解工具,主要解决将精灵表按plist元数据还原为单张图片的需求,适用于资源编辑、版本更新与多设备适配等场景,对需要管理游戏资源的中初级开发者较为友好。压缩包共6个文件,约227KB,以2个exe可执行程序为主,另含dll动态库、xml文档、pdb调试信息与manifest清单文件,分别承担主程序运行、JSON数据读写、API参考与程序元数据定义等职责。已有1286人学习下载,说明该工具在同类资源中具备一定参考价值。读者可获得一套可直接运行的分解程序,配合plist解析逻辑完成合图裁剪与输出,并借助附带文档理解JSON配置的读写方式,为后续资源优化与工具二次开发提供基础。

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

接手一个二手 Cocos、白鹭或 Laya 项目,最让人头疼的往往不是业务逻辑,而是资源目录里那堆.plist加.png。美术给的是合图,策划要改其中一个小图标,你打开 TexturePacker 想重新导出,发现原始工程早就没了;或者做 H5 换皮,只想把旧图集里的按钮单独抠出来复用,结果面对一张 2048×2048 的大图和一份几百行的 plist 描述文件,只能靠肉眼一个个对坐标。图集分解工具解决的正是这个场景:把合图按 plist 里记录的 frame 信息切回一张张独立小图,顺带把旋转、偏移、原尺寸这些元数据还原对。它适合三类人——接手遗留项目的客户端、做资源二次利用的换皮团队、以及需要批量处理图集资源的工具链维护者。关键词 plist font 在这里不是字体,而是指 plist 里那套描述帧的键值结构,读懂它,整个拆解流程就通了。

2. 先搞懂 plist 帧描述:拆图不是切豆腐

很多人以为图集分解就是拿把刀按矩形切,真动手才发现切出来的图要么位置偏了,要么方向是躺着的,要么透明边被裁没了。问题全出在对 plist 帧描述的理解上。这一章把格式和字段讲透,后面写脚本才有依据。

2.1 两种主流 plist 格式的差异

Cocos 系工具链产出的 plist 常见两种:格式 2 和格式 3。格式 2 的帧信息直接挂在frames字典下,每个 key 是图片名,value 里有frame、offset、rotated、sourceSize、sourceColorRect;格式 3 多包了一层frames数组加metadata,字段名基本一致但结构位置不同。白鹭和 Laya 的导出器大多兼容格式 2,部分新版工具会吐格式 3。判断方法很简单,打开 plist 看frames下面第一个节点是字典还是数组,字典是格式 2,数组是格式 3。

字段含义拆图时的作用
frame该小图在大图中的矩形{{x,y},{w,h}}决定裁剪起点和宽高
rotated是否顺时针旋转 90 度存放为真时裁剪后要逆时针转回
sourceSize小图未合图前的原始尺寸还原画布大小,补透明边
sourceColorRect原始图中有效像素相对 sourceSize 的偏移矩形决定贴回画布的位置
offset合图时相对原图中心的偏移与 sourceColorRect 配合校正

rotated是最容易翻车的字段。合图工具为了省空间会把瘦高图横着放,plist 里标rotated: true,你按 frame 的宽高直接裁,得到的是一张躺倒的图。正确做法是先按 frame 裁出w×h,再逆时针旋转 90 度,旋转后宽高互换,才和 sourceSize 对得上。

2.2 用 Python 读 plist 并打印帧清单

动手前先写个只读脚本,把每帧的关键字段打出来,确认格式和字段完整性。这一步花五分钟,能省后面半小时的调试。

import plistlib def load_frames(plist_path): with open(plist_path, 'rb') as f: data = plistlib.load(f) frames = data.get('frames', {}) # 格式 3 的 frames 是 list,转成 name -> info 的字典 if isinstance(frames, list): frames = {item['filename']: item for item in frames} result = [] for name, info in frames.items(): result.append({ 'name': name, 'frame': info['frame'], 'rotated': info.get('rotated', False), 'sourceSize': info.get('sourceSize', info['frame'][1]), 'sourceColorRect': info.get('sourceColorRect'), 'offset': info.get('offset', (0, 0)), }) return result if __name__ == '__main__': for item in load_frames('hero.plist'): print(item['name'], item['frame'], 'rotated=', item['rotated'])

plistlib.load以二进制模式读,能同时兼容 XML 和二进制 plist,省去判断文件头的麻烦。frames为 list 时用filename做 key,这是格式 3 的约定。sourceSize缺失时退化为 frame 的宽高,避免后续 KeyError。打印结果里重点看rotated为真的条目有多少,以及sourceColorRect是否存在,这两项决定裁剪逻辑要不要分支。

2.3 裁剪、旋转、补边的正确顺序

拿到帧信息后,处理顺序不能乱:先按 frame 从大图裁矩形,再处理 rotated 旋转,最后按 sourceSize 建透明画布、用 sourceColorRect 把像素贴到正确位置。顺序颠倒的典型后果是旋转后坐标全错,或者补边补到了错误的一侧。

from PIL import Image def extract_frame(sheet, info): (x, y), (w, h) = info['frame'] crop = sheet.crop((x, y, x + w, y + h)) if info['rotated']: crop = crop.transpose(Image.ROTATE_90) # 逆时针转回 sw, sh = info['sourceSize'] canvas = Image.new('RGBA', (sw, sh), (0, 0, 0, 0)) if info['sourceColorRect']: (cx, cy), (cw, ch) = info['sourceColorRect'] canvas.paste(crop, (cx, cy)) else: canvas.paste(crop, (0, 0)) return canvas

Image.ROTATE_90在 PIL 里是逆时针 90 度,正好抵消合图时的顺时针旋转。sourceColorRect的(cx, cy)是有效像素在原始画布中的左上角坐标,paste 到这里透明边自然补齐。如果某帧没有sourceColorRect,说明合图时没裁边,直接贴左上角即可。这套顺序跑通后,单帧拆解就稳了。

3. 批量拆解与命名还原:把几百张图一次吐出来

单帧能拆只是起点,真实项目里一个 plist 动辄两三百帧,手工点不现实。这一章解决批量循环、输出命名、目录组织,以及拆完怎么验证没拆错。

3.1 批量循环与输出目录约定

批量脚本的核心是把上一章的extract_frame套进循环,同时处理输出路径。命名直接用 plist 里的 key,因为 Cocos、白鹭、Laya 运行时就是按这个 key 找图的,保留原名意味着拆出来的图能直接回填到项目里用。

import os def split_atlas(plist_path, png_path, out_dir): os.makedirs(out_dir, exist_ok=True) sheet = Image.open(png_path).convert('RGBA') frames = load_frames(plist_path) ok, fail = 0, [] for info in frames: try: img = extract_frame(sheet, info) safe_name = info['name'].replace('/', '_') img.save(os.path.join(out_dir, safe_name)) ok += 1 except Exception as e: fail.append((info['name'], str(e))) print(f'done={ok} fail={len(fail)}') for name, err in fail: print('FAIL', name, err)

convert('RGBA')保证大图有 alpha 通道,否则带透明边的帧贴到画布上会变黑。replace('/', '_')处理 plist 里带路径分隔的 key,避免在输出目录里意外建子目录。失败不中断循环而是收集起来最后打印,批量场景下比遇错即停更实用——你能一次看到所有问题帧,而不是改一个跑一次。

3.2 命名冲突与特殊字符的处理

plist 的 key 偶尔会带空格、中文、@2x后缀,甚至重复。重复 key 在字典结构里本不该出现,但格式 3 转字典时如果filename有重名,后一个会覆盖前一个,导致少图。稳妥做法是在转换时检测重名并加序号。

def dedup_frames(frames): seen = {} for info in frames: name = info['name'] if name in seen: seen[name] += 1 base, dot, ext = name.rpartition('.') info['name'] = f'{base}_{seen[name]}{dot}{ext}' if dot else f'{name}_{seen[name]}' else: seen[name] = 0 return frames

rpartition('.')从右往左找最后一个点,能正确处理icon.small.png这种多点文件名,把序号插在扩展名前。这一步放在split_atlas循环之前调用即可。带@2x的图不用特殊处理,原样保留,因为运行时靠后缀区分倍图,改名反而破坏引用。

3.3 拆完怎么验证:三张图对一遍

拆完不能只看文件数量对不对,得抽验。我的习惯是挑三类帧各看一张:一张rotated: true的、一张sourceColorRect明显小于sourceSize的、一张普通帧。验证方法不是肉眼扫,而是把拆出的图和原图集对应区域做像素比对。

def verify(sheet, info, out_path): img = Image.open(out_path) assert img.size == tuple(info['sourceSize']), 'size mismatch' # 反向贴回大图,比对有效区域 (x, y), (w, h) = info['frame'] region = sheet.crop((x, y, x + w, y + h)) if info['rotated']: region = region.transpose(Image.ROTATE_90) if info['sourceColorRect']: (cx, cy), (cw, ch) = info['sourceColorRect'] region = region.crop((0, 0, cw, ch)) target = img.crop((cx, cy, cx + cw, cy + ch)) assert list(region.getdata()) == list(target.getdata()), 'pixel mismatch'

尺寸断言先过一遍,能抓住旋转后宽高没互换的问题;像素比对抓的是贴边位置错。getdata()逐像素比在几百帧上会慢,所以只对抽验帧跑,不进批量主循环。这套验证跑通,基本可以放心把输出目录交给美术。

4. 避坑与排查:拆图翻车的五个真实场景

工具逻辑写对不代表结果对,图集来源五花八门,下面五条是实际拆解中最常撞上的。

4.1 拆出的图整体偏移几个像素

现象:图能拆出来,但和原图集里看到的位置对不上,边缘多一条或少一条。原因:plist 的frame坐标原点在左上角,而某些导出器写的是包含 padding 的外框,sourceColorRect才是真实像素区。解决:优先用sourceColorRect定位有效像素,frame只用来裁大图区域;如果两者宽高不一致,以sourceColorRect为准再裁一次。

4.2 rotated 帧拆出来是躺倒的

现象:竖长图标拆出来变成横的,或者宽高对调。原因:rotated为真时合图工具做了顺时针 90 度存放,脚本漏了逆旋转,或者旋转方向搞反。解决:确认用逆时针 90 度(PIL 的ROTATE_90),旋转后宽高互换再和sourceSize比对;如果转完还是不对,检查是不是用了ROTATE_270。

4.3 透明区域变成黑底

现象:拆出的 PNG 在深色背景的编辑器里看正常,贴到网页或游戏里透明边变黑。原因:大图打开时没转 RGBA,或者保存时丢了 alpha。解决:Image.open后立刻convert('RGBA'),新建画布也用 RGBA 模式;保存格式必须是 PNG,存 JPG 会直接丢透明通道。

4.4 格式 3 的 plist 读出来帧数为零

现象:脚本跑完输出 0 张图,但 plist 明明有内容。原因:格式 3 的frames是数组不是字典,按字典遍历拿不到 key。解决:加载后判断isinstance(frames, list),是列表就按filename字段转成字典;同时检查metadata里的format值确认版本。

4.5 同名帧互相覆盖导致少图

现象:输出文件数比 plist 里的帧数少。原因:格式 3 转字典时filename重名,后写覆盖前写。解决:转换阶段做重名检测,用dedup_frames加序号;跑完对比len(frames)和输出文件数,不一致就打印重复名排查。

提示:拆图前先备份原 plist 和 png,脚本只读不写原文件,输出全部落到独立目录,避免误覆盖。

5. 进阶:把拆解接进资源流水线

单次拆图解决的是救急,真正省时间的是把它变成流水线的一环。我现在的习惯是拆完顺手生成一份清单,记录每张图的来源 plist、原始 key、尺寸和是否旋转,后续换皮或做资源审计直接查表,不用再回头翻 plist。

import json def export_manifest(plist_path, out_dir): frames = load_frames(plist_path) manifest = [{ 'name': f['name'], 'size': list(f['sourceSize']), 'rotated': f['rotated'], 'source': os.path.basename(plist_path), } for f in frames] with open(os.path.join(out_dir, 'manifest.json'), 'w', encoding='utf-8') as f: json.dump(manifest, f, ensure_ascii=False, indent=2)

这份清单的价值在批量换皮时体现:新图集尺寸变了,但 key 不变,拿 manifest 做映射就能自动匹配新旧资源,缺哪张一目了然。另一个进阶用法是把拆解和重新合图串起来——拆出单图后按新规则重新打包,plist 由脚本重新生成,整个换皮流程不用打开任何图形工具。

场景拆解后动作收益
换皮复用按 manifest 映射新旧 key缺图自动暴露
资源审计统计尺寸与旋转占比发现冗余大图
重新合图单图按新规则打包全程脚本化

验证流水线是否可靠,我的办法是拿一个已知帧数的旧图集跑一遍,比对输出数量、抽验三张图的像素、再检查 manifest 条目数和 plist 帧数是否一致,三项全过才算通过。从那以后我每次接二手项目,第一步都是先跑一遍拆解加 manifest,把资源底数摸清再动业务代码,省下的返工时间远比写脚本多。希望帮到你。

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

RELATED READING

延伸阅读

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