Python 图像处理:Pillow 与 OpenCV 实战

Python 图像处理实战:Pillow 的打开/裁剪/缩放/滤镜/绘制/EXIF 方向处理、OpenCV 的颜色空间与几何变换/阈值/轮廓、视频读取、PIL 与 NumPy 互转、批量处理与性能优化,附缩略图服务与 OCR 预处理完整示例。

「用 Python 处理一张图」和「用 Python 处理十万张图」是两个完全不同的问题。前者只要 API 会调,后者要求你理解像素在内存中的排布、编解码器的代价、以及什么时候该换工具。

Python 图像处理有两个绕不开的库:Pillow(PIL 的现代分支)偏「图像文件与编辑」,OpenCV 偏「计算机视觉算法与矩阵运算」。它们共享 NumPy 这个数据层,因此常常配合使用。本文先讲清楚像素与色彩的基础模型,再分别拆解两个库的核心能力,最后落到批量处理与性能优化的工程细节。

1. 图像基础模型

1.1 像素、通道与色彩模式

数字图像本质是一个多维数组。一张 1920×1080 的 RGB 图片,在内存中是形状 (1080, 1920, 3) 的 uint8 数组——注意高度在前、宽度在后,这与「宽×高」的日常说法相反,是最常见的索引 bug 来源。

模式通道数用途
L1灰度图
RGB3屏幕显示、通用处理
RGBA4带透明通道
CMYK4印刷
I / F132 位整型/浮点(中间计算)
P1调色板索引(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 / NumPyPillow 无对应能力
深度学习预处理NumPy + OpenCV需精确控制插值与归一化
生成缩略图服务Pillowthumbnail 已优化

一个常见的性能陷阱是「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 RGBcvtColor 转换
缩略图方向错未处理 EXIFImageOps.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 + 避免来回转换)。把预处理参数与训练时严格对齐、给用户上传设像素上限,是工程上最容易忽略却代价最高的两件事。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. Python 桌面 GUI 应用开发
  2. Python 正则与文本处理进阶
  3. Python GraphQL API:Strawberry 与 Schema 设计