1. 从二维图像到三维世界:为什么我们需要对极几何?
想象一下,你闭上一只眼睛,只用另一只眼睛看世界,然后试着去抓取桌上的一支笔。是不是感觉有点困难,很难判断笔离你有多远?这就是单目视觉的困境:一张照片,就像你的一只眼睛,它记录了丰富的颜色和纹理,却丢失了最关键的深度信息。我们的大脑之所以能感知三维世界,很大程度上依赖于两只眼睛(两个视角)带来的视差。
在计算机视觉领域,三维重建的目标,就是从一堆二维的“照片”中,恢复出物体和场景的三维结构。这听起来像魔法,但其核心的数学魔法之一,就是我们今天要深入探讨的对极几何。简单来说,对极几何描述的是同一个三维点,在两个不同视角(相机位置)拍摄的图像中,其投影点之间所必须遵守的几何约束关系。
这个约束关系用一个叫做基础矩阵的3x3矩阵来代数化表示。一旦我们算出了这个基础矩阵,就等于掌握了这两张照片之间的“空间密码”。它能告诉我们:
- 如何找对应点:如果我在左图看到了一个特征点(比如建筑物的一个角点),基础矩阵可以告诉我,这个点在右图中的对应点,必然落在一条特定的直线上。这条线就叫极线。这大大缩小了搜索范围,从整张图变成了一条线。
- 如何恢复相机运动:通过分析基础矩阵,我们可以反推出两个相机之间的相对运动——是旋转了,还是平移了,或者两者都有。
- 三维重建的基石:有了匹配好的对应点和相机相对姿态,结合三角测量原理,我们就能计算出这个点的三维坐标。一个点接一个点,整个三维模型就“长”出来了。
所以,无论你是想用手机拍个视频重建自己的房间模型,还是想让自动驾驶汽车理解周围环境,对极几何和基础矩阵都是你必须跨越的一道技术门槛。别被“几何”、“矩阵”这些词吓到,接下来,我会用最直白的语言和可运行的代码,带你亲手解开这个三维密码。
2. 对极几何:两幅图像之间的空间密码本
2.1 核心三要素:极点、极平面与极线
让我们暂时忘掉公式,先来看一张经典的示意图,并理解三个核心“角色”。
假设我们有两个相机,就像我们的两只眼睛,分别位于O1和O2点。在我们面前有一个空间点P(比如一个路灯)。P点在左相机(O1)的成像平面上投影为p1,在右相机(O2)的成像平面上投影为p2。
现在,连接两个相机中心O1和O2,这条线被称为基线。基线就像是连接两个视点的“轴”。
- 极平面:由空间点P和两个相机中心O1、O2这三个点所确定的平面。你可以把它想象成一片穿过路灯和两个相机位置的“薄片”。
- 极点:基线(O1O2)与成像平面相交的点。具体来说,左相机的成像平面与基线相交于e1点,这就是左图的极点;同理,右图的极点是e2。极点有一个非常直观的物理意义:它是另一个相机中心在本图像中的投影。比如,e1就是右相机中心O2在左图照片里的位置。
- 极线:极平面与成像平面的交线。对于左图上的点p1,其对应的极线l1就是极平面与左成像平面的交线。这条线穿过极点e1和点p1。它的意义至关重要:右图中p1的对应点p2,必然落在这条极线l1上。反之亦然。
这就是对极几何告诉我们的核心约束:给定一张图像上的一个点,它在另一张图像中的对应点,被约束在一条特定的直线上。这个约束将对应点搜索这个“大海捞针”的问题,变成了“沿线钓鱼”的问题。
2.2 相机运动如何影响极线形态?
理解了基本概念,我们来看看在实际拍摄中,不同的相机运动会带来怎样不同的极线图案。这能帮你直观地判断拍摄情况。
-
情况一:汇聚视角(极点位于图像内) 这是最一般的情况。比如你站在原地,先向左拍一张,再向右拍一张,或者用两个相机从不同角度对准同一个物体。此时,基线不与成像平面平行,极点e1和e2会落在图像的实际区域内。在这种情况下,极线会呈现汇聚的形态,所有极线都相交于极点。你在原始文章的实验结果图里看到的左右拍摄的建筑物,就是这种情况。
-
情况二:纯平移,且平移方向平行于成像平面(极点位于无穷远) 这是一种特殊情况,但非常常见。比如你把相机固定在滑轨上,水平移动拍摄。此时,两个相机的光轴方向是平行的,基线也平行于成像平面。想象一下,两条平行线在无穷远处相交。因此,极点位于图像平面的无穷远处。对应的极线会变成一组平行线。原始文章中像平面接近平行的书包图片,极线就是近乎水平的平行线,非常典型。
-
情况三:前后移动(极点位于图像中心附近) 如果你朝着一个物体直线前进或后退时拍照。此时,基线方向与光轴方向基本一致。两个极点e1和e2会非常接近各自图像的中心。所有极线会像太阳光芒一样,从图像中心(极点)向外放射状散开。原始文章里前后位置拍摄的房间图,就展示了这种放射状的极线。
理解这些形态非常重要。当你在实际项目中看到计算出的极线是平行的,你就能推断相机很可能是做了水平平移;如果是放射状的,则可能是前后移动。这为后续分析相机运动提供了直观线索。
3. 基础矩阵:对极几何的代数“翻译官”
几何关系很直观,但计算机需要数字和公式来计算。基础矩阵F,就是一个3x3的矩阵,它用代数语言精确地描述了对极几何约束。
3.1 从本质矩阵E到基础矩阵F
要理解F,我们先认识它的“亲戚”——本质矩阵E。本质矩阵描述的是在归一化相机坐标系下的对极约束。什么是归一化坐标系?就是假设相机的内参矩阵K是已知的,并且我们已经用K的逆矩阵,把像素坐标(x, y)转换成了在相机前方z=1的平面上的坐标(相当于去除了焦距、主点等内部参数的影响)。
本质矩阵E的推导核心在于相机的外参——旋转R和平移T。它建立了两个归一化坐标点p和p‘之间的关系:p'^T * E * p = 0。其中E = [T]_x * R,[T]_x是平移向量T的反对称矩阵。这个公式的几何意义是:向量O2p‘(右图点)、平移向量T、向量O1p(左图点)三者共面,这正是极平面的定义。
然而,我们通常拿到的是原始的像素坐标,而不是归一化坐标。这时,就需要引入相机的内参矩阵K(包含焦距、主点等参数)。我们知道像素坐标x和归一化坐标p的关系是:x = K * p, 所以 p = K^(-1) * x。
将这个关系代入本质矩阵的公式,我们就得到了基础矩阵F:
x'^T * F * x = 0, 其中 F = K'^(-T) * E * K^(-1) = K'^(-T) * [T]_x * R * K^(-1)。
看,基础矩阵F其实就是内参“包裹”下的本质矩阵。它直接关联了两幅图像上的像素坐标x和x‘。这个简洁的方程 x'^T * F * x = 0, 就是整个对极几何代数表达的核心。
3.2 基础矩阵F的性质与自由度
基础矩阵F虽然是一个3x3的矩阵,有9个元素,但它并不是随意的。它有两个关键性质,决定了它实际上只有7个自由度:
- 尺度不确定性:如果F是一个基础矩阵,那么kF(k为非零标量)也是一个有效的基础矩阵。因为
x'^T * (kF) * x = k * (x'^T * F * x) = 0。这意味着F只能确定到一个尺度因子。这好比说,我知道两条线是平行的,但无法确定它们之间精确的距离是多少倍。 - 秩为2:基础矩阵的秩必须是2。这是一个更强的约束。从它的构成
F = K'^(-T) * [T]_x * R * K^(-1)来看,其中反对称矩阵[T]_x的秩就是2(因为它代表叉乘),而内参矩阵K和旋转矩阵R都是满秩的可逆矩阵,不会改变秩。所以F的秩也是2。秩为2意味着其行列式det(F) = 0。
9个元素,减去一个尺度自由度,再减去秩为2带来的一个约束(行列式为0),所以基础矩阵F共有 7个自由度。这个性质在求解F时至关重要,理论上最少只需要7对匹配点就可以求解(七点法)。
4. 实战:用OpenCV估计基础矩阵与绘制极线
理论说得再多,不如亲手跑一遍代码。下面我们就用Python和OpenCV,完整走一遍从特征匹配到计算基础矩阵,再到绘制极线的全过程。我会详细解释每一步,并分享我调试时踩过的坑。
4.1 环境准备与数据获取
首先,确保你的环境里安装了必要的库。打开终端或Anaconda Prompt,执行以下命令:
pip install opencv-python opencv-contrib-python matplotlib numpy
注意:opencv-contrib-python 包含了SIFT等专利算法(在较新版本中,SIFT已移至主库,但安装contrib包更保险)。
准备两张从不同视角拍摄的同一场景的照片。你可以用自己的手机拍摄,注意尽量保持场景大部分重叠。我把我的示例图片命名为 left.jpg 和 right.jpg。
4.2 代码逐步解析
我们来创建一个Python脚本,我把它命名为 epipolar_geometry.py。
第一步:特征检测与匹配 我们使用SIFT算法来寻找两幅图像中的特征点(如角点、边缘等),并用FLANN匹配器进行初步匹配。
import cv2
import numpy as np
from matplotlib import pyplot as plt
# 1. 读取图像
img1 = cv2.imread('left.jpg', cv2.IMREAD_GRAYSCALE) # 左图
img2 = cv2.imread('right.jpg', cv2.IMREAD_GRAYSCALE) # 右图
# 2. 初始化SIFT检测器
sift = cv2.SIFT_create()
# 3. 检测关键点并计算描述子
kp1, des1 = sift.detectAndCompute(img1, None)
kp2, des2 = sift.detectAndCompute(img2, None)
# 4. 使用FLANN进行特征匹配
FLANN_INDEX_KDTREE = 1
index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)
search_params = dict(checks=50) # 检查次数,越高越精确越慢
flann = cv2.FlannBasedMatcher(index_params, search_params)
matches = flann.knnMatch(des1, des2, k=2) # 为左图每个特征点在右图找两个最近邻
# 5. Lowe‘s 比率测试:过滤掉不好的匹配
good_matches = []
pts1 = []
pts2 = []
ratio_thresh = 0.8 # Lowe论文推荐值,可调整
for m, n in matches:
if m.distance < ratio_thresh * n.distance:
good_matches.append(m)
pts2.append(kp2[m.trainIdx].pt) # 右图匹配点坐标
pts1.append(kp1[m.queryIdx].pt) # 左图匹配点坐标
# 将点列表转换为NumPy数组
pts1 = np.int32(pts1)
pts2 = np.int32(pts2)
print(f"找到了 {len(good_matches)} 对良好的匹配点。")
踩坑提醒:ratio_thresh 这个参数很关键。太严格(如0.6)可能过滤掉太多正确匹配,导致点数不够;太宽松(如0.9)则会引入大量错误匹配,污染基础矩阵估计。通常0.7-0.8是个不错的起点,需要根据你的图像特点微调。
第二步:估计基础矩阵
OpenCV提供了 cv2.findFundamentalMat 函数,它内部使用了RANSAC等鲁棒算法来剔除错误匹配(外点),并用八点法等计算F。
# 6. 使用RANSAC算法估计基础矩阵
# 方法 cv2.FM_RANSAC 配合置信度参数,可以更好地处理错误匹配
F, mask = cv2.findFundamentalMat(pts1, pts2, cv2.FM_RANSAC, ransacReprojThreshold=3.0, confidence=0.99)
# mask是一个掩码,标记哪些点是内点(符合模型的点)
print("估计得到的基础矩阵 F:")
print(F)
# 7. 只保留被标记为内点的匹配对
pts1_inlier = pts1[mask.ravel() == 1]
pts2_inlier = pts2[mask.ravel() == 1]
print(f"RANSAC后保留的内点数量:{len(pts1_inlier)}")
这里 ransacReprojThreshold 是RANSAC算法中判断一个点是否为内点的距离阈值(单位是像素)。值越小,判断越严格,得到的F越精确,但也可能丢弃更多点。confidence 是算法期望达到的置信度。使用RANSAC至关重要,因为第一步的匹配中必然存在错误,RANSAC能自动帮我们找出最可能正确的子集来计算模型。
第三步:计算并绘制极线 根据基础矩阵F,我们可以为一张图像中的点,计算它在另一张图像中对应的极线。
# 8. 定义一个绘制极线的函数
def draw_epilines(img1, img2, lines, pts1, pts2):
"""在img1上绘制对应于pts2的极线,并标记匹配点对"""
r, c = img1.shape
img1_color = cv2.cvtColor(img1, cv2.COLOR_GRAY2BGR)
img2_color = cv2.cvtColor(img2, cv2.COLOR_GRAY2BGR)
# 为了可视化清晰,我们随机选取一部分点来画线(比如50个)
import random
indices = random.sample(range(len(lines)), min(50, len(lines)))
for idx in indices:
r = lines[idx]
pt1 = pts1[idx]
pt2 = pts2[idx]
color = tuple(np.random.randint(0, 255, 3).tolist())
# 极线方程: l = [a, b, c], 满足 a*x + b*y + c = 0
# 计算直线在两个边界(x=0 和 x=c-1)上的点
x0, y0 = 0, int(-r[2]/r[1])
x1, y1 = c, int(-(r[2] + r[0]*c)/r[1])
# 在左图(img1)上画极线
img1_color = cv2.line(img1_color, (x0, y0), (x1, y1), color, 1)
# 在左图(img1)上标记点
img1_color = cv2.circle(img1_color, tuple(pt1), 5, color, -1)
# 在右图(img2)上标记对应的点
img2_color = cv2.circle(img2_color, tuple(pt2), 5, color, -1)
return img1_color, img2_color
# 9. 计算极线并绘制
# 为右图中的点,计算它们在左图中的极线
lines_left = cv2.computeCorrespondEpilines(pts2_inlier.reshape(-1, 1, 2), 2, F)
lines_left = lines_left.reshape(-1, 3)
img1_with_lines, img2_with_points = draw_epilines(img1, img2, lines_left, pts1_inlier, pts2_inlier)
# 为左图中的点,计算它们在右图中的极线
lines_right = cv2.computeCorrespondEpilines(pts1_inlier.reshape(-1, 1, 2), 1, F)
lines_right = lines_right.reshape(-1, 3)
img2_with_lines, img1_with_points = draw_epilines(img2, img1, lines_right, pts2_inlier, pts1_inlier)
# 10. 显示结果
plt.figure(figsize=(15, 10))
plt.subplot(221), plt.imshow(img1_with_lines), plt.title('左图:来自右图匹配点的极线')
plt.subplot(222), plt.imshow(img2_with_points), plt.title('右图:匹配点')
plt.subplot(223), plt.imshow(img2_with_lines), plt.title('右图:来自左图匹配点的极线')
plt.subplot(224), plt.imshow(img1_with_points), plt.title('左图:匹配点')
plt.tight_layout()
plt.show()
cv2.computeCorrespondEpilines 这个函数非常方便,它直接利用基础矩阵F和一批点,计算出它们在另一幅图像中对应的极线方程 ax + by + c = 0。参数 2 或 1 指定输入点来自哪一幅图像(第二幅或第一幅)。
4.3 结果分析与调试经验
运行代码后,你会看到类似原始文章中的极线图。理想情况下,所有极线都应该汇聚于一个点(极点)。如果极线看起来杂乱无章,没有汇聚趋势,通常意味着:
- 特征匹配质量太差:这是最常见的原因。可以尝试:
- 更换特征点算法,比如用ORB(速度更快,但可能精度稍低)或AKAZE。
- 调整
ratio_thresh参数,使其更严格。 - 增加
cv2.findFundamentalMat中的ransacReprojThreshold值,但别太大(一般1.0-5.0)。
- 图像重叠区域太小:两张图片拍摄的视角差异过大,共同的特征点太少。三维重建一般要求重叠度在60%以上。
- 相机存在严重的非线性畸变:如果用的是手机广角镜头,图像边缘畸变较大,会影响特征匹配和几何约束。可以在特征匹配前,先用OpenCV的
cv2.undistort函数进行镜头畸变校正。
我个人的经验是,对于室内场景或纹理丰富的建筑物,SIFT+RANSAC的组合非常稳健。对于室外远景或纹理重复的场景(如草地、天空),匹配会更困难,需要更精细的参数调整或使用其他匹配策略。
5. 从基础矩阵到三维重建:完整的路径
计算出稳健的基础矩阵F,只是三维重建长征的第一步。接下来,我们需要沿着一条清晰的路径,从二维图像走向三维点云。
5.1 分解基础矩阵:获取相机运动(R, T)
基础矩阵F蕴含了相机间的相对运动信息 [R|t],但我们需要把它“解包”出来。这个过程称为从F或E中恢复姿态。OpenCV提供了 cv2.recoverPose 函数,它可以从本质矩阵E中恢复出R和t。
但注意,cv2.recoverPose 需要的是本质矩阵E,而不是基础矩阵F。所以我们需要先“剥离”内参。假设我们已知两个相机的内参矩阵K1和K2(可以通过相机标定获得),那么本质矩阵 E = K2^T * F * K1。
# 假设我们已经通过标定得到了相机内参矩阵 K1 和 K2
# K = [[fx, 0, cx],
# [0, fy, cy],
# [0, 0, 1]]
K1 = np.array([[1000, 0, 960],
[0, 1000, 540],
[0, 0, 1]])
K2 = K1 # 假设是同一个相机,内参相同
# 从基础矩阵F计算本质矩阵E
E = K2.T @ F @ K1 # @ 是矩阵乘法
# 从本质矩阵E恢复相对旋转R和平移t
# 这个函数会从多个可能的解中,选择在相机前方有最多三维点的那个解
retval, R, t, mask_pose = cv2.recoverPose(E, pts1_inlier, pts2_inlier, K1)
print("恢复的旋转矩阵 R:")
print(R)
print("恢复的平移向量 t:")
print(t)
这里有一个关键的尺度不确定性问题。从E恢复出的平移向量t,其模长是1(即只是一个方向)。我们只知道相机移动的方向,不知道具体移动了多远。这个尺度信息需要在后续的三角测量或通过已知物体尺寸来恢复。
5.2 三角测量:从匹配点到三维坐标
有了两个相机的投影矩阵 P1 = K1 * [I | 0] 和 P2 = K2 * [R | t],以及一对经过校正的匹配像素点 x1 <-> x2,我们就可以通过三角测量计算这个点的三维坐标X。
原理很简单:从两个相机中心O1和O2分别发出一条射线,指向各自图像平面上的点x1和x2(反投影到三维空间)。理论上这两条射线应该在空间相交于一点X。由于噪声存在,它们通常不相交,我们可以求它们之间最短线段的中点,作为X的估计。
OpenCV提供了 cv2.triangulatePoints 函数来批量完成这个计算。
# 构造投影矩阵 P1 和 P2
P1 = K1 @ np.hstack((np.eye(3), np.zeros((3, 1)))) # P1 = K1 * [I | 0]
P2 = K2 @ np.hstack((R, t)) # P2 = K2 * [R | t]
# 为了使用 triangulatePoints,需要将点转换为齐次坐标并调整形状
pts1_homo = pts1_inlier.T # 形状 (2, N) -> (N, 2) 再转置?注意函数要求
pts2_homo = pts2_inlier.T
# 更稳妥的做法是直接reshape
pts1_for_tri = pts1_inlier.reshape(-1, 2).T.astype('float32')
pts2_for_tri = pts2_inlier.reshape(-1, 2).T.astype('float32')
# 进行三角测量
points_4d_homo = cv2.triangulatePoints(P1, P2, pts1_for_tri, pts2_for_tri)
# 将齐次坐标转换为3D坐标 (X, Y, Z, W) -> (X/W, Y/W, Z/W)
points_3d = points_4d_homo / points_4d_homo[3]
points_3d = points_3d[:3].T # 取前三维,并转置为 (N, 3)
print(f"重建得到了 {points_3d.shape[0]} 个三维点。")
print("前5个点的坐标:")
print(points_3d[:5])
5.3 构建点云与可视化
现在,我们有了成千上万个三维点(每个内点匹配对对应一个三维点)。我们可以用这些点构建一个稀疏的点云。虽然它看起来可能只是一团“雾”,但已经勾勒出了场景的基本三维结构。
我们可以使用 matplotlib 的 3D 绘图功能来简单查看,或者使用更专业的点云库如 open3d。
# 使用 matplotlib 进行简单3D可视化
fig = plt.figure(figsize=(10, 8))
ax = fig.add_subplot(111, projection='3d')
xs = points_3d[:, 0]
ys = points_3d[:, 1]
zs = points_3d[:, 2]
# 为了视觉效果,通常需要根据深度(Z值)着色
sc = ax.scatter(xs, ys, zs, c=zs, cmap='viridis', s=1, alpha=0.6)
ax.set_xlabel('X')
ax.set_ylabel('Y')
ax.set_zlabel('Z')
ax.set_title('稀疏三维点云')
plt.colorbar(sc, label='深度 (Z)')
# 调整视角以便观察
ax.view_init(elev=20, azim=-60)
plt.show()
运行这段代码,你就能看到一个由你拍摄的照片生成的三维点云。第一次看到时,你会觉得非常神奇——二维的像素点,通过数学和几何,真的在三维空间里找到了自己的位置。
当然,这只是稀疏重建。要得到更密集、更平滑的三维模型(比如网格表面),还需要稠密匹配、点云滤波、表面重建等一系列后续步骤,这通常会用到像COLMAP、OpenMVS这样的专业开源工具链。但对极几何和基础矩阵,无疑是这一切的起点和基石。掌握了它,你就拿到了从二维图像世界通往三维几何世界的第一把钥匙。

945

被折叠的 条评论
为什么被折叠?



