简介:这是一份面向游戏开发者的图片合并工具 imgsTools 完整工程,基于 VALib 图像库与 MFC、GDI+ 技术实现,用于把多张图片合并为透明 PNG、GIF、JPEG,并生成 Cocos2d 可识别的 .plis 资源清单。PNG 适合精细界面元素与图标,GIF 可用于简单动画,JPEG 利于压缩照片类素材,开发者可据需求选择输出格式。资源共 113 个文件,压缩包约 3.75MB;主体为 31 个头文件、25 个 C++ 源文件、24 个目标文件及 Visual Studio 工程配置,另含可执行程序、调试符号、日志和示例图片,既能直接运行,也便于二次编译开发。已有 344 人学习/下载,适合需要熟悉 Cocos2d 资源管理流程,或开发 Windows 图像处理工具的开发者参考。从对话框实现、图像数组管理、矩形自动排布等关键模块入手,可理解图片合并算法、VALib 封装方式,以及 MFC 界面与 GDI+ 渲染的配合思路;对优化游戏图集生成、简化资源导入流程有明显帮助。
1. imgsTools 图片合并是什么:先解决「多图变一图」的顺序与尺寸问题
做数据标注的时候,我经常要在一个小时内把几十张截图拼成一张长图交给下游;做报表的时候,又要把四张六张的局部图拼成一张对比图。手工拖图片太折磨人了,拖完还要反复对齐边距,效率极低。于是就有了 imgsTools 这种命令行工具的思路——把图片合并当成一个可重复、可参数化的批处理任务,而不是每次都打开画图软件手动摆。本文要讲的,就是把 imgsTools 图片合并工具的完整落地路径:从拼接方式选型、Python + Pillow 的最小实现,到对齐参数、批量处理、内存控制,再到那些容易翻车的细节。适合谁看?经常处理截图、扫描件、标注图、报告图版,且不想每次都用图形界面手动拼图的人。
2. 拼接方式、坐标体系与工具链选型:动手前先想清楚这三件事
图片合并看起来就是把图 A 放在图 B 旁边,但真正落地时第一个要面对的问题不是代码,而是你要的是哪种“合并”。横向拼接、纵向拼接、网格拼接、自由画布合成,背后对应完全不同的需求。选错拼法,后面所有参数都白调。这一章先把四种拼法的适用场景讲清楚,再解释为什么我选 Python + Pillow 而不是其他工具,最后给出 imgsTools 这类工具应该怎么设计命令行接口。
2.1 横向、纵向、网格与自由画布:四种拼法对应的真实需求
横向拼接适合对比图、全景图、轮播图,比如把同一物体在不同时刻的截图并排放在一起,让观者可以直接看差异。纵向拼接适合长截图、聊天记录、扫描件拼长页,这类场景的共同点是单张高度有限,需要按顺序从上往下堆。网格拼接适合报告图版、九宫格、素材预览表,四张六张八张图排成矩阵,讲究整齐。自由画布合成则适合模板合成、局部还原,比如把检测框裁出来的小块按原坐标贴回大图,或者把 logo 合到一张模板上。
这四种拼法的本质其实是同一件事:先算出目标画布的尺寸,再把每张图按锚点粘贴到画布上。横向拼接的锚点是 (x, 0),x 在循环里累加每张图的宽度;纵向拼接是 (0, y),y 累加每张图的高度;网格拼接的锚点是 (col * cell_w, row * cell_h);自由画布直接用预设坐标。所以只要把“画布尺寸计算”和“锚点计算”拆成两个独立逻辑,一个工具就能覆盖四种拼法,后面加新拼法也只是新增一个锚点计算函数。
| 拼法 | 适合场景 | 尺寸不一致的处理方式 | 顺序控制 |
|---|---|---|---|
| 横向 | 对比图、全景图、轮播图 | 统一高度,按比例缩放宽度 | 从左到右按输入顺序 |
| 纵向 | 长截图、扫描件、聊天记录 | 统一宽度,按比例缩放高度 | 从上到下按输入顺序 |
| 网格 | 报告图版、素材预览表 | 每格取该行/列最大宽高,居中对齐 | 按行列从左到右、从上到下填充 |
| 自由画布 | 模板合成、局部还原 | 不缩放,按坐标原样放置 | 靠坐标指定,与顺序无关 |
从这张表能看出两个关键点:一是尺寸不一致时“等比缩放”是最好的默认策略,拉伸变形会毁掉整张图;二是顺序控制必须显式化,不能依赖文件系统返回的顺序。这两个点后面会反复遇到。
2.2 为什么选 Python + Pillow:可编程、可批处理、可校验
常见的图像处理工具里,ImageMagick 的 montage 命令可以快速出网格图,几行命令就能搞定简单拼图,但精细控制很麻烦——混排、像素级对齐、把拼接坐标回写出来,这些都要靠各种奇奇怪怪的参数拼凑,写多了自己都看不懂。FFmpeg 的 hstack / vstack 适合视频帧和大量同尺寸图片拼接,但它默认要求输入尺寸一致,不一致就得先 scale,写起来很绕;而且它对单张 PNG 的精细处理能力弱。我用下来的结论是:图形界面工具学习成本低,但批处理和自动化基本为零,一次要拼五十张图的时候你就知道什么叫崩溃;命令行工具适合快速出结果,但遇到特殊需求容易绕。
所以我一般会选 Python + Pillow 把合并逻辑写成脚本。Pillow 能直接操作像素和画布,逻辑完全透明,出现不符合预期的结果时可以一步步打印检查;它还能写进标注流水线、报表生成脚本、爬虫后处理,跟其它 Python 库无缝配合。更重要的是,同一个脚本里可以顺手做尺寸校验、文件名排序、格式转换,这些在纯 GUI 工具和 montage 命令里都是额外负担。对 imgsTools 这个方向来说,可编程性意味着这个工具不是一次性脚本,而是一个可以持续加参数的合并基础库。
2.3 imgsTools 的接口设计:一个子命令对应一种拼法
工具名既然叫 imgsTools,接口设计上我一般遵循一个原则:一个子命令对应一种拼法,公共参数全部放后面。这样使用者不需要为每种拼法重新学一套命令。常见的用法长这样:
imgsTools merge -m vertical -o output.png input_dir/ imgsTools merge -m grid --cols 2 --gap 10 --bg white -o output.png input_dir/ imgsTools merge -m canvas --layout layout.json -o output.png input_dir/-m指定拼法,--cols指定网格列数,--gap指定子图间距,--bg指定背景色,-o指定输出路径。自由画布模式用一个 JSON 文件描述每张图的位置,结构大概是{"images": [{"path": "a.png", "x": 0, "y": 0}, ...]}。接口统一的好处是,后面新增“智能拼接”功能时,使用者只需要知道多了一个-m smart选项,其它参数照旧。参数设计上我的习惯是:所有非必填参数都给默认值,比如--gap默认 0、--bg默认白色,命令行短、好记;对新手友好,老手又能通过参数覆盖默认值。
3. 把合并逻辑写成脚本:纵向拼接、网格拼图与批量处理的完整实现
这一章是核心实操。我不打算堆一个“完整项目”的代码,而是按功能拆成三块:最小可用的纵向拼接、网格拼接与画布合成、批量目录处理。每一块都能独立跑通,组合起来就是一个完整的 imgsTools。代码里我会贴上关键注释,命令参数也会逐个说明。
3.1 最小可用的纵向长图拼接:十几行代码跑通第一个版本
纵向拼接是使用频率最高的拼法,聊天记录长图、扫描件、标注结果汇总都用它。最小实现不需要考虑花哨功能,先把流程跑通:读图、统一宽度、创建画布、逐张粘贴、保存。
from PIL import Image from pathlib import Path def merge_vertical(image_paths, output_path, target_width=1080): images = [] for p in image_paths: im = Image.open(p) if im.mode != "RGB": im = im.convert("RGB") ratio = target_width / im.width if ratio < 1: # 只缩小不放大,避免小图被放糊 im = im.resize((target_width, int(im.height * ratio))) images.append(im) total_height = sum(im.height for im in images) canvas = Image.new("RGB", (target_width, total_height), (255, 255, 255)) y = 0 for im in images: canvas.paste(im, (0, y)) y += im.height canvas.save(output_path, quality=95)逻辑说明:先把所有图片统一成目标宽度,这样才能保证拼接后左右边缘对齐。代码里做了一个关键取舍:只有原图宽度大于目标宽度时才缩放,小于目标宽度的保持原样。因为把 800px 宽的图强行放大到 1080px 会明显变糊,这是很多人第一次写拼接脚本会犯的错。canvas.paste(im, (0, y))是把当前图片贴到画布上的操作,y是这张图的左上角纵坐标,每贴一张就累加高度,保证下一张图紧挨着上一张。
参数说明:target_width是输出长图的宽度,一般取 1080 或 1440,这是社交分享和文档插入最常用的两个宽度;如果输入图宽度差异极大,建议先统一宽度再拼接,否则会出现参差不齐的锯齿边缘。quality=95只对 JPEG 输出有效,如果输出 PNG 这个参数会被忽略。
3.2 网格拼图与画布合成:支持横竖混排和自定义间距
网格拼接比纵向拼接复杂一点,因为要先算网格的列数和行数,再算每个格子的大小。常见的做法是:cols由用户指定,rows根据图片数量自动计算;每格宽度取该列所有图片的最大宽度,每格高度取该行所有图片的最大高度,图片在格子里居中对齐,这样网格整体看起来整齐,单张图片又不会被拉伸。
import math from PIL import Image def merge_grid(image_paths, output_path, cols=2, gap=0, bg=(255, 255, 255)): images = [Image.open(p).convert("RGB") for p in image_paths] rows = math.ceil(len(images) / cols) # 行数自动计算 cell_w = max(im.width for im in images) cell_h = max(im.height for im in images) canvas_w = cols * cell_w + gap * (cols - 1) canvas_h = rows * cell_h + gap * (rows - 1) canvas = Image.new("RGB", (canvas_w, canvas_h), bg) for idx, im in enumerate(images): row, col = divmod(idx, cols) # 居中对齐:把图片中心放到格子中心 x = col * (cell_w + gap) + (cell_w - im.width) // 2 y = row * (cell_h + gap) + (cell_h - im.height) // 2 canvas.paste(im, (int(x), int(y))) canvas.save(output_path)逻辑说明:这个实现把网格拼接抽象成两步——先统一格子尺寸,再按格子定位。cell_w和cell_h取所有图片的最大值,这样任何一张图放进格子都不会溢出;居中对齐的算法是(cell_w - im.width) // 2,把差值除以二就是左边留白的宽度。divmod(idx, cols)一次同时算出当前是第几行、第几列,比单独取模再整除更清晰。
参数说明:gap是子图间距,设为 0 就是无缝网格;bg是背景色,建议用(255, 255, 255)白底或(0, 0, 0)黑底,需要透明底时改成"RGBA"模式并用(0, 0, 0, 0),但注意输出必须是 PNG。这个实现有个可以优化的点:所有格子取全局最大宽高,如果图片尺寸差异很大,小图旁边会留大片空白。想要更紧凑,可以按行取最大高度、按列取最大宽度,代码逻辑类似,只是要多一层循环。我一般在正式工具里做成可选项,--cell-size auto走紧凑模式,--cell-size uniform走当前模式。
3.3 批量处理目录:自然排序与文件过滤是合并顺序的第一道坎
脚本能拼图了,接下来要解决“喂给它哪些文件、按什么顺序喂”。直接遍历目录会踩两个坑:一是.DS_Store、Thumbs.db这类隐藏文件混进来,二是文件名1.jpg, 2.jpg, ..., 10.jpg按字典序排序后变成1, 10, 2。第二个坑血泪经验,顺序错了整张长图的语义就反了。
import re from pathlib import Path IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} def natural_sort_key(name): # 按数字分段比较,让 2.jpg 排在 10.jpg 前面 return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", name)] def collect_images(input_dir): files = [] for p in Path(input_dir).rglob("*"): # 递归收集所有文件 if p.suffix.lower() in IMAGE_EXTS and not p.name.startswith("."): files.append(p) files.sort(key=lambda p: natural_sort_key(p.name)) return files逻辑说明:natural_sort_key是实现自然排序的核心,它把文件名"10.jpg"拆成["", 10, ".jpg"],把"2.jpg"拆成["", 2, ".jpg"],数字部分按数值比较而不是字符串比较,这样 2 就会排在 10 前面。rglob("*")会递归子目录,适合输入目录里按日期或类别分子文件夹的场景;not p.name.startswith(".")用来跳过隐藏文件。
参数说明:扩展名集合IMAGE_EXTS按需增删,如果你处理的都是 TIFF 或 BMP,加上对应后缀即可。这里有一个设计细节:过滤和排序在收集阶段做,还是在调用合并函数之前做?我建议在收集阶段做,因为一个干净的输入列表不光喂给合并函数,还要用来做后续的校验和打印预览。排序完成后,把列表前五项打印出来给使用者确认,能避免大量情况下“顺序不对导致整批返工”。
4. 让合并结果更可控:对齐策略、留白参数与内存上限
跑通基本拼图之后,真正的生产力提升来自细节控制。这一章讲三个实际问题:当相邻两张图需要像素级对齐时怎么办、间距边距背景色怎么调才不出错、批量拼大图时内存怎么控制。这些参数看上去是小事,但决定了工具是只能自用,还是能交给别人用。
4.1 像素级对齐与自动吸附:精确拼接的两种实现
普通拼接是按顺序硬贴,但有一种场景不能这么干:两张相邻的图本来是一张大图的一部分,因为扫描或截图被切成了两块,拼接时要先找出重叠区域,再把重叠部分裁掉。这个需求在扫描件、长截图修复、地图拼接里很常见。常见做法是把重叠区域灰度化,然后在可能的偏移范围内做像素差扫描,取差值最小的偏移量作为重叠高度。
import numpy as np from PIL import Image def find_overlap_offset(top_img, bottom_img, search_range=100): # 把两张图转成灰度,像素值范围转成 int16 防止相减溢出 a = np.asarray(top_img.convert("L"), dtype=np.int16) b = np.asarray(bottom_img.convert("L"), dtype=np.int16) best_offset, best_score = 0, float("inf") h_a = a.shape[0] for d in range(1, min(search_range, h_a - 1)): # top 的最后 d 行 与 bottom 的前 d 行比较 overlap_a = a[-d:, :] overlap_b = b[:d, :] score = np.mean((overlap_a - overlap_b) ** 2) # 均方误差 if score < best_score: best_score = score best_offset = d return best_offset逻辑说明:这里用均方误差衡量重叠区域相似度,分数越低说明两张图在这个偏移量下越吻合。返回的best_offset就是重叠高度,合并时把 top 图裁掉最后best_offset行,然后直接从纵坐标 0 开始贴 bottom 图,就能精确对齐。search_range限定了搜索范围,避免整张图逐行扫描,毕竟 4000px 高的图全扫一遍耗时明显增加。
参数说明:search_range一般取 50 到 200,视具体场景而定——扫描件切块的重叠区域通常比较小,截图长图的重叠区域可能很大。需要注意这个算法只适合纯位移对齐,如果两张图之间有缩放或旋转,均方误差会失效,那时就要换特征匹配方案,已经超出“合并工具”的范畴,进入“图像配准”领域。在 imgsTools 里我一般把它做成-m smart模式,只处理纵向重叠,横向重叠的原理完全一样,把行列坐标对调即可。
4.2 间距、边距与背景色:三个最常用的调整参数
间距、边距和背景色是使用者最常调的三个参数,也是出错率最高的三个地方。间距控制子图之间的空白宽度,边距控制整张画布四周的留白,背景色决定空白区域显示成什么颜色。这三个参数的坑在于它们会联动影响画布尺寸,算错一个,输出图就会多出一条白边或黑边。
| 参数 | 作用 | 常见值 | 注意事项 |
|---|---|---|---|
| gap | 相邻子图的间距 | 0–20 px | 有间距时必须同时设置背景色,否则默认黑色 |
| margin | 画布四周的留白 | 0–50 px | 与 gap 叠加计算画布尺寸,不是替代关系 |
| bg | 画布背景色 | white / black / transparent | JPEG 不支持透明,输出 PNG 才能用 RGBA |
尺寸计算公式务必记牢:纵向拼接总高度 = 所有图片高度之和 + gap × (子图数 − 1) + margin × 2。很多人第一次写会算成 gap × 子图数,结果输出图底部多出一条空白。这个细节属于“看着对、实际错”的经典案例,调试时还会误以为是代码 bug,其实是算数问题。宽度同理,横向拼接总宽度 = 所有图片宽度之和 + gap × (子图数 − 1) + margin × 2。
背景色还有一个隐蔽坑:RGB 模式不能用透明背景,如果设--bg transparent但输出是 JPG,Pillow 不会报错,会把透明区域直接变成黑色,这是很多人在拼 PNG 素材时遇到的“黑底”问题的真正来源。我的习惯是:输出文件扩展名决定格式,透明底只允许输出 PNG,如果你强制 JPG,工具直接提示并改成白底。
4.3 内存与输出压缩:批量场景下最容易被忽视的性能瓶颈
批量拼大图时最容易被卡死的不是算法,而是内存。一张 4000×3000 的 JPEG 全量读入内存转成 RGB,大约占 36MB;如果转成 RGBA 更夸张,将近 48MB。三百张这样的图同时常驻内存,直接超过 10GB,脚本跑到一半被杀是常有的事。我一般按数量分两条路:少于 100 张的小批量,全量读入没问题;超过这个量,改成两阶段处理——先遍历目录拿到所有图片路径,只读取每张图的size属性来预计算画布尺寸,然后重新逐张打开、粘贴、关闭。
def merge_vertical_low_memory(image_paths, output_path, target_width=1080): # 第一阶段:只读尺寸,不读像素 sizes = [] for p in image_paths: with Image.open(p) as im: ratio = target_width / im.width h = im.height if ratio >= 1 else int(im.height * ratio) sizes.append(h) total_h = sum(sizes) canvas = Image.new("RGB", (target_width, total_h), (255, 255, 255)) # 第二阶段:逐张粘贴,贴完立刻释放 y = 0 for p, h in zip(image_paths, sizes): with Image.open(p) as im: if im.width > target_width: im = im.resize((target_width, int(im.height * target_width / im.width))) canvas.paste(im, (0, y)) y += h canvas.save(output_path)逻辑说明:两阶段的本质是“先算后贴”。第一阶段只打开文件头拿到im.width和im.height,Pillow 的懒加载机制此时不会读取像素数据,所以内存占用极低;第二阶段每张图片在with块里完成粘贴后立刻释放。这样即使一千张图,同一时刻只有一张完整的像素数据在内存里。
参数说明:输出端也有两个省空间的参数值得提。PNG 输出时加optimize=True,会让编码器做额外的压缩尝试,体积能小 5%–15%,代价是保存时间变长;JPEG 输出时用quality=90, subsampling=0,能避免文字和线条边缘出现色块,文字截图拼接成图后放大依然清晰。适合输出端做一次基准测试:同样一批图,分别用默认参数和优化参数保存,对比体积和耗时,按自己的使用频率取平衡点。
5. 图片合并的 5 个典型翻车现场:现象、原因与排查方法
这一章把我在使用和调试图片合并脚本过程中踩过的坑集中列出来,每一条都按“现象 → 原因 → 解决”的方式写清楚。这些都是真实会遇到的场景,而且特点是一旦触发,输出图整体报废,不是局部小瑕疵。
5.1 输出方向不对、透明区变黑与尺寸被拉伸
第一条翻车记录:手机拍摄的图片拼出来方向不对,竖着拍的图到了画布上变成横的。现象是拼接结果里部分图片旋转了 90 度,位置和尺寸全乱。原因是手机照片的 EXIF 信息里有一个 Orientation 标签,Pillow 的Image.open()默认不处理这个标签,读出来的像素就是没旋转过的原始数据。解决方法是在打开图片后立即调用ImageOps.exif_transpose(im),这个函数会读取 Orientation 并自动旋转图像。注意调用时机必须在任何 resize 或粘贴之前,否则先缩放了再旋转,宽高尺寸就会算错。
第二条翻车记录:透明背景的 PNG 素材拼到白色画布上,透明区域变成黑色。现象是输出图上原本透明的区域出现一块块黑斑。原因是 RGBA 模式的 PNG 直接.convert("RGB")时,透明通道被丢弃,而透明像素的 RGB 值在底层表示里通常是(0, 0, 0),也就是黑色。解决方法是先创建一个指定背景色的画布,把 RGBA 图片作为图层贴上去,再做合并;或者用im.convert("RGB", (255, 255, 255))显式指定透明区域用什么颜色填充。这条对做素材合图、贴纸合成的人特别重要。
第三条翻车记录:拼出来的图个别区域变扁或变宽,看起来像被拉伸过。现象是某些子图不是等比缩放,纵横比变了。原因分两种:一种是在网格拼接里强行把每张图resize成完全相同的宽高,另一种是resize时只传了宽度没传高度,Pillow 会按默认算法补一个不匹配的高度值。解决方法是统一用等比缩放公式:高度 = 原高度 × (目标宽度 / 原宽度),或者反过来。还有一种容易被误判的情况:Pillow 的paste()如果目标区域比图片小,是裁切而不是缩放,很多人看到输出图边缘被切掉,误以为是拉伸,其实是粘贴区域算小了。
5.2 排序错乱、内存溢出与被忽视的 EXIF 干扰
第四条翻车记录:文件名为1.jpg, 2.jpg, ..., 10.jpg的一组图,合并顺序变成1, 10, 2, 3。现象是长图的内容顺序完全错乱,语义连贯性被破坏。原因是 Python 默认的字符串排序是字典序,"10"会排在"2"前面。解决方法是使用上一章写的自然排序函数,把文件名按数字分段,数字部分转成整型比较。这条看着简单,但几乎每个人第一次批量拼图都会遇到,因为它只在文件名带多位数字时触发,单测用小数据集根本测不出来。
第五条翻车记录:拼接几百张 2400 万像素的大图,脚本跑到一半进程被杀,提示内存不足。现象是内存占用曲线一路攀升,直到系统触发 OOM。原因是把所有图片一次性读出来放进列表,然后又创建了一张巨大的画布,双份像素数据同时存在内存里。解决方法是上一章的两阶段方案:先扫描尺寸算画布,再逐张打开粘贴并释放。如果图像数量实在太大,还可以把中间结果拆成多张小画布,分别保存再二次拼接,牺牲一点 IO 时间换内存空间。排查时可以用top或任务管理器观察 python 进程的 RES 内存,如果稳定在 2GB 以下说明方案健康,如果突破 4GB 就说明有图片没有及时释放。
6. 把合并能力接进日常流程:合并前自检、缓存复用与自动化集成
跑通脚本只是第一步,让工具真正稳定可用,还要加三道保险:合并前的自检、输出缓存和统一入口命令。这三件事加起来代码量不大,但能把“能跑的脚本”变成“敢交给别人用的工具”。
合并前自检,我一般会在命令里加一个--dry-run参数。使用者先跑一遍,工具打印出将要处理多少张图、每张图的尺寸、合并后的画布尺寸、排序后的前五项文件名,不实际生成文件。这个流程能提前暴露 90% 的问题:文件格式不支持、尺寸差异过大、排序不符合预期。我的习惯是 dry-run 通过后才真正执行,这个习惯救过我一次——某次给客户整理素材,三百多张图按字典序排好后我没检查就跑了,结果一半顺序是反的,重新拼一次花了 20 分钟。从那以后 dry-run 必跑。
输出缓存是针对重复合并场景的。报表流水线里,同一批输入图可能每天被反复合并,如果内容没变,完全可以跳过。常见做法是以输入文件列表的哈希作为输出文件名前缀,输出文件已存在且修改时间晚于所有输入文件时,直接复用旧结果。入口命令则建议封装成一个imgs_tools.py,把所有子命令收敛到main()函数里,用argparse解析参数。这样使用者不用管底层有几个脚本,一条命令搞定纵向、网格、自由画布、智能对齐四种模式。
这类工具用久了,我最大的体会是:所有参数先给默认值、再允许覆盖,命令行短、可读性好,新手敢碰,老手能调。多花半小时把 dry-run 和缓存做进去,比后续反复处理翻车结果省时得多。希望帮到你。
本文还有配套的精品资源,点击获取