ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Python批量图片垂直分割工具:基于Pillow实现长图高效切割与优化

2026/10/2 22:10:21 拓冰建站 浏览量
Python批量图片垂直分割工具:基于Pillow实现长图高效切割与优化 做图片处理做到第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, maskimg.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, typeint, requiredTrue, 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, typeint, default95, helpJPEG 输出质量 1-100) parser.add_argument(-j, --jobs, typeint, default4, help并行进程数默认 4) args parser.parse_args() os.makedirs(args.output_dir, exist_okTrue) 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_workersargs.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]} 份, flushTrue) else: error_count 1 print(f[ERR] {os.path.basename(result[file])}: {result[error]}, filesys.stderr, flushTrue) print(f\n处理完成成功 {ok_count} 张失败 {error_count} 张) print(f输出目录{args.output_dir}) if __name__ __main__: main()这段代码是有意写得工程化一点的。每个文件独立成一个任务失败不中断并行进程数可控标准输出只打成功条目错误走 stderr方便重定向日志。还有一个细节print加了flushTrue否则大量输出时容易积在缓冲区任务跑完才统一刷出来看不出实时进度。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, maskimg.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-1240P16GB 内存Windows 11。测试素材为 100 张 3000x8000 像素的 PNG平均单张约 12MB统一切成 4 份。四组对比数据如下并行进程数总耗时平均单张耗时备注146.8秒0.47秒单核满载其他核闲置224.5秒0.25秒接近线性加速413.2秒0.13秒日常推荐配置811.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 透明通道一旦强行保存为 JPEGPillow 立刻抛出异常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, exifexif_data)但要注意如果用ImageOps.exif_transpose()已经旋转过图片原 EXIF 里的 Orientation 标签还在会导致部分软件重复旋转。需要先清除该标签再保存。因为这部分逻辑偶尔还是要踩坑我就不把代码贴全了——它值得单独写一篇细说。第三个是生成校验文件。处理完三千张图之后如何确认输出完整我让脚本在处理完毕后生成一个 manifest 文件记录每张原始图片对应的输出列表。下游流程只要读取 manifest 就能核对数量不用人肉数文件。这一条在数据预处理场景里特别有用。这几个扩展虽然没有改变工具的核心功能但让它在真实工作流里变得可靠得多。毕竟批量工具这种东西用一次两次可以靠运气天天用就必须把边界情况全部兜住。