OpenCV几何变换实战:仿射与透视变换的数学原理、API使用与踩坑指南
2026/9/16 4:28:15 网站建设 项目流程

“前几天有个读者私信问我,做毕业设计时用手机拍了一堆票据和笔记,想用 OpenCV 把每张图都‘扶正’成规整的矩形。他一开始想直接用 resize 拉伸,发现怎么弄都是变形,后来查到透视变换,又被四个角点怎么选、warpPerspective 参数怎么填卡住了。这个问题其实很有代表性——几何变换看着简单,真正用起来很容易栽跟头。这一篇我想把自己在 OpenCV 里折腾仿射变换和透视变换的经验完整梳理一遍:从数学原理讲到 API 使用,再到文档矫正、图片拼接这类落地场景,最后把容易踩的坑按问题清单列出来。不管你是刚装好 OpenCV 的新手,还是已经写过几个图像处理小项目的同学,照着这篇文章的思路走一遍,能省下不少自己盲目试验的时间。先说一句,环境上日常学习用 pip 装 opencv-python 就够,真没必要上来就研究源码编译,折腾半天可能连读图这一关都没过。”

1. 先搞懂几何变换在解决什么问题

1.1 手机拍文档为什么是“歪”的:聊聊图像坐标系

在正式开始仿射变换和透视变换之前,我建议先把 OpenCV 里的坐标系这个基础打牢。很多新手第一次跑变换,结果得到的图片是镜像的、上下颠倒的,根本原因就是没搞清图像坐标系和数学里常见的坐标系不是一个东西。

OpenCV 的图像坐标以左上角为原点 (0, 0),x 轴向右,y 轴向下。也就是说,y 坐标不是“向上增长”而是“向下增长”。这一点和我们在纸上画函数图像时习惯的“左下角原点、y 向上”正好相反。做仿射变换时,旋转角度是顺时针方向为正,因为 y 轴翻转了,角度定义也跟着翻转,这一点非常容易踩坑。

举个例子:你要把一张图顺时针旋转 30 度,OpenCV 的getRotationMatrix2D里角度写正 30 就行,但如果你拿数学课上学的那套“逆时针为正”来理解,就会觉得结果方向不对。图像坐标系里 y 向下,正角度就是顺时针,这是 OpenCV 的规则。理解了这一点,后面所有关于缩放、旋转、翻转的调试都能少走很多弯路。

1.2 仿射变换和透视变换:一张表看清区别

新手最常问的就是“仿射变换和透视变换到底差在哪”。一句话概括:仿射变换只能处理“平直空间里的线性变化”,而透视变换能处理“近大远小的投影变化”。

仿射变换保持直线和平行关系。原来平行的两条线,变换后依然平行;原来在一条直线上的点,变换后依然共线。它可以做平移、旋转、缩放、剪切,但不能把“平行线变成相交线”。举个直观例子,你把一张 A4 纸平放在桌面上,上下左右移动、旋转、放大缩小,这些操作都属于仿射变换的范畴。

透视变换同样保持直线性(直线上点的共线关系不变),但不保持平行性。原来平行的两条铁轨,在透视投影下看起来会在远处交于一点,这就是“消失点”。你用手机斜着拍一张文档,文档的近边看起来宽、远边看起来窄,这就是透视投影造成的结果。要把这种斜拍的图“扶正”,就必须用透视变换。

下面这张表是我自己整理的,基本把常用信息都涵盖了:

对比项仿射变换透视变换
矩阵形状2×33×3
自由度68
至少需要点对3组(不共线)4组(无三点共线)
直线性保持保持
平行性保持不保持
平移/旋转/缩放支持支持
近大远小效果不支持支持
典型场景图像旋转、平移、缩放、矫正倾斜文档矫正、鸟瞰图、全景拼接

1.3 变换到底做了什么:从像素坐标的角度看

无论是仿射还是透视,本质都是坐标变换:给你一个原始图像上的坐标 (x, y),通过一组系数算出目标图像上的坐标 (u, v),然后把原始图像的像素值搬过去。

仿射变换的公式是下面这个形式:

u = a*x + b*y + c v = d*x + e*y + f

