图像处理里最容易被低估的知识点,几何变换绝对算一个。很多人觉得resize、rotate这种操作谁不会,调个函数完事,可一旦真去做文档矫正、做图像配准、做数据增广,马上就会发现坐标换算、插值策略、边界填充这些细节一个都躲不掉。这篇是系列的第二篇,我把自己在项目里反复踩过的缩放、平移、旋转、仿射、透视变换这堆问题集中捋一遍,尽量做到看完就能直接套进自己的代码里。
这期内容主要面向已经会一点图像处理基础、但还停留在“调API”阶段的读者。如果你之前只会读图、存图、灰度化这类基础操作,也完全跟得上,我会把每个函数背后的坐标逻辑拆开讲,不绕弯子。先说明一点:文章里的代码示例都用常见的视觉库风格的Python接口,选它是因为在图像几何变换这件事上它是最趁手的,算法原理本身和具体库没太大关系,换个环境同样成立。
1. 几何变换的底层原理:每个像素都要回答的三个问题
几何变换说穿了就一句话:对图像上每个像素点的位置重新做一次映射。缩放是让采样间距变大变小,旋转是改变坐标方向,透视是把原本平行的关系破坏掉再重组。所有操作最终都落在同一个数学模型上——给你一个坐标(x, y),通过一个函数算出新坐标(x', y'),再去取像素值。
但这里有个很关键的分叉点:你到底是遍历原图把像素搬到新位置,还是遍历目标图反查原图该取哪个位置?这两个思路对应前向映射和后向映射,直接影响图像质量和实现难度。
1.1 前向映射与后向映射:为什么实际用的都是后向
前向映射的思路很直觉:遍历原图的每个像素,用变换公式算出它该去目标图的哪个位置,然后赋过去。问题在于,多个原图像素可能映射到同一个目标位置,也可能某些目标位置根本没有像素来源,结果就是图像上出现一块一块的空洞。要修这些洞得额外做插值补点,实现起来很啰嗦。
后向映射的思路反着来:遍历目标图的每个像素位置,用逆变换公式算回原图坐标,再去原图上采样。每个目标像素都能找到自己的“来历”,不会产生空洞,代码也干净。OpenCV里warpAffine、warpPerspective这类函数全部采用后向映射,你们去读源码或者文档里关于插值的说明,会发现它们讨论的都是“目标位置反向查源图”,原因就在这。
用个具体的数感受一下。假设有个像素在原图坐标(10, 20),绕图像中心逆时针转30度。旋转公式是:
x' = cos30 * x - sin30 * y y' = sin30 * x + cos30 * y带进去算出来可能是(4.32, 17.67),这个坐标是浮点数。后向映射的做法就是在目标图的(4.32, 17.67)附近找最合理的源像素值,这就要用到插值了。
1.2 几何变换的三大件:插值、边界、坐标系
任何一个几何变换函数,背后都藏着三个必答的问题:
- 映射后坐标是浮点,到底取哪个像素?这是插值问题。
- 目标图像里有大片区域映射到原图之外,填什么?这是边界问题。
- 坐标(x, y)的起点在哪、x轴和y轴方向怎么走?这是坐标系问题。
插值这块,OpenCV给了一堆选项:最近邻、双线性、双三次、区域平均、兰索斯。它们不是随机排列的“可选参数”,背后是质量、速度、语义的三角权衡。我后面会单独讲。
边界问题最常见也最容易被忽略。旋转30度之后,图像的四个角一定有一部分落在原画布外面,如果什么都不设置,OpenCV默认用黑色填充,于是就有了大家喜闻乐见的“黑边旋转图”。这不是bug,是边界策略没选对。
坐标系问题则是无数人写错变换代码的总根源。图像坐标系的原点在左上角,x轴向右,y轴向下。这和你在数学课本里画的坐标系y轴向上的习惯正好相反。旋转方向的判断、矩阵参数的正负号,全都被这个差异影响。
2. 缩放与旋转:最常用操作里藏得最深的两个坑
2.1 resize:插值不是随便选的
resize恐怕是接触OpenCV后第一个学会的函数,但十个新手九个在插值上栽跟头。cv2.resize(src, dsize, fx, fy, interpolation)里,dsize和fx, fy二选一用即可,同时传的时候OpenCV以dsize为准。注意dsize的写法是(宽度, 高度),也就是(列数, 行数),而NumPy数组的shape是(行数, 列数),所以最常用的写法是:
import cv2 img = cv2.imread("sample.jpg") h, w = img.shape[:2] # 缩放到指定尺寸:第一维是宽度w,第二维是高度h small = cv2.resize(img, (320, 240), interpolation=cv2.INTER_AREA) # 按比例放大2倍 big = cv2.resize(img, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_LINEAR)我把不同插值方法整理成一张表,实际项目里按这个选基本稳:
| 插值方法 | 特点 | 适合场景 | 放大时表现 | 缩小时表现 |
|---|---|---|---|---|
| INTER_NEAREST | 速度最快,硬边界 | 分割mask、调色板图、像素风 | 锯齿严重 | 容易丢细节 |
| INTER_LINEAR | 默认值,质量均衡 | 自然图像缩放 | 柔和,轻微模糊 | 会产生摩尔纹 |
| INTER_CUBIC | 更平滑,计算更重 | 高质量放大 | 边缘过渡自然 | 慢,偶有过冲 |
| INTER_AREA | 区域像素平均 | 缩小图像 | 不推荐用于放大 | 抗摩尔纹效果好 |
| INTER_LANCZOS4 | 理论上最精细 | 印刷级缩放 | 细节保留好 | 计算量最大 |
有个经验原则:放大优先用INTER_LINEAR或INTER_CUBIC,缩小优先用INTER_AREA。为什么缩小不要用双线性?因为缩小时一个目标像素对应源图一大块区域,双线性只采样四个点,高频信息会被折叠成摩尔纹;而INTER_AREA把源图对应区域整体做了均值,反而更接近“正确的局部平均”。
更隐蔽的一个坑是mask或者标签图的缩放。我在一个分割项目里给标签图做缩放,顺手用了双线性插值,结果标签图里全是0.35、0.68这种中间值,损失函数直接算不下去了。标签图、掩码这类语义图,一律用INTER_NEAREST,不要有任何模糊过渡,否则会人为造出不属于任何类别的中间语义。
2.2 旋转:黑边、画布和插值一个都躲不掉
OpenCV里没有单独的rotate图像函数,旋转要用cv2.getRotationMatrix2D加cv2.warpAffine组合:
h, w = img.shape[:2] center = (w / 2, h / 2) # scale=1.0,angle正值是逆时针方向(按图像坐标系定义) M = cv2.getRotationMatrix2D(center, 30, 1.0) rotated = cv2.warpAffine(img, M, (w, h))第一个坑是方向。getRotationMatrix2D文档写得很清楚,正角度代表“counter-clockwise”,但这个“逆时针”是按图像坐标系的y轴朝下定义的。你在屏幕上看效果,30度正角度,图像其实是顺时针转了30度。我第一次用的时候完全搞反,后来记了个口诀:在OpenCV里写正角度,视觉上就按“钟表方向”理解,别去套数学课的逆时针。
第二个坑是黑边。上面这段代码运行完,四个角一定全是黑的,因为默认borderValue是0。处理方式通常有两种:一是要求不高的场景直接把边填成背景色或白色:
rotated = cv2.warpAffine( img, M, (w, h), borderMode=cv2.BORDER_CONSTANT, borderValue=(255, 255, 255) )二是让输出画布自适应,把旋转后完整的内容都包进来。需要先算出旋转后外接矩形的宽高,再平移旋转中心:
M = cv2.getRotationMatrix2D((w / 2, h / 2), 30, 1.0) 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 / 2 - w / 2 M[1, 2] += new_h / 2 - h / 2 rotated = cv2.warpAffine(img, M, (new_w, new_h))为什么外接矩形要这样算?旋转后的外接矩形宽度等于“原图高度乘以sin角度”加“原图宽度乘以cos角度”,高度反之。这个公式是纯几何结论,我自己第一次用的时候直接抄,后来画了个矩形旋转的草图才算彻底明白。
第三个坑是画布尺寸变化。默认(w, h)等于原图尺寸,旋转后边缘内容被裁掉。很多人旋转一个横图90度发现图像被压扁了,其实就是忘了把输出尺寸换成(h, w)。
2.3 90度倍数的旋转别用warpAffine硬算
如果是90度、180度、270度这类特殊角,完全没必要走矩阵映射。cv2.rotate或者NumPy的np.rot90本质是数组搬运,不涉及像素重采样,速度快得不是一个量级:
# 顺时针90度 r = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE) # 等价写法 r = cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE) # 这里是逆时针90度,视觉上和上面的顺时针180度不是一回事,别混cv2.ROTATE_180就是180度旋转,cv2.ROTATE_90_COUNTERCLOCKWISE是逆时针90度。顺便说一句,很多OCR预处理里需要把歪着的图片“转正”,如果角度是90的整数倍,优先用这个,warpAffine的重采样反而会引入不必要的模糊。
3. 平移、镜像与组合变换:坐标方向上的数学账
3.1 平移矩阵:一句话记住正负方向
平移变换的矩阵很简单,2x3的矩阵第三列就是平移量:
import numpy as np M = np.float32([[1, 0, 50], [0, 1, 80]]) shifted = cv2.warpAffine(img, M, (w, h))tx=50图像向右移,ty=80图像向下移。图像坐标系x向右、y向下,所以正tx就是把图像往右推,正ty就是往下推。这个我记得很清楚,因为曾经把一个“向下平移100像素”的需求写成了-100,结果图案跑到了画面顶部外面,找了好久才意识到方向理解错位。
平移之后原位置空出来,同样可以设置borderValue或borderMode。实际业务里平移单独用的情况不多,更多是和其他变换组合,比如数据增广时的随机平移、图像拼接时的对齐微调。
3.2 flip的三个方向和命名陷阱
cv2.flip(src, flipCode)里的flipCode只有三个值,但命名非常容易让人栽跟头:
| flipCode | 含义 | 效果 |
|---|---|---|
| 0 | 绕x轴翻转 | 上下颠倒,垂直镜像 |
| 1 | 绕y轴翻转 | 左右颠倒,水平镜像 |
| -1 | 绕两个轴都翻转 | 相当于旋转180度 |
记忆的关键是“flipCode取值表示绕着哪条轴翻”。0对应绕着水平的x轴翻,所以是上下镜像;1对应绕着垂直的y轴翻,所以是左右镜像。我见过不少人把0当水平翻转用,出来的图上下颠倒才发现自己记反了。
垂直镜像在实际项目里有个常见误区:做数据增广时,随机水平翻转用flipCode=1没问题,但车牌、文字、数码这类有方向性的数据不能随便翻,否则语义就变了。翻转本质不产生平行偏移,它是坐标轴方向的镜像映射,如果你后续要做方向分类,这种增广方式要慎用。
3.3 组合变换:矩阵乘法顺序常反
缩放、旋转、平移经常要一起上。如果分别算矩阵再连续warp,中间会产生一次额外插值,图像质量受损。更好的办法是把所有变换合并成单个矩阵一次搞定。
问题在于合并的顺序,这里几乎是几何变换里错得最多的地方。假设先缩放2倍,再向右平移50像素:
scale_M = np.float32([[2, 0, 0], [0, 2, 0]]) shift_M = np.float32([[1, 0, 50], [0, 1, 0]]) # 正确顺序:先scale_M,再shift_M M = shift_M @ scale_M # 错误写法:先shift_M,再scale_M # M_wrong = scale_M @ shift_M为什么是shift_M @ scale_M?因为图像坐标写成齐次列向量[x, y, 1]^T后,变换是从右往左依次作用的。先应用scale_M,再应用shift_M,所以右边的矩阵是先生效的缩放。如果写成反过来的顺序,就会变成先平移后缩放,平移量也被乘以2,结果完全不对。
我自己验证过很多次,正确顺序的视觉结果是:图形先放大两倍,然后整体向右挪50;错误顺序的结果是:图形先右移50,再放大两倍,能看到图形在x方向的偏移量变成了100。差50个像素,放显示器上非常明显。合并两个以上变换时,一定要把“先发生的操作放在最右边”。
4. 仿射变换:三个点算出一组可靠的变换参数
4.1 仿射变换到底在做什么
仿射变换是几何变换家族里最通用的一套框架。它等于“线性变换加平移”,数学形式是2x3矩阵:
M = [[a, b, c], [d, e, f]]变换公式:
x' = a * x + b * y + c y' = d * x + e * y + f它有一个重要性质:保持直线和平行关系。原本平行的两条线,变换后依然平行。缩放、旋转、平移、镜像全是它的特例。你不需要为每种变换单独写逻辑,只要给出正确的2x3矩阵,warpAffine通通搞定。
4.2 为什么三个点就能定义仿射变换
矩阵里有6个未知数,而一对对应点提供两个方程(x和y各一个),所以三个点可以解出全部6个参数。OpenCV里对应的函数是cv2.getAffineTransform:
pts_src = np.float32([[50, 50], [200, 50], [50, 200]]) pts_dst = np.float32([[10, 100], [200, 50], [100, 250]]) M = cv2.getAffineTransform(pts_src, pts_dst) out = cv2.warpAffine(img, M, (w, h))理解这个自由度很重要。仿射只有6个自由度,所以它只能表达“整体的平移、旋转、缩放、切变”。如果你的目标形状是一个梯形,想把它拉成矩形,仿射变换做不到,因为梯形到矩形不保持平行性(梯形的斜边和底边本来就不平行,拉伸后依然不平行,最多改变倾斜角度,无法把两条斜边掰成竖直)。这种情况就要用透视变换,也就是下一节的内容。
我在一个项目里用鼠标在图上点三个特征点做对齐,当时很疑惑“为什么三个点而不是两个点就能固定整张图的映射”,后来想明白是每个点两个方程、三个点六个方程,和未知数正好匹配。如果你点两个点,方程数不够,解出来的矩阵有无数种可能,OpenCV会直接报错或者给出不稳定结果。
4.3 getRotationMatrix2D帮你算好的那些数
回到旋转的函数。cv2.getRotationMatrix2D(center, angle, scale)返回的2x3矩阵,实际上是下面这个结构:
α = scale * cos(angle) β = scale * sin(angle) M = [[ α, β, (1 - α) * cx - β * cy], [-β, α, β * cx + (1 - α) * cy]]第三列那两项看起来吓人,其实就是“先平移到中心、旋转缩放、再平移回来”的合并结果。这个函数的存在让我在项目里省了很多事。如果你需要绕某个自定义点旋转,只要把center传进去,不用自己推导平移项,全部处理好。
4.4 warpAffine的参数逐个说清楚
cv2.warpAffine(src, M, dsize, flags, borderMode, borderValue)里最容易忽略的是dsize和borderMode。
dsize是输出尺寸,写成(width, height)。有人写(h, w)之后图像变形,多半是这里搞反。flags是插值方式,默认INTER_LINEAR。borderMode默认BORDER_CONSTANT,即填固定值;设为BORDER_REPLICATE则复制边缘像素。文档矫正这类场景,旋转后边缘延伸出画布,用BORDER_REPLICATE看起来比黑边自然得多。borderValue在BORDER_CONSTANT时生效,可以填(255, 255, 255)让边变白。
这些参数不是每个都常用,但每个我都踩过。尤其是borderMode,很多教程完全没提,导致大家做旋转时只能默默接受黑边,其实一个参数就能改掉。
5. 透视变换:把歪拍摄图掰正的完整链路
5.1 从八参数矩阵到梯形矫正
透视变换是最常用的场景重建工具,数学形式是3x3矩阵,但自由度只有8(矩阵右下角固定为1)。它的变换公式里带了分母:
x' = (a*x + b*y + c) / (g*x + h*y + 1) y' = (d*x + e*y + f) / (g*x + h*y + 1)这个分母就是“透视感”的来源。仿射变换只能做平行映射,透视变换则允许直线依然保持直线,但平行线不再平行。你看一张梯形卡片,用透视变换可以把它的四条边重新拉成一个矩形,这在文档拍照矫正里几乎是每篇都要用。
OpenCV里对应两个函数:cv2.getPerspectiveTransform(src_pts, dst_pts)用四对点求3x3矩阵,cv2.warpPerspective(img, M, dsize)做实际映射。
5.2 一个文档矫正的完整Demo
我做过一个名片拍照自动矫正的小工具,核心逻辑是找图里的四边形,映射成正矩形。完整流程分四步:找轮廓、排四角、算目标尺寸、透视变换。
第一步找四边形轮廓:
import cv2 import numpy as np img = cv2.imread("card.jpg") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur = cv2.GaussianBlur(gray, (5, 5), 0) edged = cv2.Canny(blur, 50, 150) contours, _ = cv2.findContours(edged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) bigcontour = max(contours, key=cv2.contourArea) quad = cv2.approxPolyDP(bigcontour, 0.02 * cv2.arcLength(bigcontour, True), True) pts = quad.reshape(4, 2).astype(np.float32)第二步排四角顺序。这里是最容易乱的环节。approxPolyDP给出的四个点顺序并不固定,可能从任意一个角开始,可能是顺时针也可能逆时针。我通常用排序加夹角判断保证顺序是左上、右上、右下、左下:
def order_points(pts): rect = np.zeros((4, 2), dtype=np.float32) s = pts.sum(axis=1) rect[0] = pts[np.argmin(s)] # 左上:x+y最小 rect[2] = pts[np.argmax(s)] # 右下:x+y最大 diff = np.diff(pts, axis=1) rect[1] = pts[np.argmin(diff)] # 右上:x-y最小 rect[3] = pts[np.argmax(diff)] # 左下:x-y最大 return rect这种“按坐标和与差排序”的写法在四边形接近矩形时很稳定,但遇到很畸形的梯形可能会翻车。稳妥的做法是选定一个初始角点,接着算它到其他三个点的向量角度,按角度排序。我在项目里两种都写过,最终推荐正式场景用“先找一个最左上的点,再按极角排序”的方案,sum/diff只适合文档轮廓比较正的场景。
第三步算目标矩形的宽高。用四角之间的欧氏距离近似:
width = int(max( np.linalg.norm(pts[1] - pts[0]), np.linalg.norm(pts[2] - pts[3]) )) height = int(max( np.linalg.norm(pts[3] - pts[0]), np.linalg.norm(pts[2] - pts[1]) ))第四步做透视映射:
dst = np.float32([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]]) M = cv2.getPerspectiveTransform(pts, dst) result = cv2.warpPerspective(img, M, (width, height))这四步完整跑下来,斜着拍的名片基本能矫正到正面视角。实际效果好坏主要卡在第二步的顺序和第四步的尺寸选取,我在那个工具里迭代了三轮才算稳定。
5.3 选点顺序与输出尺寸的坑
getPerspectiveTransform本身不要求四个点按某种特定顺序传入,但它要求源图四点和目标四点按相同顺序一一对应。如果你源点顺序是左上、左下、右下、右上,而目标点顺序是左上、右上、右下、左下,映射出来的图就会发生“上下翻转”或“左右翻转”,看起来像被掰了一个奇奇怪怪的扭曲。
我排查过一种情况:矫正出来的文字整体倒着,或者左右镜像。十有八九是选点顺序不一致。所以写代码时我习惯先定义一个固定的顺序常量,无论是鼠标点击、轮廓提取还是手工固定的角点,最后都先统一排序再传给getPerspectiveTransform。
输出尺寸上也有个容易翻车的点。warpPerspective的dsize是可以任意指定的。如果你按检测到的轮廓宽高来输出,得到的图是“文档本身拉直”;如果按原图尺寸输出,得到的只是一种轻微的相机视角调整,并不是真正的矫正。做OCR之前一般用前者,让文字区域最大限度展开。
6. 几何变换的落地场景:增广、拼接与视角转换
6.1 数据增广里的同步变换
几何变换在深度学习项目里最常见的位置就是数据增广。随机旋转、随机平移、随机缩放、随机水平翻转都能提升模型泛化能力,但这里有一个非常容易忽略的问题:图像变换了,标签也要跟着变。
坐标框跟着图像做同样的仿射变换,说起来一句话,写起来全是坑。比如旋转30度之后,原本的矩形框旋转后变成了一个“倾斜的矩形”,很多检测代码要求框必须是轴对齐的,你还得重新计算外接矩形。我之前做过一个管线,直接用warpAffine旋转图像,然后把bbox四个角也用矩阵乘一遍,再取新的外接框,才把这个问题搞定。
有个省心的小技巧:增广阶段不要对整图做透视变换,尽量只做仿射。因为透视变换会把矩形框变成不规则的四边形,绝大多数标注格式处理不了。
6.2 图像拼接与配准:几何变换是粘合剂
做全景拼接时,两幅图之间往往只有在重叠区域,需要先找到特征点匹配关系,再用匹配点求出透视矩阵,最后把一张图映射到另一张的坐标系里。这套流程我拆开看就是两个几何变换的时刻:求矩阵时用的是findHomography,应用时用的是warpPerspective。
说到findHomography,它和getPerspectiveTransform的区别值得提一句:前者用特征匹配出来的一堆点对做鲁棒估计,能剔除错误匹配;后者用精确给定的四对点直接解矩阵。取景正常但有轻微畸变时,用前者更稳。若某个场景只需要用手工选的四个角点做矫正,后者就够了。
6.3 鸟瞰视角转换:把相机坐标系掰成俯视图
车辆感知里经常要把前视相机画面投影成俯视的鸟瞰图。这个操作在很多文章里叫IPM,本质仍然是透视变换,只是源点和目标点从标定流程中获得:在平地上放一张棋盘格,标出网格交点,把前视图里这些点映射到俯瞰网格坐标,得到透视矩阵,之后每一帧图像都用同一个矩阵做warpPerspective。
这个场景里最需要注意的是“高度”问题。透视变换假设所有目标点都在同一个平面上,所以它能正确矫正地面上的车道线,却会把行人、路牌这些有高度的物体拉得变形。有一次做车道线检测项目,我把行人区域也一起投影到鸟瞰图,结果行人被拉成一条斜柱,问题定位了很久才想到是平面假设被打破了。
7. 动手前必看的避坑清单与参数速查
我把这些年踩过的坑整理成一张表,按问题出现的频率排序。每一条都是真实项目里消耗过时间的:
| 问题 | 根因 | 处理方式 |
|---|---|---|
| 旋转后黑边 | 默认borderValue=0 | 设置borderMode或borderValue为白边/复制边 |
| 标签图缩放出现小数类 | mask用了线性插值 | 标签图必须用INTER_NEAREST |
| resize后图像变形 | dsize写成(h, w) | 记住dsize顺序是(width, height) |
| 组合变换结果和预期不符 | 矩阵乘法顺序写反 | 先生效的变换放最右边 |
| 透视矫正出来图是翻转的 | 源点和目标点顺序不一致 | 统一先排序再传getPerspectiveTransform |
| 旋转方向老是反 | 图像坐标系y轴向下 | 记住OpenCV正角度在屏幕上表现为顺时针 |
| warpAffine直接报错矩阵格式不对 | M不是float32 | M先用np.float32转换 |
参数速查我也顺手写一下:
cv2.resize(src, (w, h), fx, fy, interpolation):dsize与fx/fy二选一;缩小用INTER_AREA,放大用INTER_LINEAR,mask用INTER_NEAREST。cv2.getRotationMatrix2D(center, angle, scale):返回2x3矩阵,正角度视觉为顺时针。cv2.warpAffine(src, M, dsize, flags, borderMode, borderValue):M是2x3浮点矩阵,dsize是(w, h)。cv2.getAffineTransform(srcTriangle, dstTriangle):各传3个点。cv2.getPerspectiveTransform(srcQuad, dstQuad):各传4个点。cv2.warpPerspective(src, M, dsize):M是3x3浮点矩阵。cv2.flip(src, flipCode):0上下,1左右,-1旋转180度。
最后再分享一个我个人的做事习惯:拿到一张要变换的图像,我不会直接写代码,而是先在草稿纸上画一个小的坐标系,手动标出几个关键点的坐标变化,再用这些“手算结果”去验证矩阵输出。做几何变换这类数值敏感的算子,纸上推演一遍能省掉很多调试时间,这套习惯在我写文档矫正模块时帮我少走了不少弯路。