简介面向游戏开发与UI设计人员的Plist合图分割工具专门把TexturePackr等生成的纹理合图及配套plist元数据还原为原始小图解决素材单独修改、复用与局部更新难题。工具基于Qt框架开发集成FreeImage图像处理库可读取常见图片格式并按plist记录的位置、大小与旋转信息精确裁剪导出。压缩包共21个文件含SplitTool.exe主程序、AesEncDecTool.exe加解密辅助工具、16个运行所需的dll如Qt核心库、FreeImage、ICU组件等以及aes.c/h源码整体18.8MB免安装、便于本地直接运行。已有291人学习下载。使用该工具可批量拆分合图并保留原始命名同时提供AES加解密源码供二次开发参考对需要维护大量图集资源的开发者来说是高效实用的图像资源管理方案。1. 为什么需要自己写一个Plist合图分割成小图工具做游戏资源还原或者UI资源拆包的同学应该都撞上过这种需求手头只有一张Plist合图和一份坐标文件原始小图却不知道丢到哪里去了。所谓Plist合图分割成小图工具就是顺着Plist里记录的位置信息把大图上一个个小图裁剪出来、还原成可用PNG的脚本或软件。它能解决资源交接、逆向还原、批量导出的问题适合独立开发者、工具链工程师和美术同学。它的价值不在于“切图”这个动作本身而在于把一份没有原图的资产变成可继续编辑的素材理解了它的原理你就不会再被这类资产生死问题卡住。2. 先搞懂Plist合图的结构frame、rotated、offset各是什么2.1 合图的本质一张大图和一份坐标账本合图Texture Atlas / Sprite Sheet是现代游戏引擎和UI编辑器里非常常见的一种资源组织方式。把小图一张张拼进一张大图渲染时只需要一次性绑定纹理、一次性提交绘制就能大幅减少绘制调用同时减少小图文件数量也能降低磁盘加载压力。PDF、动画序列帧、UI皮肤都会用这种方式打包。Plist就是那本「坐标账本」。它记录了每一个小图在大图中的位置、原始尺寸、是否旋转、偏移多少等信息。比如一张 hero.png 由 icon_001.png、icon_002.png 等几十张小图拼成Plist里就会有一条条对应记录。合图工具负责把散图拼成一张大图并生成Plist而我们的分割工具做的就是完全相反的事读Plist、算坐标、按坐标裁剪。理解这个结构是写工具的第一步。把Plist当黑匣子处理遇到特殊格式或者边缘情况就会翻车跟着字段走一遍切图脚本基本就是「查表 裁剪 微调」的组合。2.2 frame字段里藏着坐标、旋转和透明裁剪信息一份典型的Plist不管用什么工具生成核心内容都差不多顶层是一个dict里面有一个frames字段frames下每个条目代表一张小图。常见字段如下字段名作用说明frame小图在大图中的矩形区域通常写成 {{x,y},{width,height}}也可能写成嵌套dictrotated打包时是否旋转了90度很多合图工具为了节省空间会旋转细长图sourceSize原始小图的画布尺寸合图时可能裁掉了透明边缘这个字段保留原始宽高offset裁剪后内容相对中心的偏移用于把裁剪内容放回原始画布的正确位置scaleFactor资源倍率Retina或高清资源常见就是2.0或3.0sourceColorRect部分工具会记录实际内容区域和offset配合也是还原原图的辅助信息单独看frame字段最容易出问题。它代表的是「在合图大图上的一块矩形区域」按这个区域裁剪出来的图直接保存就是一张可用小图——但这里有一个前提要么这张小图在打包时没有被裁掉透明边要么你不在乎还原原始画布尺寸。如果打包时美术在源文件里留了大量透明边距合图工具通常会把这些透明区域裁掉以节省空间此时裁剪出来的图尺寸会比原始小图小。要还原原始画布就得用sourceSize和offset重建背景画布。2.3 格式差异不同工具生成的Plist并不一样不同合图工具导出的Plist字段名称几乎一样但表达方式差别很大。我见过的常见差异就有三种frames可能是dict键为文件名或list元素里再带filename字段frame的值可能是字符串{{x,y},{width,height}}也可能是嵌套dict坐标数值有的给整数有的给浮点数。更麻烦的是文件本体也有两种一种是标准XML格式的文本Plist另一种是二进制Plist文件头是bplist00。用文本编辑器打开二进制Plist看到的是一堆乱码直接用字符串解析必然会卡住。所以写脚本的第一步不是「开始切图」而是「写一个字段兼容层」。你要让脚本同时能读dict/list、字符串/嵌套dict、文本Plist/二进制Plist。好在主流脚本语言都有现成的Plist解析库之后会细说真正需要你手工处理的只有frame、rotated、offset这几个字段的多种写法。兼容层写得越稳后面换一套资源包时就越省心。3. 工具选型现成GUI和自写脚本怎么选边界在哪3.1 现成GUI工具能覆盖的场景与覆盖不到的场景市面上确实有能直接把Plist合图拆成小图的现成工具图形界面拖进去、选一下输出目录就能得到一批小图。对于「一次性、量小、只想看图、不需要精确还原原始画布」的场景用现成GUI工具非常省事。尤其是你只需要确认合图里有哪些素材、大概长什么样时GUI的预览比命令行方便得多。但用不了几次你就会发现它的边界。首先是批量处理能力弱几十上百个Plist文件GUI工具通常只能一个一个拖进去切没法在命令行里循环调用其次是格式兼容性差很多GUI工具只认某一款合图工具导出的Plist换一个引擎或者换一种导出设置就完全不认甚至直接报错。更关键的是GUI工具往往只在「切出内容区」的层面做事情帮你把frame区域裁出来却不处理offset还原、不处理旋转回正文件数量一多你根本分不清哪些小图被裁掉了透明边哪些旋转后横躺。如果只是临时救急GUI工具没问题。但如果你要维护一套资源流水线、要反复切同一个项目里的几百个Plist、要保证切出来的结果和原工程一致GUI工具就会成为瓶颈。3.2 为什么脚本方案才是通用解自写脚本的适用边界脚本方案的优势首先是批量。一个目录遍历加一个循环几百个Plist几分钟跑完其次是可控。rotated转不转、offset还原不还原、命名冲突怎么处理、输出目录结构要不要保持每一行逻辑都在自己手里出问题时可以直接断点定位。还有一点是可持续集成脚本可以直接接进构建流水线作为资源导出前的预处理步骤切完顺手改名、格式转换、生成清单都是顺手的事。那什么时候不要写脚本如果你只是偶尔切一张图、切完就不再碰写脚本反而浪费时间。另一个不要写脚本的情况是你对Plist的字段语义完全不确定而手头只有一份来路不明的资源包。这时候先手动验一张确认rotated方向、offset语义再写成脚本批量跑否则就是几百张图全部切错。我一般这样判断Plist数量超过10个、或者这个资源包未来还会更新、或者需要反复重新切就写脚本三个条件都不满足用GUI工具。当你发现自己第三次用GUI切同一个资源包时就是写脚本的时候了。4. 落地实现用Python脚本把合图分割成小图思路与可复现代码4.1 环境准备与最小可运行脚本Python Pillow是这个场景最顺手的组合。Python内置plistlib读写Plist不需要装额外解析库Pillow负责裁剪和保存图片。环境准备很简单pip install Pillow然后准备一个小目录结构把Plist和大图放好./plist/hero.plist ./img/hero.png ./out/hero/最小可运行脚本如下。它已经处理了dict/list两种frames结构、字符串和dict两种frame写法、二进制和文本两种Plist格式import plistlib import os from PIL import Image def parse_rect(value): 兼容两种frame写法字符串 {{x,y},{w,h}} 或 dict {x:..,y:..,width:..,height:..} if isinstance(value, dict): return int(value[x]), int(value[y]), int(value[width]), int(value[height]) text value.strip().replace({, ).replace(}, ) nums [int(float(part.strip())) for part in text.split(,)] return nums[0], nums[1], nums[2], nums[3] def iter_frames(frames): 兼容frames为dict键是文件名和frames为list元素带filename字段两种结构 if isinstance(frames, dict): for name, info in frames.items(): yield name, info else: for info in frames: yield info.get(filename), info def split_sheet(plist_path, image_path, output_dir, keep_original_sizeTrue): with open(plist_path, rb) as f: data plistlib.load(f) sheet Image.open(image_path).convert(RGBA) frames data.get(frames, []) os.makedirs(output_dir, exist_okTrue) count 0 for name, info in iter_frames(frames): x, y, w, h parse_rect(info[frame]) # 按frame坐标从大图上裁剪出内容区 sprite sheet.crop((x, y, x w, y h)) # 打包时顺时针旋转过这里用逆时针转回相反方向就换ROTATE_270 if info.get(rotated, False): sprite sprite.transpose(Image.ROTATE_90) if keep_original_size: # 用sourceSize重建原始画布用offset把内容放回正确位置 source_size info.get(sourceSize, {}) sx int(source_size.get(width, w)) sy int(source_size.get(height, h)) offset info.get(offset, {}) ox int(offset.get(x, 0)) oy int(offset.get(y, 0)) canvas Image.new(RGBA, (sx, sy), (0, 0, 0, 0)) pos_x (sx - sprite.width) // 2 ox pos_y (sy - sprite.height) // 2 oy canvas.paste(sprite, (pos_x, pos_y), sprite) sprite canvas # 防止Plist内部带路径导致输出目录穿越 safe_name os.path.basename(name.replace(\\, /)) sprite.save(os.path.join(output_dir, safe_name)) count 1 return count if __name__ __main__: n split_sheet(./plist/hero.plist, ./img/hero.png, ./out/hero/) print(f导出 {n} 张小图)这个脚本的核心逻辑只有三件事解析frame区域、裁剪大图、按需还原画布。plistlib.load在读取二进制Plist时也能自动识别所以不需要你手动判断文件头。转成RGBA是因为合图里的透明区域在裁剪和粘贴时最容易丢alpha通道先转RGBA再处理后面保存PNG就不会出现透明变黑的问题。4.2 处理旋转与透明裁剪代码改造与坐标换算上一小节的脚本里有两个地方是「按需处理」的rotated和offset。如果不管它们旋转图会横躺去透明边的小图会比原始画布小一圈。这两个问题几乎是所有Plist分割工具翻车的重灾区。先说rotated。合图工具在打包时为了不让细长条的小图浪费大量空白区域会在拼图时把小图旋转90度。Plist里用rotated字段标记。裁剪时frame的width和height其实已经和原图互换了所以裁剪出来的图是旋转过的。解决方案就是裁完立刻transpose转回。但旋转方向在不同工具里可能相反有些工具顺时针转有些逆时针转。判断方法很简单找一张明显不接近正方形的小图切出来看如果转一次后就正常方向就对了如果还是横躺就把Image.ROTATE_90换成Image.ROTATE_270。再说offset。合图工具裁掉透明边缘后会记录内容区相对原图中心的偏移。还原原始画布时需要先创建一个和sourceSize等大的透明画布再把裁剪内容按偏移放上去。上面代码里的pos_x和pos_y就是「居中 偏移」的标准写法。个别工具对offset的语义可能和这个公式差一个正负号如果发现切出来的图位置不对把ox和oy取反再试一次。4.3 参数说明与数据校验跑完一批后怎么确认没切错脚本里比较重要的参数是keep_original_size。它控制两种输出模式True表示还原原始画布尺寸切出来的小图和美术当初交付的原图尺寸一致False表示只保留内容区小图尺寸是紧贴内容的。多数情况下建议用True否则小图放到UI上会占位错误、总是偏一格。还有一个隐藏参数是scaleFactor。如果大图是2倍尺寸的资源Plist里可能会写scaleFactor 2。此时frame坐标是1倍坐标还是2倍坐标取决于合图工具怎么输出。常见做法是大图本身是2x图frame坐标直接按2x处理不需要额外乘缩放但有些工具会给出1x坐标这时候裁剪前需要把x、y、w、h全部乘以scaleFactor。建议拿到资源包后先看metadata里的size和实际大图尺寸是否一致很快就能判断出坐标基准然后再决定要不要做换算。跑完一批后做两个校验。第一个是数量校验脚本输出的count和Plist里frames的条目数应该完全相等。第二个是尺寸冒烟测试随机抽取几张小图用图片查看器打开确认它们不是横躺、不是纯透明、边缘没有明显杂色。如果切了几百张建议再写一小段自动校验遍历输出目录检查每张图尺寸是否非零、是否是合法PNGimport os from PIL import Image out_dir ./out/hero/ for f in os.listdir(out_dir): im Image.open(os.path.join(out_dir, f)) assert im.size[0] 0 and im.size[1] 0, f{f} 尺寸异常 assert os.path.getsize(os.path.join(out_dir, f)) 0, f{f} 文件为空校验脚本本身并不复杂但它能把「看起来好像切完了」变成「验证过确实切完了」。在几十上百个Plist的规模下这一步能挡住绝大多数低级错误。5. 分割小图的常见问题与排查5条血泪经验5.1 切出来的图边缘发黑或者颜色不对先检查alpha通道现象是内容本身看起来正常但半透明边缘带上了一圈灰色的黑边放到深色背景上尤其明显。原因是合图用的PNG可能开启了预乘Alpha或者图片在打包时边缘混入了背景色。解决方法是先确认源大图的通道用脚本采样一个半透明像素如果RGB值大于Alpha值说明不是标准的直通Alpha需要先做unpremultiply再保存。边角特别锐利的图也常见这个问题切完不要直接交出去放到实际背景里看一眼。5.2 旋转图横躺rotated字段没处理或者方向搞反这是最容易大规模翻车的一类问题。现象是切出来的一批图里一部分看着很正另一部分是横躺的看起来完全没有规律。原因是这些横躺的图在合图时被旋转过Plist里rotated为true你的脚本没有做回正。解决方法是裁剪后根据rotated字段统一transpose。注意旋转方向不同工具不一样如果转一次后发现所有图从「横躺」变成「竖躺但仍然不对」就把ROTATE_90改成ROTATE_270。我现在的习惯是第一次跑批前先挑选一张最不像正方形的图单独验证方向确认没问题再全量跑。5.3 切出来的图比原图小offset和sourceSize没还原现象是脚本输出的每张图都「很干净」但放到界面里总是位置偏移或者和动画骨骼对不上。原因是合图时工具裁掉了透明边缘只保留实际内容区域而你的脚本是直接按frame裁剪保存没有用sourceSize重建画布。解决方法是开启keep_original_size逻辑先创建和sourceSize等大的透明画布再把裁剪内容按offset放回画布中央。这样切出来的小图尺寸、透明边距都和美术原始输出一致。值得注意个别工具里offset的符号习惯和常规相反如果位置总是正好反方向偏移一截把ox和oy取反即可。5.4 读取Plist报错或者读出来全是乱码二进制Plist和编码问题现象是plistlib.load抛异常或者解析出来frames是空的。原因是文件可能是二进制Plist你在用文本方式读取也可能文件是UTF-8带BOM直接读把BOM字符带进了解析器。解决方法是统一用二进制模式打开文件open(plist_path, rb)plistlib会自行判断文本格式还是二进制格式。如果发现文件头不是bplist00也不是?xml那么这个文件可能根本不是Plist而是JSON或其他格式直接用json.load替代解析。编码问题在Windows环境里尤其常见不要在代码里硬编码编码方式让plistlib自己判断最稳。5.5 边缘出现一圈相邻小图的杂色坐标精度和padding问题现象是某些小图边缘多了一条颜色怪异的像素带仔细看能看出是隔壁小图的边。原因是合图工具在拼图时为了过滤采样会在小图之间留padding而Plist里记录的frame区域可能包含了这层padding另一个可能是坐标解析时用了浮点数直接取整误差累积导致偏了一两个像素。解决方法是先确认frame到底指内容区还是内容区加padding。如果是后者裁完再往内收缩几个像素x加padding、y加padding、width减两倍padding、height减两倍padding。坐标取整则统一用int(float())不要反复round避免边界抖动。6. 进阶用法从单张切图到批量校验流水线6.1 批量遍历目录、自动匹配大图单个Plist切完只是开始实际项目里资源都是成百上千的。写一个递归遍历函数自动找到所有Plist并匹配同名大图就能一键全量导出import glob import os for plist_path in glob.glob(assets/**/*.plist, recursiveTrue): image_path os.path.splitext(plist_path)[0] .png if not os.path.exists(image_path): print(f跳过 {plist_path}缺少同名大图) continue out_dir plist_path.replace(assets, out) n split_sheet(plist_path, image_path, out_dir) print(f{plist_path}: {n} 张小图)这段逻辑里有一个容易忽略的点大图文件名和Plist文件名通常一致但扩展名可能是png也可能是jpg。稳妥做法是先试png再试jpg两个都不存在才跳过。批量跑的时候建议把跳过原因打印出来否则某个资源包漏切了你可能很久之后才发现。6.2 用manifest记录切图结果建立可对比的基线当切图变成流水线的一环之后光有输出文件还不够建议同时生成一份manifest清单记录每个小图的文件名、尺寸、输出时间。下次跑批时先读旧manifest和这次结果做对比新增了哪些文件、删除了哪些、哪些尺寸变了。这个对比能很快暴露资源包是否更新过、脚本参数是否改动过也能让美术和工具链同学在出问题时直接定位到具体差异。我自己的习惯是脚本必须输出一个exit code切图过程中有任何一张图校验失败就非零退出。这样流水线里下一道环节才能知道这一步失败而不是默默把错误结果往后传。最后说一个翻车教训之前做某个资源还原项目时忘了处理rotated字段结果几百张序列帧切出来全是横躺的花了整整半天才定位到问题。那次之后我把「先验证一张再全量跑」写进了固定流程跑批之前先挑一张非正方形的小图切出来人工看一眼旋转方向。这条习惯一直保留到现在。希望帮到你。本文还有配套的精品资源点击获取