☰
Plist合图分割成小图工具:Python批量拆包与坐标系换算指南
2026/10/15 1:32:47 网站建设 项目流程

简介:一款面向游戏开发与UI设计人员的Plist合图分割工具,专门用于将TexturePackr打包生成的纹理合图按Plist文件中的坐标信息拆分还原为独立小图,解决合图后单张素材难以单独编辑或复用的问题。包内共21个文件,以dll动态库为主,涵盖Qt界面、FreeImage图像解析及ICU国际化支持等模块;另有2个exe可执行程序、1个png示例图片以及一组AES加解密源码文件,压缩包整体约18.8MB,解压后即可在Windows环境运行。目前已有291人学习下载,足见其在资源优化场景中的实用价值。借助该工具,读者不仅能直接完成合图拆分的批量操作,还可通过附带源码和示例文件了解Qt界面调用、FreeImage图像解析以及AES加密工具的实现思路,方便二次开发或集成到自己的资源管理流程中。

1. Plist合图分割成小图工具:图集拆包到底在解决什么

Plist合图分割成小图工具,简单说就是把合图里的每张小图拆回原始文件。游戏和App的UI资源经常被打包成一张大图加一份plist,源工程一旦丢失,想改某个按钮、换一张头像,总不能靠肉眼在大图上网格切。这个工具的价值就是照着plist里的坐标、宽高、旋转标记,把大图切回可编辑的小图。它适合客户端开发、UI美术和做资源管线的工程师,也适合在老项目里做资源清理和换引擎迁移。很多人第一次用脚本切完发现图片上下颠倒,或者带旋转标记的帧全部躺平,那是因为没弄明白plist的坐标系。下面的方案不依赖图形界面,用Python就能批量处理,整个过程会把坐标系、旋转、偏移这三个关键点讲透。

2. 先读懂Plist:坐标系、frame和offset的换算逻辑

2.1 认识plist里的原始字段:frames、metadata与每张小图的记录

Plist是苹果的Property List,合图工具导出的多数是XML格式,里面是嵌套字典。用Python标准库plistlib读取后,最外层是个<dict>,一般有两个核心键:frames和metadata。frames保存每一张小图的名字和切图信息;metadata保存合图尺寸、格式版本、大图文件名等整体信息。一个典型的plist片段长这样:

<dict> <key>frames</key> <dict> <key>sprite_001.png</key> <dict> <key>frame</key> <string>{{2,156},{96,96}}</string> <key>offset</key> <string>{0,0}</string> <key>rotated</key> <false/> <key>sourceSize</key> <string>{96,96}</string> </dict> </dict> </dict>

每个小图字典里最常见的四个字段是frame、offset、rotated、sourceSize。frame用字符串表示矩形,格式是{{x,y},{w,h}},含义是小图在大图中的左上角坐标和宽高。rotated是布尔值,true代表原图被旋转了90度后放进合图,通常是为了减少图集空白区域。sourceSize保存小图裁剪前的原始宽高,而offset保存裁剪后的小图在原始画布中相对中心点的偏移量。

字段类型和用途可以列成一张表:

字段类型含义
framestring大图中的矩形位置,格式{{x,y},{w,h}}
offsetstring裁剪后相对原始画布中心的偏移,格式{dx,dy}
rotatedbool是否被旋转90度后再合图
sourceSizestring原始图片的未裁剪宽高,格式{w,h}

这些字段名不是所有工具都统一。有的工具老版本会写originalSize、trimmed,有的把旋转标记命名为textureRotated。读取的时候要做兼容,不能写死key。这也是为什么建议直接用plistlib把整个文件解析成Python字典,而不是写正则去抠坐标字符串。正则看着头疼,还容易在版本差异上翻车,让标准库做解析是少踩坑的第一步。

2.2 坐标系换算:plist的左下角原点与图像库的左上角原点

这是plist切割最容易翻车的地方。plist记录的坐标原点在大图左下角,x向右增大,y向上增大;而几乎所有的图像处理库处理像素时,原点都在左上角,y向下增大。如果你直接把frame里的y当成图像库的y,切出来的图会上下颠倒,或者拼到错误位置。

换算公式其实不复杂。设frame为(x, y, w, h),合图高度为atlas_h。在图像库的坐标系里,裁剪框左上角坐标是:

image_x = x image_y = atlas_h - (y + h)

先用y加上高度得到上边界,再用合图高度减掉它,就得到从上往下数的y坐标。举一个具体数字:合图是1024x1024,frame是{{2,156},{96,96}},那么image_y = 1024 - (156 + 96) = 772,PIL里的裁剪框就是(2, 772, 98, 868)。这个数字可以在心里算一遍再跑脚本,一旦输出和预期不符,至少能确认是自己换算错了还是库用错了。

