☰
图集拆分工具:egret图集解析与像素校验
2026/10/28 23:55:46 网站建设 项目流程

简介:图集拆分工具面向使用白鹭引擎的游戏开发者及需要维护SpriteSheet资源的团队,用于将Egret合成的图集按JSON配置解析并切割为独立PNG图像,便于单图编辑、替换与内存优化。压缩包共22个文件,约503KB,以C#源码、WinForms窗体设计代码、项目文件与依赖库为主,并含可执行程序、编译缓存及资源配置等辅助内容,构成可直接运行、也可二次开发的工具框架。已有1712人学习下载,说明其在游戏资源管理场景中具有一定参考价值。读者可从中了解图集导入、坐标尺寸解析、拆分保存与命名规则等实现思路,并基于现有源码扩展批量处理、预览或性能优化功能;对需要拆解角色动画帧、UI元素或其他图形资源的项目,可作为轻量级辅助工具与排错参考。

1. 图集拆分工具:把一张 4096 的贴图还原成 300 张切图

接手一个上线两年的老项目,第一次打开它的资源目录我就停住了:角色动作、按钮三态、道具图标,全被压进几张 4096×4096 的大 PNG 里,旁边只躺着几十 KB 的 json。美术要去其中一张做返工,或者资源要迁到另一条渲染管线上重做——源文件早找不到了,只能从合图反着拆。这就是图集拆分工具要解决的问题:按描述文件把 egret 图集这类合图重新切成单帧。游戏开发里资源迁移、二次切割、UI 走查、特效复用,绕不开这一步。它适合两类人:手上有图集没源图的人,以及要把图集拆开做批量处理和校验的人。

2. 读懂图集描述文件:位置、裁剪、旋转三组字段

2.1 一张合图加一个 json,信息其实分三层

合图的本质是把一堆小图打包进一张大贴图,从而把 N 次绘制合并成 1 次,减少 drawcall。拆图的时候,切图并不是靠猜,而是依靠描述文件里记录的坐标。描述文件里,每张子图其实带着三层信息,很多拆分翻车就翻在没有把这三层分清楚。

第一层是在图集里的位置,通常写成frame: {x, y, w, h},表示从大图的左上角出发,向右 x、向下 y,取一块 w×h 的矩形,这是切图唯一必须有的字段。第二层是原始尺寸,写成sourceSize: {w, h},代表这张图在没被打包之前有多大;如果它和图集里那块矩形尺寸不一样,说明打包时把四周的透明区域裁掉了,专业叫法是 trim。第三层是在原始画布里的落点,写成spriteSourceSize: {x, y}或offX/offY,表示被裁剩的那块内容,原本位于原始画布中的哪个位置。

三层分清楚之后就能明白:如果直接把frame那块裁下来保存,你拿到的是被裁过、可能还被转过的图,不是美术当初做的那张。要还原成原图,就得把三层信息全部用上。

坐标系本身很简单:原点在左上角,y 轴向下,单位是像素。这一点和 PIL 的crop完全一致,所以切图时不用做任何 y 轴翻转。真正容易让人想不通的是旋转帧——下面细说。

提示:先不要急着写脚本。找一帧在游戏里朝向明确的图——比如角色待机时脚朝下——拆出来看一眼,你会发现后面所有关于旋转方向的争论,一眼就能看出答案。

2.2 先做一次描述文件体检,再动手切

拿到一份图集,我习惯先做一次只读取信息的检查,看三个数字:总帧数、带旋转的帧数、被 trim 过的帧数。这三个数字直接决定拆分脚本要不要打开对应功能,也决定了后面会踩哪些坑。

import json from collections import Counter def inspect_atlas(atlas_json: str) -> None: """只读不拆:摸清描述文件的家底""" with open(atlas_json, encoding="utf-8") as fp: data = json.load(fp) raw = data["frames"] # 数组式(json array)先归一成以帧名为 key 的字典 items = raw if isinstance(raw, dict) else {f["filename"]: f for f in raw} stat = Counter() for name, f in items.items(): # 有的格式字段平铺,有的嵌一层 frame 对象 box = f["frame"] if isinstance(f.get("frame"), dict) else f if f.get("rotated"): stat["rotated"] += 1 sw = f.get("sourceSize", {}).get("w") or f.get("sourceW") or box["w"] sh = f.get("sourceSize", {}).get("h") or f.get("sourceH") or box["h"] if (sw, sh) != (box["w"], box["h"]): stat["trimmed"] += 1 meta = data.get("meta", {}) print(f"总帧数 {len(items)} | 旋转 {stat['rotated']} | 裁剪 {stat['trimmed']}") print(f"图集尺寸 {meta.get('size')} | 缩放 {meta.get('scale', 1)}") print("首帧样例:", json.dumps(next(iter(items.values())), ensure_ascii=False)[:200])