注意这里是一次线性关系,所以无论怎么变,原来的一条直线变换后还是一条直线,平行线也依然是平行线。矩阵写法就是:

[u] [a b c] [x] [v] = [d e f] * [y] [1] [0 0 1] [1]

这里引入了一个额外维度,把二维坐标写成齐次坐标 (x, y, 1),目的是把平移操作放进矩阵乘法里。没有这个维度,平移就得单独写一个加法,矩阵就没法统一表达了。

透视变换的公式多了几个分母项:

u = (a*x + b*y + c) / (g*x + h*y + i) v = (d*x + e*y + f) / (g*x + h*y + i)

分母里的 g、h、i 就是产生“近大远小”的关键。当这些系数不为 0 时,坐标被除以了一个随位置变化的值,所以原来的平行线就可能变成相交线。但由于分子分母都是线性表达式,直线经过透视变换之后依然是直线,这就是为什么歪着拍的文档边缘仍然是直的,只是不再平行。

明白了这一层,你就能理解 OpenCV 里warpAffinewarpPerspective两个函数名里“warp”的含义——它不是简单的裁剪或缩放,而是把整个图像坐标重新“弯折”一遍,然后重新采样像素值。

2. 仿射变换入门:用 2×3 矩阵搞定平移、旋转和缩放

2.1 从矩阵到 API:getRotationMatrix2D 是怎么生成矩阵的

OpenCV 里最常用的仿射变换函数是cv2.getRotationMatrix2Dcv2.warpAffine。前者用来生成旋转矩阵,后者负责执行变换。

getRotationMatrix2D需要三个参数:旋转中心、旋转角度、缩放比例。例如:

import cv2 img = cv2.imread("demo.jpg") h, w = img.shape[:2] M = cv2.getRotationMatrix2D((w // 2, h // 2), 45, 1.0) rotated = cv2.warpAffine(img, M, (w, h))

这个 M 就是一个 2×3 的仿射变换矩阵。它的左上 2×2 部分负责旋转和缩放,第三列负责平移到指定的旋转中心。如果旋转中心不是图像中心,画面旋转后就会发生明显的位置偏移——比如绕左上角 (0, 0) 旋转,整张图会直接转出画布外。

这里有一个重要细节:getRotationMatrix2D的参数是角度制,不是弧度制。很多不熟悉 OpenCV 的朋友传入np.pi / 4这种弧度值,结果旋转角度小得几乎看不出来,还以为矩阵算错了。

再强调一点:OpenCV 里的正角度表示顺时针旋转。因为图像坐标系的 y 轴向下,数学里逆时针为正的惯性思维在这里会给出完全相反的结果。我自己最初就是从“逆时针为正”这个坑里爬出来的,所以每次写旋转之前都会默认加一句注释:这里 45 度是顺时针。

2.2 手写一个仿射变换矩阵:平移、旋转、剪切自由组合

如果只是旋转,用上面的函数就够了。但有时候我们需要自定义变换,比如“先旋转 30 度,再沿 x 方向平移 50 个像素,同时做 0.8 倍缩放”。这就要了解每个基础变换矩阵的样子。

平移矩阵:

M = [[1, 0, tx], [0, 1, ty]]

旋转矩阵(顺时针 theta 度,公式里 y 轴向下):

M = [[cos(theta), sin(theta), 0], [-sin(theta), cos(theta), 0]]

注意:由于图像坐标系 y 向下,顺时针旋转对应的 sin 符号和数学坐标系里逆时针旋转的公式是反的。你要是按数学课上的旋转矩阵来写,会发现图像往反方向转了。

缩放矩阵:

M = [[sx, 0, 0], [0, sy, 0]]

剪切矩阵(沿 x 方向剪切):

M = [[1, tan(alpha), 0], [0, 1, 0]]

组合多个基础变换时,要用矩阵乘法。这里必须提醒:矩阵乘法不满足交换律,矩阵顺序不能乱。比如“先旋转再平移”和“先平移再旋转”会得到完全不同的结果,因为平移量在旋转之后会被旋转带着转一圈。

import numpy as np angle = np.deg2rad(30) cos_a, sin_a = np.cos(angle), np.sin(angle) R = np.array([[cos_a, sin_a, 0], [-sin_a, cos_a, 0]], dtype=np.float64) T = np.array([[1, 0, 50], [0, 1, 30]], dtype=np.float64) # 先旋转,再平移 -> 结果是 R 和 T 的复合 M_combo = T @ np.vstack([R, [0, 0, 1]]) M_combo = M_combo[:2, :]

这个M_combo就是最终的 2×3 矩阵。我自己用这种手写矩阵做过不少自定义旋转实验,关键点是:把矩阵扩充成 3×3 形态(补上[0, 0, 1])再相乘,最后取前两行,这样矩阵才能正确复合。

2.3 warpAffine 实操:三个关键参数

cv2.warpAffine是执行仿射变换的函数,最常用的参数是 src、M、dsize:

dst = cv2.warpAffine(src, M, dsize)

其中 dsize 是输出图像的尺寸,格式是(宽度, 高度),不是(高度, 宽度)。这一点太容易搞反了。原因在于 OpenCV 里所有尺寸参数都遵循先 x 后 y、先宽后高的顺序,而 NumPy 数组访问是array.shape[0]是行数(高度)、array.shape[1]是列数(宽度)。所以上面代码里,h, w = img.shape[:2],如果要保持原图尺寸输出,dsize 要传(w, h)

实战中,旋转矩阵生成之后直接丢给warpAffine会出现一个问题:旋转后的四个角可能超出了原图范围,导致图像内容被截断。如果希望旋转后整张图完整保留,需要动态计算新画布的尺寸。

h, w = img.shape[:2] angle = 45 M = cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0) cos_a = abs(M[0, 0]) sin_a = abs(M[0, 1]) new_w = int(h * sin_a + w * cos_a) new_h = int(h * cos_a + w * sin_a) 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))

