Python图片处理:Pillow与NumPy常用函数实战与避坑指南 1. 写在前面为什么搞懂这几个函数就够了说到用Python做图片处理很多人第一反应就是OpenCV然后去找教程噼里啪啦装了一堆库结果第一行import cv2就报错。其实日常处理图片Pillow numpy这对组合就够用了而且轻量、好上手几乎不会遇到依赖地狱。OpenCV更适合做检测、跟踪、特征匹配这些偏视觉算法的任务如果只是改尺寸、裁剪、加水印、转灰度、批量处理Pillow加几个基础函数完全能搞定。这篇笔记我按自己的实际使用经验把Python图片处理里最常用的函数整理成一份“速查避坑”合集。每个函数我都会讲清楚它到底是干什么的、参数怎么理解、实际用的时候容易踩什么坑。最后还会给两个可以直接抄的实战脚本一篇看下来你自己就能写批量处理图片的小工具了。适合谁看刚入门Python、想在项目里快速处理图片的人以及把图片处理当辅助技能、不想在视觉算法里陷太深的同学。我以前带过不少新人发现大家卡住的点往往不是函数本身而是“不知道该用哪个函数”“参数填什么”和“为什么按教程写还是报错”这些我会重点写。2. 环境准备与工具选型别一上来就装OpenCV2.1 Pillow还是OpenCV按任务选工具先把我真实的建议说清楚。PillowPIL分支出来的维护版本是Python里最老牌的图片库接口设计得很直观读图、缩放、滤镜、保存都是“一句话”的事。OpenCV功能确实强但它的设计目标是计算机视觉很多函数参数又多又底层对只想把图片改一下的使用者来说学习成本不划算。我自己常用的分配方式是任务推荐工具原因读取、保存、格式转换Pillow / OpenCV均可都很简单选Pillow更直白缩放、裁剪、旋转、翻转Pillow接口清晰坐标逻辑好理解亮度、对比度、饱和度调整PillowImageEnhance一行搞定灰度化、二值化、边缘检测OpenCV算法函数更丰富、速度更快人脸检测、物体识别OpenCV有训练好的分类器与深度模型图像拼接、批量处理Pillow numpy代码量少逻辑清晰简单说Pillow负责“改图”OpenCV负责“看图”。如果你拿到的任务核心是“把图片变成另一种图片”优先用Pillow如果是“从图片里找出什么东西、判断什么内容”再考虑OpenCV。这样选型你十有八九不会走弯路。有人会问那matplotlib呢matplotlib的plt.imread也能读图但它的强项是画图不是改图把图读进来还得靠Pillow或OpenCV转型多绕一步没必要。等你要可视化对比处理效果时再用它不迟。2.2 安装和验证跨过最基础的坑安装很简单但我在各种机器上帮人处理过环境问题发现“装不上”绝大多数时候不是因为网络而是pip本身出了问题。最经典的是在命令行窗口输入pip install pillow结果提示“pip 不是内部或外部命令也不是可运行的程序或批处理文件”这个热搜词里反复出现确实是新人高频翻车点。我推荐两条路绕开它# 方式一用python -m pip调用绕过pip命令找不到的问题推荐 python -m pip install pillow python -m pip install opencv-python python -m pip install numpy # 方式二如果你登录了国内镜像可以加速 python -m pip install pillow -i https://pypi.tuna.tsinghua.edu.cn/simple为什么用python -m pip因为这条命令会明确告诉Python解释器“去site-packages里找pip模块运行”只要你的python能跑pip就一定能被找到完全不依赖PATH环境变量。如果你连python命令都提示找不到那大概率是安装时没勾选“Add Python to PATH”重新跑一遍安装包勾上就行或者用安装包自带的完整路径运行。装完记得验证一下别急着写业务代码from PIL import Image import cv2 import numpy as np print(Image.__version__) # Pillow版本 print(cv2.__version__) # OpenCV版本 print(np.__version__) # numpy版本这里有个历史遗留坑老教程会写import Image但现在的Pillow要求from PIL import Image少写前面的PIL包名直接“No module named Image”。看到这个报错第一反应不是去装Image而是检查是不是漏了PIL。另外新版Pillow和OpenCV对Python版本都有要求建议Python 3.8以上太低版本装新库容易触发编译错误不想折腾就把版本升上去。3. 图片读写与基本信息获取一切处理的起点3.1 三种读取方式通道顺序是重点图片处理的第一个动作一定是“把图片读进来”。Pillow、OpenCV、matplotlib都能读但三种方式返回的数据结构不一样这个差异决定了你后续代码怎么写。Pillow读图返回的是Image对象它有很多方便的方法比如.size、.convert()、.resize()OpenCV读图返回的是numpy数组形状是(高, 宽, 通道数)而且通道顺序是BGRmatplotlib读图也是numpy数组通道顺序是RGB。如果混着用没做转换就会出现“图片颜色怪怪的”的经典现象尤其是红蓝互换。我平时最顺手的方式是from PIL import Image import numpy as np # 方式一Pillow读进来保持Image对象操作 img Image.open(test.jpg) print(img.size) # (宽, 高)注意Pillow先宽后高 print(img.mode) # RGB / L / RGBA等 # 方式二转成numpy数组方便做像素级运算 arr np.array(img) print(arr.shape) # (高, 宽, 3)这里又是先高后宽这里建议背下来一个结论Pillow的.size是(width, height)numpy数组的shape是(height, width, channels)这两个顺序搞反是低级错误里出现频率最高的。写代码时养成习惯拿到图片先打印size和shape看一眼再动手。如果你用OpenCV读图import cv2 img_bgr cv2.imread(test.jpg) print(img_bgr.shape) # (高, 宽, 3) # 需要转回RGB再显示或处理吗看场景 img_rgb cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)只要记住OpenCV读图默认BGR用其他库显示或保存时记得转。如果不转直接cv2.imwrite保存倒是没关系因为imwrite按BGR存回去颜色还是对的但一旦用Pillow的Image.fromarray转成Image对象或者用plt.imshow显示颜色立马变蓝红调。很多人在这里排查半天其实是通道顺序的问题。3.2 拿到图片先问三句话图片读进来之后我建议先问三句话什么尺寸什么模式什么数据类型这三句对应的代码非常高频分别回答“图有多大”“颜色是RGB还是灰度还是带透明通道”“像素值范围是不是0到255”。from PIL import Image import numpy as np img Image.open(photo.jpg) print(尺寸:, img.size) # 例(1920, 1080) print(模式:, img.mode) # RGB / L / RGBA / CMYK arr np.array(img) print(数组形状:, arr.shape) # 例(1080, 1920, 3) print(像素类型:, arr.dtype) # uint8一般就是0~255的整数为什么要关心mode因为modeL是灰度图只有一个通道如果你拿它做Channel操作会直接报维度错modeRGBA有四个通道多一个透明度Alpha存成jpg会把Alpha丢掉存成png才能保留。为什么要关心dtype因为很多算法函数要求输入是float32直接拿uint8去算会出现溢出比如像素值255加1变成0这个等做像素级操作时非常恼火。还有一个细节Pillow的Image.open()其实是延迟加载的刚打开时并不会真把完整图片数据加载到内存而是在第一次调用.load()或转换成数组、保存时才真正解码。所以处理大批量图片时不要让Image对象一直占着变量不放用完就及时转数组或del掉否则内存会被大量图片撑爆。我做批量图片抽取时遇到过几次内存飙升后来养成习惯单张处理完立即del img再配合gc.collect()稳了很多。4. 几何变换缩放、裁剪、旋转、翻转4.1 resize和thumbnail一个改尺寸一个改“比例”缩放图片是最高频的操作之一。Pillow里有两个容易混淆的函数resize和thumbnail。resize是你指定目标宽和高直接把图片拉伸或压缩到那个尺寸不管原图比例。但如果你只指定一个方向另一个方向用-1在OpenCV里可以自动等比在Pillow里则没有这个特性你需要自己算比例。from PIL import Image img Image.open(test.jpg) # 强行改成800x600图片可能变形 img_resized img.resize((800, 600)) # 等比缩放宽度改成800高度按比例算 w, h img.size new_w 800 new_h int(h * new_w / w) img_resized img.resize((new_w, new_h))thumbnail则是“缩小且保持比例”而且它的特点是只可能缩小不可能放大原地修改Image对象。它的参数是一个“最大尺寸的框”图片会被等比缩放到恰好放进这个框内。img Image.open(test.jpg) img.thumbnail((400, 400)) # 直接改img尺寸不会超过400x400且比例不变 print(img.size)实际做网站缩略图、微信头像压缩、商品图裁剪时我基本都用thumbnail很少用resize因为用户上传的图片比例千奇百怪等比缩略永远不会被骂“变形”。再说说OpenCV的resize它是另一个写法import cv2 img cv2.imread(test.jpg) resized cv2.resize(img, (800, 600)) # 指定尺寸 resized_fx cv2.resize(img, None, fx0.5, fy0.5) # 按系数缩放关于缩放的一个提醒小图放大会模糊这是信息缺失任何库都无法真正“补”出细节。如果必须放大可以试试cv2.INTER_CUBIC或者Pillow里resampleImage.LANCZOS视觉效果会好一点点但也只是“好一点点”。我的态度是能在拍摄或生成阶段保证分辨率就别指望后期放大救回来。4.2 crop、rotate、transpose的坐标与参数细节裁剪的坑主要在坐标系理解上。Pillow的crop接收一个四元组(left, upper, right, lower)分别是左、上、右、下坐标原点在左上角而且是左闭右开区间。很多人写(0, 0, w, h)是没问题的但一旦要裁剪中间区域就经常把位置搞反。from PIL import Image img Image.open(test.jpg) # 裁剪出中间200x200的区域 w, h img.size left (w - 200) // 2 upper (h - 200) // 2 right left 200 lower upper 200 box (left, upper, right, lower) cropped img.crop(box)记住一个判断技巧right和lower是“不包含”的边界所以不存在“边界少一像素”需要头疼的问题。想裁剪右上区域就把left设为w的一半right设为w想保留透明通道crop会原样保留模式。旋转用rotate重点参数是expand。默认expandFalse时旋转后Canvas大小不变图像超出部分会被裁剪掉图片边角变成背景色设置expandTrue时画布会跟着旋转后的外接矩形扩展开图片内容完整保留但尺寸变大且多出来的区域默认填黑色可以通过fillcolor改成白色或别的颜色。img Image.open(test.jpg) # 顺时针转90度Pillow里角度是逆时针的负号表示顺时针 img_rot img.rotate(-90, expandTrue)翻转用transpose参数只有两个常用值Image.FLIP_LEFT_RIGHT是水平翻转镜子效果Image.FLIP_TOP_BOTTOM是垂直翻转。注意不是Image.FLIP_TOP_BOTTOM缩写成FLIP_Y之类别记错。OpenCV里对应的是cv2.flip(img, 1)第二个参数0表示上下翻转1表示左右翻转负数表示两者都做。这个参数设计容易被新手搞混我的记忆口诀是“0竖1横负号双翻”。5. 颜色空间与通道操作灰度、RGB拆分与合并5.1 灰度化公式和convert(L)灰度化就是把彩色图变成黑白图。别小看这个操作很多后续算法边缘检测、阈值分割、模板匹配都默认先转灰度因为减少了一个维度计算量大幅下降而且很多特征在灰度下已经很清晰。Pillow一行就能转img Image.open(test.jpg) gray img.convert(L) gray.save(gray.jpg)convert(L)的L代表Luminance亮度它内部用的是这个加权公式Y 0.299 * R 0.587 * G 0.114 * B记住这个公式面试或写算法时经常用到。它本质是在说人眼对绿色最敏感、对蓝色最不敏感所以绿色分量权重最大蓝色权重最小。如果自己用numpy实现这个转换千万别用三个通道取平均(RGB)/3那样出来的灰度图灰蒙蒙的丢失了人眼的感知特点。OpenCV里灰度化是import cv2 img cv2.imread(test.jpg) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)注意OpenCV的灰度化结果和Pillow的convert(L)在数值上略有差异因为OpenCV默认用的Rec.601系数、舍入方式不同但对绝大多数业务场景没影响。追求像素级完全一致时直接用numpy自己按公式算最可控。5.2 split/merge通道操作与BGR坑彩色图本质是三个通道叠加。有时你需要单独操作某个通道比如“把天空做得更蓝”“去掉图片里的红色偏色”这时就要拆通道。OpenCV里拆通道再合并非常直观import cv2 img cv2.imread(test.jpg) b, g, r cv2.split(img) # 注意顺序是B, G, R # 对通道做点操作 b cv2.add(b, 30) # 蓝色通道统一加30图片偏蓝 result cv2.merge([b, g, r])Pillow里拆通道用split()返回三个独立灰度Image合并用merge(RGB, (r, g, b))from PIL import Image img Image.open(test.jpg) r, g, b img.split() # 重新合并但把R和B交换会出现蓝红互换的效果 img_swap Image.merge(RGB, (b, g, r))我发现很多人在自写看图程序时会把Pillow读出来的RGB数组直接交给OpenCV处理结果颜色偏蓝偏红。原因就是上面说的通道顺序。解决方案有两个要么只用Pillow处理要么在转成数组后手动调整通道顺序import cv2 from PIL import Image import numpy as np img Image.open(test.jpg) arr_rgb np.array(img) # 按OpenCV期望的BGR顺序重新排列 arr_bgr cv2.cvtColor(arr_rgb, cv2.COLOR_RGB2BGR)通道操作还常用来做简单的颜色区域筛选比如红灯检测分离出红色通道再加一个高阈值就能把“很红”的像素圈出来。这类技巧做工业视觉、交通信号识别时非常常用但注意光照变化影响很大实际项目里要做归一化或直方图均衡那是另一个话题了。6. 滤镜与增强亮度、对比度、模糊和边缘6.1 ImageEnhance快速调亮度对比度把一张灰蒙蒙的照片调亮或者把过度曝光的图片拉回来这类需求用Pillow的ImageEnhance模块体验可以用“丝滑”来形容。from PIL import Image, ImageEnhance img Image.open(dark.jpg) # 亮度增强1.0是原始状态小于1变暗大于1变亮 enhancer ImageEnhance.Brightness(img) bright_img enhancer.enhance(1.3) # 对比度增强同样1.0为原始 enhancer2 ImageEnhance.Contrast(img) contrast_img enhancer2.enhance(1.5) # 颜色饱和度增强 enhancer3 ImageEnhance.Color(img) color_img enhancer3.enhance(1.2) # 锐度 enhancer4 ImageEnhance.Sharpness(img) sharp_img enhancer4.enhance(2.0) bright_img.save(bright.jpg)参数理解很简单enhance(1.0)就是原图1加强1减弱。这个模块底层也只是对像素做线性变换但封装得好代码很干净。我经常在一个批量工具里写个循环给一组图片统一调亮度比如把所有偏暗的手机照片提亮到1.2倍效果稳定且速度快。有一类场景要特别注意JPG的亮度提升会放大噪点。如果照片本身是在暗光下拍的JPG压缩已经让暗部出现色块和噪点用enhance拉高亮度后噪点会更明显。这种情况与其调亮度不如先用去噪滤波器再轻微提亮最后用一点锐度找回细节。处理的顺序不同最终观感差距很大。6.2 高斯模糊、Canny边缘与二值化模糊和边缘检测我一般直接用OpenCV因为函数全、速度快处理大图优势明显。高斯模糊用来去噪、磨皮、制造景深效果import cv2 img cv2.imread(test.jpg) # (5, 5)是高斯核大小必须是正奇数0表示标准差自动计算 blurred cv2.GaussianBlur(img, (5, 5), 0)高斯核越大模糊越强但运算量也变大。一个5x5的核处理1080p图片几乎瞬秒99x99就会慢起来。做预览调试时先从小核开始别一上来就上大核。边缘检测最常用的是Cannyimport cv2 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 两个阈值低于min的像素肯定不是边缘高于max的肯定是边缘中间看连接性 edges cv2.Canny(gray, 100, 200)Canny的阈值选择是很多新人的困惑点。我的经验是图片比较干净、边缘清晰用(50, 150)图片噪声多、纹理复杂用(100, 200)甚至(150, 250)。阈值高了弱边缘会被过滤阈值低了噪声会变成一堆杂乱线条。可以先用(100, 200)起步再根据结果微调。二值化就是把灰度图变成“非黑即白”。最简单的是固定阈值import cv2 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 大于127的变成255白小于等于127的变成0黑 _, binary cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)但固定阈值对光照不均匀的图片效果很差比如文档扫描件一半亮一半暗。这时候用自适应阈值adaptive cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2 )这里11是邻域大小2是常量修正值。自适应阈值的原理是每个像素的阈值由它周围一个小窗口内像素的加权平均值减去常量得到所以能适应局部的明暗变化。处理扫描件、拍歪的名片时我几乎无脑用自适应阈值效果比固定阈值好一大截。7. 实战脚本批量缩略图、加水印与图片拼接7.1 批量生成缩略图一个脚本省一天时间理论知识讲再多不如一个完整脚本。我经常遇到的情况是设计师丢给我几百张高清图让我生成一套宽度800px的缩略图。手工一张张开、缩放、另存能折磨死人。用Python写个脚本几秒钟搞定。import os from PIL import Image input_dir photos # 原始图片目录 output_dir thumbs # 缩略图输出目录 target_width 800 # 目标宽度高度等比 os.makedirs(output_dir, exist_okTrue) for filename in os.listdir(input_dir): if not filename.lower().endswith((.jpg, .jpeg, .png, .bmp)): continue img_path os.path.join(input_dir, filename) img Image.open(img_path) # 等比缩放 w, h img.size if w target_width: # 本来就小于目标宽度直接复制过去或跳过 continue new_h int(h * target_width / w) img_scaled img.resize((target_width, new_h), Image.LANCZOS) out_path os.path.join(output_dir, filename) img_scaled.save(out_path, quality90) print(f已处理 {filename}: {w}x{h} - {target_width}x{new_h})这个脚本有几个细节值得说。一是os.makedirs(output_dir, exist_okTrue)不用担心目录不存在报错二是扩展名判断用lower()避免大小写问题“.JPG”也能识别三是quality90save JPG时如果不指定quality默认是75文字边缘会出现明显锯齿放大看特别明显四是img.resize的Image.LANCZOS重采样在缩小时能保留更多细节效果比默认的最近邻插值好很多。如果图片量特别大还可以在循环里del img释放内存防止几千张图撑爆内存。7.2 文字水印与多图拼接给图片批量加个版权水印也是高频需求。Pillow里往图片上写字的核心是先建一个叠加图层再用Image.paste贴上去最后合并。如果直接往原图上draw原图的像素会被永久修改一旦画错就没法撤销。我习惯在副本上操作from PIL import Image, ImageDraw, ImageFont img Image.open(test.jpg).convert(RGBA) # 转RGBA才能有透明效果 # 建一个透明图层 overlay Image.new(RGBA, img.size, (0, 0, 0, 0)) draw ImageDraw.Draw(overlay) # 中文字体需要显式指定默认字体不支持中文否则会变方框 font ImageFont.truetype(simhei.ttf, 60) text 示例水印 # 获取文字尺寸方便居中放置 bbox draw.textbbox((0, 0), text, fontfont) text_width bbox[2] - bbox[0] text_height bbox[3] - bbox[1] x (img.width - text_width) // 2 y (img.height - text_height) // 2 draw.text((x, y), text, fill(255, 255, 255, 128), fontfont) # 半透明水印叠回原图 out Image.alpha_composite(img, overlay) out.convert(RGB).save(watermarked.jpg)关于字体新手最常踩的坑就是“中文字变成方块”。原因很简单Pillow默认字体只有英文和数字。解决方式是指定一个系统里存在的中文字体文件路径我上面写的simhei.ttf是Windows的黑体如果你在Mac上可以换成/System/Library/Fonts/PingFang.ttc或/System/Library/Fonts/STHeiti Medium.ttc。设计水印时fill(255, 255, 255, 128)里的第4个数是透明度128表示半透明这样既不影响看图又能起到版权提示作用。如果直接填255不透明水印会显得很突兀像贴了一块创可贴。多图拼接横向拼、纵向拼也一样很简单核心思想是先创建一张大画布然后用paste把图片按位置填进去from PIL import Image images [Image.open(fpic{i}.jpg) for i in range(1, 4)] widths, heights zip(*(im.size for im in images)) canvas_w sum(widths) # 横向拼接总宽是所有宽度之和 canvas_h max(heights) # 总高取最高的图 canvas Image.new(RGB, (canvas_w, canvas_h), (255, 255, 255)) x_offset 0 for im in images: canvas.paste(im, (x_offset, 0)) x_offset im.size[0] canvas.save(combined.jpg)如果要纵向拼接把canvas_w和canvas_h换一下粘贴时改y_offset就行。需要注意paste时图片和画布颜色模式最好一致不一致时先convert否则可能出现奇怪的通道错误。8. 常见报错与排查技巧实录8.1 环境问题速查表把我在实际里见过的高频报错整理成表格每一条都是真实的“翻车现场”报错信息原因解决办法pip不是内部或外部命令Python没把Scripts目录加进PATH用python -m pip代替pip或者重装Python时勾选Add to PATHModuleNotFoundError: No module named PIL没装Pillow或装错包python -m pip install pillowModuleNotFoundError: No module named cv2没装OpenCVpython -m pip install opencv-pythonAttributeError: module PIL.Image has no attribute openOpen可能拼成了小写open或者Image是文件不是模块检查文件名不要叫image.py检查from PIL import ImageOSError: cannot identify image file路径不对或文件损坏检查路径是否存在用os.path.exists验证文件可能不是有效图片TypeError: Cannot handle this data type数组dtype是float64或uint32Image.fromarray不支持先arr.astype(np.uint8)再转换中文文件名读不了OpenCV的imread对中文路径支持差用np.fromfilecv2.imdecode读取最后一行的中文路径问题是Windows用户的重灾区。很多人代码在英文路径下跑得飞起一放到“C:\用户\我的图片\测试.jpg”就返回None还以为图片损坏。OpenCV的cv2.imread确实没处理好中文路径我的通用解法是import cv2 import numpy as np # 用numpy读字节流再用imdecode解码绕开imread的路径限制 img cv2.imdecode(np.fromfile(测试/中文路径.jpg, dtypenp.uint8), cv2.IMREAD_COLOR) # 保存时也一样 cv2.imencode(.jpg, img)[1].tofile(输出/结果.jpg)8.2 运行逻辑上最容易翻车的几个点环境问题看表格能解决大部分但运行逻辑上的坑更隐蔽经常是“代码没报错但结果不对”。我挑了三个频率最高的。第一个是坐标顺序。前面反复提过Pillow的size是(宽, 高)numpy数组的shape是(高, 宽)OpenCV的shape也是(高, 宽)。写循环遍历像素时先写哪个维度决定了你的遍历方向。比如for y in range(h): for x in range(w):是先按行扫for x in range(w): for y in range(h):是先按列扫。搞反了之后校验、裁剪、缩放的区域全错。我的建议是所有图像处理代码的开头先写一行注释注明当前变量是(width, height)还是(height, width)别嫌丑关键时刻能救命。第二个是像素值越界和类型溢出。numpy数组如果是uint8直接做arr 100超过255的部分会循环回绕255加1变成0图片出现大量黑点白点。正确做法是把数组先转成int16或float运算完再np.clip(arr, 0, 255).astype(np.uint8)。我看过好几个人用numpy调亮度出来的图全是斑点就是这个原因。第三个是延迟加载和资源释放。Pillow的Image.open不立即读入全部像素如果你在循环里把几百个Image对象保存在列表里内存占用会非常吓人。处理完一张后立即del img并gc.collect()或者用with Image.open(...) as img的方式确保用完就关。做批量任务时这两行代码能让你从内存崩溃边缘稳住。另外如果图片尺寸非常大比如几亿像素的扫描图连Pillow都扛不住时可以考虑Image.open后先thumbnail再load只加载缩略部分不碰原始全分辨率。写在最后的一点个人体会最后想分享一个我自己的习惯不管脚本多简单我都会把处理函数封装成def参数尽量单独定义比如def process_image(input_path, output_path, target_width800, quality90)。一开始图省事直接写成一坨脚本后来改需求时痛苦得想死——宽度改一下、输出目录改一下要在好几处代码里同步改还容易漏。封装成函数之后再配合个配置文件或命令行参数改起来就轻松多了。另外图片处理一定要养成“分步调试”的习惯。先读进来看一眼原图处理一步保存一版最后再拼到一起。别指望一次性写出完美代码绝大多数时候都会出点小问题分步看中间结果一分钟就能定位是哪一步出了问题。做批量处理前建议先拿两三张图试跑确认效果符合预期再丢全量数据别一上来就全跑万一逻辑写反几千张图全毁了重新处理倒是小事用户给你的时间可不会重来。这套函数组合我用了很多年从简单的爬虫验证码识别预处理到上万张商品图的统一缩略与加水印一直稳得很。希望这篇笔记能让你省下一些绕弯的时间。