Egret图集拆分与源图还原:Python处理旋转帧和多页图集
简介:图集拆分工具是一款面向白鹭引擎(Egret)游戏开发者的桌面实用程序,用于把合成后的 SpriteSheet 按 JSON 配置还原为独立图像文件,解决图集统一打包后单图难以编辑、替换与内存优化的问题。压缩包共 22 个文件、约 503KB,以 6 个 C# 源码文件为核心,配合 2 个可执行程序与 Newtonsoft.Json 等 DLL 依赖,另有 csproj 项目文件、resx 资源与 settings 配置及 bin、obj 编译产物,构成一套可直接运行、也可二次改造的 Windows Forms 工程。已有 1712 人学习下载。读者可从中获得完整的图集解析与切图实现思路:读取 JSON 中的坐标、宽高信息定位子图,批量裁剪导出并按原命名规则或自定义规则保存,同时了解界面事件处理、项目结构与资源引用的组织方式。适合需要在项目中批量拆图、替换美术素材或扩展预览、批处理等功能的开发团队参考。
1. 接手一个没有源图的老项目,图集拆分工具就是那颗后悔药
外包交付过来的 Egret 项目,resource/assets/底下整整齐齐躺着几个.png加一份.json,美术离职、源文件没交接,策划要换个按钮图标,你总不能去拆 TextureMerger 工程。这时候图集拆分工具干的活很朴素:读懂描述文件里每个 frame 贴图在大图上的坐标、尺寸、偏移,把一张 1024×1024 的大图重新切成几十张小图。它解决的不是"看图"问题,而是"把打包态还原回可编辑态"的问题,适合接手遗留项目的前端、做资源替换的客户端、以及要批量改图标的 UI 同学。难点不在裁剪,而在旋转帧、透明边距和原始画布还原这三处,下面一层层拆。
2. 读懂 Egret 图集的元数据:frames 里那八个数字各管一段
动手写代码之前,必须先确认手里这份描述文件到底属于哪种结构。图集拆错的绝大多数事故,都不是 Python 写错了,而是把字段含义理解反了——尤其是offX/offY和sourceW/sourceH这一组,理解偏差会让拆出来的图要么缺一块透明边,要么整体偏移。所以这一章先把元数据吃透,代码反而是后面最轻松的部分。
2.1 一份 Egret 图集描述文件长什么样
Egret 生态里最常见的图集导出方式是 TextureMerger,它默认产出的 json 结构基本沿用 TexturePacker 的 JSON(Hash) 布局:顶层一个frames对象,键是资源名,值是这个资源的打包信息;再加一个meta描述整张大图的尺寸和缩放。典型结构如下:
{ "frames": { "btn_close_png": { "file": "ui_atlas.png", "x": 512, "y": 0, "w": 48, "h": 48, "offX": 2, "offY": 2, "sourceW": 52, "sourceH": 52, "rotated": false } }, "meta": { "image": "ui_atlas.png", "size": { "w": 1024, "h": 1024 }, "scale": "1" } }这里要分清两个层级:meta.image指向的是物理大图文件名,frames里每个键是逻辑资源名。多页图集的场景下,meta.image只管默认页,真正的归属要看每个 frame 自己的file字段。我见过有人整个脚本只读meta.image,结果多页图集从第二页开始全部裁到大图的错误区域,画面看着像"图糊了",其实是坐标对到了另一张图上。
另外注意资源名后缀。TextureMerger 会把btn_close.png写成btn_close_png,斜杠会变成下划线,ui/btn_close.png可能变成ui_btn_close_png。拆出来保存文件名时,如果要还原成目录结构,需要自己定一套反向映射规则——这一点没有标准答案,我的习惯是原样保留键名,宁可文件名丑一点,也不要自作聪明猜路径,猜错一次就会覆盖。
2.2 八个数字的真实分工:裁剪区与原始画布是两回事
x / y / w / h描述的是裁剪矩形:这块像素在大图上的左上角坐标和宽高。sourceW / sourceH描述的是原始画布尺寸,也就是这张图在打包之前有多宽多高。offX / offY描述的是裁剪矩形在原始画布里的左上角偏移。
为什么会有这套设计?因为美术出的图经常四周全是透明像素,打包工具为了省空间会把透明边裁掉,只保存有内容的区域,然后记下"这块内容原本在整张图的什么位置"。渲染时引擎按sourceW × sourceH建画布,把裁剪图贴到(offX, offY),视觉上就跟原图一模一样。
这意味着拆分有两种产出:
- 紧凑版:只裁
w × h区域,直接存盘。适合做资源替换,因为图小、好改。 - 还原版:新建
sourceW × sourceH的透明画布,把紧凑图贴到(offX, offY)。适合给不认偏移的编辑器用,比如你直接把图丢进设计软件重做。
两者的差别在 UI 按钮上特别明显。一个圆角按钮的贴图如果左右各有 6px 透明边,紧凑版看上去"没边",你按紧凑版改完再塞回去,九宫格拉伸的边界就偏了,运行时按钮文字会贴着边。所以我的默认策略是还原版优先,除非明确知道下游只吃紧凑图。
提示:
sourceW和w相等时说明这张图没有透明边被裁掉,offX/offY一般为 0,这种情况两种产出完全一致,脚本里可以走快速分支省一次粘贴操作。
2.3 Hash 与 Array 两种布局,以及 plist 的等价字段
同一个打包工具的版本不同,frames可能是对象,也可能是数组。数组布局长这样:
{ "frames": [ { "filename": "btn_close_png", "x": 512, "y": 0, "w": 48, "h": 48 } ] }字段名从"键名"挪到了filename,其余一致。脚本第一件事应该是统一成字典,后面所有逻辑就只面对一种结构。
还有一类是苹果的 plist 格式,Cocos 系工具和部分老 Egret 项目会用它。字段名换了一套,但语义完全对应:
| plist 字段 | JSON 字段 | 含义 |
|---|---|---|
frame | x/y/w/h | 裁剪矩形,plist 里写作{{x,y},{w,h}} |
offset | offX/offY | 原始画布内偏移,注意 plist 里可能是负数 |
sourceSize | sourceW/sourceH | 原始画布尺寸 |
rotated | rotated | 是否旋转存储 |
textureRect存在时 | — | 部分工具用它替代frame,优先级更高 |
offset的符号差异是个坑。TexturePacker 的offX/offY表示裁剪图左上角在原始画布中的位置,通常是非负的;而部分 plist 变体里的offset是相对中心点的偏移,会出现负值。遇到负数别慌,换算方式是offX = (sourceW - w) / 2 + offsetX。这条换算我在两个项目里验证过,但不同导出器未必一致,稳妥做法是先挑一张四周有透明边的小图跑一遍,肉眼对一下再全量跑。
3. 用 Python 写拆图脚本:从裁剪到还原原始画布的完整代码
元数据搞清楚之后,实现部分其实只有三十来行。真正决定脚本能不能长期用的是目录约定和输出策略,因为拆图集往往不是一次性动作——策划改一版图、你重跑一次,输出目录里堆出btn_close(1).png、btn_close_new.png这种垃圾,两次之后就没人敢动了。所以这一章除了核心裁剪逻辑,还会把输入输出的规矩定死。
3.1 环境、目录约定与依赖
依赖只有两个:Python 3.8 以上和 Pillow。装好之后按下面的结构组织:
# 安装依赖 pip install pillow # 目录结构(约定好就不用每次改路径) atlas_split/ ├── input/ │ ├── ui_atlas.png # 图集大图,可以有多个 │ └── ui_atlas.json # 描述文件,与 png 同名 ├── output/ # 拆出来的散图,脚本自己建 └── split_atlas.py约定三条:描述文件和图片同名;多页图集的每一页都放在input/下;output/每次运行前由脚本清空重建,不做增量。第三条看着粗暴,但能彻底避免"改了图没生效"这类排查半天的玄学问题。
3.2 核心拆分函数:裁剪、旋转、还原三步走
先看完整实现,后面拆开讲。
import json import os import shutil from PIL import Image def normalize_frames(meta): """把 Hash / Array 两种布局统一成 {资源名: frame字典}""" frames = meta.get("frames", {}) if isinstance(frames, list): return {item["filename"]: item for item in frames} return frames def load_sheet(base_dir, frame, default_image): """按 frame 的 file 字段选大图,缺省用 meta.image""" image_name = frame.get("file") or default_image path = os.path.join(base_dir, image_name) return Image.open(path).convert("RGBA") def split_one(sheet, frame): """裁出一个 frame,处理旋转,并还原到原始画布""" x, y = frame["x"], frame["y"] w, h = frame["w"], frame["h"] src_w = frame.get("sourceW", w) src_h = frame.get("sourceH", h) off_x = frame.get("offX", 0) off_y = frame.get("offY", 0) if frame.get("rotated"): # 图集里存的是旋转后的矩形,占用区域宽高互换 cell = sheet.crop((x, y, x + h, y + w)) # 打包时顺时针转 90 度,还原时逆时针转回 cell = cell.rotate(90, expand=True) else: cell = sheet.crop((x, y, x + w, y + h)) # 还原原始画布:透明底 + 按偏移贴回 canvas = Image.new("RGBA", (src_w, src_h), (0, 0, 0, 0)) canvas.paste(cell, (off_x, off_y), cell) return canvas def split_atlas(json_path, out_dir): with open(json_path, "r", encoding="utf-8") as f: meta = json.load(f) base_dir = os.path.dirname(os.path.abspath(json_path)) default_image = meta.get("meta", {}).get("image") frames = normalize_frames(meta) if os.path.isdir(out_dir): shutil.rmtree(out_dir) os.makedirs(out_dir, exist_ok=True) for name, frame in frames.items(): sheet = load_sheet(base_dir, frame, default_image) image = split_one(sheet, frame) # 资源名里的 / 可能被工具换成了 _,这里不再猜路径,直接平铺 safe_name = name.replace("/", "_").replace("\\", "_") if not safe_name.lower().endswith(".png"): safe_name += ".png" image.save(os.path.join(out_dir, safe_name)) print(f"[ok] {safe_name} {image.size[0]}x{image.size[1]}") if __name__ == "__main__": split_atlas("input/ui_atlas.json", "output")逐步说逻辑。normalize_frames负责抹平布局差异,数组布局靠filename字段补回键名,这一步做完之后整个脚本只面对字典,不用再写分支判断。
load_sheet每次调用都重新Image.open,看着浪费,但这个选择是故意的:多页图集里不同 frame 属于不同大图,缓存要按文件名做字典,而单页图集下这个开销可以忽略(Pillow 有内部缓存机制,重复打开同一路径不会真读盘)。早期我写过全局缓存,结果多页图集切换时忘了清缓存,拆出来的图全串页了,排查了两小时,从此不在这种地方省事。
split_one是核心。注意旋转分支里裁剪矩形的宽高是反的:图集存旋转帧时,占用的是h × w的矩形,所以crop的参数要写成(x, y, x + h, y + w),裁完之后再rotate(90, expand=True)转回来。expand=True不能省,否则转完尺寸还是原样,图被裁掉一半。
最后新建sourceW × sourceH的透明 RGBA 画布,用paste(cell, (off_x, off_y), cell)贴上去。第三个参数是蒙版,传cell本身可以让粘贴尊重 alpha 通道——其实目标画布已经是全透明,不传也行,但传了在后续如果有背景填充时更安全,属于花不了什么成本的习惯性写法。
参数上最需要关注的是offX/offY。如果某份描述文件里这两个值是负数(来自 plist 的中心偏移约定),直接粘贴会抛ValueError: images do not match或者把图贴到画布外。这种情况要先把偏移换算成左上角坐标,换算公式见 2.3 节,换算后再贴。
3.3 输出命名与批量入口
保存之前有两步处理。第一步把路径分隔符替换成下划线,防止资源名里残留../之类的东西把文件写到输出目录外面去——外包给的 json 不一定干净,这条是安全底线。第二步补.png后缀,因为资源名经常是btn_close_png这种没有扩展名的形式,保存出来没后缀,双击打不开,还得手动改名。
批量场景下,input/里可能躺了十来个图集。加一个入口扫描就够:
def split_all(input_dir, output_root): for fname in sorted(os.listdir(input_dir)): if not fname.endswith(".json"): continue stem = os.path.splitext(fname)[0] split_atlas( os.path.join(input_dir, fname), os.path.join(output_root, stem), ) if __name__ == "__main__": split_all("input", "output")按 json 文件名分目录,一个图集一个子目录,绝不混在一起。原因很实际:不同的图集里经常出现同名资源,比如两个 UI 包都有icon_close_png,混在一个目录里互相覆盖,你还以为拆漏了图。
4. 旋转帧与多页图集:边界情况的拆法
顺顺利利拆完一批图只是及格线,真正让脚本能复用的是那几种边界情况。旋转帧、多页图集、重复帧这三类在商业项目里出现的概率不低——大图里塞一张竖版的长条形背景图,打包工具为了省空间几乎必然旋转它。这一章把这三类情况逐个拆解,每种都给判断依据和处理方式。
4.1 rotated 帧的宽高互换与旋转方向
rotated: true的帧有两件事要同时做对:裁剪矩形宽高互换,以及旋转方向。前者是硬规则,后者要看打包工具的约定。
硬规则部分:一个逻辑上 64×128 的竖图,旋转存储后在图集里占的是 128×64 的横向区域,w和h字段记录的是旋转后的尺寸还是原始尺寸,不同工具有差异。TexturePacker 的 JSON 里w/h记录的是原始尺寸(即 64×128),所以裁剪时必须换成(x, y, x + h, y + w)。但也有导出器直接写旋转后的尺寸,这时就不能换。
判断办法很简单:看rotated: true且w != h的帧,用两种方式各拆一张,肉眼比对。批量脚本里我一般加一个开关:
def crop_rotated(sheet, frame, swap_wh=True): x, y, w, h = frame["x"], frame["y"], frame["w"], frame["h"] if swap_wh: # 常见约定:w/h 是原始尺寸,占用区域宽高互换 cell = sheet.crop((x, y, x + h, y + w)) else: cell = sheet.crop((x, y, x + w, y + h)) return cell.rotate(90, expand=True)旋转方向是第二个变量。Pillow 的rotate是逆时针,rotate(90)和rotate(-90)都能把图转回来,但只有一个是对的,转反了图是横躺的、而且内容镜像。我的经验是绝大多数打包工具按顺时针存储,所以还原用逆时针,即rotate(90, expand=True)。但这不是定律,尤其是一些自研导出器。
血泪经验在这里:不要试图从字段推断方向,直接挑三张rotated: true的图拆出来看一眼,方向对了就固定下来。花两分钟确认,比全量拆完发现几百张图全是躺着的强——那种时候你只能重新跑一遍,而且如果已经在跑下游流程,清理代价更大。
4.2 多页图集:file 字段决定一切
单个图集超过 2048×2048 时,打包工具会自动分页,产出ui_atlas.png、ui_atlas_1.png这样的多张大图,而描述文件通常只有一个。这时候meta.image只指向第一页,每个 frame 靠自己的file字段指明归属。
代码里load_sheet的写法frame.get("file") or default_image已经在处理这件事,关键在于别在外面提前把大图打开传进来。我见过一种写法是先sheet = Image.open(meta["image"]),然后整个循环都用这一个sheet去裁。单页没问题,多页就全乱了:第二页的资源会按坐标去第一页上找,裁出来的图要么是别的图标,要么一片空白。
判断是否多页也很简单:统计所有 frame 的file字段去重数量,大于 1 就是多页。日志里打一行出来,能让后面接手的人一眼看懂:
files = {f.get("file") or default_image for f in frames.values()} print(f"图集共 {len(frames)} 帧,分布在 {len(files)} 张大图上")这里还有个衍生问题:多页图集拆出来的图,如果中途有一页 png 缺失,脚本会在Image.open处直接抛FileNotFoundError,整个批处理中断。我的处理是把异常捕获掉、记录到失败清单、继续拆下一页,最后统一输出一份失败报告。拆图是脏活,脏活的脚本要能容忍局部失败。
4.3 重复帧、九宫格与不该拆的东西
有些打包工具会做去重:两张一模一样的图只在大图上存一份,描述文件里两条记录指向同一个(x, y, w, h)。这不是问题,按正常流程拆会得到两个一模一样的文件,反而符合预期——资源名不同就是两个逻辑资源,下游可能就是要两份。
真正需要注意的是九宫格数据。Egret 的九宫格信息一般不在图集 json 里,而是写在主题文件的scale9grid字段或者单独的配置里,形如"30,30,30,30"。拆图集时这部分信息不跟着图走,拆完就丢了。如果下游要做 UI 还原,一定要把九宫格数据单独导一份出来,哪怕就是一个文本文件:
# 顺手把九宫格等渲染参数导出成一份清单,别让它跟着图一起消失 grids = {} for name, frame in frames.items(): if "scale9grid" in frame: grids[name] = frame["scale9grid"] if grids: with open(os.path.join(out_dir, "_scale9grid.json"), "w", encoding="utf-8") as f: json.dump(grids, f, ensure_ascii=False, indent=2)对应地,有一类资源根本不该拆:字体位图(BMFont 的.fnt+ png 组合)、Spine 的 atlas 文件。它们虽然也是"大图 + 描述",但描述文件里的坐标是给专用解析器用的,拆成散图之后没有任何意义,反而把原本完好的资源搞坏了。扫描输入目录时建议按扩展名把这几种直接跳过,让拆分范围收窄到纯图集。
5. 避坑与排查:拆出来糊了、错位了、名字重了怎么办
拆图集的报错大多不抛异常——脚本跑完打印一堆[ok],打开一看图不对,这才是最费时间的地方。下面五条是我自己踩过或者帮别人定位过的,每条都按现象、原因、解决三段写,遇到问题可以直接对号入座。
5.1 拆出来的图整体偏了几个像素
现象:图能拆出来,尺寸也对,但边缘多了一条黑边或者少了一块,UI 拼回去之后元素之间有一两个像素的缝隙。
原因:offX/offY被当成了裁剪矩形的坐标偏移,直接加到x/y上去了。这是最常见的一类误解。offX/offY是相对原始画布的偏移,跟大图上的x/y没有任何关系,两者分属两个坐标系。把它们相加,等于在同一次裁剪里叠加了两次位移。
解决:裁剪只用x/y/w/h,offX/offY只在最后paste到透明画布时使用。如果确认代码是这样写的还是偏,那大概率是偏移符号问题,参考 2.3 节的换算公式检查一遍,重点看offX/offY是不是出现了负数。
5.2 图是躺着的,而且内容镜像
现象:横图正常,竖图全变成横向的,内容左右反了。
原因:rotated: true的帧旋转方向搞反了。rotate(90)和rotate(-90)都能把矩形转成目标尺寸,但内容方向一个对一个错,只有一个是真正的还原。
解决:把rotate(90, expand=True)改成rotate(-90, expand=True)试一次,肉眼比对一张图就能确定。更省事的判断方式是找一张带文字或者明显上下文的图(比如带箭头的按钮),旋转对不对一眼就能看出来,比对着几何图形猜快得多。
5.3 部分图凭空消失,脚本还全绿
现象:frames里有 200 条,输出目录只有 180 个文件,但控制台没报错。
原因:文件名冲突。资源名里带路径分隔符,替换成下划线之后发生碰撞,比如ui/btn.png和ui_btn.png都会变成ui_btn.png,后写的覆盖先写的。因为image.save不报错,覆盖是静默的。
解决:保存前检查目标文件是否已存在,存在就加后缀并打印警告:
target = os.path.join(out_dir, safe_name) if os.path.exists(target): stem, ext = os.path.splitext(safe_name) print(f"[warn] 重名:{safe_name},已追加序号") target = os.path.join(out_dir, f"{stem}__dup{ext}") image.save(target)跑完把[warn]的数量和图集帧数对一遍,对不上说明还有别的问题。
5.4 透明边变成了一圈黑边
现象:拆出来的 PNG 打开正常,但丢进某些编辑器或者用某些工具缩放之后,透明区域变成黑色。
原因:canvas = Image.new("RGBA", ..., (0, 0, 0, 0))这一行被改成了 RGB 模式,或者用了 JPEG 中间格式转过一道。JPEG 不支持 alpha,透明区域会按黑色填充。
解决:全程用 RGBA,中间不落任何有损格式。如果下游确实需要一张"带背景色的预览图",单独出一份,别拿那份当资源用。这条坑在资源替换流程里杀伤力最大,因为预览图看着"就是黑了点",很容易被当成美术问题而不是格式问题。
5.5 拆完图能看,但拼回去对不上
现象:单张图看着都对,用工具拼回大图跟原图对比,有几处像素不一致。
原因:极端情况下是打包工具对重叠区域做了合并,两张图的透明边缘共享了同一片像素;也可能是旋转帧的旋转方向只对了一部分图,剩下的方向相反但尺寸相同,单看一张发现不了。
解决:做一次回拼校验。按 frame 的x/y/w/h把拆出来的图(反向旋转后)贴回一张空的大图上,跟原始 png 做像素级 diff。diff 为 0 说明拆分完全无损,非 0 就定位到具体是哪个 frame,逐个查。这个方法在最后一张进阶章里详细展开,属于值得固定成流程的一步。
6. 回拼校验与批量化:把拆出来的图再拼回去当证据
拆完不做校验,等于交作业不检查。我的固定动作是:拆完立刻按原坐标把散图拼回一张大图,跟原始 png 算像素差。全零说明无损,非零就按 frame 定位。这段代码不长,但能省掉后面所有"到底哪张图错了"的扯皮。
from PIL import ImageChops def rebuild_and_diff(png_path, json_path, out_dir, diff_path="diff.png"): with open(json_path, "r", encoding="utf-8") as f: meta = json.load(f) frames = meta.get("frames", {}) if isinstance(frames, list): frames = {item["filename"]: item for item in frames} origin = Image.open(png_path).convert("RGBA") rebuilt = Image.new("RGBA", origin.size, (0, 0, 0, 0)) for name, frame in frames.items(): safe_name = name.replace("/", "_").replace("\\", "_") if not safe_name.lower().endswith(".png"): safe_name += ".png" img = Image.open(os.path.join(out_dir, safe_name)).convert("RGBA") # 只取有内容区域,去掉还原画布时的透明边 w, h = frame["w"], frame["h"] off_x, off_y = frame.get("offX", 0), frame.get("offY", 0) cell = img.crop((off_x, off_y, off_x + w, off_y + h)) if frame.get("rotated"): # 反向旋转 + 宽高互换,贴回图集 cell = cell.rotate(-90, expand=True) rebuilt.paste(cell, (frame["x"], frame["y"])) else: rebuilt.paste(cell, (frame["x"], frame["y"])) diff = ImageChops.difference(origin, rebuilt) bbox = diff.getbbox() diff.save(diff_path) print("回拼一致" if bbox is None else f"差异区域:{bbox}") return bboxgetbbox()返回None就代表两张图完全一致,这是 Pillow 里最省事的判等方式,比逐像素遍历快得多。返回了 bbox 也不代表一定错了——有时原始大图里存在未被任何 frame 覆盖的空白区(打包工具留的 padding),回拼后那片是透明的,跟原图若有底色就会差出来。所以看到差异先看 bbox 位置,对上 padding 区域就放过。
参数上有两个细节。回拼时要从拆出来的图里裁掉透明边(crop到offX, offY起点的w × h),因为贴回大图只需要内容区,带边贴会重叠挤压其他帧。旋转帧回拼要用rotate(-90),跟拆分时的rotate(90)严格互逆,这一对方向只要对不上,diff 必然非零,也算是给 4.1 节的方向判断做了一个自动化的兜底。
批量化的时候,我把这套校验接在split_all后面,每个图集拆完立刻回拼一次,日志里统一打回拼一致或者差异区域。有一次就是这么逮到的:一批 12 个图集里只有一个报差异,定位到某张图rotated为真但方向相反,其他 11 个都正常——如果只肉眼抽查,这张图大概率会被放过,等美术替换完资源上线才发现图标是镜像的。
从那以后我每次拆图集,都强制走一遍"拆完回拼、diff 归零"这两个动作,不看输出目录里有多少文件、只看 diff 打印什么。这套流程跑顺了其实只多十几秒,换来的是后面所有环节都敢放心改。希望帮到你。
本文还有配套的精品资源,点击获取