上面的代码里,新尺寸按旋转角度计算,然后将旋转矩阵的平移项做调整,把旋转中心平移到新画布中心。这样旋转 45 度后,四角的黑色区域就不会把内容挤到画面外。这个逻辑可以封装成一个函数,后面做任意角度旋转都很方便。

还有一个参数值得注意:warpAffine默认的插值方式是cv2.INTER_LINEAR。大多数场景下这个默认值就够用。如果是放大操作,可以用INTER_CUBIC让边缘更平滑;如果是缩小,用INTER_AREA能避免明显的锯齿和摩尔纹。插值方式对结果的影响在放缩比例较大时非常明显,后面在透视变换部分还会再说一次。

2.4 小实验:用仿射变换做图像旋转 180 度

热搜词里有个“opencv旋转180”,这里顺手写一下。

旋转 180 度听起来很简单,甚至有cv2.rotate可以直接用,但为了演示仿射变换的用法,我们用矩阵来实现:

M = np.array([ [1, 0, w], [0, 1, h] ], dtype=np.float64)

等等,随手这么写会把图像平移出画面。正确旋转 180 度的仿射矩阵是:

M_180 = cv2.getRotationMatrix2D((w / 2, h / 2), 180, 1.0) dst = cv2.warpAffine(img, M_180, (w, h))

我看到有人提“opencv rect函数 cols row”,这其实也和坐标误会有关。cols是矩阵列数,对应图像宽度;rows是行数,对应图像高度。很多人把(cols, rows)(height, width)混用,最终读图或者设置窗口尺寸时就乱了。记住一个口诀:cols = 列数 = x 方向 = 宽度;rows = 行数 = y 方向 = 高度。

3. 透视变换进阶:用 4 个点完成“扶正”操作

3.1 为什么仿射变换搞不定“近大远小”

回到文章开头那个读者的问题:斜着拍文档,为什么不能直接用仿射变换矫正?

因为斜拍时,文档在图像里的形状不是一个矩形,而是某种梯形或任意四边形。它的四条边本身还是直线,但相邻边不一定垂直,对边也不一定平行。仿射变换只能把“平行四边形”变成“平行四边形”,没法把一个“梯形”还原成“矩形”。

而透视变换可以把任意四边形映射到另一个四边形。它要做的就是找出 4 组对应的点对,解出 3×3 的单应矩阵,然后完成映射。