这段代码只做两件事:兼容 frames 的两种容器形态,以及用sourceSize和frame的尺寸差反推有没有 trim。stat["rotated"]是 0,后面整套旋转还原逻辑就可以关掉,少一半的出错可能。打印首帧样例是为了确认字段名:sourceSize还是sourceW,offset还是offX,各家导出器写法不统一,看一遍能省掉十次调试。

2.3 三个字段决定输出结果长什么样

rotated:打包器为了节省空间,会把某些图旋转 90° 再塞进图集。这个字段为 true 时,你按frame的宽高裁出来的是一张横躺或者竖躺的图,必须先按相反方向转回去。方向到底顺时针还是逆时针,取决于导出器,而且不同版本的实现可能有差异——所以脚本里这个方向必须是参数,不能写死。

trimmed:判断依据就是sourceSize != frame 尺寸,或者spriteSourceSize.x/y != 0。被裁过的图直接保存,尺寸会小一圈,贴到 UI 上位置会整体偏移。正确做法是新建一张sourceSize大小的透明画布,把切下来的图按 offset 贴进去,补回透明边。

scale:meta.scale大于 1 时(常见的 2 倍图集),最关键的问题不是“要不要放大”,而是“坐标到底是什么单位”。判断方法很稳妥:取所有帧中x + w的最大值,如果它不超过 PNG 的实际宽度,说明坐标已经是像素单位,直接 crop;如果明显小于,说明是逻辑单位,需要乘上 scale。有了这条边界检查,就可以避免只根据字段名来猜测单位,避免陷入无法判断的困境。

还有一个反直觉的点:rotated为 true 时,frame.w/frame.h有的导出器写的是旋转前的尺寸,图集里实际占的是 h 宽、w 高。所以按原样 crop 可能切到隔壁帧的内容。稳妥的处理方式在后面第 5 章展开,但判断方法很简单:如果裁出来的图边缘出现了不属于它的内容,就交换宽高再试一次。

3. 拆分主流程:从 atlas.png 到单帧文件的完整脚本

3.1 打开大图:内存边界先划清楚

真正会出事的地方是读入图片这一步。一张 4096×4096 的 RGBA 图,在内存里就是 64MB;很多项目同时开七八张,或者图集本身就超大,Python 进程的内存占用会瞬间飙升。PIL 对超大图有解压炸弹的阈值警告,Image.open又是惰性的——不调用load()的话,真正的解码发生在后面循环里的第一次 crop,异常也就发生在最难排查的位置。

from PIL import Image def load_sheet(path: str) -> Image.Image: """统一转 RGBA 并提前解码,把异常暴露在可控的位置""" img = Image.open(path) # P 模式调色板图、RGB 图都要转 RGBA,否则 alpha 会丢 if img.mode != "RGBA": img = img.convert("RGBA") # 惰性加载会让解码发生在循环里,这里强制解码 img.load() return img

convert("RGBA")不是可选项。P 模式(调色板)PNG 在游戏资源里很常见,直接 crop 保存会得到索引色图,某些工具链读进去 alpha 全丢,透明区域变成黑块。load()放在这里是为了让“内存不够”和“文件损坏”当场报错,而不是切到第 200 帧才崩,那时候你连是哪个文件的问题都定位不到。

如果你的图集单边超过 8192,要在脚本入口显式处理 PIL 的解压炸弹阈值:

from PIL import Image # 只在确认资源来源可信时打开;否则保留默认阈值,让它报警 Image.MAX_IMAGE_PIXELS = None

注意:关掉阈值保护等于放弃最后一道保险,图片来自外部渠道时别关,改用分块读取更安全。

内存够不够,粗略估算是宽 × 高 × 4 字节,再乘上你同时打开的图集数量。批量处理时,不要把整批图集一次性读进内存,一张张来,处理完一张显式del掉引用。

3.2 裁剪与旋转还原:方向必须是参数

切图的核心只有一行半:crop出矩形,遇到旋转帧再转回来。麻烦全在“转回来”这三个字上。PIL 的Image.ROTATE_90是逆时针90°(这一点和直觉相反,很多人第一次用都会翻车),Image.ROTATE_270等价于顺时针 90°。