再看rotated的情况。如果为true,代表原图被旋转后放进合图。这样frame里的w和h是旋转后矩形的宽高,而不是原图的宽高。切割时仍然按frame的w和h裁,因为实际存储的矩形就是这个尺寸;切完再做一次逆时针旋转还原原图方向。但这里有个约定问题:不同工具对旋转方向的定义可能不一致,有的存的是顺时针,有的存的是逆时针。我的做法是先拿一张方向明确的小图测试,转90度不对就换270度,几秒钟就能确定,不用把玄学留给批量任务。

offset和sourceSize是另一组换算。sourceSize给出原图画布的宽高,offset告诉你裁剪后的小图在这个画布里相对中心偏移了多少。要还原一张和原始资源一致的小图,需要先建一个sourceSize大小的透明画布,再把裁剪图贴到中心,最后按偏移量修正位置。常见公式是:

canvas_x = (source_w - crop_w) // 2 + offset_x canvas_y = (source_h - crop_h) // 2 - offset_y

注意offset_y要取反,因为plist坐标系向上为正。crop_w和crop_h这里应该使用原图方向(未旋转)的宽高。实际操作时,y的正负最容易让人困惑,如果发现输出整体偏低或偏高,把offset_y取反再试一次,比重新推导公式更快。

有的人并不需要还原到原始画布,只想把合图中实际存在的像素切出来,那可以完全忽略offset和sourceSize,只做crop。但我建议保留还原逻辑,因为后续要替换资源、重新合图或者做对比,原始尺寸更通用。PIL在这类任务里足够顺手,crop一行就能完成,透明通道也保留得干净,不需要为了一个矩形切图引入更重的图像库。

3. 用Python写最小命令:从plist切出全部小图

3.1 选型:为什么用Python而不是去找图形工具

常见的做法是拖一个图形工具进去直接切,但批量修改、在服务器上跑、接到资源流水线里就很难办。Python的优势在于plistlib是标准库,解析无需装额外依赖;图像库的crop、paste、transpose三个方法就能覆盖全部核心操作。如果你的场景只是偶尔切一两张,图形工具更快;但资源量超过几十张、或者要反复处理多个图集,脚本的成本其实比想象中低。这套脚本沉淀到团队工具库里,下次接到新项目直接复用,不用再绕回手动一张张导。

3.2 核心代码:解析plist并切割大图

下面这个版本是最小可用实现,能处理同目录下的plist和大图,并把所有小图输出到指定目录。

import plistlib import sys from pathlib import Path from PIL import Image def parse_rect(value): # value 形如 "{{10,20},{100,50}}" values = value.replace("{", "").replace("}", "").split(",") x, y, w, h = map(int, values) return x, y, w, h def crop_sprite(atlas, frame, rotated): x, y, w, h = frame # plist 坐标原点是左下角,PIL 是左上角 top = atlas.height - (y + h) box = (x, top, x + w, top + h) sprite = atlas.crop(box) if rotated: sprite = sprite.transpose(Image.ROTATE_90) return sprite def export_from_plist(plist_path, output_dir): with open(plist_path, "rb") as f: plist = plistlib.load(f) frames = plist["frames"] atlas_name = plist["metadata"]["textureFileName"] atlas = Image.open(plist_path.parent / atlas_name) output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) for name, info in frames.items(): frame = parse_rect(info["frame"]) rotated = info.get("rotated", False) sprite = crop_sprite(atlas, frame, rotated) sprite.save(output_dir / name) if __name__ == "__main__": export_from_plist(Path(sys.argv[1]), sys.argv[2])

这段代码的流程是:先用plistlib.load读取plist文件,得到一个嵌套字典;然后从metadata.textureFileName拿到大图文件名,并打开合图;最后遍历frames里的每个条目,按矩形裁剪并保存。crop_sprite里最关键的一行是top = atlas.height - (y + h),它把plist的左下角坐标转成PIL的左上角坐标。rotated为true时,裁剪出来的图像还需要用transpose(Image.ROTATE_90)恢复方向。

这个实现有几个参数是隐式的:plist路径和大图路径默认在同目录,输出格式跟随原图扩展名。实际使用时,我会把输出格式统一成png,避免保存成jpg后丢失透明通道。另外,plistlib.load能同时解析XML和二进制plist,遇到文件开头是乱码的二进制plist也能读,这是标准库自带的兼容能力。

3.3 命令行参数:输出目录、大图路径和格式开关