透视变换的矩阵是一个自由的 3×3 矩阵,共 9 个元素。但齐次坐标下相差一个常数倍就算同一个变换,所以实际自由变量是 8 个。8 个未知数需要至少 8 个约束方程,4 组点对正好能提供 4×2=8 个方程,所以最少需要 4 组点对。

有一点要特别提醒:4 组点对里任意 3 个点不能共线。如果三个点在同一条直线上,方程组会退化,矩阵无解。用鼠标选点时,四个点要大致形成一个四边形,不能有某个点落在另外两个点连成的线段上。

3.2 getPerspectiveTransform 的输入输出与点序规则

OpenCV 里生成透视变换矩阵的函数是cv2.getPerspectiveTransform

src_pts = np.float32([[56, 65], [368, 52], [28, 387], [389, 390]]) dst_pts = np.float32([[0, 0], [300, 0], [0, 400], [300, 400]]) M = cv2.getPerspectiveTransform(src_pts, dst_pts) result = cv2.warpPerspective(img, M, (300, 400))

这里最关键的是点序。src_ptsdst_pts的顺序必须一一对应:src 的第一个点映射到 dst 的第一个点,第二个映射到第二个,以此类推。如果你用鼠标选点时四个点没有按固定顺序记录,结果会非常神奇——图像可能被压扁、折叠,或者干脆翻转到奇怪的位置。

我一般习惯按这个顺序记录点:左上、右上、右下、左下,顺时针走一圈。无论原图还是目标图都用同一套顺序,这样基本不会乱。

np.float32传点是为了在 OpenCV 内部做浮点运算时保持精度。传整数数组在某些版本里可能被截断,导致最后的矩阵有轻微偏差,虽然肉眼不一定看得出,但在需要毫米级对齐的标定场景里会出事。

getPerspectiveTransform得到的是 3×3 矩阵,不是仿射变换里的 2×3 矩阵。如果把它直接传给warpAffine会报错,反过来把 2×3 矩阵传给warpPerspective也不行。这类参数类型问题在报错信息里通常很明确,但新手容易看不出是两个不同矩阵。

3.3 文档拍照矫正的完整流程:从找角点到输出结果

下面给出一段可运行的文档矫正流程,以身份证照片为例。

第一步,读图并手动选点。实际项目中,你可以通过轮廓检测或边缘检测自动找角点,但为了理解流程,先用固定点坐标演示:

import cv2 import numpy as np img = cv2.imread("id_card.jpg") h, w = img.shape[:2] # 身份证四个角的实际像素坐标(示例值) src_pts = np.float32([ [120, 80], # 左上 [520, 110], # 右上 [480, 340], # 右下 [100, 310] # 左下 ]) # 矫正后的目标坐标:宽 300,高 200,可根据身份证比例调整 dst_w, dst_h = 300, 200 dst_pts = np.float32([ [0, 0], [dst_w - 1, 0], [dst_w - 1, dst_h - 1], [0, dst_h - 1] ]) M = cv2.getPerspectiveTransform(src_pts, dst_pts) result = cv2.warpPerspective(img, M, (dst_w, dst_h))

注意目标坐标我这里写的是dst_w - 1而不是dst_w,是因为像素坐标是从 0 开始编号的。宽 300 的图像,像素索引范围是 0 到 299,所以最后一个点是 299。这个差 1 的细节平时不明显,但在精确测量和拼接里会造成一个像素的系统误差。

第二步,在图上画出选点位置,确认选点是否正确。你可以把四个点画出来再 show 一下,避免矩阵算出来之后根本不知道原图像哪个区域出问题:

vis = img.copy() for p in src_pts: p = tuple(p.astype(int)) cv2.circle(vis, p, 5, (0, 0, 255), -1) cv2.imshow("src_points", vis) cv2.waitKey(0)

第三步,输出矫正结果。如果矫正后图像仍有透视残留,最常见的原因是四个源点的位置不够准确。哪怕是两三个像素的偏差,在目标图放大后都会变得很明显,尤其是证件照边缘文字会发虚或倾斜。

做成交互式程序时,可以用 OpenCV 的鼠标回调函数cv2.setMouseCallback记录点击顺序,并在图上实时画点标号。实现逻辑很简单,但确实能救命,因为手点四个角看坐标很容易看花眼。

