图集拆分实战:解析描述文件并用Python还原独立图片
简介这是一款面向白鹭引擎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 为 trueframe 里记录的其实是旋转后占用的矩形原始内容宽高要先把 w 和 h 互换才成立。而 spriteSourceSize 给出的是内容在原始画布里的摆放位置如果描述文件里有这个字段还原画布时优先相信它比自己去算 offset 靠谱得多。2.3 通用拆分逻辑裁剪、旋转、还原画布三步走所有图集拆分的核心流程都可以提炼成三步不区分具体格式# 第一步从图集大图上切出 frame 矩形 sub atlas.crop((fx, fy, fx fw, fy fh)).copy() # 第二步如果存储时旋转了 90 度就逆时针转回来 if rotated: sub sub.rotate(-90, expandTrue) # 第三步把内容贴回原始尺寸画布 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(encodingutf-8)) # TexturePacker 的 JSON 可能是 {frames: {...}}也可能直接就是帧字典 frames data[frames] if isinstance(data.get(frames), dict) else data out pathlib.Path(out_dir) out.mkdir(parentsTrue, exist_okTrue) 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, expandTrue) src item.get(sourceSize) ss item.get(spriteSourceSize) if not src: # 描述文件没有原始尺寸直接保存裁剪结果 save_path out / raw_name save_path.parent.mkdir(parentsTrue, exist_okTrue) 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(parentsTrue, exist_okTrue) 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, defaultout, help输出目录) parser.add_argument(--strip-extrude, typeint, default0, 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, indent2), encodingutf-8)截图到手时先把 slice 数据落盘后续引擎适配时就不用重新对着大图数像素了。4.5 同名文件互相覆盖文件数和帧数永远对不上现象拆分日志显示成功处理了 300 帧输出目录里却只有 280 个文件而且缺的多是不同目录下同名的资源。原因图集允许不同路径的子图叫同一个名字输出时全部平铺到一个目录后写入的直接覆盖前面的。解决输出时按原始路径结构创建子目录如果描述文件没有路径信息则在重名帧后追加短哈希后缀。跑完批量后把输出文件数量跟帧数量做一次比对数量不一致就说明有覆盖发生了。5. 兼容 Cocos plist 与 LibGDX atlas解析器适配与统一拆分入口5.1 三种描述格式的字段对照JSON、plist、atlas 虽然长得完全不一样但核心字段一一对应语义TexturePacker JSONCocos plistLibGDX atlas子图位置frame.x/y/w/hframe {{x,y},{w,h}}xy、size是否旋转rotatedrotatedrotate原图尺寸sourceSizesourceSizeorig内容偏移spriteSourceSizeoffsetoffset修剪标志trimmedtrimmed有 offset 即为修剪过plist 的 frame 是用字符串表示的{{2,68},{64,96}}这种格式需要自己解析字符串或者用正则提取。LibGDX atlas 是纯文本按行读取后把冒号前后的键值拆开即可。字段对齐之后三套解析器应该输出同一个中间结构这样拆分逻辑只写一次。5.2 Cocos plist 解析把字符串矩形解析成通用 FrameSpecCocos 的 plist 文件本质是 XML 或二进制 plistPython 自带的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( namename, xx, yy, ww, hh, source_wsrc_w, source_hsrc_h, offset_xox, offset_yoy, rotatedbool(info.get(rotated, False)), )) return frames解析时要注意sourceSize和offset可能缺失老版本的 plist 这两项不一定齐全。缺失时按{0,0}兜底。rotated在 plist 里是布尔值但有些工具导出的是字符串truebool()会把非空字符串都转成 True所以这里最好显式判断info.get(rotated) True or info.get(rotated) true。这类细节不经过实际样本很难提前预知建议解析完先打印几帧核对。5.3 LibGDX atlas 解析文本逐行读取注意 rotate 与 offsetLibGDX 的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( namename, xint(xy[0]), yint(xy[1]), wint(size[0]), hint(size[1]), source_wint(orig[0]), source_hint(orig[1]), offset_xint(offset[0]), offset_yint(offset[1]), rotatedfields.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(parentsTrue, exist_okTrue) 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, expandTrue) 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(parentsTrue, exist_okTrue) 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, indent2), encodingutf-8, )这一段是所有格式的公共出口。以后遇到新的描述格式只需要多写一个parse_xxx函数转成FrameSpec不用动拆分逻辑。这个设计决定整个工具的维护成本值得在初期就做对。6. 验收图集拆分结果重组比对法与交付前的两个习惯6.1 像素级重组比对把“看着像”变成“跑得过的验收”拆分结果靠肉眼抽查永远不够。我的做法是把拆出的图按原始 frame 信息反向拼回一张大图再和原图集做逐像素 diffimport 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, expandTrue) 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(axis2) 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 多张图全部横躺后来就养成了“先拆单帧、再全量、最后重组比对”的习惯。遇到拿不准的格式语义先跑一张做对照确认无误再撒手去跑整批——希望这套流程对你有用。本文还有配套的精品资源点击获取