from PIL import Image def cut_frame(sheet: Image.Image, f, rot_dir: str = "ccw") -> Image.Image: """ sheet: 已 load 的 RGBA 大图 f: 统一后的帧模型,含 x/y/w/h/rotated rot_dir: 打包时是顺时针转的就传 cw,逆时针转的传 ccw """ # crop 的右边界是 x + w,不是 w,这是新手第一号错误 box = (f.x, f.y, f.x + f.w, f.y + f.h) tile = sheet.crop(box) if f.rotated: # ROTATE_90 逆时针,ROTATE_270 顺时针 tile = tile.transpose(Image.ROTATE_90 if rot_dir == "ccw" else Image.ROTATE_270) return tile

参数说明:rot_dir="ccw"表示“打包时顺时针转了 90°,所以我要逆时针转回来”。之所以做成参数而不是常量,是因为同一个项目里可能会遇到不同时期、不同工具导出的图集,方向约定可能不一致。判断手段很直接——找一个朝向明确的帧,两种方向各输出一张,正确的那张能立刻看出来,不需要猜。

crop的 box 是左闭右开区间,(x, y, x+w, y+h)切出来的尺寸正好是 w×h。如果写成(x, y, w, h),切出来的图会从原点开始、尺寸错乱,而且不报错,只是图全错——这类静默错误最难查。

3.3 trim 回填与批量落盘:别让输出目录影响拆分

切出来的图还不是最终产物。被 trim 过的帧要贴回原始尺寸的透明画布上,否则做 UI 走查时会发现所有图标位置都在飘。

from PIL import Image def restore_trim(tile: Image.Image, f, scale: float = 1.0) -> Image.Image: """把裁过的帧贴回原始画布,补回透明边""" if (f.src_w, f.src_h) in ((0, 0), (f.w, f.h)) and (f.off_x, f.off_y) == (0, 0): return tile # 没有被裁,直接返回 canvas = Image.new("RGBA", (f.src_w, f.src_h), (0, 0, 0, 0)) # 第三个参数 mask 不能省,否则透明区域会被覆盖成实心 canvas.paste(tile, (f.off_x, f.off_y), tile) if scale != 1.0: canvas = canvas.resize( (round(f.src_w * scale), round(f.src_h * scale)), Image.LANCZOS ) return canvas

paste的第三个参数mask是最容易被省掉的一处。省掉之后,被裁图的透明像素会以 alpha=0 的实心像素形式写进画布,结果是图看起来“有一圈黑边”或者“背景变成黑的”。带上 mask 才是真正的叠加。

scale默认 1.0,表示按像素原样输出,这是绝大多数场景想要的结果。只有当你需要把 2 倍图集拆成逻辑尺寸的切图时才传 0.5,重采样用LANCZOS质量最好,但会引入像素变化,做逐像素校验时会因此失败——这是两件事,别混在一起做。

落盘环节的问题出在文件名上。帧名里经常带层级,比如ui/btn/ok_normal,直接当路径用,在不存在的目录上写文件就会报错;Windows 下还有一堆合法字符等着让你的脚本中途停下来。

import re from pathlib import Path _BAD = re.compile(r'[<>:"|?*\x00-\x1f]') def safe_path(name: str) -> Path: """过滤非法字符,保留层级,补上 .png 后缀""" parts = [p for p in re.split(r"[\\/]+", name) if p not in ("", ".", "..")] parts = [_BAD.sub("_", p) for p in parts] if not parts: parts = ["unnamed"] if not parts[-1].lower().endswith(".png"): parts[-1] += ".png" return Path(*parts) def save_tile(tile: Image.Image, out_dir: Path, name: str, overwrite: bool = False) -> Path: target = out_dir / safe_path(name) target.parent.mkdir(parents=True, exist_ok=True) if target.exists() and not overwrite: # 同名帧不覆盖,加后缀,保留两边的现场 target = target.with_name(f"{target.stem}__dup{target.suffix}") tile.save(target) return target

overwrite=False是刻意设置的默认值。图集里出现同名帧(不同图集合并、或者美术复制粘贴)时,直接覆盖会让你在事后完全不知道少了一张图。加__dup后缀虽然丑,但至少可追溯。批量跑的时候,输出目录建议每次清空重建或者带时间戳,避免上一轮的残留文件混进这一轮的结果里,把校验结论带偏。

完整的批量入口大致是:读描述文件、用体检结果决定开关、读图、循环cut_frame→restore_trim→save_tile、统计成功与失败。单张图集几百帧,Python 循环几秒就完事,不值得上多线程;真要加速,批量图集之间用多进程,别在单张图里折腾。

4. 多格式适配:json hash、json array、plist 与扁平结构

4.1 四种描述格式的字段对照

图集描述文件没有统一标准,同一个引擎在不同时期、不同导出工具下格式都能变。写拆分工具最忌讳的就是写死一种格式——今天拆的是自家项目的图集,明天同事丢过来一份外包资源,脚本直接报 KeyError。我一般会先用一张表把差异列清楚,再决定适配层怎么写。