3.4 透视变换后图像发虚的原因与插值选择

很多同学做完透视变换之后发现,图像虽然“正”了,但文字边缘发虚,放大后能看到明显的锯齿和模糊。这个问题通常由两个原因造成,一是插值方式,二是输出分辨率设置。

透视变换在输出某个像素时,本质上是反过去查原始图里对应位置的颜色。由于浮点坐标一般不会正好落在某个整数像素中心,必须通过插值估算颜色。默认的INTER_LINEAR对文本图像来说效果一般,如果放大倍数较大,边缘会有模糊感。

对于文档类图像,我自己会尝试以下组合:

result = cv2.warpPerspective( img, M, (dst_w, dst_h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE )

INTER_CUBIC在放大时边缘过渡更平滑,适合文字和线条;INTER_AREA更适合缩小,能减少光晕感。另外,把目标图像分辨率设大一点再缩放,也是常见的保清晰度策略。比如身份证最终要 300×200,那我先输出 600×400,再用INTER_AREA缩回 300×200,效果通常比直接输出 300×200 更锐利。

borderMode这个参数我提一下:默认值是BORDER_CONSTANT,也就是变换后超出原图范围的区域填黑色。如果原图像素填充到目标区域时出现边缘黑线,可以使用BORDER_REPLICATE,它用边缘像素向外复制,适合去除因边界采样不到导致的黑边。

4. 组合变换与自动估计:让几何变换更“聪明”

4.1 多个基础变换的叠加:矩阵乘法的顺序不能乱

真实项目里很少只用一次变换就完事,常见需求是“先缩放,再旋转,最后平移到指定位置”。这三个步骤可以分别构建矩阵,然后复合:

scale = 0.8 theta = 30 S = np.array([[scale, 0, 0], [0, scale, 0], [0, 0, 1]], dtype=np.float64) angle = np.deg2rad(theta) R = np.array([[np.cos(angle), np.sin(angle), 0], [-np.sin(angle), np.cos(angle), 0], [0, 0, 1]], dtype=np.float64) T = np.array([[1, 0, 100], [0, 1, 50], [0, 0, 1]], dtype=np.float64) M = T @ R @ S M = M[:2, :]

这里的复合顺序是“先缩放,再旋转,最后平移”,对应矩阵乘法从右往左读:最右边的 S 先作用于坐标,然后 R,最后 T。如果调换顺序,结果会完全不一样。我踩过最典型的坑是想“把图像旋转并同时平移到某个位置”,结果旋转和平移的先后写反了,图像直接跑到画布外,排查半天才意识到是矩阵顺序的问题。

写复合矩阵时,建议把每一步的小矩阵都先写成 3×3,最后取前两行传给 OpenCV。这样能避免 2×3 矩阵乘法维度不匹配的烦恼,代码也更好维护。

4.2 从手工选点到特征匹配:findHomography 登场

手动选 4 个点适合做交互式矫正,但如果是批量处理几百张图片,就需要自动找对应点。

思路是这样的:用特征检测算法(比如 SIFT、ORB)在两张图里分别提取关键点和特征描述子,然后匹配出若干对对应点,再用这些点估算单应矩阵。OpenCV 里对应透视变换矩阵估算的函数是cv2.findHomography

# 假设 pts1 和 pts2 是两个数组,分别存放第一张图和第二张图中匹配点的坐标 H, mask = cv2.findHomography(pts1, pts2, cv2.RANSAC, 5.0)

findHomographygetPerspectiveTransform的区别是:后者只用 4 组点对,而且是精确解;前者可以传入几十、上百组匹配点,通过最小二乘或 RANSAC 求一个最优的单应矩阵,同时还能剔除误匹配点。

RANSAC 是这里的关键。特征匹配不可能 100% 正确,尤其是有重复纹理的图片。RANSAC 的思想是随机选几组点算出一个候选矩阵,然后统计有多少匹配点符合这个矩阵,最终留下支持者最多的矩阵。参数5.0表示允许的像素重投影误差阈值,误差小于 5 个像素的点视为内点。

这段自动估计的代码如果用在实际项目中,一般是这样的流程:检测特征 -> 匹配特征 -> 筛选匹配 -> 求 H。我在下面的小节里用一个伪全景拼接来演示。

4.3 案例:两张重叠图片的伪全景拼接

所谓“伪全景”,就是把两张有重叠区域的图片通过单应矩阵映射到同一个坐标系里,然后拼成一张更宽的图。它不如专业全景拼接那么稳健,但非常适合理解透视变换的用途。

假设有两张图img1img2,它们之间有大概 1/3 的重叠区。首先提取特征点并匹配:

import cv2 import numpy as np sift = cv2.SIFT_create() kp1, des1 = sift.detectAndCompute(img1, None) kp2, des2 = sift.detectAndCompute(img2, None) bf = cv2.BFMatcher(cv2.NORM_L2) matches = bf.knnMatch(des1, des2, k=2) good = [] for m, n in matches: if m.distance < 0.75 * n.distance: good.append(m) pts1 = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2) pts2 = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2) H, mask = cv2.findHomography(pts2, pts1, cv2.RANSAC, 5.0)

