做图片处理做到第773版想法的时候,我终于把"把一张长图垂直切开"这个听起来毫无技术含量的事情认真做成了一个批量工具。起因很简单:手头有一批三千多张的聊天记录长截图,需要按固定段数切成等高的图片用于归档分享,一张张用看图软件手动框选裁剪,切到两百张的时候我就知道自己必须写个脚本了。
这个"773批量图片分割工具"做的事情一句话就能说清:读取指定文件夹里的全部图片,对每张图片沿垂直方向切分为指定数量的子图,并按规则命名输出到新目录。它适合解决长截图切割、电商海报批量切片、UI设计稿切图、图像数据预处理这类重复性极高的需求。如果你也经常遇到"整批图片都需要纵向等分"的活儿,这篇文章把我从需求拆解、方案选型到代码实现、性能调优的完整过程都写了出来,可以直接照着用。
1. 垂直分割看似简单,实际需求远比想象中复杂
1.1 长截图归档:最典型的使用场景
先说说我最初的需求。微信聊天记录长截图、网页全文快照、数据报表长图,这一类图片的特点是单张高度特别大,宽度固定,动辄七八千甚至上万像素。这种图放在电脑上看要来回滚动,发到群里被压缩后细节全糊,打印出来更是灾难。把一张超长图按垂直方向切开,变成几张高度相近的片段,才是真正适合阅读和分享的形态。
我接到的这批截图一共有3200多张,每张高度在6000到12000像素之间,要求每张切成4份。手工操作的话,先打开图片、估算高度、裁剪、另存为、再重复三次,一张图至少两分钟。三千多张就是一百多个小时。这种量级的工作,只有脚本能解决。
1.2 电商海报与新媒体素材的批量切图
第二个高频场景出现在电商和新媒体运营。平台详情页有时候要求把一张长海报拆成多张轮播图,或者把一张长 Banner 按固定比例切成多段用于不同的展示位。这类需求往往是一批素材同时处理——一次活动几十张海报,每张都要切分,而且输出格式、命名规则必须统一。
用图像处理软件的动作录制功能也能做,但动作录制的短板很明显:批量改文件名很别扭,遇到图片尺寸不统一就容易出问题,而且它依赖庞大的图形界面软件,跑大批量任务时机器卡得没法干别的。脚本的方式就灵活得多——我可以把命名规则、输出格式、分割数量全部参数化,管它是什么尺寸,只要高度足够,传进去就能跑。
1.3 图像数据预处理与自动化流水线
还有一个容易被忽略的场景:图像数据预处理。做目标检测、图像分类模型训练时,经常需要把大分辨率图片切成小图,避免直接缩放导致细节丢失。比如一张卫星遥感图或者高清扫描件,切成若干小块后送入模型,通过率会明显提升。垂直方向的等分切割是这种预处理里最简单也最常用的一种操作。
在这种场景里,批量分割工具不是给人看图的,而是给下游程序"喂数据"的。所以工具必须做到:输出命名稳定可预期、格式可控、不丢色彩信息、处理完成后有明确的成功失败统计。这些要求决定了这个工具不能只是一个"能切图的脚本",它需要具备工程化的特征。
2. 方案选型与技术设计:为什么我选了 Python + Pillow 这条路
2.1 技术选型的考量过程
动手之前我其实比较过几条路线。第一条是用 Photoshop 的动作 + 批处理,优点是不用写代码,缺点是文件命名规则受限、无法处理 RGBA 透明通道的 PNG 转 JPEG、大批量运行时性能一般,而且环境依赖很重。第二条是直接用 Python 的 Pillow 库,代码简短、跨平台、几乎零依赖,处理流程完全可控。第三条是 OpenCV,它的图像处理能力更强,但安装包大,对这种纯切割任务有点杀鸡用牛刀。
我最终选了 Python + Pillow。原因有三个:
- Pillow 的
crop()方法做矩形区域裁剪是开箱即用的,代码量小,逻辑直白。 - Python 原生支持多进程并行,批量处理大量图片时可以充分压榨多核 CPU。
- 命令行工具形式可以嵌入任意自动化流程——比如接到一个图片文件夹,处理完再交给下一个环节,中间不用人盯着。
2.2 "垂直分割"的语义拆解
"将图片垂直方向分割为指定数量"这句话拆开来,其实包含了几个需要明确的细节:
第一,分割是等分还是按比例?标题说的是"指定数量",最直观的理解是等分。等分的意思是总高度除以份数,每份高度一致。但如果图片高度不能被份数整除,余数怎么处理?我的方案是每份高度向下取整,最后一份吸收所有余数。比如一张高度 1000px 的图片切 3 份,前两份各 333px,最后一份 334px,加起来刚好 1000px。这样视觉上几乎看不出差别,而且能保证不丢像素。
第二,分割线是否需要重叠?很多长截图在拼接场景下需要相邻两张之间有一小段重叠区域,防止接缝处内容被截断。这个作为进阶参数加进去,默认不重叠,需要时通过--overlap参数指定重叠像素数。
第三,图片的色彩模式和格式怎么处理?这是最容易踩坑的地方。PNG 可能带透明通道(RGBA 模式),直接保存为 JPEG 会报错;部分图片是 CMYK 模式(多用于印刷稿),保存时颜色会异常。工具里必须做好模式转换。
2.3 参数设计原则
命令行工具的参数设计要遵循一个原则:常用需求用简单参数满足,特殊需求用可选参数满足。核心参数只有两个——输入目录和分割数量。其余都是可选项:输出目录、命名前缀、输出格式、JPEG 压缩质量、并行进程数。
这样的设计对日常使用最友好。我第一次用的时候只需要敲一条命令:
python split_vertical.py ./input ./output -n 4剩下的交给脚本,屏幕上会逐张打印处理结果。
3. 核心代码实现:从裁剪逻辑到批量调度
3.1 单张图片的分割逻辑
单张图片的分割是整个工具的基础。用代码表示,核心逻辑其实就几行:
from PIL import Image, ImageOps def split_vertical(img, split_count): """将 PIL Image 对象沿垂直方向均分为 split_count 份,返回切片列表""" width, height = img.size if split_count <= 0: raise ValueError("分割数量必须大于 0") if split_count > height: raise ValueError(f"图片高度只有 {height}px,无法分割成 {split_count} 份") slice_height = height // split_count parts = [] for i in range(split_count): top = i * slice_height bottom = top + slice_height if i == split_count - 1: bottom = height # 最后一份吸收余数 box = (0, top, width, bottom) parts.append(img.crop(box)) return parts这里有几个细节值得说明。
首先是img.size的取值顺序。Pillow 里size返回的是(width, height),不是(height, width)。容易搞反的是刚上手的人,我在最早写测试代码时就因为这个问题把图片切歪了——横着切成了竖着,排错排了半天才发现是行列顺序理解错了。
其次是crop()的 box 参数格式。它是(left, top, right, bottom)四元组,左和上是包含的,右和下是不包含的。所以切第一份时 box 是(0, 0, width, slice_height),第二份是(0, slice_height, width, slice_height * 2),依此类推,最后一份的 bottom 直接取 height,避免余数丢失。
最后是 P 模式和透明背景问题。有的 PNG 是调色板模式(P 模式),直接crop()没问题,但后续如果要转 JPEG 或者做色彩转换,就得提前convert("RGBA")。这个在后面避坑章节详细说。
3.2 颜色模式与图片方向的前置处理
批量处理最忌讳的是"遇到一张奇葩图就中断"。我见过的高频坑包括:手机拍的照片带了 EXIF 方向信息、扫描件是 CMYK 模式、动图格式混在 JPG 文件夹里。
针对 EXIF 方向问题,Pillow 提供了一个标准函数ImageOps.exif_transpose(),它读取图片的 Orientation 标签,把图像像素真正旋转到位。注意必须在切割之前执行,否则切出来的每一片方向都是错的。
对于色彩模式,我在分割后、保存前统一做一次判断:
def normalize_color(img, target_format): """根据目标输出格式处理色彩模式,返回可安全保存的 Image 对象""" if target_format.upper() == "JPEG": if img.mode in ("RGBA", "LA", "P"): # 透明/调色板模式先转 RGBA,再以白色背景合成 RGB img = img.convert("RGBA") background = Image.new("RGB", img.size, (255, 255, 255)) background.paste(img, mask=img.split()[-1]) return background if img.mode == "CMYK": return img.convert("RGB") else: if img.mode == "CMYK": return img.convert("RGB") if img.mode == "P": return img.convert("RGBA") return img简单解释一下这段的逻辑。JPEG 不支持透明通道,所以 RGBA 模式的 PNG 如果硬存成 JPEG,会直接报cannot write mode RGBA as JPEG。正确处理方法是做像素替换:把透明部分的像素替换成白色背景,然后转成 RGB。具体手段是建立一张相同尺寸的纯白背景图,用原图的 alpha 通道作为蒙版,把原图贴到白底上。这样输出图像里透明的位置就变成了白色,人眼看起来是"底色被填白了"。
CMYK 模式则是印刷行业常见的色彩空间,PNG 格式并不支持直接保存 CMYK,所以也要转成 RGB。至于 P 模式(调色板模式),它在 PNG 里很常见,转成 RGBA 再后续处理不会有损失。
3.3 批量调度与任务分发
批量调度的核心是遍历文件、构造任务、并行执行三件事。文件遍历我用glob匹配常见图片扩展名,再sorted()排序,保证处理顺序稳定可预期。这里有个容易被忽略的坑:glob.glob在不同操作系统下返回的文件顺序并不是字典序的,不排序的话,几千张图片的处理顺序就是乱的,输出结果对不上原始批次。
完整的批量调度代码我放在这里,可以直接保存成脚本使用:
import os import sys import glob import argparse from concurrent.futures import ProcessPoolExecutor, as_completed from PIL import Image, ImageOps SUPPORTED_EXTENSIONS = (".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff") def split_one_image(task): """处理单张图片:分割 + 保存。独立函数便于多进程调用。""" filepath, output_dir, split_count, prefix, output_format, quality = task filename = os.path.splitext(os.path.basename(filepath))[0] try: with Image.open(filepath) as img: img = ImageOps.exif_transpose(img) width, height = img.size if split_count > height: raise ValueError(f"图片高度 {height}px 小于分割数量 {split_count}") slice_height = height // split_count base_name = prefix + filename for i in range(split_count): top = i * slice_height bottom = top + slice_height if i == split_count - 1: bottom = height cropped = img.crop((0, top, width, bottom)) cropped = normalize_color(cropped, output_format) out_path = os.path.join(output_dir, f"{base_name}_{i + 1}.{output_format.lower()}") save_kwargs = {} if output_format.upper() == "JPEG": save_kwargs["quality"] = quality save_kwargs["subsampling"] = 1 cropped.save(out_path, output_format.upper(), **save_kwargs) return {"status": "ok", "file": filepath, "parts": split_count} except Exception as e: return {"status": "error", "file": filepath, "error": str(e)} def main(): parser = argparse.ArgumentParser(description="批量将图片垂直方向分割为指定数量的图片") parser.add_argument("input_dir", help="图片输入目录") parser.add_argument("output_dir", help="结果输出目录") parser.add_argument("-n", "--count", type=int, required=True, help="每张图片需要分割成几份") parser.add_argument("-p", "--prefix", default="", help="输出文件名前缀(可选)") parser.add_argument("-f", "--format", default="", choices=["PNG", "JPEG", "WEBP", "BMP", "TIFF"], help="输出格式(默认保持原文件的扩展名格式)") parser.add_argument("-q", "--quality", type=int, default=95, help="JPEG 输出质量 1-100") parser.add_argument("-j", "--jobs", type=int, default=4, help="并行进程数,默认 4") args = parser.parse_args() os.makedirs(args.output_dir, exist_ok=True) files = set() for ext in SUPPORTED_EXTENSIONS: files.update(glob.glob(os.path.join(args.input_dir, f"*{ext}"))) files.update(glob.glob(os.path.join(args.input_dir, f"*{ext.upper()}"))) files = sorted(files) if not files: print("输入目录下没有找到支持的图片文件。支持格式:", ", ".join(SUPPORTED_EXTENSIONS)) return tasks = [] for f in files: fmt = args.format or os.path.splitext(f)[1].lstrip(".").upper() or "PNG" if fmt or fmt == "JPG": fmt = "JPEG" if fmt == "JPG" else fmt tasks.append((f, args.output_dir, args.count, args.prefix, fmt, args.quality)) print(f"共找到 {len(files)} 张图片,分割份数 {args.count},并行进程数 {args.jobs}") ok_count, error_count = 0, 0 with ProcessPoolExecutor(max_workers=args.jobs) as executor: futures = {executor.submit(split_one_image, task): task for task in tasks} for future in as_completed(futures): result = future.result() if result["status"] == "ok": ok_count += 1 print(f"[OK] {os.path.basename(result['file'])} -> {result['parts']} 份", flush=True) else: error_count += 1 print(f"[ERR] {os.path.basename(result['file'])}: {result['error']}", file=sys.stderr, flush=True) print(f"\n处理完成:成功 {ok_count} 张,失败 {error_count} 张") print(f"输出目录:{args.output_dir}") if __name__ == "__main__": main()这段代码是有意写得"工程化"一点的。每个文件独立成一个任务,失败不中断;并行进程数可控;标准输出只打成功条目,错误走 stderr,方便重定向日志。还有一个细节:print加了flush=True,否则大量输出时容易积在缓冲区,任务跑完才统一刷出来,看不出实时进度。
3.4 normalize_color 函数的补充实现
我在上文的批量代码里调用了normalize_color(),这个函数需要提前定义,完整的版本如下:
def normalize_color(img, target_format): """根据目标输出格式处理色彩模式,返回可安全保存的 Image 对象""" target = target_format.upper() if target in ("JPEG", "JPG"): if img.mode in ("RGBA", "LA", "P"): img = img.convert("RGBA") background = Image.new("RGB", img.size, (255, 255, 255)) background.paste(img, mask=img.split()[-1]) return background if img.mode == "CMYK": return img.convert("RGB") return img.convert("RGB") if img.mode == "CMYK": return img.convert("RGB") if img.mode == "P": return img.convert("RGBA") return img这里对 JPEG 输出统一做了convert("RGB"),最开始我只处理了 RGBA 模式,结果遇到一张灰度模式的图片,保存 JPEG 没问题,但遇到带 alpha 通道的灰度图(LA 模式)就又报错了。后来干脆把所有模式统一收敛到 RGB,逻辑最简单,行为也最稳定。
4. 性能实测与并行优化的真实收益
4.1 单进程慢在哪里
初期版本我用的是最简单的串行循环——一张图处理完再处理下一张。整个流程是:打开图片、解码像素、执行crop()、编码保存、关闭文件。这里的性能瓶颈有两个:一个是图片解码和编码本身是 CPU 密集操作,另一个是 I/O 等待。
实测下来,单进程处理一张 3000x8000 的 PNG,耗时大约 0.4 到 0.5 秒。听起来不慢,但三千张就是二十分钟以上,而且期间 CPU 只有一个核在忙,其他核心闲着。这在多核机器上显然是浪费。
4.2 多进程并行后的对比数据
我用concurrent.futures.ProcessPoolExecutor把任务分发到多个进程,每个进程独立完成"打开 -> 切割 -> 保存"的全流程。因为每个图片文件之间没有依赖,天然适合并行。
测试环境是我的日常办公机:Intel i5-1240P,16GB 内存,Windows 11。测试素材为 100 张 3000x8000 像素的 PNG,平均单张约 12MB,统一切成 4 份。四组对比数据如下:
| 并行进程数 | 总耗时 | 平均单张耗时 | 备注 |
|---|---|---|---|
| 1 | 46.8秒 | 0.47秒 | 单核满载,其他核闲置 |
| 2 | 24.5秒 | 0.25秒 | 接近线性加速 |
| 4 | 13.2秒 | 0.13秒 | 日常推荐配置 |
| 8 | 11.9秒 | 0.12秒 | 进一步提升有限,内存占用翻倍 |
从这个结果可以明显看到,进程数从 1 加到 4,速度提升了约 3.5 倍,接近线性扩展;但从 4 加到 8,只提升了 10% 左右。原因在于这台机器是 8 核(4 性能核 + 4 能效核),能效核的算力有限,而且进程数翻倍后内存带宽、文件系统并发访问都成了新的瓶颈。
我的建议是-j参数默认设 4,不追求极端并行。对大多数人的机器来说,4 进程是稳定性和速度的平衡点。
4.3 内存占用与大批量处理的稳定性
并行进程多了,内存占用是必须考虑的问题。Pillow 在打开图片时默认使用延迟解码——图片的像素数据不会立刻全部加载到内存,只有执行crop()或convert()才会真正触发解码。因此一个进程同时持有的内存大约是"一两张图片的像素数据 + 当前切片的缓冲"。
3000x8000 的 RGB 图片,像素数据大约是 3000 * 8000 * 3 = 7200 万字节,约 69MB。4 个进程就是 276MB,加上系统缓冲和输出图片编码开销,总共约占 1.5GB 内存。如果是 16GB 内存的机器,这个占用完全无压力。
但要注意一种极端情况:图片特别大,比如分辨率达到上亿像素。Pillow 默认会弹出DecompressionBombWarning警告,超过一定阈值还会直接拒绝打开。这种情况下可以通过Image.MAX_IMAGE_PIXELS = None解除限制,但风险自负——内存占用可能直接导致 OOM。我实际处理过一次高度为 50000px 的缝合长图,单张像素数据就有两百多 MB,最终是单独用一台 32GB 内存的机器跑完的。批量工具只适合常规尺寸的图片,超大图还是单张处理更稳妥。
5. 实际踩过的坑与排查全过程
5.1 PNG 透明通道丢损问题:一次典型的保存失败
最早版本的代码里,分割保存的逻辑非常简单,遇到 PNG 直接cropped.save(out_path, "PNG"),遇到 JPG 直接cropped.save(out_path, "JPEG")。看起来没问题,直到我碰到一批电商设计稿——全是带透明背景的 PNG。
这批图片切割后输出为 PNG 是没问题的。但后来有同事反馈,说有一批输出文件在网络平台上无法上传。排查后发现是他的工作流要求输出 JPEG,而 PNG 的 RGBA 透明通道一旦强行保存为 JPEG,Pillow 立刻抛出异常:
cannot write mode RGBA as JPEG这个时候我的工具没有任何容错,一张图报错,整个批次中断,之前切好的也白干了。这是所有批量处理脚本必须迈过的坎:不能让单张图片的异常终止整个任务。
修复分两层。第一层是异常隔离,我在split_one_image里用 try/except 包住了整段处理逻辑,失败只记录日志,不影响其他文件。第二层才是正确性修复——把 RGBA 的透明像素填充为白色,再转 RGB 保存为 JPEG。这样输出文件不报错,视觉上透明区域呈现为白底,对大多数使用场景完全够用。
5.2 图片高度除不尽时的余数去向
分割份数不能整除高度的情况非常常见。比如图片高度 999px,分割成 4 份,999 除以 4 等于 249.75。如果用整数除法999 // 4 = 249,四份加起来只有 996px,会丢 3px。
这 3px 从哪丢?如果每一份都严格取 249px,那么图片底部的 3px 就凭空消失了。我最早实现时就是直接img.crop((0, i * slice_height, width, (i + 1) * slice_height)),最后一份的(i + 1) * slice_height是 996,导致底部 3px 被裁掉。单张肉眼看不出来,但几千张累积下来,总有人会发现"最后一张图比前面少了一截"。
修复方案就是前文代码里的逻辑:if i == split_count - 1: bottom = height。最后一份不按计算值,直接取图片原始高度,把余数全部"吸收"进去。这样做的好处是不丢任何像素,代价是最后一份可能比其他份高 1 到 3px,视觉上完全无感。
5.3 文件名排序混乱与 EXIF 方向问题
再讲两个我在排查中花了不少时间的隐蔽问题。
第一个是文件名排序。glob.glob返回的文件名顺序在不同系统上是不一样的——Linux 上通常按目录项顺序,Windows 上也不保证字典序。如果你的图片文件命名是001.png, 002.png, ..., 100.png,按字符串排序是001, 010, 011, 012, ..., 100, 002, 003,...这样的顺序。对于只需要"处理完所有文件"的场景无所谓,但如果下游流程依赖输出顺序,这个细节会引发连锁错误。解决方式是在收集文件后强制sorted(),并且文件名统一按零填充的位数来命名,比如从00001.png开始,而不是1.png。
第二个问题是 EXIF 方向。手机拍的竖图、相机竖拍的照片,JPEG 文件里会带一个 Orientation 标签,值可能是 6 或 8,表示图像需要旋转 90 度或 270 度显示。Pillow 的Image.open()读出来的是原始像素数据,它不会自动按照 EXIF 方向旋转。如果直接切割保存,输出的图片在部分看图软件里是歪的——因为新的 JPEG 文件没有带上原图的 Orientation 标签,查看器只能按像素原始方向显示,于是竖拍的图变成了横着的。
标准解法是在打开图片后立即执行ImageOps.exif_transpose(img)。我曾经偷懒没加这一步,结果批处理了三百多张手机截图,输出后有一半在手机上是横躺的,被同事追着骂了一个下午。从那以后,这句代码就写进了所有图片处理脚本的固定开头。
5.4 大图内存溢出与 Pillow 的 DecompressionBomb 防护
最后一个坑来自 Pillow 的自我保护机制。问题表现是:处理到一张特别大的长图时,脚本直接抛DecompressionBombError,任务中断。
Pillow 出于安全考虑,默认对超大图片设了上限。Image.MAX_IMAGE_PIXELS默认值是 89478485 像素(约 9000 万像素),超过这个数值会触发警告,超过Image.DecompressionBombWarning阈值则可能直接报错。一张 3000x30000 的长图就是 9000 万像素,刚好卡在边缘上。
我的处理方式是分场景讨论:如果确知图片来源可靠,可以在脚本开头写Image.MAX_IMAGE_PIXELS = None解除限制;如果图片来源不可控,建议保留默认限制,把超过阈值的文件单列出来人工处理。我个人的经验是,只要跑批处理的文件夹是内部系统导出的,图片尺寸范围可控,解除限制是安全的。但如果是外部上传的文件,最好保留限制,同时加上异常隔离,让超大图单独报错而不影响整批任务。
6. 扩展思路:当工具从"够用"走向"顺手"
上面这些做完之后,这个工具基本覆盖了日常 90% 的需求。但我在实际使用中还加了几个小功能,这里一并分享出来。
第一个是重叠分割参数。处理聊天记录长图时,接缝处偶尔会把一行字切成两半,看起来不完整。我加了一个--overlap参数,比如设 10,那么每份切割时上方多保留 10px,相邻两份之间就有 20px 的重叠区域。切割完的文字在任何一份里都不会出现被硬切的情况。实现方式是每一份的top减去 overlap,并和前一份的bottom取最大值,避免越界:
overlap = args.overlap # 重叠像素 for i in range(split_count): top = max(0, i * slice_height - overlap) if i > 0 else 0 bottom = min(height, top + slice_height + overlap)第二个是 EXIF 保留。很多归档场景希望输出图片保留原图的拍摄日期、相机型号等信息,方便后续管理。在保存图片时把原图的 EXIF 数据带过去即可:
exif_data = img.info.get("exif") cropped.save(out_path, format, exif=exif_data)但要注意,如果用ImageOps.exif_transpose()已经旋转过图片,原 EXIF 里的 Orientation 标签还在,会导致部分软件重复旋转。需要先清除该标签再保存。因为这部分逻辑偶尔还是要踩坑,我就不把代码贴全了——它值得单独写一篇细说。
第三个是生成校验文件。处理完三千张图之后,如何确认输出完整?我让脚本在处理完毕后生成一个 manifest 文件,记录每张原始图片对应的输出列表。下游流程只要读取 manifest 就能核对数量,不用人肉数文件。这一条在数据预处理场景里特别有用。
这几个扩展虽然没有改变工具的核心功能,但让它在真实工作流里变得可靠得多。毕竟批量工具这种东西,用一次两次可以靠运气,天天用就必须把边界情况全部兜住。