Védio 3D---(Reconstruction 3D)
Vision par ordinateur
第一次课
一、理论部分---图像投影模型与3D重建理论
I. SLAM的概念
- SLAM(Simultaneous Localization and Mapping,即同步定位与地图构建)
- 通过估计每个相机的位置和场景的三维点,实现对场景的重建
II. 逆向二维图像
从二维图像中提取三维场景的信息,即进行3D重建
逆向投影
- 图像是三维场景经过投影后的二维表示,要恢复三维信息,需要逆转这个投影过程
- 建立一个数学模型,描述三维场景如何投影到二维图像中,然后尝试逆向求解
III. 针孔相机模型(Modèle Sténopé)
1. 模型概述
- 定义:针孔相机模型假设所有的光线都通过一个公共点,即光心(光学中心)
- 优点:模型简单,易于逆向计算,在三维重建中广泛使用
2. 坐标系和符号规定
- 摄像机坐标系:
- 原点OC:光学中心,坐标为 (0, 0, 0)
- 轴方向:建立右手坐标系,XC 向右,YC 向下,ZC 指向后方(场景深度方向)
- 优势:Z 轴指向后方,物体深度为正,符合直觉
3. 三维点的投影到归一化焦平面
-
三维点表示:
-
点 U:坐标为 (UX, UY, UZ),表示空间中的一个三维点
-
归一化焦平面:
- 一个与光心OC距离为 1 的平面(ZC = 1),因此称为归一化聚焦平面
- 将远处的三维点投影到此平面上 用m表示
4. 齐次坐标与非齐次坐标
-
齐次坐标(Homogeneous Coordinates):
- 定义:在原有坐标后增加一个维度(通常为 1),方便表示投影和变换
- 表示:对于二维点 m = (mX, mY)^T,其齐次坐标为
\bar{m} = (m_X, m_Y, 1)^T\- 作用:在后续理论运算中,很多情况下矩阵维度不匹配(因为三维是三个坐标,二维是两个),因此必须增加一个维度用于运算矩阵,最后运算完再将其标准化即可
-
非齐次坐标(Inhomogeneous Coordinates):
- 标准的笛卡尔坐标表示法,不包含额外的维度
IV. 摄像机的线性校准(Calibration)
1. 从归一化焦平面到图像平面
-
目的:将归一化焦平面上的点m=(m_x,m_y)^T映射到图像平面上的点p=(p_u,p_v)^T
-
线性变换:
-
变换公式:
\begin{cases} p_u = f \cdot m_x + u_0 \\ p_v = f \cdot m_y + v_0 \end{cases} -
焦距 f
-
mX, mY 是归一化焦平面上的点
-
光学中心在图像平面中的坐标(U0, V0):也就是要注意,在图像平面中的光学中心并不一定是(0,0)
-
2. 摄像机内参矩阵(矩阵 K)
-
将上述线性变换表示为矩阵形式
-
但是一般情况下,我们最先想到的是 $p=\begin{pmatrix}
f & 0 \
0 & f \
\end{pmatrix} \cdot m +\begin{pmatrix}
u_0 \
v_0 \
\end{pmatrix}$- 但是这样有用吗,我们想找一个内参矩阵的目的就是为了把 焦距f和光心坐标包含起来
- 所以利用非齐次坐标所得到的矩阵无法将想要信息拢在一个参数矩阵里,因此我们想到了利用齐次坐标
-
齐次坐标下矩阵映射关系:
\ \ \ \ \ \underline p=\begin{pmatrix} f & 0 & u_0 \\ 0 & f & v_0 \\ 0 & 0 & 1 \end{pmatrix} \cdot \underline mK = \begin{pmatrix} f & 0 & u_0 \\ 0 & f & v_0 \\ 0 & 0 & 1 \end{pmatrix}\underline{P} = K \cdot \underline{m}其中,\underline{P} 是图像平面中的点的齐次坐标
3. 逆向过程
- 从图像平面到归一化焦平面:
\underline{m} = K^{-1} \cdot \underline{p}
4. 可视锥(Cone of Visibility)
- 相机能够看到的空间范围。将图像四个角的点坐标,转换到归一化焦平面上(也是四个角),然后连接光心,这样就形成了一个锥形体
V. 畸变建模与校正
1. 相机畸变的来源
- 实际相机镜头,尤其在广角镜头中,会导致图像出现畸变,直线变曲,图像边缘出现拉伸或压缩
2.畸变函数:
-
将归一化焦平面上的理想点经过畸变函数(从理想图像到畸变图像),得到畸变后的点,此点为2D实际畸变聚焦平面
\underline{m}_d = d(\underline{m}, k)\\ 其中,k 是畸变参数 -
**举个例子: ** 多项式径向畸变模型
VI. TP1:畸变校正的实现
1. 任务描述
- 目标:将畸变的实际图像校正为理想的无畸变图像
2. 实现步骤
-
定义参数: \left\{ \begin{aligned} &理想的摄像机内参矩阵 K_{\text{ideal}} \\ &畸变的摄像机内参矩阵K_{\text{real}} \\ &失真参数k \end{aligned} \right.
-
对于每个理想图像的像素坐标,执行以下步骤
-
将像素坐标转换到归一化焦平面:
\underline{m}_{\text{ideal}} = K_{\text{ideal}}^{-1} \cdot \underline{P}_{\text{ideal}} -
应用畸变函数:
\underline{m}_d = d(\underline{m}_{\text{ideal}}, k) -
映射回实际图像坐标系:
\underline{P}_{\text{real}} = K_{\text{real}} \cdot \underline{m}_d -
插值:
-
对 \ \underline{P}_{\text{real}} \ \ \ 进行插值(由于坐标可能为非整数,可能要用双线性插值)
-
-
生成校正后的图像
VII. 具体实验内容
实验目标:
该实验的目的是根据针孔模型纠正相机拍摄的图像的失真。经过校正后的图像将被认为是针孔模型下的理想图像。因此,这个实验可以看作是对图像进行简单的变换应用
应用图像变换的技术在该文档中提醒了您:transformation_image.pdf。Python教程可在这里找到:tutoriel_rotation_image.ipynb
应该应用什么变换来纠正失真?
在这个实验中,我们将校正如下图像,图像来自于一台GoPro相机:

- 失真图像(上图)从针孔模型的角度来看并不理想。例如,我们可以注意到3D中的直线(如门边、屏幕边缘或黑板边缘)在图像中并不是直的。
在此实验中,我们假设捕捉该图像的相机已经经过校准。因此,提供了以下形式的失真模型:
其中, d是失真函数, k是参数向量, m_{\text{ideal}}是理想焦平面上的2D齐次坐标点,而m_d是畸变焦平面上的2D齐次坐标点。对于我们的相机,畸变函数的代码如下所示:
def distortion(m_focal_ideal, k):
#输入:m_focal_ideal(理想焦平面上的齐次坐标点,H x W x 3矩阵),k(模型参数)
#输出:m_focal_real(失真焦平面上的齐次坐标点,H x W x 3矩阵)
kc = k[0]
xi = k[1]
X = (1/(m_focal_ideal[:,:,2:3]+xi*np.sqrt(m_focal_ideal[:,:,0:1]**2+m_focal_ideal[:,:,1:2]**2+m_focal_ideal[:,:,2:3]**2)))*m_focal_ideal[:,:,0:2] # 从空间映射到标准平面
k1 = kc[0]
k2 = kc[1]
k3 = kc[2]
k4 = kc[3]
k5 = kc[4]
m_focal_real = np.zeros_like(m_focal_ideal)
x=X[:,:,0]
y=X[:,:,1]
r2=x**2+y**2
radDist = 1. + k1*r2 + k2*(r2**2) + k5*(r2**3)
m_focal_real[:,:,0] = x*radDist + 2*k3*x*y + k4*(r2+2*(x**2))
m_focal_real[:,:,1] = y*radDist + k3*(r2+2*(y**2)) + 2*k4*x*y
m_focal_real[:,:,2] = 1.
return m_focal_real
对于我们的相机,参数向量k包含6个值,我们将其分成一个包含5个值的向量和一个标量
# 相机校准参数
k = []
k.append(np.array([-0.616031774058559, 0.236026168622863, -0.0109419992705452, -0.00217955565809950, 0]))
k.append(1.745612606223418)
线性校准矩阵K_real也已提供:
K_real = np.array([[1.825099190841841e+03, 0., 6.486113006422010e+02],[0., 1.817526262377727e+03, 4.911359689139596e+02],[0., 0., 1.]])
因此,理想焦平面上的点m_ideal可以通过以下方式转换为实际图像平面上的点:
为了在理想图像平面与实际图像平面之间进行转换,我们需要选择期望的图像尺寸以及一个校准矩阵 可以首先使用以下参数:
# 理想参数
K_ideal = np.array([[480, 0, 825],[0, 480, 460], [0, 0, 1]])
h_ideal = 900
w_ideal = 1600
这样,我们就拥有了理想图像平面与实际图像平面之间的转换:
为了进行失真校正,只需要将上述变换应用于 ,从而得到校正后的图像 。
为此,建议从此教程的代码开始:tutoriel_rotation_image.ipynb,并进行修改 特别是,需要修改函数getInterpolationGrids,使其应用上述变换,而不是教程中的旋转变换
VIII. 教程代码 对图像应用旋转变换 (后续根据此框架修改)
图像变换应用的原理在以下文档中进行了说明:transformation_image.pdf
本教程是该原理的一个Python实现。我们在此考虑的是围绕图像中心的旋转变换。完整代码的.py文件以及待变换的图像可以在以下位置找到:main_example_rotation.py链接,Tour_eiffel.jpg链接
加载所需的库
import numpy as np #导入NumPy库,用于数组和矩阵的数值计算
import matplotlib.pyplot as plt #导入Matplotlib库,用于数据和图像的可视化
from PIL import Image #从Python图像库(PIL)中导入Image模块,用于打开和处理图像
import math #提供数学函数
我们还需要一个2D插值函数:(双线性插值函数)
def interp2_bilinear(im, x, y): #在坐标x和y处对图像im执行双线性插值
x0 = np.floor(x).astype(int) #对每个坐标计算其不大于x的最大整数(下取整)
x1 = x0 + 1 #计算下一个整数
y0 = np.floor(y).astype(int)
y1 = y0 + 1
#检查x0是否小于0或x1是否超出图像宽度/高度
maskx = np.logical_or(x0<0, x1>im.shape[1]-1)
masky = np.logical_or(y0<0, y1>im.shape[0]-1)
#合并两个掩码,标识所有越界的坐标
mask_notvalid = np.logical_or(maskx,masky)
#将无效的x0索引设置为0
x0[mask_notvalid] = 0
x1[mask_notvalid] = 0
y0[mask_notvalid] = 0
y1[mask_notvalid] = 0
if(len(im.shape)==3): #检查图像维度:检查图像是否有三维
Ia = im[ y0, x0, : ] #提取相邻像素(左上角的像素)
Ib = im[ y1, x0, : ] #左下角
Ic = im[ y0, x1, : ] #右上角
Id = im[ y1, x1, : ] #右下角
#计算插值权重
wa = np.expand_dims((x1-x) * (y1-y),axis=Ia.ndim-1) #Ia的权重
wb = np.expand_dims((x1-x) * (y-y0),axis=Ia.ndim-1)
wc = np.expand_dims((x-x0) * (y1-y),axis=Ia.ndim-1)
wd = np.expand_dims((x-x0) * (y-y0),axis=Ia.ndim-1)
else: #处理具有二维的图像(灰度图像)
Ia = im[ y0, x0]
Ib = im[ y1, x0]
Ic = im[ y0, x1]
Id = im[ y1, x1]
wa = ((x1-x) * (y1-y))
wb = ((x1-x) * (y-y0))
wc = ((x-x0) * (y1-y))
wd = ((x-x0) * (y-y0))
#返回插值后的像素值和无效坐标的掩码
return wa*Ia + wb*Ib + wc*Ic + wd*Id, mask_notvalid
加载并显示要变换的图像
I = np.array(Image.open('Tour_eiffel.jpg')).astype(float)/255.
h,w,_ = I.shape
fig1, axs1 = plt.subplots(ncols=1)
axs1.imshow(I)
axs1.set_title('原始图像')
plt.pause(0.1)
定义旋转参数
theta = 60 # 结果图像应顺时针旋转60°
center_rot = np.array([w/2., h/2.])#将旋转中心设置为图像的中心(宽度和高度的一半)
定义变换后图像的尺寸
h_new = 1280
w_new = 960
定义用于计算插值网格的函数
def getInterpolationGrids(theta, center_rot, h_new, w_new):
#计算用于旋转图像的坐标网格
theta_rad = theta*math.pi/180. #将旋转角度从度数转换为弧度
rot_mat = np.array([[np.cos(theta_rad), -np.sin(theta_rad)],[np.sin(theta_rad), np.cos(theta_rad)]]) #构建给定角度的二维旋转矩阵
#创建坐标网格
x = np.arange(0,w_new)
y = np.arange(0,h_new)
X, Y = np.meshgrid(x,y) # h_new x w_new
p = np.zeros((h_new,w_new,2)) #初始化一个数组来存储坐标对
p[:,:,0] = X #分配x坐标
p[:,:,1] = Y
p_rot = ((p - np.array([w_new/2., h_new/2.])) @ rot_mat) + center_rot # h_new x w_new x 2 旋转方程
#提取旋转后的坐标
XI = p_rot[:,:,0]
YI = p_rot[:,:,1]
return XI, YI
计算并显示插值网格
#调用函数获取用于插值的旋转网格
XI,YI = getInterpolationGrids(theta, center_rot, h_new, w_new)
fig2, axs2 = plt.subplots(ncols=2)
axs2[0].imshow(XI)
axs2[0].set_title('XI')
axs2[1].imshow(YI)
axs2[1].set_title('YI')
plt.pause(0.1)
应用变换(插值)
I_rot,_ = interp2_bilinear(I.astype(np.float32), XI.astype(np.float32), YI.astype(np.float32))#执行双线性插值来计算旋转后的图像(转换为32位浮点格式)
h,w,_ = I.shape
fig3, axs3 = plt.subplots(ncols=2)
axs3[0].imshow(I)
axs3[0].set_title('原始图像')
axs3[1].imshow(I_rot)
axs3[1].set_title('变换后的图像')
plt.pause(1.)
保存变换后的图像
Image.fromarray((I_rot*255).astype(np.uint8)).save("im_rot.bmp")
IX. 畸形校正的插值网格函数 (基于旋转变换的框架)
import numpy as np
from distortion import distortion
def getInterpolationGrids(K_reel, k_reel, K_ideal, h_ideal, w_ideal):
#计算用于畸变校正图像
# 生成理想图像平面的像素坐标网格
x = np.arange(0,w_ideal)
y = np.arange(0,h_ideal)
X, Y = np.meshgrid(x,y) # h_ideal x w_ideal
#将像素坐标转换为齐次坐标并应用逆校准矩阵:
p_ideal = np.ones((h_ideal,w_ideal,3)) #初始化一个数组来存储坐标对
p_ideal[:,:,0] = X #分配x坐标
p_ideal[:,:,1] = Y
K_ideal_inv = np.linalg.inv(K_ideal) #3x3
m_focal_ideal = p_ideal @ (K_ideal_inv.T) #h_ideal x w_ideal x 3
m_focal_distordu = distortion(m_focal_ideal, k_reel) #h_ideal x w_ideal x 3
p_image_reel = m_focal_distordu @ (K_reel.T) #h_ideal x w_ideal x 3
XI = p_image_reel[:,:,0] #h_ideal x w_ideal
YI = p_image_reel[:,:,1] #h_ideal x w_ideal
return XI, YI
X. 畸形校正全部代码实现
补充内容
- 理论部分
- 实际编程
# this code is for exercising the TP of vedio 3D ;the goal is to restore a distorted image to a normal image
#做法很简单,先将一张理想图片变换到畸形图片,这个变换过程中我们就找到了理想图和畸变图之间坐标对应关系
#知道了坐标对应关系就可以对畸变图像进行线性插值处理,即可变到理想图像
import numpy as np # numerical calculation of arrays and matrices
import matplotlib.pyplot as plt # visualization of data and image
from PIL import Image #open and operate image
from interp2_bilinear import interp2_bilinear
import cv2
import time
#%% load parameters
# 我们需要提前知道的参数如下
# 第一是相机的内参矩阵,也就是m=K^-1*p中的K,这里是K,不是k。用于将相机平面P点转换到归一化焦平面中的m点
# 第二是畸变函数 这个函数用于将归一化焦平面中的点 进行畸变 来变换到畸变焦平面下的点
# 第三是另一个K2 用于 将焦平面下的m_distortion 来变换到 相机平面下的p_distortion 对应公式 p_distortion = K2 * m_distortion
K_ideal = np.array([[480, 0, 825],[0, 480, 460], [0, 0, 1]]) # 从这个矩阵中可以看出相机的焦距为480
K_distortion = np.array([[1.825099190841841e+03, 0., 6.486113006422010e+02],[0., 1.817526262377727e+03, 4.911359689139596e+02],[0., 0., 1.]])
k_distortion = []
k_distortion.append(np.array([-0.616031774058559, 0.236026168622863, -0.0109419992705452, -0.00217955565809950, 0]))
k_distortion.append(1.745612606223418)
# 两个K已经导入了,接下来导入理想图片 但是注意,我们导入的并不是一个真的图片,而只是图片的纬度网格,因为我们并不关注像素值,而是关注于网格坐标的对应关系
# 因此构建理想图片的网格坐标(在已知图片大小的前提下)
h_ideal = 900
w_ideal = 1600
# 当我们有这些参数的时候 我们可以进行理想图像平面和畸形图像平面之间的坐标对应了,我们将使用一个函数来实现
# 这个函数的输入理应有五个,分别是 (K_ideal K_distortion 畸变函数 h_ideal w_ideal)
# 现在的问题在于,畸变函数我们还没有给出,因此下面在编写坐标对应函数之前,先实现distortion函数
#%% distortion函数的实现 这部分函数的代码由老师在题目中提供
def distortion(m_focal_ideal, k):
#输入:m_focal_ideal(理想焦平面上的齐次坐标点,H x W x 3矩阵),k(模型参数)
#输出:m_focal_real(失真焦平面上的齐次坐标点,H x W x 3矩阵)
kc = k[0]
xi = k[1]
X = (1/(m_focal_ideal[:,:,2:3]+xi*np.sqrt(m_focal_ideal[:,:,0:1]**2+m_focal_ideal[:,:,1:2]**2+m_focal_ideal[:,:,2:3]**2)))*m_focal_ideal[:,:,0:2] # 从空间映射到标准平面
k1 = kc[0]
k2 = kc[1]
k3 = kc[2]
k4 = kc[3]
k5 = kc[4]
m_focal_real = np.zeros_like(m_focal_ideal)
x=X[:,:,0]
y=X[:,:,1]
r2=x**2+y**2
radDist = 1. + k1*r2 + k2*(r2**2) + k5*(r2**3)
m_focal_real[:,:,0] = x*radDist + 2*k3*x*y + k4*(r2+2*(x**2))
m_focal_real[:,:,1] = y*radDist + k3*(r2+2*(y**2)) + 2*k4*x*y
m_focal_real[:,:,2] = 1.
return m_focal_real
# 我们对上述畸变函数代码进行稍微分析,我么有两个输入,分别是理想焦平面上的齐次坐标点m_ideal,另一个是畸变参数k 输出是畸变焦平面点的m_distortion
#%% 我们现在已经得到了畸变函数,坐标对应函数(这里起名为grid_corresponding)的五个输入量已经齐了,其输入位置的第五个参数 用畸变参数k来表现
def grid_corresponding(K_ideal,K_distortion,k_distortion,h_ideal,w_ideal):
x = np.arange(0,w_ideal) # 生成从 0 到 w_ideal-1 的整数序列,长度为 w_ideal,代表图像在 x 方向的像素索引
y = np.arange(0,h_ideal)
X,Y=np.meshgrid(x,y) # 生成了两个二维矩阵X和Y 大小为y*x (注意纬度是反过来的) 也就是 行数(y)乘列数(x)
#其中的X主要专注于行,每一行都复制了x(一维数组)的值 而Y专注于列,每一列都复制了y的值
#举例
# x = np.arange(0, 3) # x 序列: [0, 1, 2]
# y = np.arange(0, 2) # y 序列: [0, 1]
# X = [[0, 1, 2],
# [0, 1, 2]]
# Y = [[0, 0, 0],
# [1, 1, 1]]
p_ideal =np.ones((h_ideal,w_ideal,3)) # 利用理想图像的宽高来构建一个全1矩阵,用于将网格坐标填入 矩阵已经生成了,所以现在缺少网格坐标
# 另外一个问题,为什么是全1矩阵,因为它的大小为 w_ideal* h_ideal*3 三通道矩阵,每个纬度分别用来储存X Y Z 由于是齐次坐标 所以Z=1
p_ideal[:,:,0] = X #分配x坐标
p_ideal[:,:,1] = Y
# 到这一步已经蒙了,下面写什么,不要急,先写出关键对应公式,缺什么补什么 可见缺p_ideal 在上面补上
# 注意矩阵纬度 K_ideal是3*3 p_ideal是三维 ,但是其中每个像素点是一个 3* 1的齐次坐标
K_ideal_inv = np.linalg.inv(K_ideal) #3x3
m_ideal = p_ideal @ (K_ideal_inv.T) #h_ideal x w_ideal x 3
m_distortion = distortion(m_ideal, k_distortion)
p_distortion = m_distortion @ (K_distortion.T)
# 这里有个纬度的错误 导致错误的关键原因就是,不知道期望图片的尺寸是多少 ===> 900*1600 即y(代表有多少行) * x(代表有多少列)
# 因此对于网格的构建顺序就是np.meshgrid(x,y) 对于p_ideal尺寸等价于图片尺寸
# 输出
XI = p_distortion[:,:,0]
YI = p_distortion[:,:,1]
return XI,YI
#%% 函数定义完了 开始用
XI,YI = grid_corresponding(K_ideal,K_distortion,k_distortion,h_ideal,w_ideal)
#输出显示
fig1, axs1 = plt.subplots(ncols=2)
axs1[0].imshow(XI)
axs1[1].imshow(YI)
plt.pause(0.1)
#%% load image to undistort
I_reel = np.array(Image.open('videoframe-3.bmp')).astype(float)/255.
fig2, axs2 = plt.subplots(ncols=2)
axs2[0].imshow(I_reel)
plt.pause(0.1)
a = time.time()
I_undist,_ = interp2_bilinear(I_reel.astype(np.float32), XI.astype(np.float32), YI.astype(np.float32))
print('{} sec'.format(time.time()-a))
axs2[1].imshow(I_undist)
plt.pause(0.1)
Image.fromarray((I_undist*255).astype(np.uint8)).save("im_undist.bmp")
-
双线性插值函数
import numpy as np # numerical calculation of arrays and matrices def interp2_bilinear(im, x, y): #在坐标x和y处对图像im执行双线性插值 x0 = np.floor(x).astype(int) #对每个坐标计算其不大于x的最大整数(下取整) x1 = x0 + 1 #计算下一个整数 y0 = np.floor(y).astype(int) y1 = y0 + 1 #检查x0是否小于0或x1是否超出图像宽度/高度 maskx = np.logical_or(x0<0, x1>im.shape[1]-1) masky = np.logical_or(y0<0, y1>im.shape[0]-1) #合并两个掩码,标识所有越界的坐标 mask_notvalid = np.logical_or(maskx,masky) #将无效的x0索引设置为0 x0[mask_notvalid] = 0 x1[mask_notvalid] = 0 y0[mask_notvalid] = 0 y1[mask_notvalid] = 0 if(len(im.shape)==3): #检查图像维度:检查图像是否有三维 Ia = im[ y0, x0, : ] #提取相邻像素(左上角的像素) Ib = im[ y1, x0, : ] #左下角 Ic = im[ y0, x1, : ] #右上角 Id = im[ y1, x1, : ] #右下角 #计算插值权重 wa = np.expand_dims((x1-x) * (y1-y),axis=Ia.ndim-1) #Ia的权重 wb = np.expand_dims((x1-x) * (y-y0),axis=Ia.ndim-1) wc = np.expand_dims((x-x0) * (y1-y),axis=Ia.ndim-1) wd = np.expand_dims((x-x0) * (y-y0),axis=Ia.ndim-1) else: #处理具有二维的图像(灰度图像) Ia = im[ y0, x0] Ib = im[ y1, x0] Ic = im[ y0, x1] Id = im[ y1, x1] wa = ((x1-x) * (y1-y)) wb = ((x1-x) * (y-y0)) wc = ((x-x0) * (y1-y)) wd = ((x-x0) * (y-y0)) #返回插值后的像素值和无效坐标的掩码 return wa*Ia + wb*Ib + wc*Ic + wd*Id, mask_notvalid
在图像畸变校正的过程中,畸变校正的本质是对像素坐标进行变换,而不是对像素本身的颜色或灰度值进行变换。换句话说,畸变矫正只在于重新计算像素点的位置,然后根据新的位置将图像的像素值重新插值放回去
XI 和 YI 是畸变矫正后的插值网格。代表了 理想情况下图像(non distorsion)中每个像素对应的实际畸变图像(avec distorsion)中的位置 也就是说,XI[i, j] 和 YI[i, j] 代表理想图像中的 (i, j) 位置在实际畸变图像中应该被映射到的位置
一旦我们有了这些插值网格,接下来的任务是通过插值(例如双线性插值)从实际图像(avec distorsion)中提取畸变的像素值,将这些像素值映射到理想图像(non distorsion)的正确位置
I_undist, _ = interp2_bilinear(I_reel.astype(np.float32), XI.astype(np.float32), YI.astype(np.float32))
-
自定义的双线性插值函数。作用是根据插值网格(XI 和 YI)从畸变图像(I_reel)中提取像素值,生成去畸变的图像 I_undist(和 I_reel 有相同的尺寸)
-
第二个返回值 _意思是可以忽略
-
astype(np.float32) 将图像原本整数类型的像素值转换为浮点数类型 float32 . 插值过程中需要处理非整数位置的像素值
-
双线性插值的核心思想是,在实际图像 I_reel 中,坐标 XI[i, j] 和 YI[i, j] 往往是非整数的,即落在像素格子的中心或边界,而非整数像素点并不直接存在于图像中。所以我们需要从四个最邻近的像素中插值计算该位置的像素值
-
举例子: 如果它是 (150.5, 300.75),那么双线性插值会从四个邻近的整数像素(例如 (150, 300)、(151, 300)、(150, 301)、(151, 301))取值,并根据与非整数坐标的距离计算该位置的插值值
从理想的无畸变图像的像素坐标,找到它们在实际畸变图像中的位置,然后通过插值获得像素值。这是坐标的逆向映射
XI. 修正后的图像结果
通过观察校正后的图像,我们可以注意到,3D中的直线(如门边、天花板线条等)现在在图像中也呈现为直线。还可以注意到,校正后的图像在边缘丢失了一些信息。这是由于 的参数选择,尤其是焦距的选择所导致的。例如,将焦距减半相当于在理想的标准焦平面上进行缩小变焦。此时可以获得更多的信息(如果原始图像中没有这些信息,则会出现黑色区域):

XII. OpenCV的remap函数:
OpenCV库中的remap函数(import cv2)是一种非常高效的2D插值函数。该函数可以用来替代教程代码中的interp2_bilinear函数。请测试此函数(注意,您可能需要将变量从float64转换为float32,转换方式如下:.astype(np.float32))。您应该会发现remap比interp2_bilinear要快得多。然而,通过比较两幅校正后的图像,您可能会发现,在对比强烈的地方,remap会引入轻微的伪影

XIII. (可选)视场角计算:
找到一种方法来计算校正后的图像的视场角(水平视角theta_x 和垂直视角theta_y)。提示:为了计算theta_x ,可以画一个从相机上方观察的图示(因此我们有两个轴:x 和 z),标准焦平面(作为一条线,因为它是从上方观察的),图像的左右边界位于标准焦平面上以及theta_x 。 theta_x 的计算涉及反正切函数
在针孔相机模型中,视场角(Field of View,FOV)可以通过以下公式计算:
- 水平视场角(FOV_horizontal):
- 垂直视场角(FOV_vertical):
- 其中:
import numpy as np
import math
# 理想相机内参矩阵
K_ideal = np.array([[480, 0, 825],
[0, 480, 460],
[0, 0, 1]])
# 校正后图像尺寸
w_ideal = 1600 # 图像宽度(像素)
h_ideal = 900 # 图像高度(像素)
# 从内参矩阵中提取焦距
f = K_ideal[0, 0]
# 计算水平视场角(弧度)
theta_horizontal = 2 * np.arctan((w_ideal / 2) / f)
# 计算垂直视场角(弧度)
theta_vertical = 2 * np.arctan((h_ideal / 2) / f)
# 将视场角转换为度数
theta_horizontal_deg = np.degrees(theta_horizontal)
theta_vertical_deg = np.degrees(theta_vertical)
print("angle de champ horizontal(degre):{:.2f}".format(theta_horizontal_deg))
print("angle de champ vertical(degre):{:.2f}".format(theta_vertical_deg))
第二次课
一、**理论部分---**二维刚性变换和单应性
1.1 平移与旋转
二维刚性变换包括平移和旋转

旋转:
从一个参考系中选取一个向量然后转换到另一个坐标系中
平移:
1.2 刚性变换公式:
证明:
1.3 齐次坐标
1.4 反变换:
1.5 变换的组合性:
二、单应性
2.1 平面场景假设

这个方程的意思就是, \mathbf{U}_i^A 这个点可以由 \underline{\mathbf{m}}_{Ai} 来表示,怎么表示呢? \Rightarrow 乘它的深度即可 (因为\underline{\mathbf{m}}_{Ai}是单位深度、归一化焦平面)
2.2 寻找 \underline{\mathbf{m}}_{Ai} 和 \underline{\mathbf{m}}_{Bi} 之间的对应关系
光有这个方程,我们怎么找到 \underline{\mathbf{m}}_{Ai} 和 \underline{\mathbf{m}}_{Bi} 之间的对应关系呢,通俗来讲,怎么进行坐标对应变换呢?
-
法线关键公式
$\Rightarrow$ 我们需要先回顾一个性质,来得到一个法线和平面间的关键公式
[!NOTE]
在参考系 A 中,平面 P 的方程为: ax + by + cz + d = 0 其中$ a, b, c \$是平面法向量分量
d \text{ 是常数项} \quad \Rightarrow \quad 平面P 和原点O_A相对距离
在向量形式中,平面方程可以化简为:
\mathbf{n}_A^\top \mathbf{U}_i^A + d = 0\mathbf{n}_A^\top代表: \ \ \text{向量 } P \text{ 在参考系 } A \text{ 中的法向量}通过这个平面方程的向量形式,我们可以得到一个带有法向量的一个很重要的公式,目的是什么呢?
- 利用变量代换得到深度 z_i^A = -\frac{d}{\mathbf{n}_A^\top \cdot \underline{\mathbf{m}}_{A,i}} 表达式
这样,我们就把 \underline{\mathbf{m}}_{Ai} 给引进来了,其中 z_i^A = -\frac{d}{\mathbf{n}_A^\top \cdot \underline{\mathbf{m}}_{A,i}} 代表了深度。换句话说,我们利用 \mathbf{U}_i^A的两个方程,将\mathbf{U}_i^A替换掉了,这样就得到z_i^A深度 \Rightarrow 有什么用呢? \Rightarrow 也就是说光有关于 \underline{\mathbf{m}}_{Ai} 的方程是不够的,还需要从 \underline{\mathbf{m}}_{Bi} 入手
- 接下来我们找 B 坐标系下的点 \underline{\mathbf{m}}_{Bi}
我们从刚性变换公式入手\mathbf{U}^w = \mathbf{R}_{wc} \cdot \mathbf{U}^c + \mathbf{T}_{wc}可见从 c 投影到 w 只需要对 \mathbf{U}^c 进行变换即可,也就是说,为了得到 \underline{\mathbf{m}}_{Bi} 只需要对 \underline{\mathbf{m}}_{Ai} 进行刚性变换即可
[!NOTE]
问题:上述公式中左右两边都乘了- \frac{\mathbf{n}_A^\top \cdot \underline{\mathbf{m}}_{A,i}}{d},为什么保持不变?
投影函数 \Pi(\cdot) 的特点是它是一个比例不变的操作(即只看方向和相对位置,不看绝对尺度)
因此,即使我们在右边乘上 -\frac{\mathbf{n}_A^\top \cdot \underline{\mathbf{m}}_{A,i}}{d},也不会影响等式成立的条件,因为投影结果相同
2.3 寻找 \underline{\mathbf{P}}_{A,i} 和 \underline{\mathbf{P}}_{B,i} 之间的对应关系
- 回顾性质
- 利用此性质
2.4 得到单应性矩阵\mathbf{H}_{AB}
2.5 单应性矩阵估计求解
这是一个齐次矩阵 ,他有9 个参数 h_1 ==> h_9 ,齐次矩阵在尺度上具有冗余性,所以会导致自由度的丢失
- 简单的解法--参数化
- 在这种情况下只要我们了解和四个对应点 就可以求得 h_1 到 h_8
上述这个方程表明了,我们利用估计得到的h矩阵 进行运算,得到了估计的 P_{A,i} 再和实际值相减,不断迭代取使得误差最小的那个h
二、**TP部分---**二维刚性变换和单应性
- 本实验的目标是实现课程中研究的同相变换(Homography)概念
为此,我们将考虑如下图像(下载链接):

该图像对应一个平面场景(在我们的例子中是一个放置在地面上的A4纸张)在针孔模型下的理想摄像机投影。在这幅图像中,由于拍摄时相机相对于纸张的旋转和平移,A4纸上的文字难以辨认
我们已经了解了来自平面场景的两幅图像 I_A 和 I_B 之间的变换是一个同相变换 H_{AB} :
其中,\underline{\mathbf{P}}_{A,i} 是图像 A 中的一个点(以齐次坐标表示),\underline{\mathbf{P}}_{B,i} 是其在图像 B 中的对应点,\Pi( \ )是投影函数
本实验的目的是实现一种同相变换校正的方法,以估计这个同相矩阵 H_{AB} 。一旦估计出这个矩阵,它将定义一个可以应用于上述图像的变换,从而得到一个校正后的图像,在该图像中,A4纸上的文字可以清晰地阅读
在同相变换校正之后,校正后的图像应当呈现出A4纸,使其能够被完美地识别:
校正后的图像

- 对应点
为了估计同相矩阵 H_{AB} ,我们需要4个对应点\left( P_{A,i}, P_{B,i} \right)_{i = 1, 2, 3, 4},它们在图示中如下定义:

图像 B 表示一个大小为21cm x 29.7cm的A4纸。求出这四个顶点的坐标(以厘米为单位)?在代码中,选择一个分辨率,例如1 cm = 10像素,并创建一个包含像素坐标的变量:
p_B = np.array([[..., ..., ..., ...],[..., ..., ..., ...]])
关于图像 A,我们可以自动检测四个顶点 p_A,但这不是本实验的重点。相反,你可以简单地手动获取这四个顶点的坐标,然后在代码中创建一个包含这些坐标的变量:
p_A = np.array([[..., ..., ..., ...],[..., ..., ..., ...]])
- 对应点的可视化验证
大多数错误都来自于不正确的对应点。因此,在代码中重新绘制上述示意图(图像 B 会是白色的,因为这是你希望得到的图像),以确保你的对应点是正确的
- 实现估计方法 “DLT”
-
编码课程中讲解的同相矩阵估计方法
A. 构建一个大小为 8 \times 8 的矩阵 M 和一个大小为 8 的向量 b
B. 使用数值方法(函数 np.linalg.solve)求解线性方程组 M\mathbf{h} = b(参见附录中的线性最小二乘法)
C. 从向量 \mathbf{h} 构建大小为 3 \times 3 的同相矩阵 H
D. 验证是否满足 H \mathbf{x} = \mathbf{x{\prime}} ,通过在之前的图中绘制每个 \mathbf{x} 的彩色叉标 -
应用估计出的同相矩阵 H 对图像进行校正(对图像应用变换)
-
保存校正后的图像。获得的文档应该是完全可读的
-
解决方案:在文件末尾,你会找到完整的代码脚本,可以在遇到困难时参考
-
OpenCV中的 findHomography 函数(import cv2)允许根据对应点估计一个同相矩阵。测试此函数,并验证得到的结果是否与您的相同
完整代码修正
# 实验二的目标是将一个相机平面中的点投影到另一个相机平面中的点,换句话说,是两个平面场景图像的变换(homographie) 叫做单应性变换 描述两个图像之间的变换关系 换句话说 同一场景下不同视角图像
# 为了让B场景中的坐标点变换到A场景 公式描述如下 P_A(齐次坐标)=投影函数(H_AB * P_B(齐次坐标))
# 现在有两个东西要求 第一个是P_B的坐标点
# 由于我们变换的是一张纸,因此我们取四个点,找到四个角的对应关系即可
# P_B坐标点好说,因为B相机平面是我们要生成的A4纸平面,即自定义平面大小,可以根据像素来得四角坐标
# 随后我们展开了齐次坐标,找相对应的非齐次坐标 因此构建了非齐次坐标的公式 即 [P_Aix;P_Aiy]=M*[h1;h2;...;h8]
# 这样我们可以取求h1--h8了
# 所以现在的问题 首先变成了 怎么找M矩阵 以及找到P_A矩阵的四个交点(为了与B对应)
from PIL import Image
import numpy as np
import matplotlib.pyplot as plt
from grid_corresponding import grid_corresponding
from interp2_bilinear import interp2_bilinear
import cv2
# 第一步肯定是再如图片 显示是为了手动选取四个角点,记录下来
im = np.array(Image.open('./image1.jpg')).astype(float)/255.
f1 = plt.figure()
ax1 = f1.add_subplot(121)
ax1.imshow(im)
#coordonnées des coins de la feuille dans l'image
#ordre : haut gauche, haut droit, bas droit, bas gauche
p_A = np.array([[305.,831.,3671.,4293.],[2238.,244.,211.,2219.]]) # 注意尺寸 x;y 也就是说第一个点坐标为 (305,2238)
# 这段纯属好看 没用
markersize = 30
ax1.scatter(p_A[0,0],p_A[1,0],ec='r', s=markersize, fc='none')
ax1.scatter(p_A[0,1],p_A[1,1],ec='g', s=markersize, fc='none')
ax1.scatter(p_A[0,2],p_A[1,2],ec='b', s=markersize, fc='none')
ax1.scatter(p_A[0,3],p_A[1,3],ec='k', s=markersize, fc='none')
ax1.set(title='Image prise')
# 我们现在构建一个A4纸 大小是题目说的 21cm par 29,7cm 也就是宽度是21cm 高度29.7cm
#coordonnées des coins de la feuille idéale format A4
#ordre : haut gauche, haut droit, bas droit, bas gauche
scale = 100
h_rec = int(29.7*scale)
w_rec = int(21.*scale)
p_B = np.array([[0., w_rec-1, w_rec-1, 0.],[0., 0., h_rec-1, h_rec-1]]) # 四个角
ax2 = f1.add_subplot(122)
ax2.scatter(p_B[0,0],p_B[1,0],ec='r', s=markersize, fc='none')
ax2.scatter(p_B[0,1],p_B[1,1],ec='g', s=markersize, fc='none')
ax2.scatter(p_B[0,2],p_B[1,2],ec='b', s=markersize, fc='none')
ax2.scatter(p_B[0,3],p_B[1,3],ec='k', s=markersize, fc='none')
ax2.set(xlim = [0, w_rec-1],
ylim = [0, h_rec-1],
title='Document rectifié')
ax2.invert_yaxis()
# 现在P_B P_A坐标都有了 开始着手求H ===>利用M矩阵(维度8*8)
def DLT(p_A, p_B):
p_A_vec = p_A.ravel(order='F') # 不做过多要求 将二维数组转换为一维数组 按列优先顺序展平
M = np.zeros((8,8))
for i in range(4):
pBx = p_B[0,i]
pBy = p_B[1,i]
pAx = p_A[0,i]
pAy = p_A[1,i]
M_line_1 = [pBx, 0, -pBx*pAx, pBy, 0, -pBy*pAx, 1, 0]
M_line_2 = [0, pBx, -pBx*pAy, 0, pBy, -pBy*pAy, 0, 1]
M[i*2:(i+1)*2,:] = np.array([M_line_1, M_line_2])
h = np.linalg.solve(M.T.dot(M), M.T.dot(p_A_vec))
H_AB = np.array([[h[0], h[3], h[6]], [h[1], h[4], h[7]], [h[2], h[5], 1]])
return H_AB
H_AB = DLT(p_A, p_B)
# 我们利用自定义的P_A P_B对应坐标点 得到了对应像 素图像之间点H矩阵
temp = H_AB.dot(np.vstack([p_B, np.ones((1,4))]))
# np.vstack([p_B, np.ones((1,4))]) 这一步的作用是将 p_B 从 2x4 矩阵扩展为 3x4 的齐次坐标表示
p_A_pred_x = temp[0,:]/temp[2,:]
p_A_pred_y = temp[1,:]/temp[2,:]
ax1.scatter(p_A_pred_x[0],p_A_pred_y[0],fc='r', s=markersize, marker='x')
ax1.scatter(p_A_pred_x[1],p_A_pred_y[1],fc='g', s=markersize, marker='x')
ax1.scatter(p_A_pred_x[2],p_A_pred_y[2],fc='b', s=markersize, marker='x')
ax1.scatter(p_A_pred_x[3],p_A_pred_y[3],fc='k', s=markersize, marker='x')
#%% Apply transformation
# 已经得到H了,接下来我们要找的是 从理想相机图像B(A4纸)变换到相机图像A(不好的图像)的时候,之间的对应关系
# 这里非常像TP1中的网格对应关系,只是直接从一个相机图像变换到另一个相机图像 不需要K k 之类的参数 为什么? 因为有H 靠的是H参数
XI,YI = grid_corresponding(H_AB, h_rec, w_rec)
I_undist,_ = interp2_bilinear(im, XI, YI) #将图片进行双线性插值,一插值就能得到修改后的正常图像了
ax2.imshow(I_undist)
import numpy as np
def grid_corresponding(H_AB, h_ideal, w_ideal):
"""
该函数生成用于应用同基变换的插值网格,基于同基矩阵 H_AB
:param H_AB: 同基矩阵 (3x3)
:param h_ideal: B图像高度 (以像素为单位)
:param w_ideal: B图像宽度 (以像素为单位)
:return: XI, YI - 对应图像的插值网络坐标
"""
# 为校正后的图像(理想图像)创建坐标网格
x = np.arange(0, w_ideal)
y = np.arange(0, h_ideal)
X, Y = np.meshgrid(x, y) # 校正图像的坐标网格
# 创建理想图像的齐次点矩阵
p_ideal = np.ones((h_ideal, w_ideal, 3)) # (h_ideal x w_ideal x 3)
p_ideal[:, :, 0] = X # x 坐标
p_ideal[:, :, 1] = Y # y 坐标
# 应用同基变换,得到原始图像中的对应点
p_image_reel = p_ideal @ (H_AB.T) # (h_ideal x w_ideal x 3)
# 归一化,恢复齐次坐标
XI = p_image_reel[:, :, 0] / p_image_reel[:, :, 2] # 原始图像中的 x 坐标
YI = p_image_reel[:, :, 1] / p_image_reel[:, :, 2] # 原始图像中的 y 坐标
return XI, YI

#%% Comparison opencv
H_AB_cv2, mask = cv2.findHomography(p_B.astype(np.float32).T, p_A.astype(np.float32).T)
print(np.abs(H_AB-H_AB_cv2).sum())
# # 使用OpenCV计算的单应性矩阵对图像进行变换
# XI_cv2, YI_cv2 = grid_corresponding(H_AB_cv2, h_rec, w_rec)
# # 使用DLT和OpenCV得到的单应性矩阵分别对图像进行插值
# I_undist_dlt, _ = interp2_bilinear(im, XI, YI)
# I_undist_cv2, _ = interp2_bilinear(im, XI_cv2, YI_cv2)
# # 计算两个结果图像之间的差异
# image_difference = np.abs(I_undist_dlt - I_undist_cv2)
# # 显示差异图像
# plt.figure()
# plt.imshow(image_difference, cmap='gray')
# plt.title('Difference Image between DLT and OpenCV Homography')
# plt.colorbar()
# plt.show()
代码中所出现的所有数学公式均可在理论部分找到,至此第二次课程内容结束
第三次课
使用RANSAC算法进行稳健的单应性估计
一、目标
- 图像对齐与拼接:通过估计两幅图像之间的单应性(Homography),实现图像的自动拼接
二、自动建立对应关系---SIFT算法
兴趣点检测:
-
使用SIFT等算法在两幅图像中检测特征点(这段代码由老师提供),无需手动标记对应点,利用算法自动建立图像间的对应关系
-
因此我们可以找到两幅图像中找到最相似的点对,但注意,点对并不一定正确对应
-
也就是可能会出现错误匹配(离群点)这种情况下,不可以直接用对应关系,我们将使用另一种算法叫做RANSAC来自动评估对应点之间的正确性,并得到最理想的H矩阵 并输出
三、RANSAC算法进行稳健估计
-
算法思想:
- **随机抽样一致性(Random Sample Consensus)**是一种在存在离群点(错误点)的情况下估计模型参数(H)的稳健算法
- 通过反复随机抽样,寻找最符合的模型
-
RANSAC流程:
-
重复N次(迭代次数根据经验或计算确定):
-
随机选取4对匹配点:
- 4是来估计 单应性矩阵所需的最小匹配点数
- 另外是从所有的匹配点中随机选四个,不确定哪个对应关系正确 所以后续中有一个估计评判标准(欧几里得)
-
估计 单应性矩阵 \ H^k \ :
- 使用选取的4对匹配点,通过DLT算法(上个实验做过,其目的与作用是,在已知对应点点情况下,将一个相机视角转换到另一个相机视角 )估计单应性矩阵
-
计算误差并评估模型:
-
对于所有匹配点(包括未选取的),将第二幅图像的点 \ P_{B_i} 通过估计的 H^k 变换,得到 H^k P_{B_i}
-
计算变换后的点(估计点)与第一幅图像实际点 \ P_{A_i} \ 之间的欧氏距离
-
定义代价函数 :使用二值核(要么为 0 要么为 1 )函数 \phi_c(d) :
- 当距离 d < \tau 时,认为匹配正确,代价为0
- 当距离 d \geq \tau 时,认为匹配错误,代价为1
-
总代价 L^k = \sum_{i} \phi_c(\|P_{A_i} - H^k P_{B_i}\|)
-
-
-
更新最佳模型:
- 如果当前代价 L^k 小于之前的最小代价 L ,则更新 L 和对应的 H
-
-
最终输出:
- 具有最小代价的单应性矩阵 H
-
-
阈值 \tau的选择:
- \tau 是判断匹配是否为内点的距离阈值,通常根据图像分辨率和匹配精度选择,一般在0.5到3个像素之间
- 选择过大会增加错误匹配,过小会忽略正确匹配
四、为什么不用传统的二次代价函数
-
敏感性问题:
- 二次代价函数(如最小二乘法)对离群点非常敏感,如果只因为一个点很大,而导致代价函数值过大,这个时候就算其他点也很好那也没用了
-
稳健性:
- 二值核函数对那种特别大、离谱的点不敏感(都等于1),能够有效抑制离群点的影响,使得估计结果更稳健
-
其他核函数:
- 除了二值核函数,还存在其他稳健核函数,如Huber核、Lorentzian核等,可以在一定程度上兼顾误差大小和稳健性
五、RANSAC算法的局限性
-
参数数量影响:
- 当模型参数数量增加时,所需的随机采样次数会指数增长,计算成本显著提高
-
适用范围:
- RANSAC适用于参数数量较少的情况,如直线拟合、基础矩阵和单应性估计等
六、实践 --- 也就是代码部分
本实验的目标是实现RANSAC算法 所考虑的应用是构建航空影像的拼接图。这些图像是由无人机定期捕捉地面的照片:

在本实验中,我们假设地面是一个平面,从而允许使用同相变换的概念
1. 两张图片的拼接
我们从两张图片 I_A 和 I_B 的拼接开始 我们将实现以下步骤:
-
使用OpenCV的功能匹配 I_A 和 I_B
-
实现一个基于RANSAC + DLT的同相变换 H_{AB} 的估计。注意:DLT算法已经在同相变换校正实验中实现了,因此可以复用您的代码以节省时间
-
使用 H_{AB} 在图像 I_A 的平面中创建一个拼接图。如下图所示:

为了得到这个结果,我们对图像 I_A 和变换后的图像 I_B 进行了平均处理,这使得两幅图像的重叠区域更加明显
2. 使用OpenCV的功能匹配 I_A 和 I_B
本实验的目标并不是实现匹配算法。因此,我们将使用OpenCV中可用的SIFT算法实现。为了节省时间,代码如下:
from PIL import Image
import numpy as np
import cv2 as cv
import matplotlib.pyplot as plt
I_A = np.array(Image.open('images/01.jpg'))
I_B = np.array(Image.open('images/03.jpg'))
I_A_float = I_A.astype(float)/255.
I_B_float = I_B.astype(float)/255.
I_A_grey = ((I_A_float.sum(axis=2)/3)*255).astype(np.uint8)
I_B_grey = ((I_B_float.sum(axis=2)/3)*255).astype(np.uint8)
# 初始化SIFT检测器
sift = cv.SIFT_create()
# 使用SIFT查找关键点和描述符
kpA, desA = sift.detectAndCompute(I_A_grey,None)
pts_A = cv.KeyPoint_convert(kpA)
kpB, desB = sift.detectAndCompute(I_B_grey,None)
# 使用默认参数的BFMatcher
bf = cv.BFMatcher()
matches = bf.knnMatch(desA,desB,k=2)
# 应用比例测试
good = []
for m,n in matches:
if m.distance < 0.75*n.distance:
good.append([m])
# cv.drawMatchesKnn 需要以列表形式传递匹配信息
fig1, ax1 = plt.subplots(1)
ax1.imshow(cv.drawMatchesKnn(I_A,kpA,I_B,kpB,good,None,flags=cv.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS))
plt.pause(0.01)

可以通过视觉验证某些对应关系是正确的,而其他则明显是错误的。此代码生成了两个包含对应点坐标的 np.array(以齐次坐标表示):p_A_hom 和 p_B_hom
例如,p_A_hom 的第一行(p_A_hom[0,:])是 I_A 中的一个坐标,它在 I_B 中的对应点存储在 p_B_hom 的第一行(p_B_hom[0,:])
3. 实现RANSAC + DLT进行同相变换估计
利用之前建立的对应关系 (p_{A,i}, p_{B,i})_{i=1,…,N},接下来我们要实现一个RANSAC + DLT算法来估计同相矩阵 H_{AB}
def DLT(p_A, p_B):
p_A_vec = p_A.ravel(order='F')
M = np.zeros((8,8))
for i in range(4):
pBx = p_B[0,i]
pBy = p_B[1,i]
pAx = p_A[0,i]
pAy = p_A[1,i]
M_line_1 = [pBx, 0, -pBx*pAx, pBy, 0, -pBy*pAx, 1, 0]
M_line_2 = [0, pBx, -pBx*pAy, 0, pBy, -pBy*pAy, 0, 1]
M[i*2:(i+1)*2,:] = np.array([M_line_1, M_line_2])
h = np.linalg.solve(M.T.dot(M), M.T.dot(p_A_vec))
H_AB = np.array([[h[0], h[3], h[6]], [h[1], h[4], h[7]], [h[2], h[5], 1]])
return H_AB
nIt = 500
thresh = 1.5 #pix
nInliers_best = 0
for it in range (nIt):
#Sample 4 matches
rng = np.random.default_rng()
ids = rng.choice(nMatches, size=4, replace=False)
p_A_sel = p_A[ids,:]
p_B_sel = p_B[ids,:]
#Estimate homography
H_AB = DLT(p_A_sel.T, p_B_sel.T)
#H_AB, mask = cv.findHomography(p_B_sel, p_A_sel)
#Compute cost
p_A_pred = (temp := p_B_hom.dot(H_AB.T)) / temp[:, 2:3]
mask_inliers = ((p_A_hom - p_A_pred)**2).sum(axis=1) < thresh**2
nInliers = mask_inliers.sum()
if(nInliers_best<nInliers):
nInliers_best = nInliers
H_AB_best = H_AB
mask_inliers_best = mask_inliers
print('Iteration {} : {} inliers'.format(it,nInliers_best))
inlier_matches = [good[idx] for idx in np.nonzero(mask_inliers_best)[0]]
fig2, ax2 = plt.subplots(1)
ax2.imshow(cv.drawMatchesKnn(I_A,kpA,I_B,kpB,inlier_matches,None,flags=cv.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS))
plt.pause(0.01)


4. 使用 H_{AB} 在 I_A 的图像平面中创建拼接图
为了在图像 I_A 的平面中创建一个拼接图,我们首先需要确定这个拼接图的尺寸。绘制拼接图的示意图,标明 I_A 和 I_B 的四个顶点在 I_A 图像平面中的位置,并确定拼接图的边界和大小
# 计算边界
hA, wA, _ = I_A.shape
corners_IA = np.array([[0, wA, wA, 0], [0, 0, hA, hA], [1, 1, 1, 1]]).T
hB, wB, _ = I_A.shape
corners_IB = np.array([[0, wB, wB, 0], [0, 0, hB, hB], [1, 1, 1, 1]]).T
corners_IB_in_IA = (temp := corners_IB.dot(H_AB_best.T)) / temp[:, 2:3]
minx = min([min(corners_IA[:,0]),min(corners_IB_in_IA[:,0])])
maxx = max([max(corners_IA[:,0]),max(corners_IB_in_IA[:,0])])
miny = min([min(corners_IA[:,1]),min(corners_IB_in_IA[:,1])])
maxy = max([max(corners_IA[:,1]),max(corners_IB_in_IA[:,1])])
fig4, axs4 = plt.subplots(1)
axs4.plot(corners_IA[:,0],corners_IA[:,1],'x',label='corners $I_A$')
axs4.plot(corners_IB_in_IA[:,0],corners_IB_in_IA[:,1],'or',label='corners $I_B$ in $I_A$')
axs4.plot([minx, maxx, maxx, minx, minx],[miny, miny, maxy, maxy, miny],label='mosaic bounds')
axs4.grid(True)
axs4.invert_yaxis()
axs4.legend()
w_mosaic = int(maxx - minx + 1)
h_mosaic = int(maxy - miny + 1)
5. 创建拼接图像
接下来,我们将分别对图像 I_A 和 I_B 进行变换,并将它们合并到同一个拼接图中。我们需要根据图像的位置应用相应的同相变换,并将它们叠加以形成最终的拼接图

现在拼接图的边界和大小已经确定,接下来需要创建拼接图
需要对 I_A 应用什么样的变换才能将它放置在拼接图的正确位置?将此变换写成一个同相矩阵 H_{AM} 的形式,该矩阵将拼接图中的一个点转换为 I_A 中的一个点 提示: I_A 的原点应该在拼接图中的哪个位置?将 H_{AM} 应用于 I_A 并可视化结果
要应用于 I_B 的变换是同相矩阵 H_{BM} = H_{BA} H_{AM}。应用此变换并可视化结果。结合前面两幅图像(例如,通过取平均值)以得到最终的拼接图
修正:下面是创建拼接图的一个示例
def getInterpolationGrids(Hij, h_j, w_j):
x = np.arange(0,w_j)
y = np.arange(0,h_j)
X, Y = np.meshgrid(x,y) #h_j x w_j
p_j = np.ones((h_j,w_j,3)) #h_j x w_j x 3
p_j[:,:,0] = X
p_j[:,:,1] = Y
p_i = (temp := p_j @ (Hij.T)) / temp[:,:,2:3] #h_j x w_j x 3
XI = p_i[:,:,0] #h_j x w_j
YI = p_i[:,:,1] #h_j x w_j
return XI, YI
H_MA = np.array([[1., 0., -minx], [0., 1., -miny], [0, 0, 1]]) #the origin of I_A has coordinates -minx,-miny in the mosaic
H_AM = np.array([[1., 0., minx], [0., 1., miny], [0, 0, 1]])
XI_A, YI_A = getInterpolationGrids(H_AM, h_mosaic, w_mosaic)
I_mosA = cv.remap(I_A_float.astype(np.float32), XI_A.astype(np.float32), YI_A.astype(np.float32), cv.INTER_LINEAR) #very fast but inaccurate
fig5, axs5 = plt.subplots(2,2)
axs5[0,0].imshow(XI_A)
axs5[0,1].imshow(YI_A)
H_BM = np.linalg.inv(H_AB_best) @ H_AM
XI_B, YI_B = getInterpolationGrids(H_BM, h_mosaic, w_mosaic)
I_mosB = cv.remap(I_B_float.astype(np.float32), XI_B.astype(np.float32), YI_B.astype(np.float32), cv.INTER_LINEAR) #very fast but inaccurate
axs5[1,0].imshow(XI_B)
axs5[1,1].imshow(YI_B)
fig6, axs6 = plt.subplots(1,3)
axs6[0].imshow(I_mosA)
axs6[1].imshow(I_mosB)
axs6[2].imshow(0.5*(I_mosA + I_mosB))
Image.fromarray((0.5*(I_mosA + I_mosB)*255).astype(np.uint8)).save("mosaic_2_im.png")
# 我们要实现两个图片的拼接,具体思路为 使用 随机抽样一致性(Random Sample Consensus,RANSAC)算法 可实现图像的自动对应 换句话说 本实验也是和单应性相关的
# 算法步骤如下
# 进行循环 每次循环从所有的符合对应点中选取四个点(一般是四个 因为最低是四个)
# 利用对应的循环点来估计H矩阵,就是我们之前实现中的DLT算法 输入 P_B 和P_A 输出H_BA
# 但是现在有一个问题 就是我们不确定对应点是否正确,也就是说得到的H并不一定是正确的,怎么办
# 在每次循环中,我们使用得到的H 来和P_B相乘得到估计P_est_A 再将估计值和实际值做欧几里得距离 如果距离近 则说明估计的差不多 是好H 是对应点 否则 不用
# 因此评判当前矩阵H是好是坏的标准是 利用阈值得到的正确对应点个数 , 越大就说明H越优
# 注意本实验需要先找到对应点 利用已知算法SIFT 代码由老师提供 不与研究
from PIL import Image
import numpy as np
import cv2 as cv
import matplotlib.pyplot as plt
# 由此可见,这几个库函数是必须要背的,第一个就是numpy,第二个就是cv2 as cv,第三个是matplotlib.pyplot as plt,第四个是如果要导入图像,必须用的from PIL import image
# 第一步 把图像导入进来 ---一分
I_A=np.array(Image.open('01.jpg'))
I_B=np.array(Image.open('02.jpg'))
# 第二步 导入图像要动手,要动手就得用float函数来转换
I_A_float = I_A.astype(float)/255. #只要植入图像 要对图像动手 必须先转换成float
I_B_float = I_B.astype(float)/255.
I_A_grey = ((I_A_float.sum(axis=2)/3)*255).astype(np.uint8)
I_B_grey = ((I_B_float.sum(axis=2)/3)*255).astype(np.uint8)
# Initiate SIFT detector
sift = cv.SIFT_create()
# find the keypoints and descriptors with SIFT
kpA, desA = sift.detectAndCompute(I_A_grey,None)
pts_A = cv.KeyPoint_convert(kpA)
kpB, desB = sift.detectAndCompute(I_B_grey,None)
# BFMatcher with default params
bf = cv.BFMatcher()
matches = bf.knnMatch(desA,desB,k=2)
# Apply ratio test
good = []
for m,n in matches:
if m.distance < 0.75*n.distance:
good.append([m])
# cv.drawMatchesKnn expects list of lists as matches.
fig1, ax1 = plt.subplots(1)
ax1.imshow(cv.drawMatchesKnn(I_A,kpA,I_B,kpB,good,None,flags=cv.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS))
plt.pause(0.01)
nMatches = len(good)
# 上述部分不研究
p_A = np.float32([kpA[m[0].queryIdx].pt for m in good])
p_A_hom = np.ones((nMatches,3))
p_A_hom[:,:2] = p_A
p_B = np.float32([kpB[m[0].trainIdx].pt for m in good])
p_B_hom = np.ones((nMatches,3))
p_B_hom[:,:2] = p_B
# 最后我们得到了对应点P_A P_B之间的坐标, 并且用齐次坐标来表示P_A_hom P_B_hom,接下来,就要开始RANSAC算法来求H_AB了
thresh = 1.5
inlier_best = 0
# 首先定义循环
for i as range(500)
# 我们要从所有的对应点中找到四个对应点
ids=random(4) ===> ids = np.random.choice(n_matches, 4, replace=False)
P_A_select = P_A(ids,:)
P_B_select = P_B(ids,:)
# 接下来我们用对应点来寻找H矩阵 利用DLT算法
H_AB = DLT(P_A_select,P_B_select)
# 得到了H_AB继续估计P_A_est
P_A_hom_est = H_AB*P_B_hom ===> p_B_hom_transformed = (H_AB @ p_B_hom.T).T # Shape: (n_matches, 3)
P_A_est = P_A_hom_est/P_A_hom_est[:,2] ===> p_B_transformed = p_B_hom_transformed[:, :2] / p_B_hom_transformed[:, 2][:, np.newaxis]
distance = sqrt(sum((P_A_est-P_A)**2)) ===> distances = np.linalg.norm(p_B_transformed - p_A, axis=1)
if distance < thresh
inlier =
if inlier_best< inlier
inlier_best = inlier
H_best = H_AB
print(inlier_best,H_AB_best)
# 经过上述代码,我们就可以找到最优的H_AB
# 接下来我们着手拼接图像,需要构建一个新的大小,并保证足够大来包含拼接后的所有图像
# 所以首先我们必须获取两个图像的边界 也就是将两个图像四个角点坐标给出
hA,wA,_ = I_A.shape;
corners_IA = np.array([[0,wA,wA,0],[0,0,hA,hA],[1,1,1,1]])
hB,wB,_ = I_B.shape;
corners_IB = np.array([[0,wB,wB,0],[0,0,hB,hB],[1,1,1,1]])
# 但是注意 千万不要直接用corners_IB这个,因为这是两个图像屏幕的像素坐标,我们要把B变到A平面的像素坐标
corners_IB_in_IA =
# 找到角干什么用?我们把它们做对比,找到最边的四个角,来当作新图像的四个角,这样图像大小就肯定适合了
# 拼接图像的范围
minx = min(min[corners_IA(:,0) , corners_IB(:,0)] )
miny = min(min[corners_IA(:,1) , corners_IB(:,1)] )
maxx = max(max[corners_IA(:,0) , corners_IB(:,0)] )
maxx = max(max[corners_IA(:,0) , corners_IB(:,0)] )
# 然后我们定义新图像的宽和高为
w_mosaic = int(maxx-minx + 1)
h_mosaic = int(maxy-miny + 1)
# 我们将两个图片依次映射到新的图像坐标中--- TP2
# 也就是我们已经有了H_MA的情况下,得到最终的新图像 方法 === 网格插值
# 这里要定义H_AM但是我不知道为什么
XI_A,YI_A = grid_corresponding(H_AM,h_mosaic,w_mosaic)
I_imageA_in_M= = cv.remap(I_A_float.astype(np.float32), XI_A.astype(np.float32), YI_A.astype(np.float32), cv.INTER_LINEAR) #very fast but inaccurate
H_BM = np.linalg.inv(H_AB_best) @ H_AM
XI_B, YI_B = getInterpolationGrids(H_BM, h_mosaic, w_mosaic)
I_mosB = cv.remap(I_B_float.astype(np.float32), XI_B.astype(np.float32), YI_B.astype(np.float32), cv.INTER_LINEAR)

6. 十幅图像的马赛克
将用于生成两幅图像的代码泛化,以生成十幅图像的马赛克
from PIL import Image
import numpy as np
import cv2 as cv
import matplotlib.pyplot as plt
# Step 1: Load the images
image_filenames = [f'{i:02d}.jpg' for i in range(1, 11)]
images = [np.array(Image.open(fname)) for fname in image_filenames]
# Initialize the panorama with the first image
panorama = images[0]
# Step 8: Define the function to compute homography using DLT
def compute_homography(p_src, p_dst):
"""
Compute homography H such that p_dst ~ H * p_src
p_src and p_dst are arrays of shape (n, 2)
"""
n = p_src.shape[0]
if n < 4:
raise ValueError("At least four points are required to compute homography")
A = []
for i in range(n):
x_src, y_src = p_src[i, :]
x_dst, y_dst = p_dst[i, :]
A.append([-x_src, -y_src, -1, 0, 0, 0, x_src * x_dst, y_src * x_dst, x_dst])
A.append([0, 0, 0, -x_src, -y_src, -1, x_src * y_dst, y_src * y_dst, y_dst])
A = np.array(A)
# Solve A * h = 0 using SVD
U, S, Vh = np.linalg.svd(A)
h = Vh[-1, :] # The last row of Vh corresponds to the smallest singular value
H = h.reshape(3, 3)
return H
def stitch_images(I_A, I_B):
# Step 2: Convert images to float and grayscale
I_A_float = I_A.astype(float) / 255.
I_B_float = I_B.astype(float) / 255.
I_A_gray = ((I_A_float.sum(axis=2) / 3) * 255).astype(np.uint8)
I_B_gray = ((I_B_float.sum(axis=2) / 3) * 255).astype(np.uint8)
# Step 3: Detect SIFT keypoints and descriptors
sift = cv.SIFT_create()
kpA, desA = sift.detectAndCompute(I_A_gray, None)
kpB, desB = sift.detectAndCompute(I_B_gray, None)
# Step 4: Match descriptors using BFMatcher and apply ratio test
bf = cv.BFMatcher()
matches = bf.knnMatch(desA, desB, k=2)
good_matches = []
for m, n in matches:
if m.distance < 0.75 * n.distance:
good_matches.append(m)
# Step 5: Extract matching points
p_A = np.float32([kpA[m.queryIdx].pt for m in good_matches])
p_B = np.float32([kpB[m.trainIdx].pt for m in good_matches])
# Step 6: Convert points to homogeneous coordinates
n_matches = len(good_matches)
if n_matches < 4:
print("匹配点不足,无法拼接 ")
return I_A # 返回原始图像
p_A_hom = np.hstack((p_A, np.ones((n_matches, 1))))
p_B_hom = np.hstack((p_B, np.ones((n_matches, 1))))
# Step 7: Define RANSAC parameters
thresh = 1.5
inlier_best = 0
n_iterations = 500
# Step 9: Implement the RANSAC algorithm
for i in range(n_iterations):
# Randomly select 4 matching points
ids = np.random.choice(n_matches, 4, replace=False)
p_A_sel = p_A[ids, :]
p_B_sel = p_B[ids, :]
# Estimate homography using the selected points
H_AB = compute_homography(p_B_sel, p_A_sel)
# Transform all points from image B to image A coordinate system
p_B_hom_transformed = (H_AB @ p_B_hom.T).T # Shape: (n_matches, 3)
p_B_transformed = p_B_hom_transformed[:, :2] / p_B_hom_transformed[:, 2][:, np.newaxis]
# Compute Euclidean distances between the transformed points and the actual points in image A
distances = np.linalg.norm(p_B_transformed - p_A, axis=1)
# Identify inliers where the distance is less than the threshold
inliers = distances < thresh
n_inliers = np.sum(inliers)
# Update the best homography if the current one has more inliers
if n_inliers > inlier_best:
inlier_best = n_inliers
H_best = H_AB
inliers_best = inliers
# print(f'Iteration {i}: {inlier_best} inliers')
# Step 11: Warp images using the best homography
# Get image dimensions
h_A, w_A = I_A.shape[:2]
h_B, w_B = I_B.shape[:2]
# Get the corners of image B
corners_B = np.array([[0, 0], [w_B, 0], [w_B, h_B], [0, h_B]], dtype=np.float32)
corners_B_hom = np.hstack((corners_B, np.ones((4, 1))))
# Warp the corners of image B to image A coordinate system
corners_B_transformed_hom = (H_best @ corners_B_hom.T).T
corners_B_transformed = corners_B_transformed_hom[:, :2] / corners_B_transformed_hom[:, 2][:, np.newaxis]
# Combine corners from both images to get the size of the panorama
corners_A = np.array([[0, 0], [w_A, 0], [w_A, h_A], [0, h_A]], dtype=np.float32)
all_corners = np.vstack((corners_A, corners_B_transformed))
# Find the bounding box of the panorama
[x_min, y_min] = np.int32(all_corners.min(axis=0) - 0.5)
[x_max, y_max] = np.int32(all_corners.max(axis=0) + 0.5)
# Compute the translation needed
translation = [-x_min, -y_min]
# Compute the size of the panorama
panorama_size = (x_max - x_min, y_max - y_min)
# Adjust the homography to include the translation
H_translation = np.array([[1, 0, translation[0]],
[0, 1, translation[1]],
[0, 0, 1]])
# Warp image B into the panorama
I_B_warped = cv.warpPerspective(I_B, H_translation @ H_best, panorama_size)
# Create the panorama canvas
panorama = np.zeros((panorama_size[1], panorama_size[0], 3), dtype=np.uint8)
panorama[translation[1]:h_A + translation[1], translation[0]:w_A + translation[0]] = I_A
# Blend the warped image B into the panorama
mask = (I_B_warped > 0)
panorama[mask] = I_B_warped[mask]
return panorama
# Step 4: Stitch images in a loop
for i in range(1, len(images)):
print(f'正在拼接第 {i+1} 张图片...')
I_B = images[i]
panorama = stitch_images(panorama, I_B)
# Step 12: Display the final stitched image
plt.figure(figsize=(20, 15))
plt.imshow(panorama)
plt.title('Résultat de la fusion panoramique')
plt.axis('off')
plt.show()

**七、**RANSAC的重要性
- 当我们有一堆数据的时候,数据并不是完全准确的(好比本实验中对应点关系并不都是正确的),因此如何找到正确的数据是要处理的一个问题,那么用什么方法呢,就用RANSAC即随机采样一致性算法,在此算法中,我们通过当前数据(对应点)找到想要的中间变量(H矩阵),再用此中间变量得到的评估结果和实际数据相对比,也就是有一个评判标准(本实验是欧几里得距离),来以此评判得到中间变量的好与坏,进而评判数据的好与坏
- 在计算机视觉和图像处理领域,数据噪声和错误匹配普遍存在,稳健估计算法是解决此类问题的关键
第四次课
理论部分 --- 立体视觉中的对极几何 Géométrie éqipolaire
到目前为止,我们已经研究了平面场景的情况,使用了单应性(Homography)来描述两个视图之间的关系。然而,对于一般的三维场景,平面假设不再成立。为此,我们引入了对极几何(Epipolar Geometry)
一、对极几何(Epipolar Geometry)
对极几何可以通过一个示意图很好地解释:

- 考虑两个相机,分别位于参考系1和参考系2
- 问题描述:
• 在一般情况下,我们无法对点 U 做出任何假设(与之前的平面场景不同)
• 我们需要找到一种方法,在不知道 U 的情况下,建立 m_1 和 m_2 之间的关系
二、对极平面和对极线
- 对极平面(Epipolar Plane)
- 对极线(Epipolar Lines)
三、对极约束(Epipolar Constraint)
- 目标:
• 利用上述几何关系,形式化对极约束,建立 m_1 和 m_2 之间的数学关系
注意:
- 其中\times 表示的是两个向量之间的叉积运算。叉积的结果是一个向量,它垂直于运算的两个向量,方向由右手定则决定,大小为这两个向量构成的平行四边形的面积
- 法向量的坐标系变换不用考虑translation部分,因为单位法向量并不是坐标位置,方向向量在旋转过程中大小不变,不受平移的影响。总而言之,法向量只考虑旋转矩阵,而点则需要考虑旋转➕平移
回顾叉积运算性质:
四、本质矩阵(Essential Matrix)
- 公式
这个公式说明了\underline{\mathbf{m}}_2 \underline{\mathbf{m}}_1必须满足这个约束,否则,它们就不是对应点
- 自由度
自由度(degree of freedom, DoF)是指描述本质矩阵所需的独立参数数量。在几何和线性代数中,自由度反映了系统在不受限制的情况下可以独立变化的方向或方式
旋转矩阵具有 3 个自由度。描述了三维空间中的旋转
平移向量理论上在三维空间中有 3 个自由度。但是本质矩阵中的平移向量一般只关注于方向,对长度忽略(未知),所以平移向量只剩下 2 个有效的自由度,描述了平移的方向
五、基本矩阵(Fundamental Matrix)
对其齐次性做出解释: 极线几何中的约束条件\underline{\mathbf{m}}_2^\top \cdot \mathbf{E}_{21} \cdot \underline{\mathbf{m}}_1 = 0导致基本矩阵的行列式为零(总是有一个零特征值),这意味着它的秩是2
利用F_{21}将图像1中的一个点 \mathbf{P}_1 转化为图像2中的一条对极线l_2
解释: 对极线在图像平面上的方程通常用 $a P_{2,x} + b P_{2,y} + c = 0\$表示,a、b、c 是对极线的参数,定义了方向和位置
六、 基础矩阵的估计 (L’estimation de la matrice fondamental)
-
Caméra calibrée => estimation de la matrice essentiel (5 DDL) => l'algorithme des 5 correspondances
相机已经校准 (已知相机的内参矩阵k) => 本质矩阵的估计(5个自由度)=> 5点对应算法
相机已经校准 \Rightarrow 可以直接估计本质矩阵 \mathbf{E} -
Caméra non-calibrée => estimation de la matrice fondamental (7 DDL) => l'algorithme des 7 correspondances
相机未校准=> 基础矩阵的估计(7个自由度 多了k_1 k_2)=> 7点对应算法
相机未校准\Rightarrow需要估计基本矩阵 \mathbf{F} -
Solution => l'algorithme 8 correspondances => omettre intentionnellement la contrainte det(F) = 0
求解 => 8点对应算法 => 故意忽略约束条件 det(F) = 0
七、算法
-
8点对应算法步骤
\ \mathbf{F} \ 有\ 7 \ 个自由度,但在算法中忽略了秩为\ 2 \ 的约束,因此需要至少 8 对匹配点来估计\ \mathbf{F}\-
收集匹配点对:至少 8 对
-
构建线性方程组
对于每一对匹配点 (\mathbf{m}_1, \mathbf{m}_2) ,构建方程 \Rightarrow \underline{\mathbf{m}}_2^\top \cdot \mathbf{E}_{21} \cdot \underline{\mathbf{m}}_1 = 0 \Rightarrow\underline{\mathbf{P}}_2^\top \cdot \mathbf{F}_{21} \cdot \underline{\mathbf{P}}_1 = 0 -
求解:
将方程组表示为\ \underline{\mathbf{P}}_2^\top \cdot \mathbf{L_2} = 0
-
-
RANSAC 算法步骤
-
处理匹配点对中的离群点(错误匹配),稳健地估计 \mathbf{F}
-
随机采样: 利用8点对应算法来估计F
-
评估模型: 利用估计得到的F 来计算所有匹配点对的对极约束误差,即点到对应对极线的距离
-
判断内点: 根据设定的距离阈值,判断哪些匹配点是内点
-
迭代: 重复上述过程,直到找到内点数量最多的模型
-
八、TP 极线几何与立体校正
1. 目标
本实验的目标是从一对图像 (I_A, I_B)(见下文)中,通过实现一种使用8对点匹配的RANSAC算法来估计基础矩阵 F_{AB}。包含这对图像的文件可以在此处获取

也就是说,现在第二张图片是在另一个视角下拍摄的,我想把B图像变换到A图像的视角中,也就是说,在A图像的视角下B图像的位置
换句话说,B图像由于视角问题,不是我们的理想拍摄视角,我们对B进行空间立体转换,将其在视觉上和A图像呈现出一样的效果。这个过程需要由F_{AB}来实现
即 基础矩阵 F_{AB} 将随后用于对这对图像进行立体校正。所得结果是一对立体图像(见下文),即在其中一幅图像中的点在另一幅图像中对应的点位于同一条水平线上(这就是判断两个图像是否在内容上为同一视角的重要判断)
2. 自动匹配
自动匹配步骤将与航空图像拼接实验中的步骤相同。这一步是为了寻找两幅图像中的相似点,后续在相似点中随机取八个来用于估计矩阵
from PIL import Image
import numpy as np
import cv2 as cv
import matplotlib.pyplot as plt
#%% LOAD IMAGES
I_A = np.array(Image.open('data/1.png'))
I_B = np.array(Image.open('data/3.png'))
I_A_float = I_A.astype(float)/255.
I_B_float = I_B.astype(float)/255.
fig1, axs1 = plt.subplots(1,2)
axs1[0].imshow(I_A);
axs1[1].imshow(I_B);
# 可见这部分代码是必备必背的,其思路顺序为
# - 必要库函数的导入
# - 导入图像并转为float,同时显示一下原图
# 后续代码由老师提供,不管
#%% SIFT & MATCHING
I_A_grey = ((I_A_float.sum(axis=2)/3)*255).astype(np.uint8)
I_B_grey = ((I_B_float.sum(axis=2)/3)*255).astype(np.uint8)
# Initiate SIFT detector
sift = cv.SIFT_create()
# find the keypoints and descriptors with SIFT
kpA, desA = sift.detectAndCompute(I_A_grey,None)
pts_A = cv.KeyPoint_convert(kpA)
kpB, desB = sift.detectAndCompute(I_B_grey,None)
# BFMatcher with default params
bf = cv.BFMatcher()
matches = bf.knnMatch(desA,desB,k=2)
# Apply ratio test
good = []
for m,n in matches:
if m.distance < 0.75*n.distance:
good.append([m])
# cv.drawMatchesKnn expects list of lists as matches.
fig1, ax1 = plt.subplots(1)
ax1.imshow(cv.drawMatchesKnn(I_A,kpA,I_B,kpB,good,None,flags=cv.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS))
plt.pause(0.01)
nMatches = len(good)
p_A = np.float32([kpA[m[0].queryIdx].pt for m in good])
p_A_hom = np.ones((nMatches,3))
p_A_hom[:,:2] = p_A
p_B = np.float32([kpB[m[0].trainIdx].pt for m in good])
p_B_hom = np.ones((nMatches,3))
p_B_hom[:,:2] = p_B

现在得到了所有匹配点对 \{(\mathbf{p}_{A,i}, \mathbf{p}_{B,i})\}_{i=1…N_{matches}},后面开始进行RANSAC算法
3. 基础矩阵的估计
根据之前建立的对应关系 \{(\mathbf{p}_{A,i}, \mathbf{p}_{B,i})\}_{i=1…N_{matches}},要求编写一个函数 ransacF,以估计基础矩阵 F_{AB}。首先,可以使用OpenCV中8点算法的实现
(F_AB, _ = cv.findFundamentalMat(p_B_sel, p_A_sel, method=cv.FM_8POINT))# 这部分是在RANSACF代码中体现的,不是直接用在这里
#推荐的 ransacF 函数原型如下:
F_AB, mask_inliers, nInliers = ransacF(p_A_hom, p_B_hom, nItRansac, threshRansac)。
下面是正确的ransacF代码
import numpy as np
import cv2 as cv
def ransacF(p_A, p_B, nIt, thresh): # p_A 形状为 (nMatches, 3)
nMatches = p_A.shape[0] # 获取匹配点对的数量
# p_A 大小为 (nMatches, 3),也就是(匹配点对的数量,3) 3 是齐次坐标
# p_A.shape[0] 获取矩阵的第一维度的大小,即行数,这里代表匹配点对的数量
# 如果 p_A 的形状是 (500, 3),那么 nMatches 的值就是 500
nInliers_best = 0
for it in range(nIt):
#Sample 8 matches
rng = np.random.default_rng()
ids = rng.choice(nMatches, size=8, replace=False)
p_A_sel = p_A[ids,:] #
p_B_sel = p_B[ids,:]
# 这里是标准的: 已知匹配对,从中随机选x个 的代码
# 因此我们得到了,带有八个随机匹配对的(P_A,P_B)坐标 称之为select 用于估计F_AB
# 随机选择一组最小样本数据(在这种情况下是8对匹配点)来拟合模型,并使用该模型评估所有数据点的内点数量
#Estimate fondamental matrix
#F_AB = eight_pt(p_A_sel.T, p_B_sel.T)
F_AB, _ = cv.findFundamentalMat(p_B_sel, p_A_sel, method=cv.FM_8POINT)
if(F_AB is not None):
if(np.isnan(F_AB).sum() == 0):
# sum() 会计算布尔数组中 True 的个数,即矩阵中 NaN 值的数量
# 如果这个数量为 0,说明矩阵中没有任何 NaN 值,则认为矩阵有效
#Compute cost 计算每个匹配点对的代价(cost)
num = np.array([np.abs(p_A[m,:]@F_AB@(p_B[m,:].T)) for m in range(nMatches)])
denum = np.array([np.sqrt(((F_AB[:2,:]@(p_B[m,:].T))**2).sum()) for m in range(nMatches)])
dist = num/denum
mask_inliers = dist < thresh
nInliers = mask_inliers.sum()
#至此我们计算了对应距离,下面是标准的最优判断,如果距离足够小则被判断为内点,并保存最优的参数
if(nInliers_best<nInliers):
nInliers_best = nInliers
F_AB_best = F_AB
mask_inliers_best = mask_inliers
print('{} inliers'.format(nInliers_best))# 随着迭代的进行,我们可以找到越来越大的内点数
F_AB_best, _ = cv.findFundamentalMat(p_B[mask_inliers_best,:], p_A[mask_inliers_best,:], method=cv.FM_8POINT)
# 这一步我认为不需要添加
return F_AB_best, mask_inliers_best, nInliers_best
数学背景
在对极几何中,两个图像中的点 \mathbf{p}_A 和 \mathbf{p}_B 需要满足以下约束关系:
\mathbf{p}_A^T \cdot F_{AB} \cdot \mathbf{p}_B = 0
这个方程代表了 点 \mathbf{p}_A 映射到图像B中的对极线与点 \mathbf{p}_B 的几何约束 如果该方程严格成立,说明点 \mathbf{p}_B 完美地位于由点 \mathbf{p}_A 映射得到的对极线上
计算分子部分 num
对于每个匹配点对 m,计算每个匹配点对 (\mathbf{p}_A, \mathbf{p}_B) 几何偏差(点到对极线),即计算 |\mathbf{p}A^T \cdot F_{AB} \cdot \mathbf{p}_B| 同时加上abs保证差非负
计算分母部分 denum
计算的是对极线方向矢量的平方和的平方根{\sqrt{(F_{AB} \cdot \mathbf{p}_B)^2_1 + (F_{AB} \cdot \mathbf{p}_B)^2_2}},这样做是为了对残差进行标准化
以下代码可以通过显示RANSAC认为正确的对应关系来测试您的函数
#%% 以下代码用于测试
from gui_F import gui_F
from ransacF import ransacF
#%% RANSAC & FUNDAMENTAL MATRIX ESTIMATION
nItRansac = 500
threshRansac = 1.5 #in pixels
F_AB, mask_inliers, nInliers = ransacF(p_A_hom,p_B_hom,nItRansac,threshRansac)
inlier_matches = [good[idx] for idx in np.nonzero(mask_inliers)[0]]
fig2, ax2 = plt.subplots(1)
ax2.imshow(cv.drawMatchesKnn(I_A,kpA,I_B,kpB,inlier_matches,None,flags=cv.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS))
plt.pause(0.01)
p_A_inliers = p_A_hom[mask_inliers,:]
p_B_inliers = p_B_hom[mask_inliers,:]

**备注:在500次迭代和1.5像素的阈值下,您应获得超过350个RANSAC认为正确的匹配点 **

4. 图形界面
可用的图形界面(点击此处)允许显示由基础矩阵 F_{AB} 生成的极线,这些极线对应于鼠标光标的位置。您可以通过此界面直观地验证极线是否正确
from gui_F import gui_F
gui_F(Image.fromarray(I_A), Image.fromarray(I_B), F_AB)
第五次课
Ajustement de faisceaux (Bundle Adjustment) (束调整)
- 束调整是一种同时优化摄像机参数(包括位置、姿态和内参)和场景中三维点位置的技术
- 其核心思想是通过最小化三维点在图像上的重投影误差,使得优化后的模型与实际观测更加吻合
- 记住五个字 最小化投影误差
Cas de 2 caméra
-
données
\left( P_{A,i}, P_{B,i} \right)_{i=1,\dots,N} \implies N \text{ correspondences} -
Paramètres à estimer
pôle de caméra (摄像机的中心) 以及 nuage de point 3D (三维点云数据集) \mathbf{R}_{W1} \ \mathbf{t}_{W1} \quad \mathbf{R}_{W2} \ \mathbf{t}_{W2} \quad \left\{ \mathbf{U}^w_i \right\}_{i=1,\dots,N}
-
Fonction de coût
\mathcal{L} \left( \mathbf{R}_{w1}, \mathbf{t}_{w1}, \mathbf{R}_{w2}, \mathbf{t}_{w2}, \left\{ \mathbf{U}^w_i \right\}_{i=1,\dots,N} \right)= \sum_{i=1}^{N} \left( \left\lVert P_{1,i} - K_1 \Pi \left( \mathbf{R}_{w1}^T \mathbf{U}_i^{w} - \mathbf{R}_{w1}^T \mathbf{t}_{w1} \right) \right\rVert_2^2 +\left\lVert P_{2,i} - K_2 \Pi \left( \mathbf{R}_{w2}^T \mathbf{U}_i^{w} - \mathbf{R}_{w2}^T \mathbf{t}_{w2} \right) \right\rVert_2^2 \right)
其中:
- K_A和K_B是摄像机A和B的内参矩阵
- \Pi(\cdot)是投影函数,将三维点投影到二维平面上
- \mathbf{R}_{w1}^T和\mathbf{R}_{w2}^T 等价于 \mathbf{R}_{1w}和\mathbf{R}_{2w} \Rightarrow将点从世界坐标系转换到摄像机坐标系
- \mathbf{R}_{w1}^T \mathbf{t}_{w1} 等价于 \mathbf{t}_{1w} \Rightarrow 表示平移向量
- \mathbf{U}_i^{1}= \mathbf{R}_{w1}^T \cdot \mathbf{U}_i^{w} - \mathbf{R}_{w1}^T \cdot \mathbf{t}_{w1} 代表 \mathbf{U}_i^{1}= \mathbf{R}_{1w} \cdot \mathbf{U}_i^{w}- \mathbf{t}_{1w} 也就是将\mathbf{U}_i^{w}变换到\mathbf{U}_i^{1} 即从世界坐标系变换到相机坐标系
- 做差: 相机A或B中的图像坐标(实际) - 三维空间旋转变换得来的估计图像坐标 = erreur de reprojection (重投影误差)
Cas de M caméras
-
Données
每张图像中检测到的点为: \left\{ \left\{ P_{m,i} \right\}_{i=1,\dots,N_m} \right\}_{m=1,\dots,M}
-
这些点在不同视角下的图像中可以形成轨迹(tracks)
-
第m个摄像机检测到的点,其中N_m是第m个摄像机检测到的点的数量
-
其中
-
p2d-id_m是 indices dans la liste des points détectés: 二维点在图像中的索引
-
p3d-id_m是 indices dans la liste des points 3D: 对应的三维点在点云中的索引
-
它们的大小尺寸都是 C_m \times 1
-
Paramètre à estimer
相机外参: \left\{ \left( \mathbf{R}_{wm}, \mathbf{t}_{wm} \right) \right\}_{m=1,\dots,M} \quad 三维点的位置: \left\{ \mathbf{U}_i^{w} \right\}_{i=1,\dots,N}
-
Fonction de coût
代价函数扩展为对所有摄像机和所有检测到的点进行误差计算,将投影点与实际观测点之间的距离最小化:
L \left( \left\{ \mathbf{R}_{wm}, \mathbf{t}_{wm} \right\}_{m=1,\dots,M}, \left\{ \mathbf{U}_i^{w} \right\}_{i=1,\dots,N} \right) = \sum_{m=1}^{M} \sum_{c=1}^{C_m} \left\| P_{m,\ p2d-id_m(c)} - k_m \pi \left( \mathbf{U}_{\text{p3d-id}_m(c)}^{w} - \mathbf{R}_{wm}^{T} \mathbf{t}_{wm} \right) \right\|_2^2
- C_m是第m台摄像机的观测数量
- \mathbf{U}_{p3d-id_m(c)}^{w}是与观测对应的三维点
我们可以简单的将上述代价函数简化成 $L(x) = \sum_{i=1}^{N} \left| f_i(x) \right|_2^2 \quad \left{
\begin{array}{l}
x \in \mathbb{R}^D \
f_i : \mathbb{R}^D \rightarrow \mathbb{R}^B
\end{array}
\right.$- x是所有待优化的参数(摄像机参数和三维点坐标)
- f_i(x)是第i个残差函数,表示第i个观测的重投影误差
- 我们的目标是找到x,使得L(x)最小化。这是一个非线性最小二乘问题,通常使用迭代的方法求解
L'algorithme Gauss-Newton :
- 用于非线性最小二乘问题的一种迭代优化算法 \Rightarrow \text{ iteratif } \quad \delta_{k+1} = \delta_k + d_k
-
\text{Linearisation de } f_i : \quad f_i(x_k + d_k) \approx f_i(x_k) + \mathbf{J}_i(x_k)\cdot d_k
-
\delta x是参数的增量,需要求解
-
对于每次迭代,我们在当前估计x_k附近对f_i(x)进行泰勒展开,并忽略高阶项
-
f_i(\delta_k + d_k) \in \mathbb{R}^B
-
f_i(\delta_k) \in \mathbb{R}^B \mathbf{J}_i(\delta_k) \in \mathbb{R}^{B \times D} d_k \in \mathbb{R}^D
-
-
其中雅可比矩阵为: \mathbf{J}_i(x_k) = \frac{\partial f_i(x_k + d_k)}{\partial d_k} \bigg|_{d_k=0}
- 代表了在点 x_k 处函数 f_i 对于 d_k 的偏导数,并且此偏导数是在 d_k = 0 的条件下计算的
- 描述了在点 x_k 处函数 f_i 的线性变化率
-
线性最小二乘法
L_k(d_k) = \sum_{i=1}^{N} \left\| f_i(x_k) + \mathbf{J}_i(x_k) \cdot d_k \right\|_2^2 $\mathbf{J}_k =
\begin{bmatrix}
J_1(x_k) \
J_2(x_k) \
J_3(x_k) \
\vdots \
J_N(x_k)
\end{bmatrix}$ $\mathbf{b}_k =
\begin{bmatrix}
f_1(x_k) \
f_2(x_k) \
\vdots \
f_N(x_k)
\end{bmatrix}$- \mathbf{b}_k是所有残差的组合
线性最小二乘问题变为:
L_k(d_k) = \lVert b_k + J_k \cdot d_k \rVert_2^2通过最小化L_k(\delta x),我们可以得到线性方程组:J_k^T \cdot J_k \cdot d k = -J_k^T \cdot b_k \quad
-
其中b_k正比于 \text{ gradient}
-
左边的矩阵\mathbf{J}_k^T \mathbf{J}_k是海森矩阵的近似 右边的向量-\mathbf{J}_k^T \mathbf{b}_k是梯度的负值
-
求解这个线性系统,得到参数更新d_k
-
Levenberg-Marquardt算法
在高斯-牛顿算法的基础上引入阻尼因子\lambda,使得优化过程在接近解时具有高斯-牛顿的快速收敛特性,而在远离解时具有梯度下降的稳定性
-
常用于非线性最小二乘问题的迭代优化算法
-
目标函数: L_k(d_k) = \lVert b_k + J_k d_k \rVert_2^2 + \lambda \lVert d_k \rVert_2^2
-
\Rightarrow (J_k^T J_k + \lambda I_k)d_k = -J_k^T b_k
-
\lambda是阻尼因子
-
- **如果新的代价函数值降低了**(说明更新有效),则减小$\lambda$,使算法更接近高斯-牛顿法,加快收敛
- **如果代价函数值没有降低**, 则增大$\lambda$,使算法更接近梯度下降法,保证稳定性
算法步骤总结:
[!NOTE]
设定初始参数x和阻尼因子\lambda 并且计算初始代价函数L_{\min}
\text{Tout } S \text{ est faux}\ \ \ \Rightarrow \ \ \ \text{Calcul de } b \text{ et } J (计算雅可比矩阵\mathbf{J}和残差\mathbf{b})
\text{Tant que } \lambda < \lambda_{\text{max}}, \quad \Rightarrow \quad \text{arrt de l'algorithme si } \lambda \text{ depasse } \lambda_{\text{max}}
$\text{Si } L' < L_{\min} \quad \text{(acceptation de la mise à jour)} \$
$ \quad S = \text{faux}(继续更新), \quad L_{\min} = L', \quad \mathbf{x} = \mathbf{x}'\$
$ \text{Si } \lambda > \lambda_{\min} \text{ alors } \lambda = \frac{\lambda}{2} \quad \text{(refus de la mise à jour)}\$
$ \text{Sortie Tant que}\$
$ \text{Sinon }\$
\quad S = \text{vrai}, \quad \lambda = 2\lambda
七、算法步骤总结
在实际应用中,Levenberg-Marquardt算法的步骤如下:
-
初始化:
- 设定初始参数x和阻尼因子\lambda
- 计算初始代价函数L_{\min}
-
迭代:
-
计算雅可比矩阵\mathbf{J}和残差\mathbf{b}
-
求解线性系统:
(J^T J + \lambda I_d) d = -J^T b
-
更新参数:
x′=x+d
-
计算新的代价函数L'
-
-
判断更新效果:
- 如果L' < L_{\min}(代价函数降低):
- 接受更新:x = x',L_{\min} = L'
- 减小\lambda:\lambda = \lambda / 2
- 继续迭代
- 否则(代价函数未降低):
- 拒绝更新,不改变x
- 增大\lambda:\lambda = 2\lambda
- 检查\lambda是否超过最大值,若超过则停止迭代
- 如果L' < L_{\min}(代价函数降低):
-
终止条件:
- 当\lambda超过预设的最大值,或者参数更新的幅度小于阈值时,停止迭代