手机里攒了一大堆纸质材料的照片:会议投影拍糊了的角度、课桌上歪着拍的笔记、发票单据随手一按就存了下来。这类照片有个共同的毛病——成像平面和物体平面不平行,导致画面内容倾斜,还有近大远小的透视畸变,想直接归档根本没法看。OpenCV里能处理这个问题的两个核心工具,就是本期标题里的仿射变换和透视变换。它俩能把图像里任意一个四边形区域重新映射到目标矩形区域,做文档矫正、图像配准、场景拼接之前的几何对齐,都离不开。
这周的 OpenCV 每周实例练习 03,我把这两个变换放在一起讲透。不只是教你调用 cv2.warpAffine 和 cv2.warpPerspective,而是从矩阵怎么构造、为什么是2x3和3x3、取点顺序有什么坑、旋转之后黑边怎么处理,到实际场景里到底该用哪个,全部过一遍。适合刚接触图像处理、想搞懂几何变换原理的同学,也适合已经写过不少 warp 代码、但经常被参数和报错折腾的开发者。
1. 先从坐标变换的视角理解这两个名字到底在干什么
1.1 像素点是怎么被“搬”到新位置的
图像处理里的几何变换,本质上是做一次坐标映射。原图每个像素点 (x, y) 存着一个颜色值,变换要做的是把它搬到新位置 (x', y')。向右平移10像素,就是 x' = x + 10;绕中心转30度,也是一组坐标公式,只是稍微复杂一点。整张图所有点都搬完,再通过插值算法算新位置上的像素值,这就是 OpenCV 的 warp 系列函数内部干的事。
很多初学的人把 warpAffine 当成“滤镜”,输入一张图输出一张图,中间是黑盒。这个理解会坑到你,因为后面一旦涉及到坐标换算、ROI 提取、拼接重叠区域,你全得手算某个点被变换到了哪儿。OpenCV 提供了两个函数专门干这个事:cv2.transform 用于仿射矩阵的坐标映射,cv2.perspectiveTransform 用于透视矩阵的坐标映射。我建议在跑任何 warp 之前,先拿原图的几个角点坐标,用这两个函数算一下变换后的坐标,看看和你预想的是否一致。这一步能绕开一大半“玄学变形”。
1.2 2x3 还是 3x3:多出来的一行究竟多了什么能力
仿射变换的矩阵是2x3,写作 [A | t],A 是2x2线性部分,负责旋转、缩放、倾斜,t 是平移向量。它的几何性质可以用一句话记住:直线还是直线,平行线还是平行线。也就是说,不管你怎么旋转、缩放、平移、切变,原本平行的两条边在结果里依然平行。
透视变换的矩阵是3x3,比仿射多了一整行参数。它表达的是针孔相机成像模型,也就是我们常说的“近大远小”的投影过程。透视变换下直线依然是直线,但原本平行的线可能在结果中相交于一点,这就是绘画理论里的灭点现象。
换算到自由度上:仿射矩阵有6个自由参数(线性部分4个加平移2个),解这6个未知数至少需要6个方程,所以 getAffineTransform 要求你给3组对应点,因为3组点产生3×2=6个方程。透视矩阵去掉整体缩放自由度后还有8个自由参数,需要4组对应点提供4×2=8个方程,所以 getPerspectiveTransform 要4组点。
从这个角度再看两者关系就清楚了:透视变换是更一般的模型,仿射变换是透视变换的一个特例。当透视矩阵的第三行固定成 [0 0 1] 时,齐次坐标的 w 恒等于1,不需要归一化,就退化成仿射。OpenCV 给仿射函数返回2x3矩阵而不是3x3,也正是因为这个第三行永远是常数,没必要存。
2. 仿射变换实操:旋转、平移、缩放和倾斜的配套用法
2.1 一行生成旋转矩阵:getRotationMatrix2D 参数单位别搞错
先看最常用的旋转。cv2.getRotationMatrix2D(center, angle, scale) 生成一个2x3矩阵,参数分别是旋转中心、旋转角度、缩放比例。这里最隐蔽的坑是角度单位:这个函数用的是角度制(degree),不是弧度制。我之前见过有人把 math.pi / 4 直接塞进去,结果图像旋转角度完全不对劲,因为函数内部默认你给的是度。如果是弧度,得先转成角度,直接用 angle_degree = angle_radian * 180 / math.pi。
代码很直接:
import cv2 import numpy as np img = cv2.imread("memo.jpg") h, w = img.shape[:2] center = (w // 2, h // 2) M = cv2.getRotationMatrix2D(center, 15, 1.0) # 逆时针15度,不缩放 rotated = cv2.warpAffine(img, M, (w, h)) cv2.imwrite("memo_rotated.jpg", rotated)得到 M 之后,如果你好奇矩阵里每个位置是什么,可以看这个公式。设角度为 θ,缩放为 s,旋转中心为 (cx, cy),那么:
M = [ α β (1 - α)cx - βcy ] [ -β α βcx + (1 - α)cy ]其中 α = s · cos θ,β = s · sin θ。注意第三列是两个平移项,它并不是简单地为0,而是为了保证绕 (cx, cy) 旋转时不产生额外的偏移。手动拼旋转矩阵的时候,千万别把角度值直接填进去,要先算 cos 和 sin,否则出来的矩阵根本不代表旋转。
如果你只需要旋转,这段代码够用了。但有两个问题必须留意:一是旋转后图像四角会超出原画布,原尺寸直接 warp 会裁掉内容;二是旋转角度较大时,图像边缘会留下大块黑边。裁剪和黑边的解决方案,放到第5节避坑部分详细说。
2.2 三点定仿射:getAffineTransform 为什么只要三组点
旋转只是仿射变换的一个特例,更一般的情况是给定三组对应点,求整个仿射矩阵。cv2.getAffineTransform(src, dst) 就是干这个的,src 和 dst 分别是源图像上的3个点和目标图像上对应的3个点。
src = np.float32([[50, 80], [400, 120], [80, 500]]) dst = np.float32([[0, 0], [400, 0], [0, 600]]) M = cv2.getAffineTransform(src, dst) aligned = cv2.warpAffine(img, M, (400, 600))这个例子里,我把源图中一个三角形的三个顶点映射到目标矩形的三个角,第四角由矩阵外推出来。三个点必须不共线,否则几何上无法唯一确定变换,凑出来的矩阵也不稳定。
实际操作中选点有个讲究:三个点尽量分散,覆盖图像中你想校正的主要区域。如果三个点全挤在图像一角,矩阵对远处点的外推误差会大得吓人。比如要校正一整张文稿,我习惯选左上、右上、左下这三个点,让三角形尽量覆盖纸面区域,这样右下角虽然不是原始数据点,但外推出来的结果通常也能看。
2.3 实操案例:把文本倾斜的扫描图像自动摆正
自动摆正文本图像,算是仿射变换一个非常典型的应用。思路不复杂:先把图像二值化,找到文字区域的最小外接矩形,拿到倾斜角,然后旋转回去。
def get_text_angle(gray): # 反色二值化,文字区域变成白色 binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1] # 所有非零像素的坐标,即所有文字像素 coords = np.column_stack(np.where(binary > 0)) angle = cv2.minAreaRect(coords)[-1] # minAreaRect返回的角度范围是[-90, 0) # 需要根据长短边方向换算成实际的文本旋转角 if angle < -45: angle = -(90 + angle) else: angle = -angle return anglecv2.minAreaRect 返回一个旋转矩形,最后一个值是矩形相对于水平方向的夹角,范围在 -90 到 0 之间。这里需要对角度做换算,否则容易把横排文本判断成 90 度旋转。拿到角度后,用第2.1节的方式构造旋转矩阵,把图转回来:
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) angle = get_text_angle(gray) center = (w // 2, h // 2) M = cv2.getRotationMatrix2D(center, angle, 1.0) rotated = cv2.warpAffine(img, M, (w, h), borderMode=cv2.BORDER_CONSTANT, borderValue=(255, 255, 255))这个流程对白底黑字的扫描件效果很稳。但注意如果原图带有复杂背景或者照片是倾斜透视拍出来的,仅靠旋转摆正是做不到的,那种情况要交给下一节的透视变换。
3. 透视变换实操:四角确定一个映射,把歪斜照片拉正
3.1 单应性与四对点:为什么四点就能唯一确定
当拍摄平面物体(比如一张纸、一块牌、一页屏幕)时,从三维世界到二维照片的映射可以用一个3x3单应矩阵描述,这个矩阵就是透视变换矩阵。单应矩阵有8个自由度,所以理论上4对对应点就能解出唯一解。
这4个点怎么来的,取决于你的场景。有交互式程序,人拿鼠标在图上点4个角;有自动化流程,先用边缘检测加轮廓提取找到四边形,再取4个顶点;还有特征匹配路线,在两张图之间找4组匹配点对,算两图之间的单应。
无论哪条路线,都需要保证4个点覆盖的区域是合理的凸四边形,而且点的顺序必须一致。getPerspectiveTransform 内部是按你给的顺序建立对应关系的,src 的第 i 个点映射到 dst 的第 i 个点。顺序一旦错乱,输出的图像就是拧巴的。
3.2 warpPerspective 完整流程:手动取四角实现页面矫正
先看一组最简单的调用,假设我已经手工从照片里确定了文档四角坐标。
import cv2 import numpy as np img = cv2.imread("receipt.jpg") h, w = img.shape[:2] # 源点顺序:左上 -> 右上 -> 右下 -> 左下 src = np.float32([[170, 95], [620, 135], [690, 760], [110, 720]]) # 目标矩形:这里先用原图分辨率 dst = np.float32([[0, 0], [w, 0], [w, h], [0, h]]) M = cv2.getPerspectiveTransform(src, dst) warped = cv2.warpPerspective(img, M, (w, h)) cv2.imwrite("receipt_warped.jpg", warped)这个例子里,dst 用了原图的宽高,所以校正后的图像保持了原始分辨率,只是把四边形的视觉透视拉平了。如果照片里的文档区域本身很小,你希望输出更大、更清晰的图,就把 dst 的宽高设得比原图更大,warpPerspective 的第三个参数同步改成输出尺寸。
这里还有一个非常重要的细节:getPerspectiveTransform 和 getAffineTransform 都要求传入浮点数组。我用 np.float32 就是为了满足数据类型要求,直接传 Python 的 int 列表会报类型错误。
3.3 目标宽高比的估算:怎么避免矫完发现形状不对
很多初学的人会直接跳过宽高比,拿原图尺寸或者随便一个比例做 dst,矫正完发现纸张被横向拉宽或者纵向拉长,整个形状不对。道理很简单:纸面在照片中是四边形,四条边因为透视各不一样长。如果目标矩形还按原图分辨率做,映射时就会产生一个随机的缩放比例。
更稳妥的做法是,根据源点之间的距离估算纸张的真实宽高比,再按比例构建 dst。
# 计算源四边形的四边长度 left = np.linalg.norm(src[0] - src[3]) right = np.linalg.norm(src[1] - src[2]) top = np.linalg.norm(src[0] - src[1]) bottom = np.linalg.norm(src[2] - src[3]) # 取平均作为目标宽高 out_w = int(round((top + bottom) / 2)) out_h = int(round((left + right) / 2)) dst = np.float32([[0, 0], [out_w, 0], [out_w, out_h], [0, out_h]]) warped = cv2.warpPerspective(img, dst, (out_w, out_h))如果明确知道拍摄对象的标准尺寸比例,比如普通名片大概是 1.586 : 1,A4 纸大概是 1.414 : 1,直接用这个比例构造 dst 更准。四边平均法适合对比例没有硬要求、只求顺眼的场合。
3.4 自动提取四角:轮廓近似那条经典流程
手动点四个点适合调试和 demo,但真要做批量矫正,就得让程序自己找文档角点。经典流程是用 Canny 边缘检测、findContours 找轮廓、approxPolyDP 把轮廓近似成四边形。
def find_document_corners(gray): blurred = cv2.GaussianBlur(gray, (5, 5), 0) edged = cv2.Canny(blurred, 75, 200) contours, _ = cv2.findContours(edged, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) contours = sorted(contours, key=cv2.contourArea, reverse=True) for c in contours: peri = cv2.arcLength(c, True) approx = cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) == 4: return approx.reshape(4, 2).astype(np.float32) return NoneapproxPolyDP 的第二个参数是“最大距离阈值”,取周长的 2% 是比较常用的初始值。如果轮廓本身很平滑,2% 会得到过多顶点,调大一点;如果轮廓抖动大,调小一点。这个参数说白了就是“允许折线偏离原始轮廓多远”,理解了这个,调起参来就不会瞎试。
拿到四个角之后,顺序大概率是乱的。我在下一节专门说排序问题,因为这一步直接决定透视变换结果要不要“拧麻花”。
4. 仿射和透视到底怎么选:场景选择的核心判断法
4.1 一句话判断法:平行线还是不是平行线
选仿射还是透视,最直观的判断标准就看一件事:你需要的输出里,原本平行的线是否必须保持平行。
拍桌子上的便签纸,便签本身是矩形,照片里由于角度关系变成了不规则四边形,上下两条边在照片里已经不平行了。你的目标是把它矫正回一个矩形,让上下边平行,这需要透视变换。反过来,一张已经扫描好的电子文档,只是扫描时纸张放歪了,图像里纸的边依然是直的、上下边依然平行,只是整体旋转了一个角度,这种用仿射变换就行。
| 场景 | 成像特点 | 推荐变换 |
|---|---|---|
| 扫描件倾斜摆正 | 平行关系没坏,只是旋转 | 仿射 |
| 手机斜拍文档矫正 | 近大远小,四边形变梯形 | 透视 |
| 图像平移缩放拼接 | 线性操作,平行保持不变 | 仿射 |
| 照片中的广告牌拉正 | 透视畸变明显 | 透视 |
| 模板特征点较少、快速对齐 | 只需要近似旋转平移 | 仿射 |
核心道理就一句:仿射变换保持平行,透视变换不保持平行。你的目标需要哪种性质,就用哪种变换。
4.2 从自由度和计算开销看选型
仿射只需要3组点,数据需求少;透视需要4组点,对点的精度更敏感。在线上识别管线里,如果特征匹配只能稳定找到3组点,你没法拼出4组可用点,硬做透视变换只会放大噪声。更好的做法是先退化到仿射,把整体姿态纠正掉一部分,后面再做精细对齐。
计算开销方面,同样尺寸的图像,warpPerspective 比 warpAffine 要慢一些。因为透视变换在插值循环里多了一步除以 w 的归一化操作,像素越多差距越明显。在大图上跑实时视频流时,如果你确认位移基本是平移和旋转,就别非用透视不可。反过来也不能因为有性能压力就一律用仿射,透视畸变明显的图硬用仿射凑,角点位置差得离谱,后面所有下游处理都会跟着崩。
4.3 组合使用:一张图里先透视再仿射
实际项目里常常是二者配合的。比如手机拍了一张倾斜的表格照片,第一步用透视变换把纸张区域拉成正面矩形,消除投影畸变;第二步再用仿射变换微调角度和缩放,让表格线精确对齐到像素网格,或者把图像缩放到统一分辨率。
矩阵叠加也简单,先把仿射矩阵扩成3x3,然后做矩阵乘法,最后再截回需要的维度。顺序是右乘:先作用的矩阵放在右边。
def combine_affine(M1, M2): # M1 先作用,M2 后作用 M1_3 = np.vstack([M1, [0, 0, 1]]) M2_3 = np.vstack([M2, [0, 0, 1]]) combined = M2_3 @ M1_3 return combined[:2]这个习惯养成之后,你会发现很多看似复杂的几何校正,拆成几个简单的仿射步骤,比直接怼一个透视矩阵好调试得多。
5. 常见报错与避坑手册:这些坑我自己都踩过
5.1 数据类型不匹配,函数直接报错
OpenCV 的 getAffineTransform 和 getPerspectiveTransform 对输入类型有严格要求,要 float32 或 float64。如果你直接传包含 int 的列表或者 numpy int 数组,最常见的报错是:
error: (-215:Assertion failed) src.checkVector(2, CV_32F) > 0这类报错的意思是“需要CV_32F类型的数据”。解决办法很无脑但很有效:所有传给变换函数的点,统一 np.float32。别在代码里一会儿 int 一会儿 float 地混着来,Python 不报错不代表 OpenCV 不报错。
另外注意,warpAffine 的 M 参数必须是2x3矩阵,warpPerspective 的 M 必须是3x3矩阵。形状不对会直接碰到 assert 失败。如果自己手动构造矩阵,记得 m.shape 先看一眼。
5.2 旋转之后黑边怎么处理
用 warpAffine 旋转时,默认边界填充是黑色。嵌入到白色背景的扫描文档里,黑边非常突兀。两种办法:
一是设置边框颜色,用白边填充:
rotated = cv2.warpAffine(img, M, (w, h), borderMode=cv2.BORDER_CONSTANT, borderValue=(255, 255, 255))二是旋转的同时扩大画布,不裁掉任何像素。计算旋转后的新宽高有个固定公式:
angle_rad = np.deg2rad(45) cos_a = abs(np.cos(angle_rad)) sin_a = abs(np.sin(angle_rad)) new_w = int(h * sin_a + w * cos_a) new_h = int(h * cos_a + w * sin_a) M = cv2.getRotationMatrix2D(center, 45, 1.0) # 把旋转中心和画布偏移叠进矩阵第三列 M[0, 2] += (new_w - w) / 2 M[1, 2] += (new_h - h) / 2 rotated = cv2.warpAffine(img, M, (new_w, new_h))这里的关键是,getRotationMatrix2D 生成的矩阵只负责旋转,画布变大了之后图像整体会偏在左上角,所以要把画布偏移的一半补进平移项。这个手法很有用,做旋转扩边统一用这个。
5.3 点顺序乱掉,图像拧成麻花
透视变换里最经典的失败现场:src 和 dst 的点顺序不一致,输出图像四个角互相错位,看上去像图像被“拧”了一下。原因就是 getPerspectiveTransform 按索引建立对应关系,src[0] 映射到 dst[0],src[1] 映射到 dst[1],以此类推。顺序一旦乱,对应关系全错。
自动提取轮廓得到的四点顺序是任意的,所以要做一次排序。很多人用“坐标求和最小是左上角、最大是右下角”这个经典技巧:
def order_points(pts): pts = np.array(pts, dtype="float32") rect = np.zeros((4, 2), dtype="float32") s = np.sum(pts, axis=1) d = np.diff(pts, axis=1) rect[0] = pts[np.argmin(s)] # 左上 rect[2] = pts[np.argmax(s)] # 右下 rect[1] = pts[np.argmin(d)] # 右上 rect[3] = pts[np.argmax(d)] # 左下 return rect这个方法对“比较正”的四边形适用,但如果文档旋转角度很大,或者拍摄视角特别刁钻,sum/diff 排序会出错。更稳健一点的办法是先算四个点的质心,然后按与质心连线与水平轴的夹角排序,这样始终能得到按角度旋转排列的顺序。我在生产代码里一般用角度排序,经典 sum/diff 只用在界面预览这种要求不高的场景。
5.4 组合变换时,平移项算不明白
把旋转、缩放、平移三个操作用矩阵乘法组合时,最常出错的是平移项。很多人以为第三列就是简单的目标位置偏移量,忘了旋转和缩放会改变坐标基准。比如你想先旋转再平移,结果却把平移量加到旋转矩阵的第三列上,得到的效果完全不是预想位置。
我调试这类问题有个土办法:取原图左上角 (0,0)、右上角 (w,0)、中心点 (w/2,h/2) 三个点,用 cv2.transform 直接算变换后的坐标,看看落在哪。如果坐标不在预期区域,就知道是平移项加错了,还是旋转中心不对。这个办法比干盯矩阵数字直观得多,推荐你也试试。
5.5 问题排查速查表
| 问题现象 | 可能原因 | 对策 |
|---|---|---|
| 输出图像大面积纯黑 | 矩阵平移项过大或过小,画布偏移 | 检查M第三列偏移量 |
| 图像内容被裁掉 | 旋转后画布尺寸没更新 | 用新宽高计算包围盒 |
| 图形扭曲成麻花 | src/dst点顺序不一致 | 统一四点排序方法 |
| 角度严重不对 | 角度单位用了弧度 | 转成角度制 |
| 报错CV_32F | 点数据不是float32 | 统一np.float32 |
| 校正后长宽比例失真 | dst矩形比例和物体实际比例不符 | 按实体宽高比设置dst |
6. 最后再分享一点个人经验
做仿射和透视变换这一年多,我最大的体会是:几何变换的代码量不大,难的是对矩阵的理解和对坐标位置的敏感。很多人卡在参数调不出来,其实就是没搞清变换前后坐标是怎么对应的。我现在每写一段 warp 代码,必然先手动确认三个点变换后的位置,确认无误再往下做。
另外,OpenCV 里这类函数的参数名有点误导性,warpAffine 的第三参数是输出图像的尺寸,也就是画布宽高,不是“缩放比例”。一开始我老在这里填 (0.5, 0.5),结果图像被裁得只剩一个小角,排查了半天才反应过来。这类 API 细节,文档里写得再清楚,都不如自己踩一次坑记得牢。
如果你手头正好有一批歪斜的照片或者不平整的文档扫描件,建议先别急着套滤镜,按这篇文章的顺序,先取点、再算矩阵、再验证、最后 warp。这套流程跑顺之后,你会发现所谓的“图像几何校正”,本质上就是一个矩阵的事。下一期我打算接着写这些变换在图像拼接里的应用,感兴趣的话可以先把今天这两个函数的参数吃透,到时候直接上手。