ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从图集到散图:用Python+Pillow实现图集解包工具

从图集到散图:用Python+Pillow实现图集解包工具 简介TextureUnpacker是一套用于解析和查看TexturePacker生成的纹理图集资源的工具源码面向游戏开发者、UI设计师及资源管线相关程序员。TexturePacker在游戏和UI开发中普遍用于将2D图形打包成纹理图集而此工具则帮助使用者直接读取plist元数据与位图数据快速定位精灵的尺寸、坐标、裁剪等信息便于调试和二次处理。包内共34个文件以cpp/h源代码为主辅以Qt界面设计文件、png图标、资源描述文件及工程配置压缩包仅93KB结构紧凑。目前已有240人学习此项目适合有C基础并对图形资源管理感兴趣的读者。通过阅读源码可以掌握plist/XML解析方法、QDomDocument的使用、自定义Qt控件的实现以及像素图读取逻辑还能了解主窗口交互、保存导出对话框等模块的构建方式为理解游戏资源打包与解包流程提供一份完整的工程范例。1. 从一张图里“拆”资源我为什么写了 TextureUnpacker做游戏的人尤其是搞 Unity、Cocos 或者 2D 骨骼动画的几乎天天跟图集Texture Atlas / Sprite Sheet打交道。美术同学交付的资源是一张又大又整齐的 PNG加上一个记录坐标的 JSON 或者 XML 文件。程序这边直接丢给引擎用 SpriteRenderer 或者九宫格切一下就能跑正常情况下没人会去关心图集里面那一块块小图是怎么排布的。但总有需要“逆方向”操作的时候。我有一次接了个老项目的移植活儿对方给过来的资源包里面全是已经打好的图集偏偏缺了原始的单张切图。我要改其中一张 UI 图标比如把“开始按钮”换成新的样式可我从图集里抠出来的永远是一整张带背景的大图要么是旁边多了别的控件要么就是把整块九宫格切得乱七八糟。那段时间我几乎是手动用 PS 里的矩形选区照着 JSON 坐标一个个抠图一个图集几十个元素抠到眼瞎。后来我实在忍不了花了一个周末写了个小工具给它起了个名字叫 TextureUnpacker。核心功能很简单读取一张图集 PNG 和对应的元数据文件按坐标把每个小图裁出来存成单独的文件。但真正做起来之后发现这里面坑多得超乎想象。这篇文章我就把整个拆解思路、核心代码和踩坑过程完整记录下来希望能帮到跟我一样被图集坑过的朋友。这套方案适合谁独立游戏开发者、做资源整理的美术、需要迁移老项目的程序还有那些手头有一堆来源不明的图集、想把它变成可编辑的散图的人。看完你不仅能直接跑命令拆图还能彻底搞懂图集文件里每一行数字的含义。2. 核心思路与方案选型先搞清楚“图集”到底存了什么东西2.1 图集文件里不只有一张图正常的图集文件拆开来看就两种内容一个是像素数据本体PNG一个是描述“每一块小图在哪”的元数据文件JSON / XML / plist。我的思路是把元数据解析出来得到每个 Sprite 的矩形区域然后利用 Python 的 Pillow 库从大图里把那一块区域单独裁剪出来。听起来很简单对不对但这里有一个很多人第一次做时会忽略的地方图集里面的小图绝大多数情况下并不是原始切图的原样排列而是经过了裁边trim处理的。打个比方美术画了一个“金币”图标原始文件是一张 256x256 的透明背景正方形但金币本身只占中间的一小块。打包图集的时候为了节省空间打包工具会把金币四周多余的透明区域裁掉只保留金币的实际像素范围。这时候元数据里记录的就不是“原始文件大小”而是一堆额外的字段frame原始图中的位置、spriteSourceSize原始尺寸中实际裁剪区域的位置、sourceSize原始文件的大小。如果我的工具只是简单地把矩形坐标框选出来另存最后得到的图片尺寸会比原始切图小一圈而且坐标位置会全部偏移。对于只需要“看起来是这张图”的场景还好说但要放进引擎里做动画或者对坐标就完全对不上了。2.2 为什么不用现成工具偏要自己写我当时也搜过现成方案。比如 TexturePacker 自带解包功能但它主要针对自家打包的格式还有一些在线的拆图网站传上去就能帮你把图集切开但碰到大图比如 4096x4096就容易超限而且涉及项目资源保密的问题我是不太愿意把素材往外传的。更重要的是我手上遇到的老项目用的是Sparrow 格式SubTexture标签那种 XMLUnity 打包出来的图集又是另一种命名规则Cocos 的 plist 又是 key-value 结构。不同来源的图集元数据格式五花八门。用现成工具就只能它支持什么你用什么时候遇到格式不兼容就傻眼。自己写的话我可以统一解析入口把所有格式转成同一个数据结构后面的一切就好办了。所以我当时的选型逻辑是这样的语言Python因为 Pillow 处理图像太方便不需要额外编译环境拿到代码就能跑。图像库Pillow稳定、跨平台裁剪和保存的 API 简单直接。元数据解析内置的json和xml.etree.ElementTree零依赖不用 pip 装额外的包。代码组织单脚本 配置参数不需要 GUI因为命令行在批量处理多个图集的时候效率最高。这套组合的优点就是轻量、可改、能批量。缺点嘛就是如果你完全不懂 Python跑起来会有点门槛但我会把运行方式写得很详细。2.3 关键数据结构坐标与旋转不管元数据格式怎么变核心信息其实就四组数字frame_x, frame_y, frame_w, frame_h // 小图在大图中的像素区域 offset_x, offset_y // 裁剪掉的透明边缘补偿值 original_w, original_h // 原始文件的宽高 rotated // 是否做了 90 度旋转旋转这个字段是最容易犯迷糊的地方。TexturePacker 在打包时为了更紧密地排列图集会把部分小图旋转 90 度。如果解包的时候不处理旋转标志拆出来的图就是横着的而且宽高是反的。我在写代码的时候专门为这个字段做了判断如果rotatedTrue裁出来之后要transpose(Image.ROTATE_90)才能恢复正向。理解这个数据结构之后后面所有逻辑都围绕它展开。3. 核心细节解析解包的三种关键情况上面说到核心数据结构但真正把代码跑通还需要处理几个细节否则遇到复杂图集会直接翻车。3.1 情况一裁边补偿trimmed这是默认情况。TexturePacker 的 JSON 输出里frame字段表示裁切后的实际矩形sourceSize表示原始尺寸spriteSourceSize表示相对于原始尺寸的偏移。要还原成跟原图一模一样的大小裁剪时不只是从frame取图而是要创建一个sourceSize大小的透明画布把裁出来的图按spriteSourceSize的 x、y 贴上去。很多网上流传的“拆图工具”不做这步。它们直接把frame矩形保存成 PNG。遇到有透明边被裁掉的图最终结果就跟美术交付的原图尺寸不一致。如果项目里别的代码是按照固定尺寸读取这张图的比如一些老旧的帧动画播放器那对不齐就是必然的。我的做法是默认开启恢复原始尺寸。因为绝大多数情况下我们要的是“跟原始散图完全一致”的文件而不是“图集里那块更小的矩形”。3.2 情况二无裁边截图式图集有些程序运行时才生成的图集或者某些批量合图工具为了保证排序简单不会做透明裁切每一帧都是一样大小的格子。这种情况元数据里的frame和sourceSize是一模一样的spriteSourceSize为(0, 0)。不用特殊处理按正常裁剪逻辑走就行。3.3 情况三旋转标记刚才提过TexturePacker 默认会对某些图片做 90 度旋转以优化排列密度。JSON 里会有rotated: true字段。处理方式是先按旋转后的矩形尺寸裁出图片再旋转回去。但注意旋转方向ROTATE_90是逆时针转 90 度还是ROTATE_270得看打包工具的具体实现。实测下来TexturePacker 生成的文件用Image.ROTATE_90刚好能转回正有些 Unity 插件生成的图集则可能相反。所以我写了一个rotate_direction参数默认ccw遇到特殊情况手动改成cw就行。这几种情况处理完之后解包脚本的基本框架就稳了。下面就是具体实现。4. 实操过程从零手写一个可复用的图集解包脚本4.1 代码骨架与解析器设计我先定义了一个统一的SpriteData数据结构这样不管输入是 JSON 还是 XML最终都转成这个结构再处理from dataclasses import dataclass from typing import Tuple from PIL import Image dataclass class SpriteData: name: str frame: Tuple[int, int, int, int] # x, y, w, h - 大图上的裁剪区域 source_size: Tuple[int, int] # w, h - 原始文件尺寸 sprite_source_size: Tuple[int, int, int, int] # x, y, w, h - 原图中含有效像素的区域 rotated: bool False接着写解析函数。这里我实现了两种最常用的格式TexturePacker JSON 和 Sparrow XML。不管实际项目碰到的是 plist 还是别的格式核心逻辑都一样无非是换一个取字段的名字。import json import xml.etree.ElementTree as ET def parse_texturepacker_json(json_path): with open(json_path, r, encodingutf-8) as f: data json.load(f) frames data[frames] sprites [] if isinstance(frames, dict): # 新版本格式frames 是 dict键名为图片名 for name, info in frames.items(): frame info[frame] src info[spriteSourceSize] src_size info[sourceSize] rotated info.get(rotated, False) sprites.append(SpriteData( namename, frame(frame[x], frame[y], frame[w], frame[h]), source_size(src_size[w], src_size[h]), sprite_source_size(src[x], src[y], src[w], src[h]), rotatedrotated )) else: # 旧版本格式frames 是 list for info in frames: fn info[filename] frame info[frame] src info[spriteSourceSize] src_size info[sourceSize] rotated info.get(rotated, False) sprites.append(SpriteData( namefn, frame(frame[x], frame[y], frame[w], frame[h]), source_size(src_size[w], src_size[h]), sprite_source_size(src[x], src[y], src[w], src[h]), rotatedrotated )) return spritesSparrow 格式的解析更像是一种通用 XML 属性读取def parse_sparrow_xml(xml_path): tree ET.parse(xml_path) root tree.getroot() sprites [] for sub in root.findall(SubTexture): name sub.get(name) x int(sub.get(x, 0)) y int(sub.get(y, 0)) w int(sub.get(width, 0)) h int(sub.get(height, 0)) frame_x int(sub.get(frameX, 0)) frame_y int(sub.get(frameY, 0)) frame_w int(sub.get(frameWidth, w)) frame_h int(sub.get(frameHeight, h)) rotated sub.get(rotated, false).lower() true sprites.append(SpriteData( namename, frame(x, y, w, h), source_size(frame_w, frame_h), sprite_source_size(frame_x, frame_y, w, h), rotatedrotated )) return sprites4.2 裁剪与旋转的核心逻辑这部分是整个工具的灵魂根据SpriteData从大图上把区域抠出来然后做旋转校正和透明画布补偿。def extract_sprite(atlas_img, sprite, output_dir, rotate_directionccw): x, y, w, h sprite.frame # 大图上可能存在超出边界的四舍五入误差做一个安全裁剪 x max(0, min(x, atlas_img.width - 1)) y max(0, min(y, atlas_img.height - 1)) w min(w, atlas_img.width - x) h min(h, atlas_img.height - y) img atlas_img.crop((x, y, x w, y h)) if sprite.rotated: if rotate_direction ccw: img img.transpose(Image.ROTATE_90) else: img img.transpose(Image.ROTATE_270) # 旋转之后宽高互换 w, h h, w if (w, h) sprite.source_size: # 没有裁边需求直接保存 img.save(f{output_dir}/{sprite.name}.png) return # 有裁边补偿新建一张 source_size 大小的透明画布把图片贴到对应偏移位置 canvas Image.new(RGBA, sprite.source_size, (0, 0, 0, 0)) sx sprite.sprite_source_size[0] sy sprite.sprite_source_size[1] # 注意sprite_source_size 的 x,y 可能为负数表示裁掉了左边/上边的多少像素 # 如果为正数说明原图左边有多余的空白未裁干净少数情况下发生 if sx 0: paste_x -sx else: paste_x 0 # 如果原图左边有空白需要先裁剪掉这部分空白避免叠加偏移 img img.crop((sx, 0, img.width, img.height)) if sy 0: paste_y -sy else: paste_y 0 if sy 0 and sy img.height: img img.crop((0, sy, img.width, img.height)) canvas.paste(img, (paste_x, paste_y)) canvas.save(f{output_dir}/{sprite.name}.png)这段代码我测过很多次对spriteSourceSize的正负号处理是从实际数据里总结出来的TexturePacker 中如果小图的可见内容是靠左上的那frameX就是负数表示“要往右边挪多少才能回到原位”如果是正数说明这个小图在原始画布中本身左边就有空白。这个符号逻辑是新手最容易栽跟头的地方。4.3 主流程与命令行封装为了让工具能在命令行里直接跑我加了一个简单的 argv 解析支持传入图集 PNG 路径、元数据路径和输出目录import os import sys def main(): if len(sys.argv) 4: print(用法: python TextureUnpacker.py atlas.png metadata.json|xml output_dir [--rotate ccw|cw]) return atlas_path sys.argv[1] meta_path sys.argv[2] output_dir sys.argv[3] rotate_dir ccw if --rotate in sys.argv: idx sys.argv.index(--rotate) rotate_dir sys.argv[idx 1] os.makedirs(output_dir, exist_okTrue) atlas_img Image.open(atlas_path).convert(RGBA) if meta_path.endswith(.json): sprites parse_texturepacker_json(meta_path) elif meta_path.endswith(.xml): sprites parse_sparrow_xml(meta_path) else: print(不支持的元数据格式仅支持 .json 和 .xml) return for idx, sp in enumerate(sprites): extract_sprite(atlas_img, sp, output_dir, rotate_dir) if (idx 1) % 20 0: print(f已处理 {idx 1} / {len(sprites)} 个精灵) print(f完成共导出 {len(sprites)} 张图片到 {output_dir}) if __name__ __main__: main()运行的时候切到脚本所在目录执行python TextureUnpacker.py atlas.png atlas.json output_sprites如果图集是从 Cocos 导出的 plist可以先转成 JSON 再跑或者直接用 plistlib 解析逻辑大同小异这里就不重复写了。4.4 参数选择与性能细节有几个参数值得专门说明一下输出格式默认保存为 PNG因为要保留 alpha 通道。如果你确定所有图都是不透明的 UI 元素可以改成 JPG 或 WebP 减小体积但我建议还是统一 PNG省得后面又出问题。内存占用如果用 Pillow 打开一张 4096x4096 的图集RGBA 模式下会占到 64MB 左右内存。批量处理几十个图集时注意在循环里手动atlas_img.close()避免内存爆掉。批量处理我建议写一个外层脚本遍历某个目录下所有atlas_*.png和同名元数据文件一次性解包整个资源目录比手动一个个跑效率高很多。5. 常见问题与排查技巧实录5.1 裁出来的图颜色偏淡或发灰这个现象通常出现在用 Pillow 打开索引色 PNG 图集时。图集如果是 P 模式调色板模式直接convert(RGBA)有时会丢失部分透明信息。我的解决办法是加载时统一做一次img Image.open(atlas_path).convert(RGBA)然后再裁剪不要裁剪完再转否则颜色会失真。如果你发现转换后透明边缘出现黑边可以先img img.convert(RGBA)之后再叠加一层白色背景再抠图或者用img.convert(RGBA)后通过point把 alpha 低于 128 的像素全部设为全透明能起到去黑边效果。5.2 输出文件名冲突有些图集内部会存在同名但不同路径的子目录比如ui/button.png和icon/button.png。直接保存到同一目录就会互相覆盖。我的处理方式是把名字里的/替换成_或者自动创建子目录。建议使用后者因为重命名会破坏资源路径关联信息。5.3 旋转后 alpha 边缘出现锯齿Pillow 的transpose不会做插值处理所以理论上不会产生锯齿。但如果你在旋转之前做了一些 RGBA 混合操作边缘像素值发生变化旋转后可能出现斜线锯齿。这种情况多半不是旋转的问题而是原来的图集导出时就带了 edge bleed边缘出血也就是相邻小图的一个像素被复制到了边缘。处理方式是把裁出来的图边缘向外扩 1-2 像素再裁掉或者用img.filter(ImageFilter.SMOOTH)但不是万能的。最稳妥的方法是从打包源头解决把 TexturePacker 的Edge Padding设置为 1 或 2这样生产图集时就不容易出现边缘出血。5.4 坐标偏移图片整体错位99% 的情况是spriteSourceSize没处理对。我遇到过一个 Unity 打出来的图集它的 JSON 里frame用的是裁剪后的矩形但spriteSourceSize的x/y表示的是“原图中左上角的透明区域大小”而不是负偏移。两种方式一混代码里都按负值处理就错位了。我的建议是调试时先输出几个精灵的完整SpriteData字段肉眼对比一下各个字段的具体值判断清楚之后再去套公式不要盲目相信某一个格式的约定。5.5 怎么验证解包结果是对的这是最容易被忽略的一点。我解包完之后会写一个简单的脚本把所有输出的小图粘贴回一张新的透明画布上按spriteSourceSize反向偏移再跟原始图集做像素级对比误差越小说明解包越准确。这个验证步骤非常有用尤其当你要批量处理几百个图集的时候靠人眼去翻是不可能的。6. 从解包反过来图集工具链的延伸思考写 TextureUnpacker 的过程中我最大的收获不是代码本身而是对整个图集工具链有了更深的理解。因为要做像素级对比我不得不去研究 TexturePacker 的打包算法了解它的MaxRects算法和Shelf算法有什么不同因为要处理各种来源的元数据格式我去翻了不少 Unity、Cocos、Godot 的资源导出源码最后总结出一套通用的元数据映射方案。这些经验反过来让我的开发效率提升了不少——之后项目里需要做自定义打包图集的工具我直接基于这份理解改一版就行不用再从零开始。另外这个小工具也帮我解决了一个实际问题有一次设计师给了一批只能在手机上预览的动画素材格式是经过加密处理的图集加二进制配置根本没法在编辑器里编辑。我拿到之后虽然不能完全破解加密格式但至少能把里面的 PNG 图集拆出来再配合帧动画参数重建散图最后成功在编辑器里复刻了动画效果。所以说TextureUnpacker 虽然是个看起来很简单的工具但它背后的“元数据理解、裁边补偿、旋转处理、坐标校验”这四件事是任何一个做 2D 游戏资源工具的人都绕不开的基本功。如果你也经常被图集资源折腾我建议你花半天时间自己写一个这样的脚本不是仅仅为了用而是在写的过程中你能把所有跟图集相关的东西彻底搞清楚。后面不管是做资源裁剪、图集批量压缩、还是帧动画编辑器这些经验都能直接复用。本文还有配套的精品资源点击获取
返回列表