资讯动态

图集拆图工具解析:从plist/json到碎图的完整实现指南

发布时间:2026/9/7 7:15:47 来源:尧图企业网站定制
简介这款用 Go 语言编写的拆图工具面向游戏客户端开发、资源优化以及需要处理 TexturePacker 合图的开发者。它能读取 plist、json、fnt 位图字体及 Spine 的 atlas 配置文件将打包后的子图精准拆分并还原原始尺寸从而满足发布包中贴图资源的调试、恢复和二次编辑需求。资源包共包含 11 个文件核心是 5 个 Go 源码文件对应 plist、json、fnt、atlas 各格式解析器及命令行主程序另有 README、开源许可证和预览图等说明整体仅 59KB非常适合快速阅读和移植。工具通过简单的参数指定文件或目录即可批量导出还支持 --ext 按后缀筛选且具备跨平台特性可运行在 Windows、Mac 与 Linux 系统。目前已有 750 人学习下载这份小巧的源码完整展示了从合图配置读取到图片还原的实现思路对希望理解拆图原理或构建类似 Go 工具链的开发者很有参考价值。1. 为什么需要拆图工具从图集到碎图的最后一公里做游戏客户端或者搞 UI 素材的同行应该都有体会美术从 TexturePacker 里打包好的图集最后交付到程序手里只是半成品。真正落到代码里用的是一张张小碎图——按钮的背景、角色的某帧动作、位图字体的某个字符。图集文件固然省内存、省 DrawCall但如果你要单独改某一个小图标或者要按图集坐标把原图还原出来供给其他环节使用没有好用的拆图工具就只能在 PS 里对着 plist 里的坐标手动抠一张两张还行遇到成百上千帧的序列帧直接崩溃。PlistDumper 就是干这事的它能把 TexturePacker 导出的 plist、json、fnt、atlas 这几种常见图集描述文件解析出来再把大图里对应的子图按原始尺寸和位置切出来自动命名导出。简单说就是图集的“逆向工程”。我最早接触它是因为接到一个老项目美术资源全在 atlas 里但需要把其中某个角色的技能特效单独拆出来做换皮。手动画了一个下午眼睛都快瞎了后来发现这类工具其实原理并不复杂核心就是读懂图集描述文件的数据结构再做好像素级的坐标还原。这篇文章就结合我自己的折腾经验把 PlistDumper 这类工具的拆解思路、实现细节和实战中最容易踩的坑一次说清楚。适合谁看游戏客户端开发、UI 特效美术、TA技术美术或者说所有需要频繁处理图集资源的同行。哪怕你之前完全没写过解析代码照着下面的思路也能用现成工具把拆图流程跑通。2. 先搞懂图集描述文件plist、json、fnt、atlas 到底存了啥想拆图先得知道要拆的文件里面是什么结构。这几种格式用大白话说就是“一张大图 一份说明书”。说明书里记录了每个小图在大图中的哪块位置、原始尺寸是多少、是否旋转过、有没有裁掉透明边。下面一个一个看。2.1 plist 格式TexturePacker 默认输出的老牌格式plist 本质是苹果的 XML 属性列表文件大致长这样dict keyframes/key dict keyicon_001.png/key dict keyframe/key string{{0,0},{64,64}}/string keyoffset/key string{0,0}/string keyrotated/key false/ keysourceColorRect/key string{{0,0},{64,64}}/string keysourceSize/key string{64,64}/string /dict /dict /dict我不推荐直接人肉去读这个文件去算坐标但你需要理解每个字段的含义不然程序写出来遇到特殊参数配置就抓瞎frame小图在大图中的实际裁切区域格式是{{x,y},{w,h}}这里的 x、y 是大图坐标系中的左上角起点。offset裁切后小图中心点相对于原图中心点的偏移。如果美术导出时没有做透明的 trim裁掉透明边offset 就是{0,0}一旦裁过透明边offset 的值就需要用来做位置补偿。rotated是否旋转 90 度。TexturePacker 为了尽量提高大图的打包率会把某些小图旋转 90 度放进图集取出来的时候必须逆旋转回来。sourceColorRect小图在原始图片中实际有内容的区域也就是没被裁掉的可见像素范围。sourceSize小图原始文件的完整尺寸包含透明部分。提示mac 上常见的 “plist parsing error” 这类报错多数并不是文件内容坏了而是文件带 BOM、编码不是 UTF-8或者plutil -lint校验时因为证书、签名问题导致读取异常。拆图工具如果读不了别人给的 plist先拿plutil -lint检查语法比盲目改代码高效得多。2.2 json 格式Cocos、Egret、Laya 项目的常客json 图集描述文件现在用得越来越多因为 Cocos Creator 导出的合图就是 JSON 格式。它的结构大体是{ frames: { icon_001.png: { frame: {x: 0, y: 0, w: 64, h: 64}, rotated: false, trimmed: true, spriteSourceSize: {x: 3, y: 5, w: 60, h: 58}, sourceSize: {w: 64, h: 64} } } }注意 json 格式里frame的坐标是用四个独立字段表示的没有 plist 里那层花括号包装。另外 Cocos 的 json 里还有一个trimmed字段表示是否裁剪过透明区域。如果trimmed为 false那spriteSourceSize基本可以忽略如果为 true要还原原始图片就比 plist 要稍微绕一点——后面讲还原计算时我会细说。json 格式拆图的实际坑主要在编码和非法字符上。很多项目从美术那边拿到的 json 是 Windows 下 Excel 改过的或者写入的时候带了 BOM。Python 的json.load直接读这种文件会报invalid JSON你先用文本编辑器把文件另存为 UTF-8 无 BOM往往就能解决一大半问题。2.3 fnt 格式位图字体的字符映射表fnt 主要用于位图字体。TexturePacker 导出的位图字体通常包含一个.fnt文件加一张或多张 PNG 图。fnt 有文本格式和二进制格式TextPacker 默认导出的是文本格式结构类似info faceArial size32 bold0 common lineHeight38 base26 scaleW512 scaleH512 pages1 page id0 filefont_0.png chars count95 char id32 x0 y0 width0 height0 xoffset0 yoffset19 xadvance18 page0 chnl0拆 fnt 的关键不在切图因为char已经告诉你每个字符在字体大图中的坐标和宽高你按坐标把字符裁出来就行。难点在于位图字体由多个文件组成.fnt 若干页 PNG而且page字段会指示字符属于第几张贴图。如果你的工具只处理单页字体遇到pages2以上就会漏字符。经验提醒如果你只是为了给聊天系统做动态表情或者把位图字体转回 TTF 风格的小图切记要保留xadvance字符间距信息否则重新排列时文字会挤成一团。2.4 atlas 格式LibGDX 系的紧凑文本格式atlas 是文本格式结构上和前面几种完全不同没有层级包裹直接平铺键值对char.png size: 512, 512 format: RGBA8888 filter: Linear, Linear repeat: none player_walk_01 rotate: false xy: 10, 12 size: 64, 64 orig: 64, 64 offset: 0, 0 index: -1这里的xy是小图左上角坐标size是裁切后的尺寸orig是原始含透明区域的尺寸offset是中心偏移index一般用于序列帧命名比如同一名字的player_walk_01.png用 index 区分值为 -1 表示无序列帧索引。atlas 和 plist 一样核心坑点是orig和offset的组合。很多刚上手的同学只读xy和size导出的图片永远多一圈透明边或者少一段像素原因就是没有处理orig和offset的还原算法。3. 工具设计思路解析、还原、导出三板斧理解了格式PlistDumper 这类工具的整体设计就清晰了。不管用什么语言实现核心流程都逃不开“解析 → 还原像素 → 按规则导出”这三步。下面把这几个环节逐个拆开讲。3.1 不同格式的差异如何屏蔽掉实际开发里老项目用 plist新项目用 json特殊需求还会碰 atlas 和 fnt。所以工具的第一步必须做一个“格式识别 统一数据结构”。我最开始写的版本是四个格式各写一套解析逻辑结果代码重复得厉害后面重构才发现与其解析完直接切图不如先转换成统一的中间模型class SpriteItem: name: str # 子图名称 page: int # 所属图集页fnt/atlas 有多页 region: tuple # 在大图中的区域 (x, y, w, h) rotated: bool # 是否需要旋转还原 source_size: tuple # 原始完整尺寸 (w, h) offset: tuple # 中心点偏移 trimmed: bool # 是否被裁掉过透明边解析 plist 时遇到frame{{x,y},{w,h}}就把它拆成region解析 json 时直接读frame.x等字段解析 atlas 时依次按缩进读取块。最终都塞进同一个SpriteItem。这样切图函数只需要写一遍后续加新格式也只是加个解析器的事。这个屏蔽层带来的收益是巨大的排错的时候不用四份代码分别调试出图的逻辑只在同一个函数里。建议所有做这类工具的同学都先花半小时把中间数据结构定义好别上来就直接写“plist 切图函数”“json 切图函数”。3.2 透明裁切还原offset 与 sourceColorRect 的计算逻辑这是拆图工具里最容易算错的一环。先说 plist。sourceColorRect记录的是原图中可见像素的区域offset记录的是裁切后区域中心相对于原图中心的偏移。还原原图的算法是按sourceColorRect的 x、y 反向计算贴片位置在新建的sourceSize大小的透明画布上把sourceColorRect中的可见区域放到对应坐标。从大图中切出frame区域对应的像素。如果rotated为 true先把像素逆时针旋转 90 度也可按工具导出的旋转方向调整。将切出的像素贴到可见区域位置。简单地说从大图截出来的像素块面积会比原始图片小因为透明边被裁掉了所以不能直接保存为 PNG。必须创建一个原始尺寸的透明画布然后把像素块平移到偏移补偿后的位置。plist 的offset是根据 center 算的而sourceColorRect给出的是可见内容相对原图左上角的精确坐标所以按sourceColorRect定位即可。json 的情况更直白spriteSourceSize中的x和y就是可见区域相对于原图左上角的偏移量。直接用它作为贴片坐标就行。所以 json 格式还原时不用去算中心偏移直接按spriteSourceSize贴。3.3 命令行的交互设计参数越少越好做工具最忌讳参数轰炸。PlistDumper 我后来把默认行为固定成“智能模式”用户只需提供图集描述文件路径工具自动在同目录查找同名的 png/jpg/webp如果描述文件里指定了不同的图片路径则优先用描述文件里的。其余参数只留几个常见的--format强制指定输入格式默认自动识别。--output输出目录默认是描述文件同目录下的dump/文件夹。--scale导出缩放比例比如 0.5 用于生成缩略图一般不常用。--rename-prefix给导出的文件加统一前缀方便区分图集来源。这样设计的好处是美术同学拿到工具后只需要记住一条命令python plist_dumper.py assets/role.atlas所有碎图自动导出到assets/dump/role/下命名保持图集里的原始名字。我认为这类资源处理工具的重点不是功能多而是“顺手”如果每次都要查文档回忆参数那离吃灰就不远了。3.4 命名冲突和自定义前缀的坑图集里的子图名字有时会包含路径比如images/icon/btn_start.png。如果直接按这个全路径导出输出目录会自动创建多层文件夹通常没问题。但遇到同一个图集里有两张不同来源的同名图比如 A 帧和 B 帧都叫frame_01直接落盘会互相覆盖。我的做法是在输出时做一次冲突检测如果发现重名自动追加_1、_2后缀。另外批量处理多个图集时强烈建议在导出时加上图集名前缀比如输出成player_icon_01.png否则几十个图集的碎图混在一起后续维护根本分不清哪张来自哪里。4. 实操记录拿 Python Pillow 写一个完整拆图脚本理论讲完直接上实操。这个脚本我目前还在用逻辑不算复杂但足够应付绝大多数拆图场景。用 Python 是因为 Pillow 处理图像最方便而且跨平台美术电脑上装个 Python 环境也不费劲。4.1 环境准备和依赖先安装 Python 3.9 和 Pillowpip install pillow如果要读取 webp 或某些特殊格式的图集Pillow 需要额外编译支持不过日常用 PNG 和 JPG 基本没问题。顺便说一句图集如果是pvr.ccz或者ktx这类 GPU 压缩格式Pillow 读不了这种情况建议先用 TexturePacker 自带的命令行工具解压成 PNG再做拆图。4.2 核心代码实现下面给出完整的拆图脚本核心部分我精简了异常处理保留主干方便阅读import json import os import sys import xml.etree.ElementTree as ET from PIL import Image def parse_plist(path): tree ET.parse(path) root tree.getroot() frames {} for e in root.iter(dict): pass # 这里简单起见用 plistlib 更稳 import plistlib with open(path, rb) as f: data plistlib.load(f) meta data.get(metadata, {}) frames data.get(frames, {}) items [] for name, info in frames.items(): frame parse_region(info[frame]) offset parse_offset(info.get(offset, {0,0})) source_size parse_size(info.get(sourceSize, {0,0})) source_color_rect parse_region(info.get(sourceColorRect, {{0,0},{0,0}})) items.append(SpriteItem( name, 0, frame, info.get(rotated, False), source_size, offset, True, source_color_rect )) return items def parse_json(path): with open(path, r, encodingutf-8-sig) as f: data json.load(f) frames data[frames] items [] for name in frames: if isinstance(frames[name], dict): info frames[name] frame (info[frame][x], info[frame][y], info[frame][w], info[frame][h]) rotated info.get(rotated, False) trimmed info.get(trimmed, False) ss info.get(spriteSourceSize, {}) ss_size info.get(sourceSize, {}) items.append(SpriteItem( name, 0, frame, rotated, (ss_size[w], ss_size[h]), (0, 0), trimmed, (ss[x], ss[y], ss[w], ss[h]) )) return items def parse_atlas(path): with open(path, r, encodingutf-8) as f: lines f.readlines() items [] i 0 page 0 image_path None while i len(lines): line lines[i].strip() if not line: i 1 continue if line.endswith((.png, .jpg, .jpeg, .webp)) and , not in line: image_path line[:-1] if line.startswith() else line i 1 # 下一行 size 信息属于 page if i len(lines): size_line lines[i].strip() i 1 page 1 continue if : not in line: # 这是子图名称行 name line.strip() i 1 region None rotated False orig (0, 0) offset (0, 0) while i len(lines) and : in lines[i] and not lines[i].strip().endswith((.png, .jpg)): key_val lines[i].strip().split(:) key key_val[0].strip() val key_val[1].strip() if key xy: x, y map(int, val.split(,)) elif key size: w, h map(int, val.split(,)) region (x, y, w, h) elif key orig: ow, oh map(int, val.split(,)) orig (ow, oh) elif key offset: ox, oy map(int, val.split(,)) offset (ox, oy) elif key rotate: rotated (val true) i 1 items.append(SpriteItem(name, page - 1, region, rotated, orig, offset, True, region)) else: i 1 return items然后是切图和导出的核心def dump_sprite(atlas_img, item, out_path): x, y, w, h item.region if w 0 or h 0: return sprite atlas_img.crop((x, y, x w, y h)) if item.rotated: sprite sprite.transpose(Image.ROTATE_90) # 按需改方向 canvas Image.new(RGBA, item.source_size, (0, 0, 0, 0)) if item.trimmed: # 将裁切后的像素贴回原图位置 sx item.source_color_rect[0] sy item.source_color_rect[1] else: sx item.source_size[0] // 2 - sprite.width // 2 sy item.source_size[1] // 2 - sprite.height // 2 canvas.paste(sprite, (int(sx), int(sy)), sprite) canvas.save(out_path)注意atlas_img.crop()出来的图不带 alpha 信息时粘贴到透明画布上必须用sprite本身作为第三个参数mask否则透明区域会变成黑色这是新手最容易犯的错。4.3 实际运行的输出示例我用一个 Cocos 项目导出的ui_atlas.json跑了一遍命令和结果如下python plist_dumper.py assets/ui/ui_atlas.json --output export运行完成后控制台会打出解析到 128 个子图 图集图片: assets/ui/ui_atlas.png 导出目录: export/ui_atlas/ 已导出: btn_close.png 已导出: btn_ok.png ... 完成共导出 128 个文件耗时 1.2s导出目录里的碎图我随机抽了几张放到 PS 里对比原图像素完全一致包括带透明边的按钮和旋转过的小图标。整个过程不到两秒比手抠快了一个数量级。5. 常见问题与排查技巧从报错到结果不对工具写好了不代表不会出问题尤其是在不同项目、不同 TexturePacker 版本之间流转的时候。下面是我在实操中反复遇到的几个典型问题按出现频率排序整理成一张速查表。5.1 图集描述文件解析失败现象原因排查/解决plist 报parsing error文件带 BOM、编码不对用 plutil 或文本编辑器转成 UTF-8 无 BOMjson 报invalid JSON文件里有注释、尾逗号、非法字符先检查文件末尾是否多了逗号去掉注释json 报Expecting property name enclosed in double quotes键名用了单引号统一替换成双引号json 报missing field反序列化类报错不是标准图集 json而是接口返回的数据确认文件是不是 TexturePacker/Cocos 导出的atlas 报index out of range子图块的键值对没解析完就跳出循环检查行尾是否有空行或大小写不一致5.2 导出图片和原图对不上这是拆图工具最常见的“隐性 bug”表面不报错但结果错误。我遇到过几个典型场景场景一透明边没有被还原导出的图片是一个小方块直接铺在原图上位置不对。这通常是trimmed处理绕过了spriteSourceSize或sourceColorRect的计算。记住一个原则只要trimmed为 true必须用sourceColorRectplist或spriteSourceSizejson来定位。场景二图片旋转后发现方向不对TexturePacker 的rotated字段只告诉你“旋转过”但不同版本可能旋转方向不同顺时针还是逆时针。遇到导出的图横竖颠倒时把Image.ROTATE_90换成Image.ROTATE_270通常一两张测试图就能试出来。场景三有 1~2 像素的偏差如果所有图都往右上角偏了 1 像素多半是图集导出时设置了Extrude边缘外扩防锯齿或者Padding。TexturePacker 默认会给子图之间留 2 像素 padding并且有时会做 1 像素的描边外扩。这种情况最稳妥的方法是在 TexturePacker 里重新导出一次关闭 extrude或者让工具支持读 metadata 里的 padding 参数根据它再偏移坐标。注意新版 TexturePacker 的 plist 会在metadata里写入realTextureFileName、size等信息但 padding/extrude 不一定有明确字段。如果你频繁遇到 1 像素偏差建议导出图集时统一取消 Extrude。5.3 fnt 位图字体的独有坑fnt 格式还要额外注意char id是对应字符的 Unicode/ASCII 编码导出文件名不要直接用 id最好转成可读字符否则一堆char_32.png根本看不懂。kerning字距调整信息常常单独成块如果只是拆图可以忽略但如果要做字体生成器一定要解析kerning pairs。多页字体pages2以上需要根据page字段选择对应的图片否则坐标全部错位。5.4 内存和性能问题大图集动辄 4096×4096Pillow 直接加载没太大问题但如果你批量拆十几个图集内存可能一路飙升。我的建议是逐张处理用完立刻close()如果还嫌慢可以先用缩略图测试坐标对不对再跑全量导出。另外 Pillow 对超大 PNG 的解压速度不算快追求效率可以考虑用 pyvips但日常工具没必要过度优化。6. 扩展思路从拆图到整套资源管线PlistDumper 只是个开始把它接入资源管线之后你会发现拆图的需求往往伴随着一系列后续操作。这里给你几个实际可以做的扩展方向都是我已经在用的。批量替换图集里的某个子图先拆图 → 修改碎图 → 用 TexturePacker 的 CLI 重新打包。整套流程自动化之后改 UI 素材的效率能提升不少。把 fnt 位图字体的每个字符导出成单独图片再配合 OCR 生成书源、词库等数据虽然用途偏门但关键时刻很省事。检查图集内是否有重复子图做个 md5 碰撞检测帮美术瘦身图集尺寸。拆出来的碎图按目录结构重新组织可以用于生成雪碧图预览页方便策划验收资源。我个人的体会是拆图这件事本身不难难的是把不同格式的差异、旋转和透明裁切的还原这些细节处理好。这套工具写好之后后来不管是切序列帧、导位图字体还是给外包交付单图资源都是几十秒的事。如果你也有类似需求建议直接照着上面的思路写一个自己用的版本跑通后你会回来感谢我的。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价