为了让脚本可以被其他人复用,我一般会把参数做成显式的:

python split_plist.py --plist res/atlas.plist --atlas res/atlas.png --out assets/win --format png

参数说明如下:

参数默认值作用
--plist必填plist文件路径
--atlas从metadata读取合图大图路径,用于plist里路径失效时手动指定
--out./out输出目录
--formatpng输出格式,建议强制png
--no-rotateFalse遇到rotated帧时不做转正处理

这里有一个容易忽略的边界:metadata.textureFileName有时是纯文件名,有时带了相对目录。所以--atlas参数很重要,它让大图路径不依赖plist内部写的内容。输出格式如果不指定,我会默认覆盖成png,因为图集里的小图多数带透明通道,jpg会直接翻车。这套最小方案已经能覆盖大部分合图资源,接着把它扩展成批量版本。

4. 批量分割与还原:从单张plist到整个资源目录

4.1 用glob扫目录,批量找对应的大图

实际项目里很少只有一张plist,更多是几十个图集散落在不同目录。批量处理的思路是扫描某个根目录下所有的*.plist文件,然后为每个plist找它对应的大图。大图名字优先从metadata.textureFileName拿,拿不到就用plist的同名文件替换扩展名。代码如下:

import glob from pathlib import Path def find_atlas(plist, metadata): atlas_name = metadata.get("textureFileName", "") candidates = [] if atlas_name: candidates.append(plist.parent / atlas_name) candidates.append(plist.parent / Path(atlas_name).name) candidates.append(plist.with_suffix(".png")) candidates.append(plist.with_suffix(".pvr.ccz")) for c in candidates: if c.exists(): return c return None

这个函数会把所有可能的路径放进候选列表,逐个检查是否存在。这么做的原因是plist内部的textureFileName有时候写的是相对路径,有时候只是文件名,直接拼接容易报错。glob扫描比os.walk更简洁,也更容易控制递归范围。如果你遇到的大图是pvr等压缩纹理格式,PIL默认打不开,需要在进入脚本前先转成png。压缩纹理是另一个话题,但切图脚本本身不需要关心编码,只要大图能加载成图像对象就能继续。

4.2 批量主循环:保留目录结构、跳过已处理目录

有了find_atlas之后,批量主循环就清晰了。下面这段代码扫描根目录,把所有plist的相对路径映射到输出目录,并支持跳过已经处理过的文件。

def batch_convert(root_dir, out_root, overwrite=False): plist_files = sorted(Path(root_dir).glob("**/*.plist")) for plist_path in plist_files: with open(plist_path, "rb") as f: plist = plistlib.load(f) atlas_path = find_atlas(plist_path, plist["metadata"]) if atlas_path is None: print("skip:", plist_path, "no atlas found") continue rel = plist_path.relative_to(root_dir).with_suffix("") out_dir = Path(out_root) / rel if not overwrite and out_dir.exists(): print("skip existed:", out_dir) continue export_from_atlas(plist, atlas_path, out_dir)

这里保留了输出目录的层级结构,比如res/ui/atlas.plist切出来的小图会放到out/ui/atlas/下。好处是当一个目录下有多张plist时,不会全部压成一堆散文件。overwrite开关解决增量处理:如果plist没改过,但输出目录已经存在,就直接跳过;改过图集需要重新切,就加上--overwrite。这是一个很小的习惯,但批量任务里很能救命,不会因为重复跑而浪费时间。

4.3 还原原始尺寸:sourceSize与offset在批量中的处理

批量切图过程中,如果plist里的小图是裁掉透明边后存入的,直接保存裁剪块会得到一张比原图小的图。要还原成原图,需要创建一张sourceSize大小的透明画布,把裁剪后的图贴回去。示例代码:

def restore_sprite(sprite, info): source_w, source_h = parse_size(info["sourceSize"]) crop_w, crop_h = sprite.size canvas = Image.new("RGBA", (source_w, source_h), (0, 0, 0, 0)) offset_x, offset_y = parse_point(info.get("offset", "{0,0}")) x = round((source_w - crop_w) / 2 + offset_x) y = round((source_h - crop_h) / 2 - offset_y) canvas.paste(sprite, (x, y), sprite) return canvas

parse_point用于把{dx,dy}拆成两个整数,逻辑和前面的parse_rect类似。注意paste的第三个参数填了sprite本身,它会把sprite的alpha通道当作mask,这样透明区域不会被贴成黑色。x的公式是原始宽度减去裁剪宽度除以二,再加上x方向偏移量;y方向要减去偏移量,因为offset的y轴方向与大图坐标系一致,向上为正。