这里有一个细节:我想把img2映射到img1的坐标系里,所以findHomography的第一个参数传的是pts2,第二个传pts1。矩阵 H 表示从 img2 坐标到 img1 坐标的映射关系。如果写反了,拼接结果会把 img2 映射到整个画面的某个奇怪位置上。

然后计算拼接画布尺寸,并把 img2 warp 到 img1 的右侧:

h1, w1 = img1.shape[:2] h2, w2 = img2.shape[:2] corners_img2 = np.float32([ [0, 0], [w2, 0], [w2, h2], [0, h2] ]).reshape(-1, 1, 2) # 把 img2 的四个角映射到 img1 坐标系 warped_corners = cv2.perspectiveTransform(corners_img2, H) all_corners = np.concatenate((np.float32([ [0, 0], [w1, 0], [w1, h1], [0, h1] ]).reshape(-1, 1, 2), warped_corners), axis=0) x_min, y_min = all_corners[:, 0, :].min(axis=0).astype(int) x_max, y_max = all_corners[:, 0, :].max(axis=0).astype(int)

这个步骤很多人会直接跳过,结果拼接图像一部分被裁掉了,另一部分留下大片黑边。正确的做法是先估算所有角点在拼接坐标系中的范围,再设置画布大小。

最后把 img2 映射到大画布上,再把 img1 放进去:

translation = np.array([ [1, 0, -x_min], [0, 1, -y_min], [0, 0, 1] ], dtype=np.float64) canvas_w = x_max - x_min canvas_h = y_max - y_min M_full = translation @ H panorama = cv2.warpPerspective(img2, M_full, (canvas_w, canvas_h)) panorama[-y_min:-y_min + h1, -x_min:-x_min + w1] = img1

这里直接把 img1 覆盖上去,重叠区域会以 img1 为准。真实项目里,重叠区如果能做一下加权融合,接缝会更自然。最简单的做法是生成一个从 0 到 1 渐变的 alpha 掩码,在重叠区做线性混合。虽然效果不如多频段融合那么专业,但对于理解拼接原理来说已经足够。

5. 常见问题与排查技巧实录

5.1 变换后整幅图只剩黑边

这个现象在旋转和透视变换里都经常出现,原因基本两类:

第一类是新画布尺寸没有根据旋转角度扩展。原始图像旋转 45 度之后,四个角会超出原图边界,如果输出尺寸还是原图尺寸,四角就被裁掉了,看起来像“图变小了”。解决办法就是我前面写的,动态计算新画布尺寸,并调整矩阵平移量。

第二类是变换矩阵里的平移量设置过大,把图像平移到画面外。尤其是自己写仿射矩阵时,第二列最后一个数字(y 方向平移)给错了,图像会直接跑出画面。排查方法很简单:先把平移量设成 0,看看图能不能正常显示,再逐步加上平移看变化。

5.2 坐标明明对了,结果却是镜像或翻转

出现镜像或翻转,大多数情况是点对顺序没有保持一致。透视变换里,如果你把源点的“左上、左下、右上、右下”和目标点的“左上、右上、右下、左下”对应,那映射出来的图像就会有一条边被拉长,另一条边被压缩,看起来严重变形,甚至翻转。

