☰
OpenCV仿射变换与齐次坐标:旋转矩阵、插值与warpAffine实战
2026/10/15 13:29:46 网站建设 项目流程

1. 从OpenCV那个让人疑惑的矩阵谈起

第一次用cv2.getRotationMatrix2D的时候,我盯着返回的 2x3 矩阵看了很久。明明只是旋转30度、缩放1.0倍,按书上的旋转矩阵公式应该只有四个三角函数值,为什么返回矩阵里还带着两个看起来无关的平移项?更奇怪的是,矩阵第一列第二列分别是 cos 和 sin,可第三列的两个数字既不等于零,也不是原图中心坐标,得算半天才能对上号。

这个现象背后其实藏着一个非常关键的认知:二维平面的线性变换,根本表达不了旋转中心不在原点的情况。你如果只在 2x2 矩阵里写三角函数,旋转之后图像会绕着画布左上角转,而不是绕着图像中心转。而图像处理里绝大多数旋转、缩放需求,都是希望以图像中心为基准操作的,这就逼着我们把问题从二维升到三维。

1.1 先看一个让你怀疑教程的实例

假设有一张 640x480 的图,对它做顺时针旋转30度、中心旋转、缩放系数1.0,代码就一行:

import cv2 import numpy as np import math h, w = 480, 640 center = (w // 2, h // 2) # (320, 240) M = cv2.getRotationMatrix2D(center, 30, 1.0) print(M)

输出大概是:

[[ 0.8660254 0.5 -76.160078 ] [-0.5 0.8660254 406.848073 ]]

熟悉旋转矩阵的人看到这个输出会愣一下:2x3 矩阵左边 2x2 部分,0.866 是 cos30°,0.5 是 sin30°,这个没问题。但第三列那两个数字是什么?你拿原图中心 (320, 240) 去算:320×0.866 + 240×0.5 ≈ 397.1,再减 76.16 ≈ 320.9,差不多还是 320;另一个分量算出来也是 240 附近。也就是说,这两个数字存在的意义,就是保证原图中心经过变换后仍然映射到新图的中心位置。

这其实就是"绕着中心旋转"的矩阵化表达。旋转本身只改变像素的排布方向,不改变中心点的位置,所以矩阵必须是"旋转 + 平移补偿"的组合,而不是单纯的 2x2 旋转。

1.2 换个视角:它其实是一个三维投影

我们整天说仿射变换是"线性变换 + 平移",但很少有人从这个角度理解那个 2x3 矩阵。如果把 x、y 坐标补一个恒等于 1 的维度,那这个矩阵就是对一个三维齐次向量的线性变换,然后再丢弃第三个分量、投影回二维平面:

x' = a*x + b*y + tx y' = c*x + d*y + ty

用公式写就是这样。所以 2x3 矩阵的第三列 (tx, ty) 不是可选项,而是仿射变换的组成部分。你不可能在不引入平移项的情况下,让变换矩阵同时表达"旋转"和"旋转中心不在原点"这两件事。真正的旋转矩阵只能绕原点转,想让中心不跑,就得额外补平移。

1.3 插一句:为什么矩阵第三行是隐藏的 0 0 1

形式上 OpenCV 返回 2x3 矩阵是为了节省存储,但在数学推导里,我们通常把它补成 3x3:

[ a b tx ] [ c d ty ] [ 0 0 1 ]

第三行从来不参与视觉计算,但它让所有变换可以连乘、求逆,这套机制就是下面要展开的核心内容。理解这个 3x3 形态,再看 warpAffine 的 M 参数、再看深度学习里的仿射网格生成器,都会顺很多。

2. 齐次坐标:为什么仿射变换必须升维

聊到这一步,很多人会问:矩阵乘法不是已经能表达旋转和缩放了吗?为什么非要引入一个看起来像"凑数"的第三维?

这里的关键在于:2x2 矩阵只能表达线性变换,而线性变换有个硬约束——原点必须映射到原点。平移变换偏偏会把原点挪走,所以任何 2x2 矩阵都不可能表达"向右移动 50 像素"这种操作。你有两个选择:一是放弃矩阵,用x' = A*x + t这种"先线性后平移"的两段式写法,但这样多个变换叠加时表达式会迅速失控;二是把维度抬高,让平移在更高维空间里看起来也像一个线性变换,从而把所有组合都变成矩阵连乘。

2.1 用一个温度计类比理解坐标升维

如果觉得齐次坐标太抽象,可以这么想:普通二维坐标是一个"绝对地址",它没有余量让你再描述"位置自身的偏移"。齐次坐标等于是给每个点附带了一张"恒为1的标签卡",平移操作实际上是在这张标签卡上做文章,让它把加法伪装成乘法。

具体做法很简单:二维点 (x, y) 写成齐次坐标就是 (x, y, 1)。对点做平移 (tx, ty),可以设计一个 3x3 矩阵:

[ 1 0 tx ] [ 0 1 ty ] [ 0 0 1 ]

你乘一下就会发现:

x' = 1*x + 0*y + tx*1 = x + tx y' = 0*x + 1*y + ty*1 = y + ty

平移确实被表达成了"乘以一个矩阵",而不是单独的加法。这就是齐次坐标的全部秘密——多出来的那个 1 就是为了承载平移项的。

2.2 另一面:齐次坐标的自由度

齐次坐标其实并不要求第三个分量恒等于 1,任何非零的 w 都表示同一个点,即 (x, y, w) 和 (x/w, y/w, 1) 等价。这意味着你可以在三维空间里做各种变换,最后只要除以 w 就能回到二维平面。

这个概念在投影几何里意义很大,但图像仿射这块我们通常只用 w=1 这一支,因为仿射变换不会改变 w 分量。记住一条就够了:用 3x3 矩阵乘齐次坐标,最后一个分量如果不为 1,就归一化回来。这样以后接触透视变换、单应性矩阵、3D 渲染里的投影矩阵,都不会觉得陌生。

2.3 复合变换为什么能一路连乘

有了齐次坐标,连续做多次变换就变成矩阵连乘。比如先旋转 R、再平移 T、最后缩放 S,最终矩阵就是 S × T × R。注意是"后来居上"地往左边乘,这个顺序问题特别容易出错,后面专门展开。

这带来的工程价值非常直接:你不需要对每个像素循环执行三组不同的映射,而是先把所有操作合并成一个 3x3 矩阵,再一次性作用到整张图上。合并后的矩阵仍然满足仿射形式,OpenCV 的 warpAffine 拿到的 M 永远只有一个,这也是仿射变换在实际代码里如此干净的原因。

3. 背矩阵不如会推导:旋转、平移、缩放的矩阵形态

从教学角度来说,直接甩三个矩阵公式让人背是不负责任的。图像坐标系的 y 轴和数学坐标系是反的,角度正方向也容易搞混,只背公式的人遇到"顺时针还是逆时针"马上翻车。这一节我们把三个基本变换从头推一遍,顺便把坐标方向问题讲清楚。

3.1 缩放矩阵:最简单也最容易踩坑

缩放矩阵的推导几乎不需要推导,每个坐标分量独立乘一个系数:

[ sx 0 0 ] [ 0 sy 0 ] [ 0 0 1 ]

但实际使用时,几乎没人直接拿这个矩阵在完整图像上操作。为什么?因为图像会变尺寸。OpenCV 的 resize 走的是另一个通道,它内部会重新计算输出图像尺寸,还要做插值。如果你只想"缩放图像显示大小",用 resize 就行,根本不用构造矩阵。

真正需要手动构造缩放矩阵的场景,是在保持图像尺寸不变的前提下缩放内容。比如把画面中的目标缩小一些,周围的背景用固定颜色填充。这时的缩放矩阵必须配合平移使用:先平移到原点、缩放、再平移回中心附近,和旋转的处理逻辑完全一致。所以你会发现,缩放矩阵单独出现的频率并不高,更多是嵌在组合矩阵里。

3.2 平移矩阵:位姿的搬运工

平移矩阵上面给过了,它本身毫无悬念。但平移矩阵有一个容易忽略的细节:它不改变图像的"形状",只是改变位置。在 warpAffine 里,如果你让图像平移后超出了画布范围,会被裁掉,而不是自动扩展画布。这个行为需要通过修改 dsize 或 warpAffine 的边界模式来配合处理。

我实际做图像配准、做简单的数据增强时,会大量使用平移来模拟目标的轻微抖动。它的好处是计算代价为 0,而且不会引入插值噪声,是验证流水线是否正确的最便宜的变换。

3.3 旋转矩阵的方向约定

旋转矩阵的标准形态是:

[ cosθ -sinθ 0 ] [ sinθ cosθ 0 ] [ 0 0 1 ]

但这个形态是在数学坐标系(y 轴向上)里推导的。图像坐标系 y 轴向下,所以直接套这个公式,正角度带来的视觉方向会反过来。OpenCV 的角度正方向是顺时针,因为它的 y 轴向下。严格说,公式里 sinθ 的符号要按图像坐标系重新推一遍。

不用硬记符号,我给一个每次都不会错的判断方法:把坐标原点放在左上角,x 向右,y 向下。现在要旋转一点到 (1, 0)(即正右方),如果你希望它转到正下方(x=0, y=1),那么这个变换是顺时针旋转 90°。代入公式验证:

x' = cos90°*1 - sin90°*0 = 0 y' = sin90°*1 + cos90°*0 = 1

正好符合。所以图像坐标系下的旋转矩阵直接用:

[ cosθ sinθ 0 ] [-sinθ cosθ 0 ] [ 0 0 1 ]

注意就是把副对角线符号换了。写代码的时候你拿这个矩阵去乘 warpAffine,结果就是顺时针方向。想逆时针就把角度取负。

3.4 逆变换:让一切可以撤销

仿射变换有一个非常好的性质——几乎总是可逆的。只要左上角 2x2 部分的行列式不为 0(缩放系数不为 0),你就能求逆矩阵:

M_inv = np.linalg.inv(np.vstack([M, [0, 0, 1]]))[:2, :]

逆变换的实际用途很多。最常见的是数据增强里做坐标映射:你对图像做了旋转,想找到原图某个点的对应位置、或者把标注框从旋转后的图像映射回来,都需要求逆矩阵。另外,在做双线性插值式反向映射时,算法为了避免正向映射带来的空洞,会遍历目标图像每个像素、用逆矩阵找它在原图的位置,warpAffine 内部就是这么干的。

4. 图像旋转为什么会出现锯齿与空洞:插值才是画质关键

有基础的人知道旋转要有插值,但很少有人真的对比过最近邻、双线性、双三次之间的视觉差异,更少人知道"空洞"和"锯齿"这两个问题其实是不同机制造成的。这一节我们搞清楚它们的本质,再给一套在 OpenCV 中的实操选择建议。

4.1 正向映射 vs 反向映射

最直觉的实现方式是正向映射:遍历原图像素,算旋转后的目标位置,把灰度值搬过去。这样做的缺陷很明显:目标图像某些像素可能永远等不到原图像素"落到"它头上,形成空洞;而有些像素可能同时接收到多个原图像素,产生重叠。更糟的是,旋转后的坐标不是整数,取整会引入误差。

现代图像处理几乎不用正向映射,而是用反向映射:遍历目标图像的每个像素,用逆矩阵计算它在原图上的采样位置。因为目标图像的每个像素都会被精确覆盖一次,不会出现空洞。代价是采样位置往往落在非整数坐标上,因此需要一个插值函数来估计这个位置的灰度值。

4.2 三种插值方式的取舍

  • 最近邻(INTER_NEAREST):取最近的整数坐标像素值。速度快得离谱,适合像素风、调色板类图像,或者对精度要求极低的实时预览。缺点是边缘锯齿明显,旋转一个圆形很容易看到棱角。
  • 双线性(INTER_LINEAR,默认):取周围 2x2 邻域的加权平均。画质均衡、速度也不错,是默认选择。旋转照片、缩放图标用它基本够用。
  • 双三次(INTER_CUBIC):取 4x4 邻域做三次多项式拟合,效果更平滑,但计算量更大。对高质量输出有帮助,尤其是扫描文档、大图缩放这种对细节敏感的场合。

实际工程中,我默认用双线性,只有在输出最终成品时才切换到双三次,不要在每一帧实时处理上都用重插值,那很浪费算力。

4.3 一个可复现的插值对比实验

写段简单代码对比一下:

img = cv2.imread('demo.png') h, w = img.shape[:2] M = cv2.getRotationMatrix2D((w//2, h//2), 30, 1.0) for interp, name in [ (cv2.INTER_NEAREST, 'nearest'), (cv2.INTER_LINEAR, 'linear'), (cv2.INTER_CUBIC, 'cubic'), ]: rotated = cv2.warpAffine(img, M, (w, h), flags=interp) cv2.imwrite(f'rotated_{name}.png', rotated)

肉眼对比就能看出来,最近邻的结果像马赛克,双线性平滑但稍糊,双三次细节保留最好。如果你处理的是文字、条形码等高频细节图像,建议直接上双三次或者配合锐化后处理。

5. warpAffine 工具箱里的隐藏参数与边界细节

知道矩阵怎么构造,接下来就看 OpenCV 怎么消费这个矩阵。warpAffine的签名看起来长,其实真正影响结果的参数就几个,而且有几个坑是文档里不会明说的。

5.1 dsize:变换后图像尺寸决定一切

dsize是输出图像的宽度和高度。很多人以为旋转 30 度之后,图像自动变大,其实 warpAffine 不会帮你扩尺寸。你给定什么 dsize,它就给你多大画布,超出画布的内容直接切掉。

如果想让整个旋转后的图像完整显示,必须先算出四个角点经过 M 变换后的新包围盒,然后调整输出画布尺寸,甚至要重新修改 M 里的平移项让内容居中显示:

corners = np.array([[0, 0], [w, 0], [w, h], [0, h]], dtype=np.float32) transformed = cv2.transform(corners.reshape(-1, 1, 2), M).reshape(-1, 2) min_xy = transformed.min(axis=0) max_xy = transformed.max(axis=0) new_w = int(math.ceil(max_xy[0] - min_xy[0])) new_h = int(math.ceil(max_xy[1] - min_xy[1])) # 把内容平移回非负区域 M[0, 2] -= min_xy[0] M[1, 2] -= min_xy[1]

这段代码是做图像增强(random rotation)时最常用到的。不管怎么转,最终输出内容不会缺失。

5.2 borderMode 与 borderValue:处理边界的那点事

旋转后画布四个角往往没有原图像素覆盖,此时需要决定用什么颜色填充。默认是BORDER_CONSTANT+borderValue=0,也就是黑色填充。另一种常用的是BORDER_REPLICATE,用最边缘像素的颜色延伸填充,在高斯模糊、卷积操作里很常见。在仿射里,如果你希望旋转后背景与边缘颜色自然衔接,BORDER_REPLICATE往往比黑色填充观感好很多。

5.3 翻转变换的矩阵形态

也许你会觉得翻转变换和仿射无关,但它确实可以用仿射矩阵写出来。水平翻转等价于 x 方向缩放系数 -1,并做平移补偿:

[-1 0 w-1] [ 0 1 0]

这里w-1不是从 0 到 w 的距离,而是像素坐标从 0 到 w-1 的范围修正。直接用cv2.flip(img, 1)也能实现,但从矩阵角度理解有助于你理解"原点在左上角"这一约定会对所有变换产生偏移影响。

5.4 用矩阵实现缩放与复合变换

很多人以为 getRotationMatrix2D 只能做旋转,其实它的最后一个参数 scale 同时做缩放。它返回的矩阵是"旋转 + 缩放 + 中心平移补偿"的组合。但要注意,这里的缩放是各向同性的(x、y 方向相同)。如果需要 x、y 方向不同缩放,就得自己构造矩阵并复合。

一个常见的需求:把图像绕中心旋转 45 度后再横向压扁 0.8 倍。不能简单地把两个矩阵相加或直接改 alpha 和 beta,正确做法是矩阵连乘:

theta = math.radians(45) scale_x = 0.8 R = np.array([ [math.cos(theta), math.sin(theta), 0], [-math.sin(theta), math.cos(theta), 0], [0, 0, 1] ], dtype=np.float32) T1 = np.array([ [1, 0, -w/2], [0, 1, -h/2], [0, 0, 1] ], dtype=np.float32) S = np.array([ [scale_x, 0, 0], [0, 1, 0], [0, 0, 1] ], dtype=np.float32) T2 = np.array([ [1, 0, w/2], [0, 1, h/2], [0, 0, 1] ], dtype=np.float32) M = T2 @ S @ R @ T1 M = M[:2, :]

这个顺序可以展开为:先把图像中心平移到原点,然后旋转(绕原点转),接着按方向缩放,最后平移回原中心。如果你把 S 和 R 换个位置,后果就是先缩放再旋转,映射出来的结果完全不同,因为缩放改变了 x、y 方向比例后,旋转角度对应的空间关系就变掉了。

6. 组合变换的顺序问题与绕任意点旋转的完整推导

这一节是我最想强调的内容。仿射变换的每个"坑",几乎都来自"矩阵乘法不可交换"这一性质。我见过不少人把旋转和缩放的矩阵直接相加,或者在绕中心旋转时漏了平移步骤,结果图像飞到画布外。

6.1 顺序敏感的直观验证

拿 (1, 0) 这个点做实验。先旋转 90 度再沿 x 轴平移 1,和先平移再旋转 90 度,结果完全不同:

  • 顺序一:旋转后变成 (0, 1),平移后变成 (1, 1)。
  • 顺序二:平移后变成 (2, 0),旋转后变成 (0, 2)。

同样的两个操作,同样的参数,顺序不同结果差了十万八千里。所以代码里矩阵连乘时,建议在注释里写清每一步的语义,否则三天后回来看,自己都搞不清这组矩阵是"先平移到中心再旋转"还是"先旋转再平移回中心"。

6.2 绕任意点旋转的标准三步走

绕任意点 p=(px, py) 旋转的矩阵构造,本质上是三个矩阵连乘:

M = T(p) * R(θ) * T(-p)

也就是:先把坐标系原点移动到 p,然后绕原点转 θ,最后把原点移回 p。顺序必须是"移过去、转、移回来",少一步都会错位。上面代码里的 T1、R、T2 就是这个逻辑。我现在遇到所有"绕中心缩放""绕某个特征点旋转"的需求,都是按这个模板套,几乎不会出错。

6.3 工程中的浮点与精度陷阱

仿射矩阵里的三角函数、缩放系数都是浮点数,多个矩阵连乘后误差会累积。尤其在视频序列中逐帧估计变换矩阵时,如果直接把上一帧矩阵叠加到当前帧,几次迭代后会出现明显的漂移。建议多做一步:对最终矩阵做数值检查,确保左上角 2x2 部分的行列式不为 0、平移分量在合理范围内,必要时重新归一化。

另外,角度用弧度还是度数也容易搞混。OpenCV 的旋转矩阵函数接收的是度数,而手动用 numpy 构造矩阵时math.cos和np.cos接收的都是弧度。这个单位差异会让很多人对着一个仿佛旋转了 57.3 倍的矩阵发呆,排查半天才发现是单位问题。

6.4 从组合矩阵到深度学习里的空间变换网络

最后提一个扩展方向:仿射变换的可微版本。在深度学习中,空间变换网络(Spatial Transformer Network)就使用了仿射矩阵来生成采样网格,并用双线性插值实现端到端训练。你会发现,它内部的网格生成器、采样器,本质上就是本节讲的这套"反向映射 + 插值"机制。理解传统图像处理的仿射原理,再看论文里的公式会轻松很多。

我用这套知识做过的一个实际案例,是把某个目标检测数据增强里的随机旋转、随机缩放、随机平移全部合并成一个 warpAffine 矩阵。之前每次变换都单独调用一次 OpenCV 接口,速度慢还容易因为中间状态不一致导致标注框偏移;合并成单一矩阵后,所有操作一次完成,标注坐标只经过一次映射,效果又快又稳。这个思路也推荐给你:能用一次矩阵变换解决的事情,不要拆成多次。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询