「用 Python 处理一张图」和「用 Python 处理十万张图」是两个完全不同的问题。前者只要 API 会调,后者要求你理解像素在内存中的排布、编解码器的代价、以及什么时候该换工具。
Python 图像处理有两个绕不开的库:Pillow(PIL 的现代分支)偏「图像文件与编辑」,OpenCV 偏「计算机视觉算法与矩阵运算」。它们共享 NumPy 这个数据层,因此常常配合使用。本文先讲清楚像素与色彩的基础模型,再分别拆解两个库的核心能力,最后落到批量处理与性能优化的工程细节。
1. 图像基础模型
1.1 像素、通道与色彩模式
数字图像本质是一个多维数组。一张 1920×1080 的 RGB 图片,在内存中是形状 (1080, 1920, 3) 的 uint8 数组——注意高度在前、宽度在后,这与「宽×高」的日常说法相反,是最常见的索引 bug 来源。
| 模式 | 通道数 | 用途 |
|---|---|---|
L | 1 | 灰度图 |
RGB | 3 | 屏幕显示、通用处理 |
RGBA | 4 | 带透明通道 |
CMYK | 4 | 印刷 |
I / F | 1 | 32 位整型/浮点(中间计算) |
P | 1 | 调色板索引(GIF) |
uint8 的取值范围是 0~255,做加法、乘法时极易溢出回绕(250 + 10 = 4)。凡是涉及亮度调整、卷积这类运算,都应先转成 float32 或 int16,算完再裁剪并转回。
1.2 颜色空间
| 空间 | 分量 | 优势 |
|---|---|---|
| RGB | 红绿蓝 | 显示原生,通道强相关 |
| HSV | 色相/饱和度/明度 | 按颜色分割更直观 |
| LAB | 亮度 + a/b | 感知均匀,色差计算 |
| YCbCr | 亮度 + 色度 | JPEG 压缩基础 |
| GRAY | 单通道 | 边缘/阈值处理 |
在 RGB 里做「按颜色筛选」要同时约束三个通道,在 HSV 里只需约束色相一个区间。这是颜色分割任务先转 HSV 的根本原因。
2. Pillow 核心能力
2.1 打开、转换与保存
from PIL import Image
with Image.open("input.jpg") as im:
print(im.size, im.mode, im.format) # (1920, 1080) RGB JPEG
gray = im.convert("L")
gray.save("out.png")
Image.open 是惰性的:它只读文件头,真正的像素数据要等第一次访问才解码。因此必须用 with 保证文件句柄释放,或者显式调用 im.load() 把数据读进内存。忘记关闭是处理大量图片时句柄耗尽的原因。
格式选择直接影响体积与质量:
im.save("out.jpg", quality=85, optimize=True, progressive=True)
im.save("out.webp", quality=80, method=6)
im.save("out.avif", quality=70) # 需 pillow-avif-plugin
| 格式 | 压缩 | 透明 | 适用 |
|---|---|---|---|
| JPEG | 有损 | 否 | 照片 |
| PNG | 无损 | 是 | 截图、图标 |
| WebP | 两者皆可 | 是 | Web 通用 |
| AVIF | 有损,体积最小 | 是 | 现代 Web |
| TIFF | 无损 | 是 | 印刷、存档 |
2.2 缩放与重采样
# 缩放到固定尺寸(可能变形)
im.resize((800, 600), Image.Resampling.LANCZOS)
# 等比缩放到不超过 800x600 的缩略图
im.thumbnail((800, 600), Image.Resampling.LANCZOS)
# 按比例缩放
w, h = im.size
im.resize((w // 2, h // 2), Image.Resampling.BICUBIC)
重采样滤波器(Resampling Filter)的选择有明确取舍:
| 滤波器 | 放大 | 缩小 | 说明 |
|---|---|---|---|
NEAREST | ✅ | ❌ | 最快,像素风 |
BILINEAR | 一般 | 一般 | 速度质量平衡 |
BICUBIC | 好 | 好 | 通用推荐 |
LANCZOS | 最好 | 最好 | 最慢,缩略图首选 |
缩小时用 LANCZOS 能显著减少摩尔纹;放大时超过 2 倍建议先 LANCZOS 再配合锐化。thumbnail 会原地修改并保持宽高比,且不会放大(若原图小于目标尺寸则不变),是生成缩略图的正解。
2.3 裁剪、旋转与合成
box = (100, 100, 500, 400) # (left, upper, right, lower)
crop = im.crop(box)
rotated = im.rotate(45, expand=True, resample=Image.Resampling.BICUBIC)
flipped = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
# 粘贴带蒙版
canvas = Image.new("RGB", (1920, 1080), "white")
canvas.paste(crop, (0, 0), mask=alpha_channel)
crop 的 box 是左闭右开的:(0, 0, 100, 100) 得到 100×100 的图。expand=True 让旋转后自动扩大画布避免裁切。paste 的第三参数是蒙版,实现不规则形状的叠加。
2.4 滤镜与增强
from PIL import ImageFilter, ImageEnhance, ImageOps
blurred = im.filter(ImageFilter.GaussianBlur(radius=2))
edges = im.filter(ImageFilter.FIND_EDGES)
sharp = im.filter(ImageFilter.UnsharpMask(radius=2, percent=150, threshold=3))
bright = ImageEnhance.Brightness(im).enhance(1.2)
contrast = ImageEnhance.Contrast(bright).enhance(1.1)
saturated = ImageEnhance.Color(contrast).enhance(1.3)
auto = ImageOps.autocontrast(im, cutoff=1) # 自动对比度拉伸
ImageOps 里还有几个高频工具:grayscale、invert、posterize、equalize、contain(等比缩放到框内)、pad(补齐到指定尺寸)。
2.5 绘制文字与图形
from PIL import ImageDraw, ImageFont
draw = ImageDraw.Draw(im)
draw.rectangle([10, 10, 200, 60], outline="red", width=3)
draw.line([(0, 0), (100, 100)], fill="blue", width=2)
draw.ellipse([300, 300, 400, 400], fill=(255, 200, 0, 128))
font = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 32)
draw.text((20, 20), "中文水印", font=font, fill="white", stroke_width=2, stroke_fill="black")
# 文本尺寸测量
bbox = draw.textbbox((0, 0), "文本", font=font)
中文字体必须显式加载 truetype,默认位图字体不支持中文(会显示方框)。textbbox 用于计算居中位置;anchor="mm" 可直接指定锚点为居中。
2.6 EXIF:最容易被忽略的方向问题
手机拍照时传感器方向固定,方向信息写在 EXIF 的 Orientation 标签里。不做处理就会出现「缩略图和原图方向不一致」的经典 bug:
from PIL import ImageOps, ExifTags
with Image.open("phone.jpg") as im:
im = ImageOps.exif_transpose(im) # 按 EXIF 自动旋转并清除该标签
im.save("fixed.jpg")
exif_transpose 一步解决旋转与镜像,且会修正 EXIF 本身,避免二次处理时重复旋转。处理用户上传图片时,这一步必须放在所有其它操作之前。
# 读取全部 EXIF
exif = im.getexif()
for tag_id, value in exif.items():
print(ExifTags.TAGS.get(tag_id, tag_id), value)
隐私提示:EXIF 里常含 GPS 坐标与设备型号。对外发布图片前应主动剥离:
def strip_exif(im: Image.Image) -> Image.Image:
data = list(im.getdata())
clean = Image.new(im.mode, im.size)
clean.putdata(data)
return clean
2.7 多帧图像与文档
Pillow 还能处理 GIF 动画、多页 TIFF 与 PDF:
# 拆分 GIF 帧
with Image.open("anim.gif") as im:
for i in range(im.n_frames):
im.seek(i)
im.convert("RGBA").save(f"frame_{i:03d}.png")
# 合并为 GIF
frames = [Image.open(f"frame_{i:03d}.png") for i in range(10)]
frames[0].save("out.gif", save_all=True, append_images=frames[1:],
duration=100, loop=0)
# PDF 逐页转图
with Image.open("doc.pdf") as pdf:
for i in range(len(pdf.pages)):
page = pdf.pages[i] if hasattr(pdf, "pages") else pdf
page.convert("RGB").save(f"page_{i}.jpg", quality=90)
| 需求 | 方法 | 注意 |
|---|---|---|
| GIF 拆帧 | seek(i) + convert("RGBA") | 帧间可能有局部更新 |
| GIF 合成 | save(save_all=True, loop=0) | 需统一尺寸与模式 |
| 多页 TIFF | 同上 seek 机制 | 大文件建议分页处理 |
| PDF 转图 | Image.open + 逐页 | 需安装 pypdfium2/pdf2image |
多帧图像的一个坑是局部更新帧:GIF 为省体积只存变化区域,直接 convert 可能得到残缺画面。稳妥做法是用 ImageSequence.Iterator 配合 im.copy() 逐帧取完整画面。
3. OpenCV 核心能力
3.1 读写与颜色通道差异
import cv2
import numpy as np
img = cv2.imread("input.jpg") # 默认 BGR!
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
cv2.imwrite("out.jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 90])
OpenCV 的默认通道顺序是 BGR,而 Pillow、matplotlib、浏览器都是 RGB。忘记转换会导致红蓝互换——这是 OpenCV 新手最常见的事故。统一原则:只在显示/保存/交给其它库时转换,内部处理全程用一种顺序。
3.2 几何变换
h, w = img.shape[:2]
resized = cv2.resize(img, (800, 600), interpolation=cv2.INTER_AREA) # 缩小用 AREA
M = cv2.getRotationMatrix2D((w / 2, h / 2), 45, 1.0)
rotated = cv2.warpAffine(img, M, (w, h))
# 透视校正(四点映射)
src = np.float32([[56, 65], [368, 52], [28, 387], [389, 390]])
dst = np.float32([[0, 0], [300, 0], [0, 300], [300, 300]])
Mp = cv2.getPerspectiveTransform(src, dst)
warped = cv2.warpPerspective(img, Mp, (300, 300))
| 场景 | 插值方法 |
|---|---|
| 放大 | INTER_LINEAR / INTER_CUBIC |
| 缩小 | INTER_AREA(抗摩尔纹最佳) |
| 保持边缘 | INTER_LANCZOS4 |
3.3 颜色空间与阈值
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
# 提取蓝色区域
lower = np.array([100, 100, 50])
upper = np.array([130, 255, 255])
mask = cv2.inRange(hsv, lower, upper)
# 自适应阈值(光照不均时优于全局阈值)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
th = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, 11, 2)
# Otsu 自动阈值
_, otsu = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
inRange 返回单通道掩码,配合 bitwise_and 可抠出目标区域。HSV 中 H 的范围是 0179(不是 0360),这是 OpenCV 的历史包袱,写阈值时极易搞错。
3.4 滤波、边缘与形态学
blur = cv2.GaussianBlur(img, (5, 5), 0)
median = cv2.medianBlur(img, 5) # 椒盐噪声首选
edges = cv2.Canny(gray, 50, 150) # 双阈值滞后
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
opened = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) # 去小噪点
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 补小孔洞
Canny 的两个阈值是「滞后阈值」:高于高阈值的确定是边缘,低于低阈值的确定不是,介于两者之间的仅当与确定边缘相连时才保留。典型比例是 1:2 或 1:3。
3.5 轮廓与检测
contours, _ = cv2.findContours(opened, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for c in contours:
area = cv2.contourArea(c)
if area < 100:
continue
x, y, bw, bh = cv2.boundingRect(c)
cv2.rectangle(img, (x, y), (x + bw, y + bh), (0, 255, 0), 2)
# 多边形逼近
peri = cv2.arcLength(c, True)
approx = cv2.approxPolyDP(c, 0.02 * peri, True)
findContours 在 OpenCV 4 中返回 (contours, hierarchy) 二元组(OpenCV 3 返回三元组,网上老代码会报错)。RETR_EXTERNAL 只要最外层轮廓,CHAIN_APPROX_SIMPLE 压缩水平/垂直线段以省内存。
3.6 视频与摄像头
cap = cv2.VideoCapture(0) # 0 = 默认摄像头,或传文件路径
while cap.isOpened():
ok, frame = cap.read()
if not ok:
break
cv2.imshow("preview", frame)
if cv2.waitKey(1) & 0xFF == ord("q"):
break
cap.release()
cv2.destroyAllWindows()
waitKey 既是按键检测也是刷新事件循环,& 0xFF 用于跨平台兼容(部分系统返回 32 位键码)。
4. Pillow 与 NumPy 互转
两个库通过 NumPy 桥接,这是混用它们的关键:
import numpy as np
from PIL import Image
import cv2
# PIL -> ndarray
arr = np.asarray(im) # 只读视图
arr_w = np.array(im) # 可写副本
# ndarray -> PIL
im2 = Image.fromarray(arr) # 要求 uint8 且形状 (H,W) 或 (H,W,3/4)
# OpenCV(BGR) <-> PIL(RGB)
def cv2_to_pil(bgr: np.ndarray) -> Image.Image:
return Image.fromarray(cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB))
def pil_to_cv2(im: Image.Image) -> np.ndarray:
return cv2.cvtColor(np.array(im), cv2.COLOR_RGB2BGR)
| 操作 | 用哪个 | 理由 |
|---|---|---|
| 读写文件、格式转换 | Pillow | 格式支持最全,含 EXIF |
| 几何变换、滤镜 | 两者皆可 | Pillow 接口更简洁 |
| 矩阵运算、阈值、轮廓 | OpenCV / NumPy | Pillow 无对应能力 |
| 深度学习预处理 | NumPy + OpenCV | 需精确控制插值与归一化 |
| 生成缩略图服务 | Pillow | thumbnail 已优化 |
一个常见的性能陷阱是「Pillow → NumPy → Pillow」来回转换。每次转换都会复制整块像素内存,1080p 图约 6MB,十万张就是 600GB 的内存拷贝。因此批量流程应尽量在一端完成全部处理再转出。
5. 批量处理与性能
5.1 批量流水线
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
def make_thumb(src: Path, dst: Path, size=(320, 320)) -> Path:
with Image.open(src) as im:
im = ImageOps.exif_transpose(im)
im.thumbnail(size, Image.Resampling.LANCZOS)
im.convert("RGB").save(dst, "WEBP", quality=80, method=6)
return dst
def batch(files: list[Path], outdir: Path, workers: int = 8) -> None:
outdir.mkdir(parents=True, exist_ok=True)
tasks = [(f, outdir / f"{f.stem}.webp") for f in files]
with ProcessPoolExecutor(max_workers=workers) as pool:
for _ in pool.map(lambda a: make_thumb(*a), tasks):
pass
为什么用 ProcessPoolExecutor 而不是线程池?因为 Pillow 的解码/缩放是 C 层操作,但大部分会持有 GIL,多线程收益有限;而进程池能真正并行利用多核。这与 Python 并发与性能
中「CPU 密集用多进程、IO 密集用多线程」的判断完全一致。
5.2 内存与速度优化
| 手段 | 收益 | 代价 |
|---|---|---|
draft() 预降采样 JPEG | 解码提速 2~4 倍 | 仅 JPEG,精度略降 |
Image.MAX_IMAGE_PIXELS 限制 | 防解压炸弹 | 超大图需显式放行 |
直接 resize 而不先 load | 省一次全尺寸解码 | 需配合 draft |
| 用 WebP 而非 PNG | 体积降 30~60% | 老浏览器兼容 |
关闭 optimize | 保存更快 | 体积略大 |
| 流式处理而非全量读入 | 内存恒定 | 实现更复杂 |
# JPEG 快速缩略图:让解码器直接输出接近目标尺寸
with Image.open("big.jpg") as im:
im.draft("RGB", (400, 400)) # 解码器按 1/2、1/4、1/8 降采样
im = ImageOps.exif_transpose(im)
im.thumbnail((400, 400), Image.Resampling.LANCZOS)
draft 是 JPEG 独有的加速开关,能让解码阶段的输出直接是目标尺寸的近似值,随后 thumbnail 只需做一次小幅缩放。处理大量手机照片时提速非常明显。
5.3 安全:解压炸弹与恶意文件
from PIL import Image
Image.MAX_IMAGE_PIXELS = 50_000_000 # 约 50MP,超过抛 DecompressionBombError
一张几 KB 的 PNG 可以解压成几 GB 的像素数据(解压炸弹),足以打满内存。对用户上传的图片,必须设置像素上限,并结合文件大小、超时与沙箱隔离。Image.open 对损坏文件可能抛 UnidentifiedImageError、OSError,务必捕获并转成业务错误。
from PIL import UnidentifiedImageError
def safe_open(path: str) -> Image.Image | None:
try:
im = Image.open(path)
im.verify() # 校验完整性(会消耗句柄,需重新打开)
return Image.open(path)
except (UnidentifiedImageError, OSError):
return None
5.4 与 AI 流程的衔接
图像处理常常是模型推理的前置步骤:裁剪出目标区域、统一尺寸、归一化到 [0, 1]、转成张量。这部分与 Python 数据与 AI
中的数据处理链路直接衔接,也与 计算机视觉概览
里讨论的任务范式(分类/检测/分割)对应——预处理做错,再好的模型也救不回来。若任务本身属于视觉范畴,建议先通读 计算机视觉专题
明确技术选型,再回到本文解决工程实现细节。
def preprocess(bgr: np.ndarray, size=(224, 224)) -> np.ndarray:
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
resized = cv2.resize(rgb, size, interpolation=cv2.INTER_LINEAR)
x = resized.astype(np.float32) / 255.0
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
x = (x - mean) / std
return x.transpose(2, 0, 1)[None, ...] # NCHW
归一化参数必须与训练时完全一致(ImageNet 的 mean/std 或模型自带参数),顺序必须是 HWC→CHW,否则推理结果会莫名其妙地差。
6. 实战:缩略图服务
6.1 需求与设计
一个典型的缩略图服务需要:按需生成、多尺寸缓存、格式协商、并发安全、失败降级。
from pathlib import Path
from PIL import Image, ImageOps
SIZES = {"sm": (160, 160), "md": (640, 640), "lg": (1280, 1280)}
def thumbnail(src: Path, cache: Path, size_key: str, fmt: str = "webp") -> Path:
w, h = SIZES[size_key]
dst = cache / f"{src.stem}_{size_key}.{fmt}"
if dst.exists() and dst.stat().st_mtime >= src.stat().st_mtime:
return dst # 缓存命中且不过期
with Image.open(src) as im:
im.draft("RGB", (w, h)) # 加速
im = ImageOps.exif_transpose(im)
im.thumbnail((w, h), Image.Resampling.LANCZOS)
tmp = dst.with_suffix(".tmp") # 先写临时文件再原子重命名
im.convert("RGB").save(tmp, fmt.upper(), quality=82, method=6)
tmp.replace(dst)
return dst
三个关键点:缓存失效用 mtime 比较而非固定 TTL;先写临时文件再 replace 保证并发读取不会拿到半成品;draft + thumbnail 组合让大图处理不至于拖垮服务。
6.2 OCR 预处理
给 OCR 引擎(Tesseract、PaddleOCR)喂图前,二值化与去噪能显著提升识别率:
def prepare_for_ocr(bgr: np.ndarray) -> np.ndarray:
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
gray = cv2.bilateralFilter(gray, 9, 75, 75) # 保边去噪
_, th = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# 轻微膨胀,连接断裂笔画
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
return cv2.dilate(th, kernel, iterations=1)
| 预处理 | 作用 | 常见参数 |
|---|---|---|
| 灰度化 | 降维 | — |
| 双边滤波 | 去噪且保边 | d=9, sigmaColor=75, sigmaSpace=75 |
| Otsu 二值化 | 自适应阈值 | 光照不均改 adaptiveThreshold |
| 倾斜校正 | 摆正文本行 | minAreaRect 求角度后 warpAffine |
| 尺寸放大 | 小字提升识别率 | 目标高度 ≥ 32px |
6.3 常见问题排查
| 症状 | 原因 | 修复 |
|---|---|---|
| 红蓝互换 | OpenCV BGR vs RGB | cvtColor 转换 |
| 缩略图方向错 | 未处理 EXIF | ImageOps.exif_transpose |
| 文字显示成方框 | 未加载中文字体 | ImageFont.truetype |
| 亮度调整出现色块 | uint8 溢出回绕 | 转 int16/float32 |
| 内存暴涨/被 OOM kill | 解压炸弹 | MAX_IMAGE_PIXELS |
| 批量处理很慢 | 单进程 + 全尺寸解码 | 进程池 + draft |
| 保存后体积反而变大 | 用 PNG 存照片 | 改 JPEG/WebP |
小结
Python 图像处理的知识框架可以压缩成四句话:搞清模式与通道顺序(RGB/BGR/uint8 溢出),读写与元数据交给 Pillow(exif_transpose、格式协商),算法与矩阵交给 OpenCV/NumPy(HSV、阈值、轮廓),批量的瓶颈在解码与内存拷贝(进程池 + draft + 避免来回转换)。把预处理参数与训练时严格对齐、给用户上传设像素上限,是工程上最容易忽略却代价最高的两件事。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。