还有一个原因是鼠标回调记录坐标时,不小心把(x, y)写成了(y, x)。图像坐标里 x 是列、y 是行,OpenCV 的点和 NumPy 数组的索引正好相反。如果你用img[y, x]去取像素,写反了就会得到转置效果。

调试技巧:用彩色笔画一个小箭头或者数字标号在图上,把每个选点的顺序可视化。这样变换结果出来后,你一眼就能看出“原本标号 1 的点跑到哪去了”。

5.3 dsize 和图片尺寸不一致导致裁切

warpAffinewarpPerspectivedsize是输出画布尺寸,它和源图像尺寸没有任何关系。设置小了会裁掉内容,设置大了会留出大片空白。

比如你把一张 1000×800 的图旋转 90 度,期望输出是 800×1000。如果直接用(1000, 800)作为 dsize,就会看到原本竖下来的图像被横向压扁,两边留黑。我建议每次调用之前都打印一下 dsize 的宽高,和预期输出尺寸对比,这种问题就没了。

另外,dsize的类型是整数元组,不要传浮点数。传了浮点数在某些版本里可以直接报错,另一些版本会静默截断,导致整张图错位几个像素。

5.4 鼠标选点做透视变换时点序混乱

用鼠标选点做透视变换是常见做法,很多人写出来的交互代码如下:

points = [] def on_mouse(event, x, y, flags, param): if event == cv2.EVENT_LBUTTONDOWN: points.append((x, y))

问题是:用户点完四个点之后,points里的顺序完全取决于用户点击的先后。如果用户先点了右上,再点左上,接着点左下,最后点右下,这组点虽然包含了四个角,但顺序是乱的。传给getPerspectiveTransform之后,目标点又按左上、右上、右下、左下排列,那结果必然不对。

我自己的解决办法是对已选点做排序。先按 y 坐标分为上半部分和下半部分,上半部分再按 x 排序得到左上和右上,下半部分按 x 排序得到左下和右下。或者更懒一点:在鼠标回调里画标号,并提示用户“按左上、右上、右下、左下依次点击”。如果四个点围成的四边形接近矩形,还可以用最小外接矩形的四个角点重新排布,但这种方法对斜得厉害的场景并不稳妥。

这个坑在交互式工具里实在太常见了,值得单独拿出来说。

5.5 常见问题速查表

最后把前面这些零散的问题整理成一张速查表,方便遇到问题时快速定位:

现象可能原因排查方向
图像内容被裁掉dsize 设置过小或旋转后画布未扩展打印 dsize,对比预期尺寸
大面积黑边平移量过大或画布太大将平移量归零测试
图像翻转/镜像点对顺序不一致可视化标记点序号
图像严重变形源点顺序与目标点顺序不对应检查点序,确保一一对应
文字边缘发虚插值方式不合适换 INTER_CUBIC / INTER_AREA
透视矫正后还有倾斜残留角点选取不准确放大图像后重新选点
程序运行时窗口无响应缺少 waitKey调用 cv2.waitKey(0)

关于waitKey,热搜词里有“opencv库waitkey为啥没参数时会卡主”。解释一下:cv2.waitKey(0)表示无限等待键盘事件,此时窗口会持续显示图像;如果写cv2.waitKey(30),那就是每 30 毫秒返回一次,适合视频流。很多人以为没有参数就会立刻返回,其实不是。显示静态图像时用waitKey(0)是最常用的做法。

再补一个实用小技巧:做完变换后,如果你想知道这次变换大概改变了多少内容,可以计算变换前后非零像素的占比,或者用cv2.matchTemplate去对比某个局部区域是否还对得上。这个方法在做图像配准调试时特别有用,能帮你在没有参考图的情况下判断矩阵是否大致正确。

关于这个几何变换系列,之后我打算接着写鱼眼图像矫正、立体匹配里的基础矩阵估计、图像配准在医学影像和遥感里的应用。这些话题全都建立在仿射变换和透视变换的底子上,把这篇的基础打牢,后面会顺畅很多。如果你在跑实验时也遇到过类似有意思的问题,欢迎在评论区里讲讲你处理的现象,我看到后会回复。

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

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

立即咨询