来源形态frames 结构位置字段旋转字段裁剪字段缩放来源
json hash对象,key 为帧名frame.{x,y,w,h}rotatedspriteSourceSize/sourceSizemeta.scale
json array数组,元素内含filenameframe.{x,y,w,h}rotated同上meta.scale
扁平结构对象,值直接是坐标x/y/w/h少见offX/offY/sourceW/sourceH一般无
plist字典,值为字符串几何frame字符串rotatedsourceColorRect/sourceSizemetadata.format

选型理由很直接:与其为每种格式写一套切图逻辑,不如把所有格式都归一化成同一个帧模型,切图、还原、落盘只写一份。格式适配层只负责“读进来变模型”,出了 bug 范围也小。

4.2 plist 与数组式 frames 的解析补丁

plist 的几何字段是字符串,形如{{12,34},{56,78}},得用正则拆。注意sourceSize在 plist 里是{56,78}两个数的形式,和frame的嵌套括号不一样,正则要分开写。

import plistlib import re _RECT = re.compile(r"\{\{(-?\d+),(-?\d+)\},\{(\d+),(\d+)\}\}") _SIZE = re.compile(r"\{(\d+),(\d+)\}") def parse_plist(path: str) -> list: with open(path, "rb") as fp: data = plistlib.load(fp) frames = [] for name, f in data["frames"].items(): x, y, w, h = map(int, _RECT.match(f["frame"].replace(" ", "")).groups()) sc = _RECT.match(f.get("sourceColorRect", "").replace(" ", "")) off_x, off_y = (int(sc.group(1)), int(sc.group(2))) if sc else (0, 0) ss = _SIZE.match(f.get("sourceSize", "").replace(" ", "")) src_w, src_h = (int(ss.group(1)), int(ss.group(2))) if ss else (w, h) frames.append(Frame(name, x, y, w, h, bool(f.get("rotated", False)), src_w, src_h, off_x, off_y)) return frames

replace(" ", "")是为了消除导出器之间空格数量的差异,不然正则匹配会无声失败——_RECT.match返回 None 之后再去.groups()就是 AttributeError,报错信息还看不出是哪一帧。生产脚本里应该把.match()的结果先判断再取。

数组式 frames 的补丁很短,在入口处把列表转成字典就行:

raw = data["frames"] if isinstance(raw, list): raw = {item["filename"]: item for item in raw}

4.3 用统一帧模型隔离格式差异

归一化的载体是一个 dataclass,字段不多,但每个都有明确含义。写的时候把“图集里的矩形”和“原始画布”两套尺寸分开命名,能避免后面一路把w和src_w用混。

from dataclasses import dataclass @dataclass class Frame: name: str x: int # 在图集中的左上角 x y: int # 在图集中的左上角 y w: int # 在图集中占用的宽(旋转后可能需与 h 交换) h: int rotated: bool src_w: int # 原始画布宽 src_h: int off_x: int # 内容在原始画布中的落点 off_y: int

有了这个模型,前面第 3 章的切图函数完全不用改,新增一种格式只需要写一个parse_xxx返回list[Frame]。我一般会在入口按扩展名分派:.plist走 plist 解析,.json先看 frames 是对象还是数组、再看值里有没有嵌frame,三行判断就能覆盖绝大多数情况。

这种写法的另一个好处是,校验环节可以复用同一批Frame对象反向拼图,不需要再读一遍描述文件,也就不会出现“切割用一套解析、校验用另一套解析”的错位问题。

5. 避坑与排查:拆分翻车的五个现场

5.1 几何类:图躺平了、缺边了、尺寸对不上

现象:拆出来的角色是横躺的,头朝左。原因是对旋转帧做了“还原”,但方向反了——打包时顺时针转了 90°,脚本又顺时针转了一次,等于转了 180°(或者按错误方向转了 90°)。解决:把rot_dir参数切换成另一个值重跑,用一个朝向明确的帧验证;确认之后把这个值写进项目配置,别每次靠猜。

现象:旋转帧裁出来的图边缘带上了旁边那帧的内容,或者干脆越界报错。原因是rotated=true时frame.w/h的语义是“旋转前的尺寸”,图集里实际占的是 h 宽、w 高。解决:先按未交换裁一次,如果越界或者肉眼看出内容串了,就交换宽高再裁;更稳的做法是在cut_frame里加一层边界限制:

# 旋转帧先按交换后的尺寸裁,裁不出内容再回退 bw, bh = (f.h, f.w) if f.rotated else (f.w, f.h) box = (f.x, f.y, min(f.x + bw, sheet.width), min(f.y + bh, sheet.height))

