简介:这是一款面向白鹭引擎(Egret)游戏开发者的图集拆分工具,用于将SpriteSheet合成图集按JSON配置精确切割为独立PNG小图,方便单独替换角色动画帧、UI元素或重新整理资源,进而优化游戏内存占用。资源包共22个文件、约503KB,涵盖完整的Visual Studio解决方案:6个C#源文件(窗体逻辑、程序入口及设置)、csproj项目定义、resx窗体资源、编译后的exe、运行依赖的Newtonsoft.Json.dll及XML文档、调试缓存和配置文件,既能直接运行也便于二次修改。工具流程包括导入图集JSON与PNG、解析各子图坐标与尺寸、按原始命名规则裁剪并保存到指定路径,核心逻辑清晰,开发者可轻松加入批量处理、图像预览、自定义命名或批量格式转换等功能。目录还展示了Windows Forms项目从源文件到编译产物的组织方式,对学习桌面软件结构有参考价值。已有1709人学习下载,适合需要高效管理Egret美术资源或实现图集逆向拆分的游戏开发者。
1. 图集拆分工具:把一张 2048 图集还原成几百张原始 PNG 的逆向工序
接手一个只留下图集 PNG 和描述文件的老项目,美术想改其中几张 UI 图标,但工程里找不到单独的源图,这种场景几乎每个做客户端或游戏开发的都遇到过。图集拆分工具做的就是这件事:输入的是一张合成好的大图加一份机器可读的坐标清单,输出的是一张张可以继续编辑的独立 PNG。它本质上不是画图软件,而是一个按照描述文件把大图逆向裁回原图的解析程序。拆法并不涉及高深的图形学,真正的门槛全藏在描述文件的格式语义里——旋转方向、修剪偏移、外扩边缘这些字段一旦理解错,拆出来的图张张能用却又张张不对。这篇文章适合要做旧项目资源还原、把图集切回散图重新维护的开发者,也适合想给自己的素材工具链补上逆向环节的从业者。
2. 图集描述文件字段拆解:frame、rotated、offset、sourceSize 数据模型
2.1 一张图集 PNG 背后,必须有一份机器可读的坐标清单
图集技术流行的原因很简单:把很多小图拼到一张大图里,运行时只需要加载一次纹理、提交一次绘制批次,性能收益非常明显。但代价是“原始小图去哪了”这个问题没了答案。常见图集工具导出时通常会带一份描述文件,里面记录每个子图在大图中的位置、是否旋转过、原图尺寸是多少、透明边被裁掉了多少。拆分工具的核心工作,就是把这份描述翻译回像素操作。
不同引擎和工具链的描述文件格式差别很大,常见的有 JSON、plist、LibGDX 的文本 atlas,但它们的语义高度相似。只要能先把字段含义吃透,后面无论面对哪种格式,都是解析层的事情,真正裁剪拼接的逻辑可以一套代码通吃。这也是为什么我建议写拆分工具时先把数据结构抽象出来,而不是一上来就针对某个格式写死逻辑。
2.2 五个决定拆分结果的字段:frame、rotated、spriteSourceSize、sourceSize、offset
先看一张 TexturePacker JSON 里典型的单帧描述长什么样,然后逐个字段拆:
{ "walk_01.png": { "frame": {"x": 2, "y": 68, "w": 64, "h": 96}, "rotated": false, "trimmed": true, "spriteSourceSize": {"x": 8, "y": 4, "w": 64, "h": 96}, "sourceSize": {"w": 80, "h": 100} } }字段含义如下:
| 字段 | 含义 | 拆分时忽略的后果 |
|---|---|---|
| frame | 子图在大图中的矩形位置 x/y/w/h | 裁错位置,取到邻图内容 |
| rotated | 存储时是否顺时针旋转了 90 度 | 方向不对,输出横躺或颠倒 |
| trimmed | 是否裁掉了透明边缘 | offset 和 spriteSourceSize 失去效用 |
| spriteSourceSize | 裁剪后的内容在原始尺寸画布中的放置位置 | 还原画布时位置偏移 |
| sourceSize | 这张图未裁剪前的原始宽高 | 画布尺寸不对,透明边分布不均匀 |
| offset | 内容中心相对原图中心的偏移,plist 格式常见 | 精灵叠回背景时错位 |
这里最容易犯的认知错误是:frame 的 w/h 是“存储时”的宽高,不是原始宽高。如果 rotated 为 true,frame 里记录的其实是旋转后占用的矩形,原始内容宽高要先把 w 和 h 互换才成立。而 spriteSourceSize 给出的是内容在原始画布里的摆放位置,如果描述文件里有这个字段,还原画布时优先相信它,比自己去算 offset 靠谱得多。
2.3 通用拆分逻辑:裁剪、旋转、还原画布三步走
所有图集拆分的核心流程都可以提炼成三步,不区分具体格式:
# 第一步:从图集大图上切出 frame 矩形 sub = atlas.crop((fx, fy, fx + fw, fy + fh)).copy() # 第二步:如果存储时旋转了 90 度,就逆时针转回来 if rotated: sub = sub.rotate(-90, expand=True) # 第三步:把内容贴回原始尺寸画布 canvas = Image.new("RGBA", (source_w, source_h), (0, 0, 0, 0)) left = (source_w - sub.width) // 2 top = (source_h - sub.height) // 2 canvas.paste(sub, (left, top))为什么第二步要用-90而不是90?绝大多数图集工具的约定是:存储时将原始内容顺时针旋转 90 度以换取更紧凑的矩形,所以拆分时要把这个操作逆回去。第三步里的// 2是默认居中放置,适用于没有修剪、没有偏移的帧;一旦 trimmed 或 offset 存在,就得用 spriteSourceSize 或者 offset 重新计算位置。后面避坑章节会专门展开这里的细节。
2.4 padding 与 extrude:不影响字段语义,却会影响像素内容
图集打包时通常会留“内边距”和“外扩”。内边距是子图和子图之间的空像素,避免线性过滤采样到隔壁图片的颜色;外扩是把每个子图边缘的像素向外复制几圈,防止纹理边缘在缩放或插值时出现黑边。这两者不会出现在描述文件的坐标字段里,但会直接影响 frame 矩形里存的像素是否“干净”。
具体来说,有的工具会把外扩部分算进 frame 的 w/h,有的不算。就算描述文件说得清清楚楚,不同版本的工具行为也不一定一致。我一般会拿到图集后先肉眼检查几个帧的边缘,看到外扩颜色异常时再决定是否在裁剪后剥离边缘一圈像素。这种判断不适合写死在代码里,做成参数让用户按实际图集行为去调才稳妥。
3. 用 Python 30 行跑通 TexturePacker JSON 图集拆分脚本
3.1 环境与输入准备
Python 搭配 Pillow 是最适合做这件事的组合:Pillow 的crop、rotate、paste三个 API 正好覆盖拆分三步走,完全不需要引入 OpenCV。开始之前准备好两样东西:图集 PNG、它对应的 JSON 描述文件。
mkdir atlas-tool && cd atlas-tool python -m venv .venv source .venv/bin/activate pip install Pillow如果追求更快的批量处理速度,可以额外装一个numpy用来做后面章节提到的像素级验证,但拆分本身不需要。图集图片的格式可能是 PNG、WebP 甚至压缩过的格式,建议统一用Image.open(...).convert("RGBA")读入,避免之后 paste 时因为颜色模式不一致报错。
3.2 最小拆分脚本:JSON + PNG 到独立 PNG
完整的最小脚本如下:
import json import pathlib from PIL import Image def split_texture_packer(png_path: str, json_path: str, out_dir: str = "out") -> None: atlas = Image.open(png_path).convert("RGBA") data = json.loads(pathlib.Path(json_path).read_text(encoding="utf-8")) # TexturePacker 的 JSON 可能是 {"frames": {...}},也可能直接就是帧字典 frames = data["frames"] if isinstance(data.get("frames"), dict) else data out = pathlib.Path(out_dir) out.mkdir(parents=True, exist_ok=True) for raw_name, item in frames.items(): # item 可能是 {"frame": {...}, ...},也可能本身就是 frame 数据 fd = item if "frame" not in item else item["frame"] x, y, w, h = (int(fd[k]) for k in ("x", "y", "w", "h")) sub = atlas.crop((x, y, x + w, y + h)).copy() if item.get("rotated", False): # 存储时顺时针旋转 90 度,恢复时逆时针转回 sub = sub.rotate(-90, expand=True) src = item.get("sourceSize") ss = item.get("spriteSourceSize") if not src: # 描述文件没有原始尺寸,直接保存裁剪结果 save_path = out / raw_name save_path.parent.mkdir(parents=True, exist_ok=True) sub.save(save_path) continue canvas = Image.new("RGBA", (int(src["w"]), int(src["h"])), (0, 0, 0, 0)) if ss: # 有 spriteSourceSize 时直接采用它的坐标,最可靠 canvas.paste(sub, (int(ss["x"]), int(ss["y"]))) else: # 没有时退化为居中放置 left = (canvas.width - sub.width) // 2 top = (canvas.height - sub.height) // 2 canvas.paste(sub, (left, top)) save_path = out / raw_name save_path.parent.mkdir(parents=True, exist_ok=True) canvas.save(save_path)几个关键点:crop之后必须.copy(),否则它返回的是原图的一个视图,后续rotate和paste会碰到奇怪的共享内存问题;rotated的判断放在裁剪之后、画布还原之前,顺序不能反;spriteSourceSize的出现优先级高于手工计算 offset,因为前者已经是工具算好的最终坐标。这段脚本里没有剥除外扩、没有处理九宫格,属于最小可用版本,后面避坑章节会补上这些能力。
3.3 参数化入口与目录批量
单个脚本很容易写,但真实项目往往是一个目录下几十张图集、几十份 JSON。我给这个脚本加了标准的命令行入口,方便在 CI 或批量任务里直接调用:
import argparse def main(): parser = argparse.ArgumentParser(description="图集拆分工具:JSON 描述 + PNG 生成独立小图") parser.add_argument("atlas", help="图集 PNG 路径") parser.add_argument("meta", help="图集 JSON 描述路径") parser.add_argument("-o", "--out", default="out", help="输出目录") parser.add_argument("--strip-extrude", type=int, default=0, help="剥除外围像素数,按实际图集行为设置") args = parser.parse_args() strip_extrude = args.strip_extrude if strip_extrude: # 这里是对最小脚本的增强:裁掉 frame 外围像素 pass split_texture_packer(args.atlas, args.meta, args.out)批量处理时,只要目录下有配对的 PNG 和 JSON,用一个for循环逐对调用split_texture_packer即可。我习惯在批量前先跑一张图集人工检查输出,确认旋转和裁剪方向都对,再全量跑。真实项目里一次跑错几十张图集,返工比慢慢跑还费时间。
4. 图集拆分避坑:旋转方向、修剪偏移、外扩边缘与九宫格的五个翻车现场
4.1 旋转方向搞反:拆出来全部横躺
现象:图集里某些帧裁出来内容是横的,或者上下颠倒,而且不是全部帧出问题,只有标记了 rotated 的帧出错。原因:把顺时针存储理解成了逆时针存储,恢复时用了rotate(90)而不是rotate(-90)。解决:先把单个帧拆出来,用图片查看器确认内容方向,再批量执行。不要盲目相信记忆中的约定,不同格式甚至同一种格式不同版本的工具都可能改方向。验证方法也很简单:找一张旋转过的帧,拆出来后和原始工程里对应的资源对比一次方向即可。
4.2 trimmed + offset 没还原:图拆出来了,位置全在乱
现象:输出的 PNG 单独看内容都对,但导入编辑软件或者按 old 坐标叠回背景时,图片位置整体偏移,尤其是动画序列帧特别明显。原因:frame 记录的是“裁剪后”的矩形,sourceSize 记录的是“原始”画布尺寸,中间差的透明边全靠 spriteSourceSize 或 offset 来补齐。如果忽略这两个字段,所有内容都被居中或贴到画布角落,换来的就是系统性偏移。解决:优先使用 spriteSourceSize 直接放置;没有该字段时再退回 offset 公式:
left = (source_w - sub.width) // 2 + offset_x top = (source_h - sub.height) // 2 - offset_y注意这里offset_y前面是减号。plist 格式里 y 轴正方向习惯往上,而 Pillow 的坐标系是从左上角往下,不做符号翻转就会上下颠倒错位。
4.3 外扩边缘没剥:拆出的图带上了邻图的颜色
现象:某些小图边缘出现一条狭长的、明显不属于本图的颜色,通常是半透明的,叠加到深色背景上尤其明显。原因:图集生成时开启了外扩或者羽化,把边缘像素向外复制了几圈;frame 矩形如果包含这部分外扩,拆分脚本就会把它原样保存成图片内容。解决:为脚本增加--strip-extrude参数,裁剪后剥掉外围指定像素数。但不要对每张图都强行剥,有的图集外扩是透明的,剥离反而会让有效内容变小。做法是先裁一张图肉眼确认,再决定全局参数。
4.4 九宫格信息丢掉:UI 控件拉伸后变形
现象:拆出来的按钮、对话框背景图单独看没问题,但放到界面里拉伸后圆角变成椭圆、边框粗细不均匀。原因:图集描述文件里通常记录了几宫格切片信息,例如 TextrurePacker 的 slices 字段、LibGDX atlas 的 split 字段,拆分脚本只输出了 PNG,九宫格信息被留在元数据里没有带出来。解决:把 slices 信息写成同名 sidecar 文件,或者按 Android.9.png的规范直接生成带黑边的九宫格图:
slices = item.get("slices", []) if slices: sidecar_path = save_path.with_suffix(save_path.suffix + ".slice.json") sidecar_path.write_text(json.dumps(slices, indent=2), encoding="utf-8")截图到手时先把 slice 数据落盘,后续引擎适配时就不用重新对着大图数像素了。
4.5 同名文件互相覆盖:文件数和帧数永远对不上
现象:拆分日志显示成功处理了 300 帧,输出目录里却只有 280 个文件,而且缺的多是不同目录下同名的资源。原因:图集允许不同路径的子图叫同一个名字,输出时全部平铺到一个目录,后写入的直接覆盖前面的。解决:输出时按原始路径结构创建子目录;如果描述文件没有路径信息,则在重名帧后追加短哈希后缀。跑完批量后,把输出文件数量跟帧数量做一次比对,数量不一致就说明有覆盖发生了。
5. 兼容 Cocos plist 与 LibGDX atlas:解析器适配与统一拆分入口
5.1 三种描述格式的字段对照
JSON、plist、atlas 虽然长得完全不一样,但核心字段一一对应:
| 语义 | TexturePacker JSON | Cocos plist | LibGDX atlas |
|---|---|---|---|
| 子图位置 | frame.x/y/w/h | frame "{{x,y},{w,h}}" | xy、size |
| 是否旋转 | rotated | rotated | rotate |
| 原图尺寸 | sourceSize | sourceSize | orig |
| 内容偏移 | spriteSourceSize | offset | offset |
| 修剪标志 | trimmed | trimmed | 有 offset 即为修剪过 |
plist 的 frame 是用字符串表示的,"{{2,68},{64,96}}"这种格式需要自己解析字符串或者用正则提取。LibGDX atlas 是纯文本,按行读取后把冒号前后的键值拆开即可。字段对齐之后,三套解析器应该输出同一个中间结构,这样拆分逻辑只写一次。
5.2 Cocos plist 解析:把字符串矩形解析成通用 FrameSpec
Cocos 的 plist 文件本质是 XML 或二进制 plist,Python 自带的plistlib可以直接读:
import plistlib import re RECT_RE = re.compile(r"\{\{(\d+),(\d+)\},\{(\d+),(\d+)\}\}") def parse_cocos_plist(plist_path): with open(plist_path, "rb") as f: data = plistlib.load(f) frames = [] for name, info in data["frames"].items(): m = RECT_RE.search(info["frame"]) x, y, w, h = map(int, m.groups()) src_w, src_h = (int(v) for v in info["sourceSize"].strip("{}").split(",")) offset_str = info.get("offset", "{0,0}") ox, oy = (int(v) for v in offset_str.strip("{}").split(",")) frames.append(FrameSpec( name=name, x=x, y=y, w=w, h=h, source_w=src_w, source_h=src_h, offset_x=ox, offset_y=oy, rotated=bool(info.get("rotated", False)), )) return frames解析时要注意sourceSize和offset可能缺失,老版本的 plist 这两项不一定齐全。缺失时按"{0,0}"兜底。rotated在 plist 里是布尔值,但有些工具导出的是字符串"true",bool()会把非空字符串都转成 True,所以这里最好显式判断info.get("rotated") == True or info.get("rotated") == "true"。这类细节不经过实际样本很难提前预知,建议解析完先打印几帧核对。
5.3 LibGDX atlas 解析:文本逐行读取,注意 rotate 与 offset
LibGDX 的atlas文件结构如下:
demo.png size: 1024, 1024 format: RGBA8888 filter: Linear, Linear repeat: none player_run_01.png rotate: false xy: 10, 10 size: 64, 96 orig: 80, 100 offset: 4, 2解析代码:
def parse_libgdx_atlas(text: str): lines = [ln.strip() for ln in text.splitlines() if ln.strip()] frames, i = [], 0 while i < len(lines): # 如果下行以 size: 开头,说明当前行是页面图片名,跳过页头块 if i + 1 < len(lines) and lines[i + 1].startswith("size:"): i += 2 while i < len(lines) and ":" in lines[i]: i += 1 continue name = lines[i] i += 1 fields = {} while i < len(lines) and ":" in lines[i]: k, v = lines[i].split(":", 1) fields[k.strip()] = v.strip() i += 1 xy = fields.get("xy", "0, 0").replace(" ", "").split(",") size = fields.get("size", "0, 0").replace(" ", "").split(",") orig = fields.get("orig", size).replace(" ", "").split(",") offset = fields.get("offset", "0, 0").replace(" ", "").split(",") frames.append(FrameSpec( name=name, x=int(xy[0]), y=int(xy[1]), w=int(size[0]), h=int(size[1]), source_w=int(orig[0]), source_h=int(orig[1]), offset_x=int(offset[0]), offset_y=int(offset[1]), rotated=fields.get("rotate", "false") == "true", )) return frames这里有个容易混淆的点:LibGDX 的size到底是不是旋转后的宽高,不同生成器的理解存在差异。遇到 rotated 的帧时,我会额外打印解析结果和实际图集做一次比对,确认后再全量处理。另外split字段用来描述九宫格,解析时可以顺手存进FrameSpec.slices,避免信息丢在文本里。
5.4 统一出口:所有格式共用同一段裁剪旋转逻辑
解析器输出统一结构后,拆分部分就能收敛成一个函数:
from dataclasses import dataclass import pathlib from PIL import Image @dataclass class FrameSpec: name: str x: int y: int w: int h: int source_w: int source_h: int offset_x: int = 0 offset_y: int = 0 rotated: bool = False slices: tuple = () def dump_frames(atlas_img: Image.Image, frames: list[FrameSpec], out_dir: str) -> None: out = pathlib.Path(out_dir) out.mkdir(parents=True, exist_ok=True) for f in frames: sub = atlas_img.crop((f.x, f.y, f.x + f.w, f.y + f.h)).copy() if f.rotated: sub = sub.rotate(-90, expand=True) canvas = Image.new("RGBA", (f.source_w, f.source_h), (0, 0, 0, 0)) left = (canvas.width - sub.width) // 2 + f.offset_x top = (canvas.height - sub.height) // 2 - f.offset_y canvas.paste(sub, (left, top)) save_path = out / f.name save_path.parent.mkdir(parents=True, exist_ok=True) canvas.save(save_path) if f.slices: sidecar_path = save_path.with_suffix(save_path.suffix + ".slice.json") sidecar_path.write_text( __import__("json").dumps(f.slices, indent=2), encoding="utf-8", )这一段是所有格式的公共出口。以后遇到新的描述格式,只需要多写一个parse_xxx函数转成FrameSpec,不用动拆分逻辑。这个设计决定整个工具的维护成本,值得在初期就做对。
6. 验收图集拆分结果:重组比对法与交付前的两个习惯
6.1 像素级重组比对:把“看着像”变成“跑得过的验收”
拆分结果靠肉眼抽查永远不够。我的做法是把拆出的图按原始 frame 信息反向拼回一张大图,再和原图集做逐像素 diff:
import numpy as np from PIL import Image def verify_restore(atlas_img: Image.Image, frames: list[FrameSpec], result_dir: str) -> int: restored = Image.new("RGBA", atlas_img.size, (0, 0, 0, 0)) for f in frames: path = pathlib.Path(result_dir) / f.name if not path.exists(): continue img = Image.open(path).convert("RGBA") # 使用未还原画布的中间产物时直接旋转贴回 frame if f.rotated: img = img.rotate(90, expand=True) restored.paste(img, (f.x, f.y)) arr1 = np.asarray(atlas_img).astype(np.int16) arr2 = np.asarray(restored).astype(np.int16) diff = np.abs(arr1 - arr2)[..., :3].sum(axis=2) return int(np.count_nonzero(diff > 10))这个函数返回不一致像素数。如果没有剥离外扩,理论上应该接近 0;剥离了外扩则会有固定边缘差值,数量稳定即可。跑通一次比对,之后再改解析逻辑或参数,回归成本都很低。比一张一张看靠谱得多。
6.2 交付前两个习惯:保留原图集、输出 manifest
交付给美术或程序之前,我习惯做两件事。第一,原图集和描述文件永远保留,拆分脚本必须能从原图集随时重新生成,不要让输出目录变成唯一资源。第二,在输出目录生成一份split_manifest.csv,记录每个帧的名称、坐标、旋转、原始尺寸,方便别人排查问题,也方便未来写回工具复用:
name,x,y,w,h,source_w,source_h,rotated player_run_01.png,10,10,64,96,80,100,false第一次拆 Cocos 图集时,我在旋转方向上栽过一次,整批 200 多张图全部横躺,后来就养成了“先拆单帧、再全量、最后重组比对”的习惯。遇到拿不准的格式语义,先跑一张做对照,确认无误再撒手去跑整批——希望这套流程对你有用。
本文还有配套的精品资源,点击获取