ARTICLE DETAIL

资讯详情

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

TextureUnpacker:Python+Pillow 实现图集拆包与旋转还原

TextureUnpacker:Python+Pillow 实现图集拆包与旋转还原 简介TextureUnpacker是一套用Qt/C实现的纹理图集解包工具源码专门解析TexturePacker生成的plist及像素地图文件帮助游戏开发和UI设计者快速查看、调试并导出精灵素材。资源共34个文件压缩包约93KB核心源码包含plist解析、像素数据解析、DOM解析等模块覆盖从数据读取到界面交互的完整逻辑同时配有主窗口、保存对话框、自定义控件等界面代码以及png图标、ui布局和翻译文件构成完整的跨平台Qt工程。已有240人浏览学习。该工程结构清晰按解析层、界面层与资源文件分层组织适合直接阅读与二次开发。读者可借此掌握纹理打包/解包原理、plist格式细节、Qt图形界面开发与自定义控件封装技巧也可作为中期规模的C项目范本对游戏开发者和Qt初学者都具有较好的参考价值。 大多数做游戏或UI的人第一次接触 TextureUnpacker 这个命名都是因为手上拿到了一张被纹理打包工具压扁的图集。前一阵子我在处理一个 H5 项目换肤需求美术那边只给了一张atlas.png和一份atlas.json要我改其中几个图标的状态。看着编辑器里整整齐齐的合图我却连单独的图标文件都没有。试着用 Photoshop 手动切切完发现图标全是歪的——图集打包时为了优化空间部分子图被旋转了 90 度。这种场景遇到一次就够了。与其反复求人重新导出资源不如写一个程序把图集原样拆开。这个工具我命名为 TextureUnpacker做的事很简单读取图集元数据从大图上把每个子图裁剪出来做旋转还原和透明留白还原最终导出成独立 PNG。对任何一个经常和资源打交道的开发者来说这都是一件能少加班的实用工具。1. 图集拆包的三个真实场景为什么最终选择自研1.1 拿到手的只有一张大图最典型的场景就是跨团队协作。游戏项目里策划、美术、程序各管一段资源交给外包以后源文件往往只留在外包手里。等版本迭代到半路突然说要换一张 UI 底图结果美术源文件已经找不到了能用的只剩一份合图。这种时候把图集还原成单图不是可选项而是唯一路径。我遇到的情况还要再复杂一层图集里的子图命名因为引擎要求被统一改成了icon_001这种编号而正常业务里的名字是btn_start、btn_stop这类可读性强的名称。没有源码命名替换资源时需要先自己建一张“图集资源映射表”。如果不写程序光靠肉眼从大图里找编号对应的图标基本是灾难。第二个常见场景是引擎迁移。Unity 项目要转到 Cocos或者 Cocos 项目里已经打好的图集要转成另一种格式。市面上有商业工具支持从图集反推但格式覆盖不一定全还经常遇到老版本导出的图集不被支持。反推出来以后资源命名、九宫格信息、中心点信息是否保留也要逐项核对。与其赌工具不如自己写一个最小可行版本自己控制所有细节。第三个场景是质量校验。图集压缩成 ASTC 或 PVRTC 后单张截图很难判断是否出现噪点。把图集解开逐张对比原始 PNG能快速定位哪些图被压坏了。这个需求在移动端项目里很常见但美术工具里没有“照妖镜”只能靠脚本批量拆图再对比哈希值。1.2 现成方案为什么不好用市面不是没有解包工具但用起来总有卡点。TexturePacker 本身是打包工具逆向功能需要额外购买而且老版本对 plist、JSON 的支持各不相同。在线解包网站需要上传资源出于项目保密考虑很多公司不允许把内部素材传到外部服务。命令行工具也有但大多只支持特定引擎格式遇到自定义命名规则或者附加字段就无能为力了。更重要的是解包通常不是“一张图拆成小图”就结束。我需要保留原始文件名、剔除旋转标记、还原透明留白、输出目录结构甚至把九宫格数据单独存成一份 JSON。通用工具做不到这些定制自己写反而更容易满足真实项目流程。1.3 选型Python Pillow 的理由我最后选了 Python 和 Pillow没有引入 OpenCV。理由很简单图集拆包的核心操作是矩形裁剪Pillow 的crop和paste已经足够用元数据解析用标准库json就能处理不需要额外依赖。如果后续要做连通域自动切图再考虑引入 OpenCV 也不迟。考虑到团队里其他人可能也要用我把它做成了命令行工具输入大图路径、元数据路径、输出目录跑完自动生成结果。这样美术不懂 Python 也能直接用。2. 动手写拆包器前必须吃透的图集元数据2.1 一张大图里到底存了什么图集打包工具做的事是把多张透明底小图紧密排列在一张大图上方向可能相同也可能旋转只保留非透明区域从而最大程度节省空间。大图之外还会生成一份元数据记录每个子图在大图中的位置、是否旋转、原始尺寸以及透明裁边信息。没有这份元数据拆包就是盲人摸象。不同引擎的元数据格式不同。TexturePacker 默认输出 JSONCocos2d 常用 plistUnity 的图集信息存在 meta 文件里。虽然格式不同但核心字段大同小异。我第一个版本只针对 TexturePacker 导出的 JSON 格式因为它是开源格式字段说明最清楚也最容易验证。2.2 元数据字段逐项解读以一份精简过的 JSON 为例{ frames: { icon_001.png: { frame: {x: 10, y: 20, w: 100, h: 80}, rotated: false, trimmed: true, spriteSourceSize: {x: 10, y: 5, w: 100, h: 80}, sourceSize: {w: 120, h: 100} } } }每个字段的含义字段含义拆包时的作用frame子图在大图中的像素矩形直接决定crop的坐标rotated子图存储时是否顺时针旋转了 90 度需要反向旋转还原trimmed打包时是否裁剪掉了透明边缘决定要不要做留白还原spriteSourceSize裁剪后的内容区域在原图中的位置和大小粘贴回画布的偏移量sourceSize原始完整小图的宽高新建画布的尺寸这里最容易忽略的是trimmed和spriteSourceSize。如果美术给的小图本身带透明留白比如规格是 120x100但实际画的内容只有中间 100x80打包工具就会把四周透明部分裁掉。拆包时不能只把大图上的区域切下来还要把切下来的内容放回一个 120x100 的透明画布中位置由spriteSourceSize的 x 和 y 决定。2.3 坐标系、旋转标记和 sourceSize 的隐晦约定开始写代码前一定要确认坐标原点。TexturePacker 的 JSON 里frame.x和frame.y是相对大图左上角的像素坐标y 轴向下增长和 PIL 的坐标系统一致。plist 格式里坐标原点可能在大图左下角y 轴向上增长转换公式是y atlas_height - y - h。如果一开始没注意第一个版本拆出来的图就是上下颠倒的。另一个隐晦点rotated为 true 时frame.w和frame.h是旋转后存入大图的宽高不是原始内容的宽高。比如原始内容尺寸是 100x80顺时针旋转 90 度存储后在大图中的矩形变成 80x100。拆包时如果仍按 100x80 去裁剪会多裁一块、少裁一块直接导致内容错位。sourceSize则是原始图片未经裁剪的尺寸它和spriteSourceSize.w/h不一定相等。只有trimmed为 false 时二者才一致。理解这三者的关系后面代码写起来才不会翻车。3. TextureUnpacker 核心代码裁剪、旋转与留白还原3.1 数据读取与目录准备先做一个能跑的最小版本。读取大图、读取 JSON、创建输出目录import json from pathlib import Path from PIL import Image def unpack(atlas_path, meta_path, output_dir): atlas Image.open(atlas_path).convert(RGBA) data json.loads(Path(meta_path).read_text(encodingutf-8)) frames data[frames] out_dir Path(output_dir) out_dir.mkdir(parentsTrue, exist_okTrue) for name, info in frames.items(): # 核心处理逻辑 pass这里把大图强制转成 RGBA 是为了避免某些图片只有 RGB 模式在paste透明画布时出现异常。Path.read_text指定utf-8编码也是必须的很多 JSON 里包含中文字符Windows 下默认编码容易出错。3.2 裁剪与旋转的数学关系拿到一个子图条目后先从frame里取出矩形坐标用crop剪出区域。如果rotated为 true再做旋转还原frame info[frame] x, y, w, h frame[x], frame[y], frame[w], frame[h] region atlas.crop((x, y, x w, y h)) if info[rotated]: region region.transpose(Image.Transpose.ROTATE_90)为什么用transpose(ROTATE_90)而不是rotate(90)因为这里只需要 90 度的整数旋转transpose不会做插值像素无损。rotate(90)在 Pillow 里默认是逆时针旋转方向虽然对了但用了插值算法对于像素艺术风格的图标会带来不必要的边缘混色。旋转方向的核心逻辑是打包工具把子图顺时针旋转了 90 度所以要还原就必须逆时针旋转 90 度也就是ROTATE_90。如果你遇到拆出来的图方向反过来说明你的图集导出工具存的是逆时针方向把枚举改成ROTATE_270即可。这个只能靠实测确认。3.3 trimmed 图片的还原流程裁剪完并旋转后如果trimmed为 true就要构建原始画布src_size info[sourceSize] src_offset info[spriteSourceSize] if info[trimmed]: canvas Image.new(RGBA, (src_size[w], src_size[h]), (0, 0, 0, 0)) canvas.paste(region, (src_offset[x], src_offset[y])) region canvas safe_name name.replace(.., _).replace(/, _) region.save(out_dir / safe_name)这里safe_name的处理很重要。图集元数据里的 name 可能带路径比如ui/btn/start.png直接 join 到输出目录会创建子目录但可能引入..这种危险路径。简单做法是把/和..全部替换成下划线保证所有输出都在指定目录内。canvas.paste的偏移是src_offset[x]和src_offset[y]也就是内容区域在原图中的左上角位置。这里一定要用spriteSourceSize不能用自己推算的居中坐标。美术图如果不对称居中会使图标整体偏移几个像素肉眼不易察觉但放到 UI 上就会明显歪。完整跑通这个最小版本已经能处理大部分 TexturePacker JSON 图集了。4. 实测阶段最折磨人的五个坑4.1 旋转方向全反源图横竖不分的错觉第一个版本我自信满满地把所有rotated的子图用region.rotate(90, expandTrue)处理。结果拆出来的图凡是旋转过的全部顺时针转了 90 度等于原本向右歪的图标变成了向下歪。排查下来发现Pillow 的rotate(angle)正数表示逆时针旋转而我还以为正数代表顺时针。换成transpose(Image.Transpose.ROTATE_90)后问题才解决。但真正的坑不是 API 角度方向而是你根本无法确定打包工具存的方向。同一份 JSON从 TexturePacker 导出的rotatedtrue是顺时针换一个引擎导出可能就变成逆时针。最稳妥的做法是拿一张有明显上下方向的原图打出图集再用自己的工具拆肉眼确认旋转方向写死之后再验证一批。不要试图在理论上猜直接跑数据。4.2 坐标系一个像素的偏差第二版拆包时我遇到一批图整体右移了 1 像素每个图标边缘都有一条透明线。检查后发现元数据里的frame.x不是像素坐标而是中心点坐标。某些引擎导出 JSON 时会把矩形记录成{x: center_x, y: center_y, w: width, h: height}而不是左上角坐标。直接用center_x去裁剪等于起点右移了半个宽度完全错位。解决办法是读取时统一做一次坐标转换如果确定字段是中心点则left x - w / 2top y - h / 2。这个字段语义在官方文档里不显眼需要在不同工具的导出结果里来回比对才能确认。遇到这种偏差我一般先输出一张大图的矩形可视化图把frame画到图上再和真实图形对比一眼就能看出是起点偏差还是尺寸偏差。4.3 透明像素被裁掉后偏移算不对早期版本我偷懒trimmed直接忽略把所有子图按frame区域切出来另存。切出来的内容本身没缺但很多图标在显示时整体向左上方偏了几像素。原因是打包工具把四周空白裁掉了我拿到的region只是内容区域直接保存后原图里左侧的透明边距全部丢失。正确做法是像上面代码那样用spriteSourceSize的偏移量把内容贴回原始尺寸的透明画布。这里额外提醒spriteSourceSize的 x 和 y 是内容区域在原图中的左上角位置不是内容区域的中心点。如果美术在 120x100 画布上画了内容区域 100x80且内容是居中偏右的那么 x 就不等于(120 - 100) / 2而应该等于实际偏移。所以完全依赖字段不要用公式推算。4.4 预乘 Alpha 导致颜色发黑拆出一批图标后发现半透明边缘的颜色整体变暗像是外边压了一层黑。当时我怀疑是 PNG 保存参数问题折腾半天才发现是预乘 AlphaPremultiplied Alpha在作祟。很多游戏引擎的图集纹理为了加速渲染会预先把 RGB 乘以 Alpha 值再写入。用 PIL 读出后直接保存为标准 PNG原本半透明像素的 RGB 值已经偏小看起来就像带了一圈黑边。检测方法很简单做一个半透明的橙色像素如 alpha128如果读出来的 RGB 是 (255, 128, 0) 左右就是标准直通 Alpha如果读出来大约是 (128, 64, 0)那就是预乘 Alpha。如果确认是预乘 Alpha需要手动还原对每个像素如果 alpha 为 0RGB 设为 0否则rgb rgb * 255 / alpha。但这一步会引入舍入误差所以最好在拆包时就明确大图的 alpha 格式避免在流程末端才发现颜色不对。4.5 UV 坐标被当成像素坐标有一次我拿到的元数据是给 Shader 用的 UV 坐标范围在 0 到 1 之间。直接把 UV 乘以大图宽高当像素坐标使用得到的矩形整体偏了 0.5 像素到 1 像素。原因是 UV 坐标通常对应像素中心点而不是像素左上角。比如大图宽 1024某个子图 x 像素为 0UV 却是 0这没问题但 x 像素为 100 时UV 可能是 100.5 / 1024此时乘以 1024 得到 100.5取整方式不同结果会偏差 1 像素。遇到这种情况我建议统一用floor(value 0.5)做四舍五入再结合上一节的矩形可视化验证。不要天真地认为所有元数据都给你左上角整数像素坐标跨引擎对接时这个坑非常隐蔽。5. 没有元数据也能拆后续扩展方向5.1 基于透明通道的自动切图有时候你只有一张大图没有 JSON也没有 plist这种场景就要靠图像算法自己找分隔线。最朴素的思路是扫描整张图把每一行和每一列的透明像素分布统计出来然后以“完全透明”的行或列作为切分边界得到一个个矩形区域。这个方法对规则排列、间距均匀的图集有效但遇到错位装箱的图集就会切碎。要处理任意装箱布局需要走连通域分析。把大图上非透明像素标记为前景用 OpenCV 的connectedComponentsWithStats找出每个连通块再计算包围盒这样就能得到每个子图的外框。但连通域只能得到内容区域丢失了原始画布的透明留白和旋转信息所以导出的图片尺寸可能比原始资源小一圈。这时如果有sourceSize之类的信息只能靠猜或者让使用者手动指定是否还原留白。从实际项目角度看这部分功能可以作为“辅助模式”。能拿到元数据的优先用元数据拿不到再用自动切图最后人工核对一批命名和尺寸。完全自动化的智能拆包需要训练模型对绝大多数项目来说投入产出比不高。5.2 命令行化与更多格式支持我现在的 TextureUnpacker 已经支持命令行调用核心入口长这样python texture_unpacker.py atlas.png atlas.json -o output/用argparse解析参数加上--rotation允许手动指定旋转方向--trim允许关闭留白还原方便处理不同引擎导出的资源。后续计划支持 plist 和 Unity 的 sprite meta解析逻辑不同但输出逻辑完全可以复用。做这个工具最大的体会是拆包最怕的从来不是图像裁剪代码而是元数据里那些缺乏规范的隐式约定。不同工具、不同引擎、不同版本的图集格式都有细微差别与其追求一个大而全的方案不如先把一种最常见的格式做到极致再逐步加兼容层。如果你也要写类似的工具我建议从一份带旋转、带裁边、带中文命名的测试图集开始把边界情况都覆盖到再拿真实项目数据验收。这样一个能沉淀成团队工具的 TextureUnpacker才真正值得长期维护下去。本文还有配套的精品资源点击获取
返回列表