这段代码在批量中的收益很明显:切出来的每一张小图直接就是源文件尺寸,没有多余的透明边,也没有裁剪偏移。美术拿来就能编辑,不需要再手动对齐。这个还原逻辑同样存在y轴取向的兼容问题,如果发现所有图片在垂直方向上都偏离了中心,优先把offset_y前面的减号换成加号重跑一次。相比去查文档猜版本,直接把可执行脚本当成实验工具会快得多。

5. Plist合图分割避坑指南:坐标原点、旋转方向与版本差异

5.1 输出全部上下颠倒:plist左下角坐标没换算

现象:切出来的图片内容颠倒,或者位置乱成一团。

原因:frame里的y坐标是左下角原点的值,直接把它当作图像库的y,等于把图按顶部对齐切到了底部。

解决:在裁剪前执行top = atlas.height - (y + h),并且确认top + h不超过合图高度。如果大图带透明边,这个换算依然成立,不需要额外补偿。可以在代码里顺手加一个断言式检查:assert top + h <= atlas.height,一旦plist里出现越界坐标就能立刻暴露出来。这个坑是最常见的,也是所有图形工具导出时都遵循的坐标系差异,踩过一次之后几乎不会再忘。

5.2 旋转帧输出成横条:旋转方向和宽高互换

现象:部分小图宽高与源文件反了,内容旋转了90度或者270度。

原因:rotated为true时,frame里的w和h是旋转后矩形的宽高。如果裁剪后不做转正,或者转正方向反了,输出就会“躺平”。

解决:先确认rotated标记存在且为true,再做转正。PIL的Image.ROTATE_90是逆时针旋转90度,如果切出来的图仍然转错了方向,改成Image.ROTATE_270。另外,在restore_sprite里计算画布坐标时,crop_w和crop_h要用转正后的尺寸,否则还原出来的源文件中心位置会偏差。最稳的方法是准备一张已知原图做对比,一次测试就能确定工具的方向约定。

5.3 图片边缘出现毛边或黑线:合图出血边导致

现象:切出的小图四周有一圈半透明像素、白边或黑线。

原因:部分合图工具会在小图周围多留1到2像素的边,用于避免压缩时出现接缝。frame矩形有时已经包含了这部分边,导致切割结果带上了相邻信息。

解决:在裁框时向内收缩。x和y各加1,宽高各减2,然后重新计算偏移量。收缩像素数取决于具体工具的padding设置,一般1到2像素就能消除毛边。如果源文件本身是带透明边生成的,收缩会裁掉真实内容,所以不能盲目套用。遇到边缘异常先放大检查一张,再决定收缩量。

5.4 换一个工程就报KeyError:老版本与新版格式字段不一样

现象:同一套脚本,之前跑得正常,换一个项目直接报KeyError: 'sourceSize'。

原因:不同工具、不同时期的plist字段命名不统一。老版本可能用originalSize,新版本可能用textureRect、textureRotated,甚至有的plist是二进制格式,文本编辑器打开是乱码。

解决:写一个兼容读取层,优先读取sourceSize,没有就找originalSize;rotated读不到就试textureRotated;frame读不到就找textureRect。另外,plistlib.load虽然能读二进制plist,但二进制格式一旦出现解析问题,排查很痛苦。我一般会把二进制plist先转成XML文本保存一份,用文本搜索确认字段名,再回脚本里调整。这一条属于换项目时最常见的隐藏成本,提前兼容能省掉大半天排查时间。

6. 进阶:校验切图结果和自动化集成的三个习惯

切图脚本跑完不代表结束,真正决定工具能否复用的是验证和流程。我会先检查是否还原到了预期的原始画布:用ImageChops.difference对比脚本输出和原图,统计非零像素比例,比例接近0说明像素完全一致;只有边缘1像素不同,多半是出血边或四舍五入的误差。

批量处理前,我会让脚本先生成一份manifest,记录每张小图的文件名、目标坐标、原始尺寸和旋转标记,人工抽查五张小图确认视觉还原正确,再全量跑。这个习惯在某跨平台系统的资源拆包中救过我:有一次没先验证就直接切了三百多张,结果y轴换算错了整整一天。后来无论脚本多小,我都先抽检再批量。

另一个调试技巧是给每张小图随机填充一个不透明色块,拼到一张预览图上输出。通过肉眼看色块分布就能发现坐标重叠、越界和旋转方向错误,比逐个打开文件高效得多。切图脚本的功能不复杂,但坐标系和偏移量的坑很隐蔽,愿意先验证、再批量,才能让它变成可靠的工具,而不是一次性脚本。希望帮到你。

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

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

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

立即咨询