目录
1、图像拼接的原理
图像拼接就是将两幅或多幅具有重叠区域的图像,合并成一张大图。例如下图,对七张图片进行缝合,生成一张视野更大的全景图。
1.1 基本流程
① 针对某个场景拍摄多张/序列图像
② 计算第二张图像与第一张图像之间的变换关系
- 提取特征点
- 生成描述子
- 特征匹配
- 计算变换结构
本文运用的是SIFT算法来提取图像的特征和匹配,SIFT算法对于旋转和尺度均具有不变性,并且对于噪声、视角变化和光照变化具有良好的鲁棒性。对于提取特征点、生成描述算子以及特征匹配,在上一篇博客中已经有详细讲解了,因而本文会注重于计算变换结构的讲解。
④ 变换后的融合/合成
⑤ 在多图场景中,重复上述过程
1.2 图像拼接的几何原理
全景融合的 3D 几何解释:
- 图像被投影到共同的拼接平面上(同一坐标系);
- 在拼接平面上实现全景融合;
- 在拼接的应用中,其实可以简化理解为 2D图像的变换,叠加过程;
在拼接的应用中,其实可以简化理解为 2D图像的变换, 叠加过程。
1.3 2D图像变换原理
计算变换结构流程:
- 提取特征点,生成描述符
- 特征匹配
- 计算变换结构
第一点和第二点已经在上一篇博客中实现了,现在我们在此基础上进行实验。现在要考虑的就是变换类型的选择。
将两幅图像叠加在一起,需要采用什么模型?
- translation?(位移)
- rotation?(旋转)
- scale?(尺度/大小)
- affine?(仿射)
- Perspective?(透视)
图像滤波: 改变图像的像素点取值范围
图像变换: 改变图像的坐标取值范围
图像映射类型:
1.3.1 2D 图像变换类型
# | DoF(自由度) | Preserves(不变性) | Icon(图型) |
平移 | 2 | orientation | |
旋转+平移 | 3 | lengths | |
相似 | 4 | angles | |
仿射 | 6 | parallelism |
平移 :自由度为2,大小不改变
旋转+ 平移(刚体):自由度为3,长度不变
相似:自由度为4,角度不变
仿射:自由度为3,平行关系
单应性变换:
1.3.2 变换参数求解
针对不同问题,需要多少对匹配特征才能计算出模型参数?
参数求解: 平移问题
第 i 个特征点位移量 = ,用 表示位移量,则
给定任意的像素点 ,则
定义映射残差为:
优化目标: 残差平方和最小化
得到的结果称为“最小二乘” 解。可以验证,在平移问题中最小二乘解和平均残差得到的结果等价。
最小二乘策略:
求解 t ,使得下述公式值最小:
矩阵的最小二乘方法:
参数求解:仿射变换
6个自由度/未知参数
残差:
平方和代价函数:
矩阵表达式:
参数求解:单应性变换
变换后的坐标 :
两边同时参与分母得:
矩阵表达式:
由于
最小二乘解: 最小特征值对应的特征向量,需要至少4对匹配特。
1.3.3 2D 图像变换
给定变换模型 x'= h(x) ,以及输入图像 f(x), 如何根据f(x)计算变换后的图像 g(x') = f(h(x))?
有两种方法,分别为前向映射和逆向映射。
前向映射:
对于 f(x) 中的每个像素 x,根据变换模型计 算相应的映射坐标x' = h(x),并将x的像素值赋给 g(x')。
当像素落在两个像素之间时:近邻插值
逆向映射:
对于 g(x')中的每个像素 x',根据变换模型计算相应的映射坐标 x = h -1 (x'),并将x的像素值赋给g(x')。
当像素落在两个像素之间时:线性插值/双线性插值
主要的插值方法:
- 最近邻插值;
- 线性插值;
- 双线性插值;
- 三线性插值。
图像映射基本流程
- 针对两张/多张图像提取特征;
- 特征匹配;
- 根据图像变换特点,选取合适的变换结构;
- 根据DLT等方法计算变换结构;
- 采用正向/逆向映射,利用插值方式实现图像映射变换。
1.4 直线拟合
给定若干二维空间中的点,求直线 y=ax+b , 使得该直线对空间点的拟合误差最小。
直线确定步骤:
- 随机选择两个点;
- 根据该点构造直线;
- 给定阈值,计算 inliers 数量;
在空间中随机寻找到两点,然后用这两点构造出一条直线 y=ax+b ,然后计算在这条线上的对应inliers的数量,当inliers值达到所需的阈值,可以得到一条拟合直线。
如何拟合一个圆 ?
对于拟合圆,同样也适用,两点确定一条直线,确定一个圆三点就可以,随机取三个点,便可确定一个圆的方程,然后计算在该圆上的inliers值,之后的过程与拟合直线的方法一样。
如何拟合多条直线 ?
如何拟合一个复杂方程?
对于复杂的曲线方程,需要先给定一个方程式,例如 ,我们要求出这个方程式的参数,求出一个解,使得在inliers值尽可能多,或者是求出的曲线与点之间是最逼近。
2、RANSAC算法
2.1 RANSAC简介
RANSAC(RAndom SAmple Consensus,随机采样一致)。该方法是用来找到正确模型来拟合带有噪声数据的迭代方法。给定一个模型,例如点集之间的单应性矩阵,RANSAC 基本的思想是,数据中包含正确的点和噪声点,合理的模型应该能够在描述正确数据点的同时摒弃噪声点。
RANSAC 的标准例子:用一条直线拟合带有噪声数据的点集。简单的最小二乘在该例子中可能会失效,但是 RANSAC能够挑选出正确的点,然后获取能够正确拟合的直线。下面来看使用 RANSAC 的例子。你可以从 http://www.scipy.org/Cookbook/ RANSAC下载 ransac.py,里面包含了特定的例子作为测试用例。图 3-10 为运行 ransac.text() 的例子。可以看到,该算法只选择了和直线模型一致的数据点,成功地找到了正确的解。
RANSAC loop:
- 随机选择四对匹配特征
- 根据直接线性变换解法DLT计算单应矩阵 H ( 唯一解)
- 对所有匹配点,计算映射误差ε= ||pi ’, H pi ||
- 根据误差阈值,确定inliers(例如3-5像素)
- 针对最大inliers集合,重新计算单应矩阵 H
2.2 稳健的单应性矩阵估计
我们在任何模型中都可以使用 RANSAC 模块。在使用 RANSAC 模块时,我们只需要在相应 Python 类中实现 fit() 和 get_error() 方法,剩下就是正确地使用 ransac.py。 我们这里使用可能的对应点集来自动找到用于全景图像的单应性矩阵。图 3-11 所示 为使用 SIFT 特征自动找到匹配对应。这可以通过运行下面的命令来实现:
from PCV.localdescriptors import sift
# 设置数据文件夹的路径
featname = ['C:/Users/Administrator/Desktop/Test3/picture'+str(i+1)+'.sift' for i in range(5)]
imname = ['C:/Users/Administrator/Desktop/Test3/picture'+str(i+1)+'.jpg' for i in range(5)]
# 提取特征和匹配
l = {}
d = {}
for i in range(5):
sift.process_image(imname[i],featname[i]) # 处理图像生成sift并保存在文件中
l[i],d[i] = sift.read_features_from_file(featname[i]) # 读取特征符并以矩阵形式返回
matches = {}
for i in range(4):
matches[i] = sift.match(d[i+1],d[i]) # 匹配特征符
我们使用 RANSAC 算法来求解单应性矩阵,首先需要将下面模型类添加到 homography.py 文件中:
class RansacModel(object):
""" 用于测试单应性矩阵的类,其中单应性矩阵是由网站http://www.scipy.org/Cookbook/RANSAC 上的ransac.py 计算出来的"""
def __init__(self, debug=False):
self.debug = debug
def fit(self, data):
""" 计算选取的4 个对应的单应性矩阵 """
# 将其转置,来调用H_from_points() 计算单应性矩阵
data = data.T
# 映射的起始点
fp = data[:3, :4]
# 映射的目标点
tp = data[3:, :4]
# 计算单应性矩阵,然后返回
return H_from_points(fp, tp)
def get_error(self, data, H):
""" 对所有的对应计算单应性矩阵,然后对每个变换后的点,返回相应的误差"""
data = data.T
# 映射的起始点
fp = data[:3]
# 映射的目标点
tp = data[3:]
# 变换fp
fp_transformed = dot(H, fp)
# 归一化齐次坐标
# for i in range(3):
# fp_transformed[i] /= fp_transformed[2]
fp_transformed = normalize(fp_transformed)
# 返回每个点的误差
return sqrt(sum((tp - fp_transformed) ** 2, axis=0))
可以看到,这个类包含 fit() 方法。该方法仅仅接受由 ransac.py 选择的4个对应点对(data 中的前4个点对),然后拟合一个单应性矩阵。记住,4个点对是计算单应性矩阵所需的最少数目。由于 get_error() 方法对每个对应点对使用该单应性矩 阵,然后返回相应的平方距离之和,因此 RANSAC 算法能够判定哪些点对是正确 的,哪些是错误的。在实际中,我们需要在距离上使用一个阈值来决定哪些单应性矩阵是合理的。为了方便使用,将下面的函数添加到 homography.py 文件中:
def H_from_ransac(fp, tp, model, maxiter=1000, match_theshold=10):
""" 使用RANSAC 稳健性估计点对应间的单应性矩阵H(ransac.py 为从 http://www.scipy.org/Cookbook/RANSAC 下载的版本)
# 输入:齐次坐标表示的点fp,tp(3×n 的数组)"""
from PCV.tools import ransac
# 对应点组
data = vstack((fp, tp))
# 计算H,并返回
H, ransac_data = ransac.ransac(data.T, model, 4, maxiter, match_theshold, 10, return_all=True)
return H, ransac_data['inliers']
该函数同样允许提供阈值和最小期望的点对数目。最重要的参数是最大迭代次数: 程序退出太早可能得到一个坏解;迭代次数太多会占用太多时间。函数的返回结果 为单应性矩阵和对应该单应性矩阵的正确点对。 类似于下面的操作,你可以将 RANSAC 算法应用于对应点对上:
# 将匹配转化成齐次坐标点的函数
def convert_points(j):
ndx = matches[j].nonzero()[0]
fp = homography.make_homog(l[j + 1][ndx, :2].T)
ndx2 = [int(matches[j][i]) for i in ndx]
tp = homography.make_homog(l[j][ndx2, :2].T) # 将点集转化为齐次坐标表示
# 转化x和y - TODO这应该移动到其他地方
fp = vstack([fp[1], fp[0], fp[2]])
tp = vstack([tp[1], tp[0], tp[2]])
return fp, tp
# 估计单应性矩阵
model = homography.RansacModel()
fp, tp = convert_points(0)
H_01 = homography.H_from_ransac(fp, tp, model)[0] # m0到m1的单应性矩阵
2.3 图像拼接
估计出图像间的单应性矩阵(使用 RANSAC 算法),现在我们需要将所有的图像扭曲到一个公共的图像平面上。通常,这里的公共平面为中心图像平面(否则,需要进行大量变形)。一种方法是创建一个很大的图像,比如图像中全部填充 0,使其和中心图像平行,然后将所有的图像扭曲到上面。由于我们所有的图像是由照相机水平旋转拍摄的,因此我们可以使用一个较简单的步骤:将中心图像左边或者右边的区域填充0,以便为扭曲的图像腾出空间。将下面的代码添加到 warp.py 文件中:
def panorama(H,fromim,toim,padding=2400,delta=2400):
""" 使用单应性矩阵H(使用RANSAC稳健性估计得出),协调两幅图像,创建水平全景图。结果
为一幅和toim具有相同高度的图像。padding指定填充像素的数目,delta指定额外的平移量"""
#检查图像是灰度图像,还是彩色图像
is_color = len(fromim.shape) == 3
# 用于geometric_transform()的单应性变换
def transf(p):
p2 = dot(H,[p[0],p[1],1])
return (p2[0]/p2[2],p2[1]/p2[2])
if H[1,2]<0: # fromim在右边
print 'warp - right'
# 变换fromim
if is_color:
# 在目标图像的右边填充0
toim_t = hstack((toim,zeros((toim.shape[0],padding,3))))
fromim_t = zeros((toim.shape[0],toim.shape[1]+padding,toim.shape[2]))
for col in range(3):
fromim_t[:,:,col] = ndimage.geometric_transform(fromim[:,:,col],
transf,(toim.shape[0],toim.shape[1]+padding))
else:
# 在目标图像的右边填充0
toim_t = hstack((toim,zeros((toim.shape[0],padding))))
fromim_t = ndimage.geometric_transform(fromim,transf,
(toim.shape[0],toim.shape[1]+padding))
else:
print 'warp - left'
#为了补偿填充效果,在左边加入平移量
H_delta = array([[1,0,0],[0,1,-delta],[0,0,1]])
H = dot(H,H_delta)
#fromim变换
if is_color:
# 在目标图像的左边填充0
toim_t = hstack((zeros((toim.shape[0],padding,3)),toim))
fromim_t = zeros((toim.shape[0],toim.shape[1]+padding,toim.shape[2]))
for col in range(3):
fromim_t[:,:,col] = ndimage.geometric_transform(fromim[:,:,col],
transf,(toim.shape[0],toim.shape[1]+padding))
else:
# 在目标图像的左边填充0
toim_t = hstack((zeros((toim.shape[0],padding)),toim))
fromim_t = ndimage.geometric_transform(fromim,
transf,(toim.shape[0],toim.shape[1]+padding))
# 协调后返回(将fromim放在toim上)
if is_color:
# 所有非黑像素
alpha = ((fromim_t[:,:,0] * fromim_t[:,:,1] * fromim_t[:,:,2] ) > 0)
for col in range(3):
toim_t[:,:,col] = fromim_t[:,:,col]*alpha + toim_t[:,:,col]*(1-alpha)
else:
alpha = (fromim_t > 0)
toim_t = fromim_t*alpha + toim_t*(1-alpha)
return toim_t
对于通用的 geometric_transform() 函数,我们需要指定能够描述像素到像素间映射的函数。在这个例子中,transf() 函数就是该指定的函数。该函数通过将像素和 H 相乘,然后对齐次坐标进行归一化来实现像素间的映射。通过查看 H 中的平移量, 我们可以决定应该将该图像填补到左边还是右边。当该图像填补到左边时,由于目标图像中点的坐标也变化了,所以在“左边”情况中,需要在单应性矩阵中加入 移。简单起见,我们同样使用 0 像素的技巧来寻找 alpha 图。 现在在图像中使用该操作,函数如下所示:
# 扭曲图像
delta = 600 # 用于填充和平移
im1 = array(Image.open(imname[0]), "uint8")
im2 = array(Image.open(imname[1]), "uint8")
im_01 = warp.panorama(H_01, im1, im2, delta, delta)
3、 图像拼接编程实现
数据集:
3.1 完整代码
from pylab import *
from numpy import *
from PIL import Image
from PCV.geometry import homography, warp
from PCV.localdescriptors import sift
# 需要拼接的图片数量
pictureNum = 5
# 设置数据文件夹的路径
featname = ['C:/Users/Administrator/Desktop/Test3/test' + str(i + 1) + '.sift' for i in range(pictureNum)]
imname = ['C:/Users/Administrator/Desktop/Test3/test' + str(i + 1) + '.jpg' for i in range(pictureNum)]
# 提取特征和匹配
l = {}
d = {}
for i in range(pictureNum):
sift.process_image(imname[i], featname[i]) # 处理图像生成sift并保存在文件中
l[i], d[i] = sift.read_features_from_file(featname[i]) # 读取特征符并以矩阵形式返回
matches = {}
for i in range(pictureNum - 1):
matches[i] = sift.match(d[i + 1], d[i]) # 匹配特征符
# 可视化匹配
for i in range(pictureNum - 1):
im1 = array(Image.open(imname[i]))
im2 = array(Image.open(imname[i + 1]))
figure()
sift.plot_matches(im2, im1, l[i + 1], l[i], matches[i], show_below=True)
# 将匹配转化成齐次坐标点的函数
def convert_points(j):
ndx = matches[j].nonzero()[0]
fp = homography.make_homog(l[j + 1][ndx, :2].T)
ndx2 = [int(matches[j][i]) for i in ndx]
tp = homography.make_homog(l[j][ndx2, :2].T) # 将点集转化为齐次坐标表示
# 转化x和y - TODO这应该移动到其他地方
fp = vstack([fp[1], fp[0], fp[2]])
tp = vstack([tp[1], tp[0], tp[2]])
return fp, tp
# 估计单应性矩阵
model = homography.RansacModel()
fp, tp = convert_points(1)
H_12 = homography.H_from_ransac(fp, tp, model)[0] # im1 到im2 的单应性矩阵
fp, tp = convert_points(0)
H_01 = homography.H_from_ransac(fp, tp, model)[0] # im0 到im1 的单应性矩阵
tp, fp = convert_points(2) # 注意:点是反序的
H_32 = homography.H_from_ransac(fp, tp, model)[0] # im3 到im2 的单应性矩阵
tp, fp = convert_points(3) # 注意:点是反序的
H_43 = homography.H_from_ransac(fp, tp, model)[0] # im4 到im3 的单应性矩阵
# 扭曲图像
delta = 2000 # 用于填充和平移
im1 = array(Image.open(imname[1]), "uint8")
im2 = array(Image.open(imname[2]), "uint8")
im_12 = warp.panorama(H_12, im1, im2, delta, delta)
im1 = array(Image.open(imname[0]), "f")
im_02 = warp.panorama(dot(H_12, H_01), im1, im_12, delta, delta)
im1 = array(Image.open(imname[3]), "f")
im_32 = warp.panorama(H_32, im1, im_02, delta, delta)
im1 = array(Image.open(imname[4]), "f")
im_42 = warp.panorama(dot(H_32, H_43), im1, im_32, delta, 2 * delta)
figure()
imshow(array(im_42, "uint8"))
axis('off')
show()
3.2 运行结果
拼接全景图:
3.3 结果分析
从运行结果来看,拼接图像基本对齐,但中间图片存在明显的拼接缝,因为图片的曝光程度不同导致,导致拼接后的全景图可以看出有明暗差异。或者是由于图像景深的复杂程度,导致算法在匹配特征点错误多,导致匹配拼接的结果差。
3.4 实验遇到的问题
ModuleNotFoundError: No module named 'matplotlib.delaunay'
报错位置如下:
解决办法:
第一步:进入 PCV/geometry/warp.py文件
第二步:把import matplotlib.delaunay as md 改成
from scipy.spatial import Delaunay
第三步:对triangulate_points(x,y)函数进行修改,如下为正确修改方式
def triangulate_points(x,y):
""" Delaunay triangulation of 2D points. """
# centers,edges,tri,neighbors = md.delaunay(x,y)
tri = Delaunay(np.c_[x, y]).simplices
return tri
然后运行,没有问题了!