引言
无论下游模型多先进,输入永远是像素。预处理做错,模型再好也白搭。OpenCV 是 CV 工程里最常用的图像处理库,它用 numpy.ndarray 表示图像,读写快、算子全、跨语言。但它也有几个反直觉的默认行为:通道顺序是 BGR 而非 RGB、cv2.resize 的默认插值不是最省事的那个、cv2.imread 遇到不存在的路径返回 None 而不是抛异常。
本文按「表示 → 色彩空间 → 几何变换 → 对比度 → 滤波 → 边缘」的顺序拆解 OpenCV 4.x 的核心 API,并给出可直接运行的三段代码。目标是把「读图到张量」这段最容易被忽视的链路讲透。
目录
- 图像在内存中的表示
- 读写与色彩空间
- 几何变换
- 直方图与对比度增强
- 滤波与去噪
- 边缘检测与形态学
- 完整预处理管线
- 直方图可视化
- 与 PyTorch 的衔接
- 关键 API 汇总
- 权衡取舍
- 常见坑清单
- 小结
1. 图像在内存中的表示
OpenCV 读入的图像是一个三维 numpy.ndarray,形状为 (H, W, C),即高度、宽度、通道数。几个必须记住的性质:
- dtype 通常是
uint8,取值范围 0 到 255。做算术运算前要转float32,否则会溢出回绕。 - 通道顺序是 BGR,不是 RGB。这是历史原因,也是新手第一大坑。
- 灰度图是二维
(H, W),没有通道维。 - 图像是行优先存储,
img[y, x]先 y 后 x,与数学里的(x, y)相反。
一个像素的访问与修改示例:
import cv2
import numpy as np
img = cv2.imread("cat.jpg") # 返回 BGR,dtype=uint8
print(img.shape, img.dtype) # (H, W, 3) uint8
h, w = img.shape[:2]
px = img[100, 200] # 第 100 行、第 200 列的 BGR 值
img[100, 200] = [255, 0, 0] # 写为蓝色(BGR)
# 算术运算前先转 float,避免 uint8 溢出
bright = np.clip(img.astype(np.float32) + 30, 0, 255).astype(np.uint8)
注意最后一行:如果直接 img + 30,uint8 会在 255 处回绕成小值,画面出现黑斑。这是预处理里最隐蔽的 bug 之一。
2. 读写与色彩空间
2.1 BGR 与 RGB
cv2.imread 读出来是 BGR,而 PIL、matplotlib、torchvision 都按 RGB 处理。两者混用会导致颜色错乱(红蓝互换)。转换方式:
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
从 PIL 读入的图像是 RGB,若要交给 OpenCV 处理,先 np.array(pil_img)[:, :, ::-1] 或 cv2.cvtColor 转成 BGR。
2.2 灰度与 HSV
- 灰度:单通道,亮度信息,用于边缘、阈值、形态学。
- HSV:色相(Hue)、饱和度(Saturation)、明度(Value)。做颜色分割时比 RGB 更稳,因为光照变化主要影响 V 通道。
- LAB:感知均匀的色彩空间,L 是亮度,a/b 是色度。做颜色校正与色差计算时更符合人眼。
- YCrCb:亮度与色度分离,常用于肤色检测与人脸。
颜色分割的典型写法:
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
# 提取红色(HSV 中红色跨 0 度,需要两段区间)
mask1 = cv2.inRange(hsv, (0, 70, 50), (10, 255, 255))
mask2 = cv2.inRange(hsv, (170, 70, 50), (180, 255, 255))
mask = cv2.bitwise_or(mask1, mask2)
2.3 色彩空间选择建议
| 色彩空间 | 用途 | 注意 |
|---|---|---|
| BGR | OpenCV 默认读写 | 与 RGB 库混用需转换 |
| RGB | 深度学习输入 | PIL/torchvision 默认 |
| GRAY | 边缘、阈值、形态学 | 丢失颜色信息 |
| HSV | 颜色分割、跟踪 | H 通道在红色处环绕 |
| LAB | 颜色校正、色差 | 转换稍慢 |
| YCrCb | 肤色检测 | 光照鲁棒 |
3. 几何变换
3.1 resize 与插值
cv2.resize 是最常用的预处理。插值方法的选择直接影响质量与速度:
INTER_NEAREST:最近邻,最快,有锯齿,适合标签图(掩码)缩放,保证像素值不插值出非法类别。INTER_LINEAR:双线性,默认,速度质量平衡,适合放大。INTER_AREA:区域重采样,适合缩小,能避免摩尔纹。INTER_CUBIC:双三次,质量好但慢,适合放大。INTER_LANCZOS4:质量最好,最慢。
small = cv2.resize(img, (224, 224), interpolation=cv2.INTER_AREA) # 缩小
big = cv2.resize(img, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC) # 放大
mask_small = cv2.resize(mask, (224, 224), interpolation=cv2.INTER_NEAREST) # 掩码
关键点:缩放掩码与标签图必须用最近邻,否则会出现 0.5 这种不存在的类别。
3.2 仿射变换
仿射变换保持平行线,用 2×3 矩阵描述,可实现旋转、平移、缩放、错切。用 cv2.getRotationMatrix2D 生成旋转矩阵:
h, w = img.shape[:2]
M = cv2.getRotationMatrix2D((w / 2, h / 2), angle=30, scale=1.0)
rotated = cv2.warpAffine(img, M, (w, h))
3.3 透视变换
透视变换用 3×3 矩阵,可把倾斜拍摄的平面矫正为正视,例如车牌、文档、白板。先找四个角点,再映射到目标矩形:
src = np.float32([[56, 65], [368, 52], [28, 387], [389, 390]])
dst = np.float32([[0, 0], [300, 0], [0, 300], [300, 300]])
M = cv2.getPerspectiveTransform(src, dst)
warped = cv2.warpPerspective(img, M, (300, 300))
透视矫正是 OCR 前处理的标准步骤,能显著提升字符识别率。
4. 直方图与对比度增强
4.1 直方图计算
直方图统计各灰度级的像素数,是判断图像曝光与对比度的利器。
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
hist = cv2.calcHist([gray], [0], None, [256], [0, 256]) # 256 个 bin
若直方图集中在低端,说明整体偏暗;集中在中间一小段,说明对比度低。
4.2 直方图均衡与 CLAHE
全局直方图均衡 cv2.equalizeHist 简单但容易放大噪声、在局部过曝。CLAHE(对比度受限自适应直方图均衡)分块均衡并限制对比度,效果更自然,是医学与低照度增强的常用手段。
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
enhanced = clahe.apply(gray) # 注意:只接受单通道
clipLimit 越大增强越强,但噪声也越明显;tileGridSize 越小越局部。实战常用 clipLimit=2.0 到 4.0、tile=8×8。
5. 滤波与去噪
| 滤波器 | 适用噪声 | 特点 |
|---|---|---|
| 均值滤波 | 高斯噪声 | 模糊边缘 |
| 高斯滤波 | 高斯噪声 | 可调核,边缘稍糊 |
| 中值滤波 | 椒盐噪声 | 保边,非线性 |
| 双边滤波 | 混合噪声 | 保边最好,最慢 |
gauss = cv2.GaussianBlur(img, (5, 5), sigmaX=1.0)
median = cv2.medianBlur(img, 5) # 去椒盐噪点
bilateral = cv2.bilateralFilter(img, 9, 75, 75) # 保边平滑
去噪要克制:过度滤波会抹掉小目标与细纹理,反而降低检测性能。
6. 边缘检测与形态学
Canny 是经典边缘检测器,双阈值控制边缘的强与弱:
edges = cv2.Canny(gray, threshold1=100, threshold2=200)
形态学操作用于去噪与形状分析:
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
opened = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) # 先腐蚀后膨胀,去小噪点
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 先膨胀后腐蚀,补小孔洞
掩码后处理里,开运算去毛刺、闭运算填空洞,几乎必用。
7. 完整预处理管线
下面把上述步骤串成一条从磁盘到模型输入的管线,覆盖读图、色彩转换、缩放、增强与归一化。
import cv2
import numpy as np
def preprocess(path, size=(224, 224), use_clahe=True):
img = cv2.imread(path)
if img is None:
raise FileNotFoundError(path) # 显式报错,避免 None 传播
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 统一为 RGB
# 等比例缩放后中心裁剪,避免拉伸变形
h, w = img.shape[:2]
scale = max(size[0] / h, size[1] / w)
nh, nw = int(round(h * scale)), int(round(w * scale))
img = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR)
top = (nh - size[0]) // 2
left = (nw - size[1]) // 2
img = img[top:top + size[0], left:left + size[1]]
# 可选:在亮度通道做 CLAHE,提升低照度鲁棒性
if use_clahe:
lab = cv2.cvtColor(img, cv2.COLOR_RGB2LAB)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
lab[:, :, 0] = clahe.apply(lab[:, :, 0])
img = cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)
# 归一化:与预训练模型一致
x = img.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
x = np.transpose(x, (2, 0, 1)) # HWC -> CHW
return x
tensor = preprocess("cat.jpg")
print(tensor.shape, tensor.dtype) # (3, 224, 224) float32
这条管线做了三件容易漏的事:等比例缩放加中心裁剪(保持长宽比)、在 LAB 亮度通道做 CLAHE(不破坏颜色)、用与 ImageNet 预训练一致的均值方差归一化。
8. 直方图可视化
调参时把直方图画出来,能快速判断曝光与对比度问题。
import cv2
import numpy as np
import matplotlib.pyplot as plt
img = cv2.imread("cat.jpg")
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
hist = cv2.calcHist([gray], [0], None, [256], [0, 256]).ravel()
equalized = cv2.equalizeHist(gray)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(gray)
plt.figure(figsize=(10, 4))
plt.subplot(1, 2, 1)
plt.plot(hist, color="black")
plt.title("gray histogram")
plt.subplot(1, 2, 2)
plt.hist(equalized.ravel(), bins=256, range=(0, 256), color="gray")
plt.title("after equalize")
plt.tight_layout()
plt.savefig("hist.png")
print("mean before:", gray.mean(), "after CLAHE:", clahe.mean())
对比均衡前后的直方图,可以直观看到动态范围是否被拉开。
9. 与 PyTorch 的衔接
预处理最后的 HWC -> CHW 与归一化,可以直接交给 torchvision 的 transform,避免手写错误:
import torch
import numpy as np
import cv2
from torchvision import transforms
img = cv2.cvtColor(cv2.imread("cat.jpg"), cv2.COLOR_BGR2RGB)
tf = transforms.Compose([
transforms.ToPILImage(),
transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(), # 自动 HWC->CHW 并除以 255
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]),
])
tensor = tf(img).unsqueeze(0) # (1, 3, 224, 224)
print(tensor.shape, tensor.dtype)
工程上建议训练与线上使用同一段预处理代码,最好把它抽成独立模块,两边都 import,从根上杜绝不一致。
10. 关键 API 汇总
| 需求 | API | 备注 |
|---|---|---|
| 读图 | cv2.imread | 返回 BGR,失败返回 None |
| 写图 | cv2.imwrite | JPEG 质量参数 0 到 100 |
| 色彩转换 | cv2.cvtColor | 转换码常量区分方向 |
| 缩放 | cv2.resize | 掩码用 INTER_NEAREST |
| 旋转 | cv2.getRotationMatrix2D + warpAffine | 2×3 矩阵 |
| 透视 | getPerspectiveTransform + warpPerspective | 3×3 矩阵 |
| 直方图 | cv2.calcHist | 返回列向量 |
| 自适应均衡 | cv2.createCLAHE | 单通道 |
| 去噪 | medianBlur / bilateralFilter | 保边选双边 |
| 边缘 | cv2.Canny | 双阈值 |
| 形态学 | morphologyEx | 开闭运算 |
11. 权衡取舍
- 质量与速度:
INTER_CUBIC、双边滤波质量好但慢;实时管线优先INTER_LINEAR与高斯。 - 增强与失真:CLAHE 提升低照度对比度,但过强会放大噪声、改变颜色分布,可能让模型在正常光照下变差。
- 统一与灵活:线上固定一套预处理最稳,但不同来源(手机、监控、扫描件)差异大时,可能需要按来源分支处理。
- 预处理位置:放 CPU 简单但可能成瓶颈,放 GPU 快但要重写算子;通常图像解码与几何变换在 CPU,归一化与张量转换在 GPU。
12. 常见坑清单
- BGR 与 RGB 混淆:OpenCV 读入交给 torchvision 前忘记
cvtColor,红蓝互换。 cv2.imread返回 None:路径错误或格式不支持时不报错,后续代码在 None 上崩溃。- 中文路径失败:
cv2.imread对非 ASCII 路径支持差,需cv2.imdecode(np.fromfile(...))。 - uint8 溢出:亮度加减不做 float 转换,出现回绕黑斑。
- 掩码用双线性插值:缩小分割标签时插值出非法类别。
- 拉伸变形:直接
resize到方形而不保持长宽比,目标被拉扁。 - CLAHE 用在彩色图上:必须只作用于亮度通道,否则颜色失真。
- 训练与线上预处理不一致:一边 RGB 一边 BGR、归一化参数不同。
- 忽略 EXIF 方向:手机图带旋转信息,读入后方向错误。
13. 小结
OpenCV 的预处理链路可以概括为:读图(BGR)→ 转 RGB → 保持长宽比缩放裁剪 → 可选 CLAHE 增强 → 归一化 → HWC 转 CHW。每一步都有默认行为和陷阱,最稳妥的做法是把预处理封装成一个函数,训练与线上共用。记住三条铁律:色彩空间要统一、掩码缩放用最近邻、归一化参数与预训练模型一致。
延伸阅读
- 计算机视觉任务全景与工程链路 — 预处理在整个链路中的位置
- 特征提取与图像匹配 — 传统特征的输入同样依赖正确预处理
- 图形学色彩管理与 HDR 管线 — 更底层的色彩科学
- 计算机视觉基础入门 — 图像表示与 CNN 分类基础
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。