1. 图片网格化分割到底在切什么
第一次听到“图片网格化分割”这个词,很多人脑子里浮现的是把一张照片切成九宫格发朋友圈。这个理解不算错,但只摸到了皮毛。真正的网格化分割,是把一张完整图像按照固定的行数和列数,机械地切成 M×N 个等尺寸的小图块,每个图块独立保存或独立处理。它不关心图片里有什么内容,不识别主体,不做语义理解,纯粹按坐标切分。
这件事听起来简单到不值一提,但我在实际项目里用它解决过不少麻烦。比如做图像分类数据集的时候,手头只有几十张大图,标注成本高得离谱,把每张大图切成小块之后,样本量直接翻了十几倍,而且每个小图块还能单独做数据增强。再比如做文档扫描件的局部识别,整页识别精度不够,切成网格后逐块处理,准确率明显提升。还有做拼图游戏、做马赛克预览、做图像分块压缩传输,底层都是同一套逻辑。
Python 做这件事有天然优势。Pillow 库几行代码就能搞定基本切分,OpenCV 和 NumPy 配合能玩出更多花样。但真正要写好一个健壮的网格化分割工具,要考虑的东西远比想象中多:边界怎么处理、尺寸除不尽怎么办、切完怎么命名、批量处理怎么加速、内存怎么控制。这些细节才是区分“能跑”和“好用”的关键。
这篇文章适合谁看?如果你刚学 Python 图像处理,想找一个能立刻上手的小项目练手,网格化分割是绝佳选择。如果你已经在做深度学习数据预处理,需要一套可靠的切图方案,这里面的参数计算和避坑经验能帮你省不少时间。如果你只是想把一张大图切成壁纸尺寸的小图,下面的代码直接抄就能用。
提示:网格化分割和图像分块(tiling)在概念上高度重叠,但网格化更强调规则的行列切分,分块则可能包含重叠区域或自适应尺寸。本文聚焦规则网格切分,后续会提到重叠切分的扩展思路。
2. 核心思路与方案选型
2.1 为什么选 Pillow 而不是 OpenCV
Python 里处理图像的主流库就那么几个:Pillow、OpenCV、scikit-image、imageio。做网格化分割,我首选 Pillow。原因很直接:Pillow 的 crop 方法语义清晰,坐标系统直观,安装轻量,不依赖一堆系统库。OpenCV 当然也能做,用 NumPy 切片就行,但 OpenCV 的安装在某些环境下会碰到依赖问题,而且它的 BGR 通道顺序对新手不太友好。
不过 OpenCV 在批量处理大图时速度确实有优势,因为它的底层是 C++ 优化过的。如果你要处理几千张高分辨率图片,OpenCV 的 NumPy 切片方式可能比 Pillow 快 20% 到 30%。但差距没有想象中那么大,Pillow-SIMD 版本(Pillow 的 SIMD 优化分支)能把差距抹平甚至反超。
我的建议是:日常使用 Pillow,追求极致速度且环境允许时用 OpenCV。两者代码逻辑几乎一样,切换成本很低。
2.2 切分尺寸的计算逻辑
这是整个项目最核心的部分。假设原图宽度为 W,高度为 H,要切成 rows 行 cols 列,那么每个小块的宽度是 W/cols,高度是 H/rows。但问题来了:W 不一定能被 cols 整除。
举个例子,一张 1920×1080 的图,切成 3×3 网格。1920/3=640,刚好整除。1080/3=360,也整除。完美。但如果是 1920×1081 呢?1081/3=360.333...,除不尽。
这时候有三种处理策略:
- 向下取整,丢弃边缘:每个小块取 360 像素高,3 行共 1080 像素,最后 1 像素被丢掉。简单粗暴,适合对边缘不敏感的场景。
- 向上取整,边缘补零:每个小块取 361 像素高,最后一行超出原图的部分用黑色或白色填充。适合需要保持网格规整的场景。
- 动态分配,余数分摊:前几个块多 1 像素,后面的块少 1 像素,保证总高度不变。最精确,但代码稍复杂。
我在实际项目里最常用的是第三种,因为它不丢信息也不引入人工填充。具体做法是用divmod函数算出商和余数,然后前余数个块各多分 1 像素。
def calculate_grid_sizes(total, parts): base, remainder = divmod(total, parts) sizes = [] for i in range(parts): if i < remainder: sizes.append(base + 1) else: sizes.append(base) return sizes这段代码值得反复看。divmod(1081, 3)返回(360, 1),意思是每块至少 360,多出 1 像素分给第一块。最终尺寸列表是[361, 360, 360],加起来正好 1081。不丢像素,不补黑边,完美。
2.3 命名规则与输出结构
切完的图怎么命名,这事看着小,但批量处理时命名混乱会让你痛不欲生。我踩过的坑:早期用output_0.jpg、output_1.jpg这种全局递增编号,结果切了 100 张原图,输出目录里 10000 个文件混在一起,根本分不清哪个块属于哪张原图。
后来改成两级目录结构:每张原图对应一个子文件夹,子文件夹名就是原图文件名(去掉扩展名),里面的小图按row_col.jpg命名。比如photo1/0_0.jpg、photo1/0_1.jpg、photo1/1_0.jpg。这样结构清晰,回溯方便,做数据集时直接按文件夹划分训练集验证集也不会乱。
如果你需要把切块重新拼回原图,命名里必须包含行列信息。row_col格式最直观,解析也简单,用split('_')就能拿到坐标。
注意:文件名里不要用中文或特殊字符,某些深度学习框架的 DataLoader 对非 ASCII 路径支持不好,会在你意想不到的地方报错。
3. 核心细节与实操要点
3.1 坐标计算:左闭右开原则
Pillow 的crop方法接收一个四元组(left, upper, right, lower),遵循左闭右开原则。也就是说,right和lower坐标对应的像素不包含在裁剪结果里。这个细节极其重要,搞错了会导致相邻块之间要么重叠一列像素,要么漏掉一列像素。
假设每块宽度为w,第col块的左边界是col * w,右边界是(col + 1) * w。当col从 0 到cols-1遍历时,最后一块的右边界正好等于原图宽度。这就是左闭右开的精妙之处:不用做任何特殊处理,循环自然覆盖全图。
但如果你用的是动态尺寸分配(前面说的第三种策略),每块宽度不一样,就不能简单用col * w了。需要先累加前面所有块的宽度,得到当前块的起始位置。
x_offsets = [0] for w in col_widths: x_offsets.append(x_offsets[-1] + w) # 第 col 块的左边界是 x_offsets[col],右边界是 x_offsets[col+1]这种累加方式通用性最强,不管尺寸是否均匀都能正确工作。我现在的代码里统一用这种写法,省得每次都要判断能不能整除。
3.2 批量处理的目录遍历
批量处理时,遍历目录用pathlib.Path比os.path舒服得多。Path.glob('*.jpg')一行就能拿到所有 JPG 文件,还能链式调用.sorted()排序。更重要的是,pathlib的路径拼接用/运算符,写起来像自然语言。
from pathlib import Path input_dir = Path('input_images') output_dir = Path('output_tiles') output_dir.mkdir(parents=True, exist_ok=True) for img_path in sorted(input_dir.glob('*.jpg')): stem = img_path.stem # 文件名不含扩展名 save_dir = output_dir / stem save_dir.mkdir(exist_ok=True) # 后续处理...这段代码里mkdir(parents=True, exist_ok=True)是标准操作。parents=True表示如果父目录不存在就一起创建,exist_ok=True表示目录已存在时不报错。少了任何一个参数,第二次运行脚本就会崩。
3.3 内存控制:别一次性读入所有图
新手最容易犯的错是把所有图片读进内存再处理。一张 4000×3000 的 RGB 图,在内存里占 4000×3000×3 字节,约 34MB。100 张就是 3.4GB,普通笔记本直接爆内存。
正确做法是流式处理:读一张,切一张,存一张,释放一张。Python 的垃圾回收机制会在变量重新赋值时自动释放旧对象,你只需要确保不要在列表里累积图像对象。
for img_path in sorted(input_dir.glob('*.jpg')): with Image.open(img_path) as img: # 在 with 块内完成所有切分和保存 # 离开 with 块后文件句柄自动关闭 ...用with语句打开图片还有个好处:Pillow 是惰性加载的,Image.open只读文件头,真正的像素数据在你调用crop或load时才读取。with块确保文件句柄及时释放,避免“打开文件过多”的错误。
3.4 图像格式与质量参数
保存切块时,格式选择影响文件大小和后续处理。JPEG 适合照片类图像,PNG 适合有透明通道或需要无损保存的场景。如果原图是 PNG 但切块要用于深度学习训练,转成 JPEG 能大幅减小文件体积,加快数据加载速度。
JPEG 的quality参数范围是 1 到 95(100 表示无损但文件巨大)。我一般用 90 到 95,肉眼几乎看不出差异,文件大小比 100 小一半以上。optimize=True会让 Pillow 尝试无损压缩,稍微增加保存时间但能再减小几个百分点。
tile.save(save_path, quality=92, optimize=True)如果是 PNG,可以用compress_level参数控制压缩级别,0 最快文件最大,9 最慢文件最小。默认是 6,平衡得不错,一般不用改。
提示:保存切块时不要用
img.save()直接保存 crop 返回的对象,因为 crop 返回的是新图像对象,但某些元数据(如 EXIF)可能丢失。如果需要保留 EXIF,得手动复制。
4. 完整实操流程与代码实现
4.1 基础版:等尺寸切分
先上最简版本,适合快速验证和简单场景。这个版本假设图片尺寸能被网格整除,不处理边界情况。
from PIL import Image from pathlib import Path def split_image_grid(img_path, output_dir, rows, cols): img = Image.open(img_path) w, h = img.size tile_w = w // cols tile_h = h // rows stem = Path(img_path).stem save_dir = Path(output_dir) / stem save_dir.mkdir(parents=True, exist_ok=True) for r in range(rows): for c in range(cols): left = c * tile_w upper = r * tile_h right = left + tile_w lower = upper + tile_h tile = img.crop((left, upper, right, lower)) tile.save(save_dir / f"{r}_{c}.jpg", quality=92) img.close() # 使用示例 split_image_grid("test.jpg", "output", rows=3, cols=3)这段代码 20 行不到,能解决 80% 的简单需求。但注意w // cols是向下取整,如果除不尽,右边和下边的边缘像素会被丢弃。对于 1920×1080 切 3×3 这种整除情况,没有任何问题。
4.2 进阶版:动态尺寸分配
现在处理除不尽的情况。核心是用前面说的calculate_grid_sizes函数算出每块的实际尺寸,然后累加得到坐标偏移。
from PIL import Image from pathlib import Path def calculate_grid_sizes(total, parts): base, remainder = divmod(total, parts) return [base + 1 if i < remainder else base for i in range(parts)] def split_image_grid_advanced(img_path, output_dir, rows, cols): img = Image.open(img_path) w, h = img.size col_widths = calculate_grid_sizes(w, cols) row_heights = calculate_grid_sizes(h, rows) x_offsets = [0] for cw in col_widths: x_offsets.append(x_offsets[-1] + cw) y_offsets = [0] for rh in row_heights: y_offsets.append(y_offsets[-1] + rh) stem = Path(img_path).stem save_dir = Path(output_dir) / stem save_dir.mkdir(parents=True, exist_ok=True) for r in range(rows): for c in range(cols): left = x_offsets[c] upper = y_offsets[r] right = x_offsets[c + 1] lower = y_offsets[r + 1] tile = img.crop((left, upper, right, lower)) tile.save(save_dir / f"{r}_{c}.jpg", quality=92) img.close()用 1920×1081 的图切 3×3 测试:col_widths是[640, 640, 640],row_heights是[361, 360, 360]。y_offsets是[0, 361, 721, 1081]。最后一块的下边界是 1081,正好等于原图高度。所有像素都被覆盖,没有丢失也没有重复。
4.3 批量处理版:带进度显示
处理大量图片时,没有进度显示会让人焦虑。用tqdm库加个进度条,几行代码的事。
from PIL import Image from pathlib import Path from tqdm import tqdm def batch_split(input_dir, output_dir, rows, cols): input_path = Path(input_dir) output_path = Path(output_dir) output_path.mkdir(parents=True, exist_ok=True) image_files = sorted(input_path.glob('*.jpg')) + sorted(input_path.glob('*.png')) for img_path in tqdm(image_files, desc="切分进度"): try: img = Image.open(img_path) w, h = img.size col_widths = calculate_grid_sizes(w, cols) row_heights = calculate_grid_sizes(h, rows) x_offsets = [0] for cw in col_widths: x_offsets.append(x_offsets[-1] + cw) y_offsets = [0] for rh in row_heights: y_offsets.append(y_offsets[-1] + rh) save_dir = output_path / img_path.stem save_dir.mkdir(exist_ok=True) for r in range(rows): for c in range(cols): tile = img.crop(( x_offsets[c], y_offsets[r], x_offsets[c + 1], y_offsets[r + 1] )) tile.save(save_dir / f"{r}_{c}.jpg", quality=92) img.close() except Exception as e: print(f"处理 {img_path.name} 时出错: {e}") continuetqdm的desc参数给进度条加描述文字,处理大批量时一眼就能看出在干什么。try-except块确保单张图片出错不会中断整个批次,错误信息打印出来方便排查。
4.4 参数选择与性能实测
我用一台普通笔记本(i5 处理器,16GB 内存)做了组实测,数据供参考:
| 图片数量 | 单图尺寸 | 网格 | 总耗时 | 平均每张 |
|---|---|---|---|---|
| 100 | 1920×1080 | 3×3 | 8.2秒 | 82毫秒 |
| 100 | 4000×3000 | 4×4 | 31.5秒 | 315毫秒 |
| 500 | 1920×1080 | 3×3 | 39.8秒 | 80毫秒 |
| 500 | 4000×3000 | 4×4 | 156秒 | 312毫秒 |
耗时主要花在图片解码和 JPEG 编码上,crop 操作本身几乎不耗时。如果换成 PNG 输出,耗时增加约 40%,因为 PNG 压缩更慢。如果输出质量从 92 降到 80,耗时减少约 15%,文件大小减少约 30%。
实操心得:如果切块是用于深度学习训练,质量设 85 到 90 足够,再高对模型精度几乎没有提升,但会显著增加磁盘 I/O 时间。训练时的数据加载瓶颈往往在磁盘读取,不在解码。
5. 常见问题与排查技巧
5.1 切块边缘出现黑边或白边
这是最常见的问题,通常是因为用了向上取整策略但填充了默认颜色。检查你的尺寸计算逻辑,如果用了math.ceil而不是divmod,最后一块的右边界或下边界会超出原图范围,Pillow 的 crop 会自动用黑色填充超出部分。
解决方法:改用divmod动态分配,或者手动把最后一块的边界钳制到原图尺寸。我推荐前者,代码更统一,不用写特殊判断。
5.2 相邻切块之间有重叠或缝隙
重叠通常是因为右边界计算错误,比如用了left + tile_w + 1。缝隙是因为右边界用了left + tile_w - 1。记住左闭右开原则:右边界就是下一块的左边界,不要加一减一。
如果你确实需要重叠切块(比如做图像拼接时避免接缝),那就在计算右边界时加上重叠像素数,但要注意最后一块的右边界不能超过原图宽度。
5.3 内存占用过高导致程序崩溃
前面提过,不要把所有图片读进内存。但还有一个隐藏陷阱:Pillow 的Image.open是惰性的,如果你在循环里打开图片但没有及时关闭,文件句柄会累积。用with语句或者手动调用img.close()。
另外,如果你在 Jupyter Notebook 里跑代码,变量不会自动回收,处理完一批图片后手动del img并调用gc.collect()能释放内存。
5.4 保存的切块文件名冲突
如果两张原图文件名相同但位于不同子目录,切块输出到同一个目录时会覆盖。解决方法是用原图的完整相对路径生成唯一标识,或者保持两级目录结构,每张原图一个子文件夹。
我习惯用img_path.stem作为子文件夹名,但如果不同子目录下有同名文件,还是会有冲突。更稳妥的做法是用img_path.parent.name + "_" + img_path.stem作为文件夹名,把父目录名也带上。
5.5 处理透明 PNG 时背景变黑
PNG 有 RGBA 四个通道,JPEG 只有 RGB 三个通道。把 RGBA 图片保存为 JPEG 时,透明区域会变成黑色。解决方法是在保存前把透明背景合成到白色背景上:
if img.mode == 'RGBA': background = Image.new('RGB', img.size, (255, 255, 255)) background.paste(img, mask=img.split()[3]) img = background这段代码创建一个白色背景图,然后用原图的 alpha 通道作为掩码把原图贴上去。透明区域自然显示为白色。
5.6 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 边缘黑边 | 向上取整未钳制边界 | 改用 divmod 动态分配 |
| 切块重叠 | 右边界多加像素 | 检查左闭右开逻辑 |
| 切块有缝隙 | 右边界少减像素 | 右边界等于下一块左边界 |
| 内存爆满 | 累积图像对象 | 流式处理,及时 close |
| 文件名冲突 | 同名原图覆盖 | 两级目录或加父目录前缀 |
| 透明变黑 | RGBA 转 RGB | 合成白色背景 |
| 处理速度慢 | JPEG 质量过高 | 降到 85-90 |
| 进度无显示 | 未加进度条 | 用 tqdm 包装循环 |
避坑技巧:在正式处理大批量图片前,先用 5 张图片做测试,检查输出目录结构、切块尺寸、边缘处理是否符合预期。确认无误后再跑全量,能避免大量返工。
6. 扩展玩法与进阶思路
6.1 重叠切块用于图像拼接
做全景图拼接或超分辨率重建时,切块之间需要重叠区域,否则拼接缝会很明显。实现方法很简单:在计算右边界和下边界时加上重叠像素数overlap。
right = min(left + tile_w + overlap, w) lower = min(upper + tile_h + overlap, h)min确保不超出原图边界。重叠区域的大小通常是切块尺寸的 10% 到 20%,太小拼接效果不好,太大计算量浪费。
6.2 按内容自适应切分
规则网格切分不关心图片内容,但有时候我们希望切块尽量不切断主体。一个简单思路是先用边缘检测找出图像中的主要轮廓,然后调整网格线位置避开轮廓密集区域。这属于内容感知切分,实现复杂度高不少,但效果在某些场景下提升明显。
更实用的做法是结合目标检测:先用检测模型找出所有目标框,然后根据目标框分布动态规划网格线位置。这个思路在遥感图像处理和医学图像分析里用得比较多。
6.3 切块后批量数据增强
切块用于深度学习训练时,可以在切分的同时做数据增强。比如随机翻转、随机旋转小角度、随机调整亮度对比度。Pillow 的ImageOps和ImageEnhance模块提供了这些功能,几行代码就能集成到切分流程里。
但要注意:增强后的切块如果用于目标检测任务,标注框坐标也要同步变换。这是另一个话题了,展开讲能写一整篇。
6.4 用多进程加速批量处理
Python 的 GIL 限制了多线程在 CPU 密集型任务上的效果,但多进程可以绕过这个限制。用concurrent.futures.ProcessPoolExecutor把图片列表分成多份,每个进程处理一部分,速度能提升 3 到 4 倍(取决于 CPU 核心数)。
from concurrent.futures import ProcessPoolExecutor def process_single(img_path): # 切分逻辑 pass with ProcessPoolExecutor(max_workers=4) as executor: executor.map(process_single, image_files)注意多进程下每个进程有独立内存空间,不能共享图像对象。每个进程需要自己打开和关闭图片。另外,Windows 系统下多进程需要把主逻辑放在if __name__ == '__main__':块里,否则会无限递归创建进程。
我在实际使用中发现,多进程加速在图片数量超过 200 张时效果明显,少于 50 张时进程创建和通信的开销反而拖慢速度。所以小批量任务直接用单进程就好,别为了用而用。
最后分享一个小技巧:如果你经常需要调整网格参数做对比实验,把切分逻辑封装成命令行工具会方便很多。用argparse接收输入目录、输出目录、行数、列数、输出质量等参数,一行命令就能跑不同配置,不用每次改代码。这个工具我用了两年多,处理过几十万张图片,稳定性经过验证。