4、图像预处理(上):图像缩放、图像裁剪、图像旋转与仿射变换、图像翻转

图像预处理,说白了就是给原始图像「洗个澡、理个发」,让它变成算法喜欢的样子。我刚开始做计算机视觉项目时,总觉得预处理是小事,结果模型死活不收敛,最后发现是输入尺寸没对齐。嗯,从那以后我再也不敢小看这一步了。

这一章我们先聊四个最基础的操作:缩放、裁剪、旋转与仿射变换、翻转。它们看似简单,但坑不少,我一个个说。

4.1 图像缩放:别让像素「打架」

图像缩放,就是把图片变大或变小。你想想看,一张 1920×1080 的图直接塞进 224×224 的模型,不缩放怎么行?

缩放的核心是插值算法。我简单列一下常用的几种:

插值方法 特点 我常用的场景
最近邻插值 速度快,但锯齿明显 像素风格、实时预览
双线性插值 平滑,速度适中 日常缩放,大部分情况够用
双三次插值 更平滑,计算量大 高质量输出,比如打印
Lanczos 插值 细节保留好,边缘锐利 遥感图像、医学图像
我的小习惯:训练时用双线性插值,推理时如果追求速度可以用最近邻。但千万别在分类任务里用最近邻缩放过小图,否则猫和狗的特征会「糊」在一起。

OpenCV 里缩放用 cv2.resize(),举个例子:

import cv2

img = cv2.imread('cat.jpg')
# 指定目标尺寸
resized = cv2.resize(img, (224, 224), interpolation=cv2.INTER_LINEAR)
# 按比例缩放
scale_percent = 0.5
width = int(img.shape[1] * scale_percent)
height = int(img.shape[0] * scale_percent)
resized = cv2.resize(img, (width, height))
注意:OpenCV 中 resize 的尺寸参数是 (宽度, 高度),而 numpy 数组的 shape 是 (高度, 宽度, 通道)。我刚开始就搞反过,调试了半天才发现图是歪的。

4.2 图像裁剪:只取精华部分

裁剪就是切图。为什么要切?因为原始图像里可能有很多无关区域。比如做车牌识别,你总得先把车牌从整车照片里切出来吧?

裁剪在 OpenCV 里其实就是数组切片,简单到不行:

# 裁剪 [y1:y2, x1:x2]
cropped = img[100:400, 200:500]

但这里有个坑——边界检查。我在项目中遇到过,训练数据里有一张图特别小,裁剪坐标超出了图像范围,程序直接崩了。所以一定要加判断:

h, w = img.shape[:2]
x1, y1, x2, y2 = 100, 100, 500, 500
# 确保不越界
x1 = max(0, x1)
y1 = max(0, y1)
x2 = min(w, x2)
y2 = min(h, y2)
cropped = img[y1:y2, x1:x2]
实战经验:做数据增强时,我经常用随机裁剪。比如从 256×256 的图里随机裁出 224×224 的区域,相当于变相增加了数据量。但注意,随机裁剪不能裁掉目标主体,否则模型会学到「空图」这种错误样本。

4.3 图像旋转与仿射变换:不只是转个角度

旋转看起来简单,但实际做起来有门道。直接旋转会怎样?图像会超出边界,或者出现黑边。

OpenCV 里旋转需要两步:先算旋转矩阵,再应用仿射变换。

import cv2
import numpy as np

h, w = img.shape[:2]
center = (w // 2, h // 2)
angle = 45
scale = 1.0

# 计算旋转矩阵
M = cv2.getRotationMatrix2D(center, angle, scale)
# 应用仿射变换
rotated = cv2.warpAffine(img, M, (w, h))

这样旋转后,四个角会被裁掉。如果想保留完整图像,需要调整输出尺寸:

# 计算旋转后的新边界
cos = abs(M[0, 0])
sin = abs(M[0, 1])
new_w = int(h * sin + w * cos)
new_h = int(h * cos + w * sin)
# 调整平移量,使图像居中
M[0, 2] += (new_w - w) / 2
M[1, 2] += (new_h - h) / 2
rotated = cv2.warpAffine(img, M, (new_w, new_h))
避坑指南:我曾经在 OCR 项目里直接旋转文本图像,结果文字变形了。后来发现,对于文本矫正,应该用透视变换而不是仿射变换。仿射变换只能处理平行线,而文本倾斜往往是透视造成的。

仿射变换不止能旋转,还能做平移、缩放、错切。说白了,任何保持「平行线依旧平行」的变换,都是仿射变换。通用公式是:

# 自定义仿射变换矩阵
# 通过三个点映射来定义
pts1 = np.float32([[50,50], [200,50], [50,200]])
pts2 = np.float32([[10,100], [200,50], [100,250]])
M = cv2.getAffineTransform(pts1, pts2)
transformed = cv2.warpAffine(img, M, (w, h))

4.4 图像翻转:镜像世界的秘密

翻转就是镜像。水平翻转、垂直翻转、同时翻转。在 OpenCV 里用 cv2.flip()

# 水平翻转(左右镜像)
flip_h = cv2.flip(img, 1)
# 垂直翻转(上下颠倒)
flip_v = cv2.flip(img, 0)
# 同时翻转
flip_hv = cv2.flip(img, -1)

翻转在数据增强里特别常用。比如做目标检测,水平翻转后,标注框的坐标也要跟着变。我习惯这样处理:

def horizontal_flip(img, bboxes):
    h, w = img.shape[:2]
    flipped_img = cv2.flip(img, 1)
    flipped_bboxes = []
    for bbox in bboxes:
        x1, y1, x2, y2 = bbox
        # 水平翻转后,x 坐标变成 w - x
        new_x1 = w - x2
        new_x2 = w - x1
        flipped_bboxes.append([new_x1, y1, new_x2, y2])
    return flipped_img, flipped_bboxes
注意:不是所有任务都适合翻转。比如做数字识别,翻转 6 会变成 9,这就出大问题了。我做过一个车牌识别项目,一开始无脑翻转,结果模型把「湘A」识别成了「湘A」的镜像,完全不对。后来我根据任务特性,只做水平翻转,而且只对非对称字符做。

4.5 组合使用:预处理流水线

实际项目中,这些操作很少单独用。我一般会搭一个预处理流水线:

  1. 先裁剪:去掉无关背景,减少计算量
  2. 再缩放:统一到模型输入尺寸
  3. 然后翻转/旋转:数据增强,随机应用
  4. 最后归一化:把像素值缩放到 [0,1] 或 [-1,1]

举个例子,一个简单的训练预处理函数:

def preprocess(img, target_size=(224, 224), augment=False):
    # 裁剪中心区域
    h, w = img.shape[:2]
    crop_size = min(h, w)
    start_x = (w - crop_size) // 2
    start_y = (h - crop_size) // 2
    img = img[start_y:start_y+crop_size, start_x:start_x+crop_size]
    
    # 缩放
    img = cv2.resize(img, target_size)
    
    # 数据增强
    if augment:
        if np.random.rand() > 0.5:
            img = cv2.flip(img, 1)  # 随机水平翻转
        angle = np.random.randint(-10, 10)
        M = cv2.getRotationMatrix2D((112, 112), angle, 1.0)
        img = cv2.warpAffine(img, M, target_size)
    
    # 归一化
    img = img.astype(np.float32) / 255.0
    return img

嗯,这一章就到这里。图像预处理看似基础,但做得好不好,直接决定模型的上限。下一章我们继续聊滤波、直方图均衡化和形态学操作,那些才是真正让图像「脱胎换骨」的功夫。