min()限制是为了不让 crop 悄悄用越界坐标补黑边——PIL 在超出图像范围时会用透明像素填充,不报错,你只会觉得图“多了一块”。

现象:图标拆出来尺寸是对的,但贴到界面里所有元素整体偏移了几个像素。原因是 trim 没回填,spriteSourceSize的 offset 被丢掉了。解决:走restore_trim,新建src_w × src_h的透明画布,按(off_x, off_y)贴入。判断有没有 trim,别只看尺寸,offset != (0,0)同样要处理。

5.2 文件与环境类:透明变黑、名字炸裂

现象:拆出来的 PNG 透明区域是黑的,或者边缘有一圈黑毛边。三个常见原因:原图是 P 模式没转 RGBA;中途用 JPEG 转过一手,alpha 直接没了;paste时忘了传 mask。解决:入口统一convert("RGBA"),全流程不碰 JPEG,paste一定带第三个参数。已经出问题的图可以这样救:

from PIL import Image img = Image.open("bad.png") # 如果透明区被填成了黑色,把纯黑当透明键处理 if img.mode == "RGB": img = img.convert("RGBA") datas = [(0, 0, 0, 0) if px[:3] == (0, 0, 0) else px for px in img.getdata()] img.putdata(datas)

这是补救手段不是正解,纯黑是画面内容的图会被误伤,别对整套资源无脑跑。

现象:跑到一半报 FileNotFoundError 或者 PermissionError,或者跑完发现少了图。原因是帧名带ui/btn/ok这类层级,目录不存在;或者名字里有:、?这类字符;或者两批图集有同名帧,后写的把先写的盖了。解决:用safe_path过滤并自动建目录,冲突时加后缀而不是覆盖;落盘前先写临时目录,整批成功后再替换目标目录,避免半途失败留下一堆不完整的结果。

注意:图集拆分出来的文件名会直接进入资源管线。带空格的、以数字开头的、大小写只差一个字母的名字,在某些打包工具里会出问题,建议在safe_path里统一处理掉。

6. 还原校验:把 300 张切图重新拼回去做像素比对

拆完就交付,是这份工作里最容易留下隐患的习惯。判断拆得对不对,靠肉眼翻几百张图不现实,我一般用反向验证:把拆出来的每一张,按描述文件重新贴回一张同样尺寸的空画布上,再和原图逐像素比对。一致的话,说明坐标、旋转、trim 三件事全都还原对了;不一致的话,只要检查差异区域的位置,就能判断是哪个字段出了错。

from PIL import Image, ImageChops def verify(sheet_path: str, frames: list, tiles: list): """把切图拼回原图并逐像素比对,返回差异区域的 bbox""" base = Image.open(sheet_path).convert("RGBA") canvas = Image.new("RGBA", base.size, (0, 0, 0, 0)) for f, tile in zip(frames, tiles): t = tile if f.rotated: # 与切割时方向相反 t = t.transpose(Image.ROTATE_270) if (f.off_x, f.off_y) != (0, 0) or (f.src_w, f.src_h) != (f.w, f.h): # 把 trim 补出来的透明边裁掉,还原成紧凑块 t = t.crop((f.off_x, f.off_y, f.off_x + f.w, f.off_y + f.h)) canvas.paste(t, (f.x, f.y)) diff = ImageChops.difference(base, canvas) return diff.getbbox() # None 表示完全一致

getbbox()返回None就是通过,返回一个四元组就把出问题的区域圈出来了:差异集中在某个角,大概率是旋转方向错了;差异沿边缘一圈,八成是 trim 的 offset 少了;整块区域全差异,先怀疑坐标单位和meta.scale。这套定位路径比逐帧调试快得多,因为它直接告诉你去图纸的哪个位置看。

我的严格标准是:校验必须一字不差,不接受容差。只要做过任何重采样(缩放、格式转换),差异就必然存在,这时候重点就转为观察误差分布,而不应去调整阈值放宽条件。

再往前一步,可以把它接进资源流水线:每次图集有更新,先自动拆一遍、校验一遍,getbbox()不为None就拒绝入库。这套东西最有价值的地方在于,它能在资源变更的当下把问题拦下来,不用等到版本上线后由玩家先发现。

从那以后我每次拆图集,不管对方说“这批图很简单没旋转”,都会先把verify跑出None再交付——有几次正是这一步,才发现某张图集的旋转方向和其他帧不一致。拆分脚本和这几段校验代码我整理在一起了,拿去对着自己的图集跑一遍再改参数,比从头写快得多。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询