一、前言
在今天的博客文章中,我将演示如何使用Python和OpenCV执行图像拼接和全景图构建。给定两个图像,我们将它们“拼接”在一起以创建一个简单的全景图,如上面的示例所示。
要构建图像全景图,我们将利用计算机视觉和图像处理技术,例如:关键点检测和局部不变描述符;关键点匹配;RANSAC;透视变换等技术
各个软件版本如下:
python:Python 3.8.6
opencv-python:4.4.0.44
opencv-contrib-python:4.4.0.44
需要注意,由于本次实验需要使用到SIFT这种非自由专利算法,而在2020年3月5日之前该专利算法是不能通过pip工具安装的opencv-python和opencv-contrib-python使用的,但是在2020年3月5日之后该专利就到期了。可以通过使用新版本的python,然后搭配pip工具安装最新的opencv-python和opencv-contrib-python,从而使用SIFT这种非自由专利算法!
二、编写图像拼接代码
我们的全景拼接算法将包含以下四个步骤
- Step #1:检测关键点(DoG,Harris等),并从两个输入图像中提取局部不变描述符(SIFT,SURF等)。
- Step #2:在两个图像之间匹配描述符。
- Step #3:使用RANSAC算法通过匹配的特征向量估计单应矩阵(或者叫变换矩阵)(homography matrix )。
- Step #4:用 step #3 中的单应矩阵进行透视变换
2.1、Panorama.py编写
我们将所有这四个步骤封装在Panorama.py
中,接着在其中定义用于构建全景图的Stitcher
类。
Stitcher
类将依赖于imutils
Python软件包,因此,如果你尚未在系统上安装该软件包,则要立即进行操作:
pip install imutils
接着,我们开始写 Panorama.py 程序吧。
# 导入必要的包
import numpy as np
import imutils
import cv2
class Stitcher:
def __init__(self):
# 确定是否使用的是OpenCV v3.X
self.isv3 = imutils.is_cv3(or_better=True)
第2~4行:导包。其中,numpy
:进行矩阵/数组操作;imutils
:一套OpenCV便捷方法;cv2
:OpenCV绑定。
第6行:定义Stitcher
类。在该类的构造函数中通过调用is_cv3
方法简单地检查我们正在使用哪个版本的OpenCV。由于OpenCV 2.4和OpenCV 3在处理关键点检测和局部不变描述符方面存在重大差异,因此确定所使用的OpenCV版本非常重要。
接下来,开始编写stitch
函数:
def stitch(self, images, ratio=0.75, reprojThresh=4.0,
showMatches=False):
# 解压缩图像,然后从它们中检测关键点以及提取局部不变描述符(SIFT)
(imageB, imageA) = images
(kpsA, featuresA) = self.detectAndDescribe(imageA)
(kpsB, featuresB) = self.detectAndDescribe(imageB)
# 匹配两幅图像之间的特征
M = self.matchKeypoints(kpsA, kpsB,
featuresA, featuresB, ratio, reprojThresh)
# 如果匹配结果M返回空,表示没有足够多的关键点匹配信息去创建一副全景图
if M is None:
return None
stitch
函数仅需要一个参数images
,是我们将拼接在一起以形成全景图的(两个)图像的列表。
我们还可以选择提供ratio
,用于在匹配特征时时进行David Lowe的比例测试(在本教程的后面部分中将有更多关于此比例测试的信息),reprojThresh
表示RANSAC算法允许的最大像素``摆动空间’’(阈值),最后是showMatches
(一个布尔值)用于指示关键点匹配是否应该可视化。
解压缩了图像列表(同样,我们假定只包含两个图像)。图片列表的排序很重要:我们希望图片以从左到右的顺序提供。如果未按此顺序提供图像,则我们的代码仍将运行-但我们的输出全景图将仅包含一个图像,而不是两个图像。
第4行解压缩了图像列表(同样,我们假定只包含两个图像)。图片列表的排序很重要:我们希望图片以从左到右的顺序提供。如果未按此顺序提供图像,则我们的代码仍将运行-但我们的输出全景图将仅包含一个图像,而不是两个图像。
解压图像列表后,我们将在第5行和第6行上调用detectAndDescribe
方法。此方法仅检测关键点并从两个图像中提取局部不变描述符(即SIFT)。
给定关键点和特征,我们使用matchKeypoints
(第9和10行)来匹配两个图像中的特征。
如果返回的匹配项M
为None
,则匹配的关键点不足以创建全景图,因此我们仅返回到调用函数(第13和14行)。
否则(即M
不为None
),我们将进行透视变换:
# 若M不为None,则使用透视变换来拼接图像
(matches, H, status) = M
result = cv2.warpPerspective(imageA, H,
(imageA.shape[1] + imageB.shape[1], imageA.shape[0]))
result[0:imageB.shape[0], 0:imageB.shape[1]] = imageB
# 检查是否应该可视化关键点匹配
if showMatches:
vis = self.drawMatches(imageA, imageB, kpsA, kpsB, matches,
status)
# 返回拼接图像的元组和可视化
return (result, vis)
# 返回拼接图像
return result
假设M
不为None
,我们在第3行上解压元组,为我们提供关键点匹配列表,从RANSAC算法派生的单应性矩阵H
以及列表索引status
,以指示使用RANSAC在空间上成功验证了matches
中的哪些关键点。
给定单应性矩阵H
,我们现在准备将两个图像拼接在一起。首先,我们调用cv2.warpPerspective
,它需要三个参数:我们要变形的图像(在这种情况下为右侧图像),3 x 3转换矩阵(H
),最后是输出图像的形状。我们通过获取两个图像的宽度之和然后使用第一个图像的高度来得出输出图像的形状。
第3行进行检查,看看是否应该可视化关键点匹配,如果是,我们调用drawMatches
并将全景图和可视化的元组返回给调用方法(第9-13行)。
否则,我们只返回拼接的图像(第16行)。
现在已经定义了switch
函数,接着我们看一下它调用的函数的实现。我们将从detectAndDescribe
开始:
def detectAndDescribe(self, image):
# 将图像转换为灰度
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# 检查我们是否正在使用OpenCV 3.X
if self.isv3:
# 从图像中检测并提取特征
descriptor = cv2.xfeatures2d.SIFT_create()
(kps, features) = descriptor.detectAndCompute(image, None)
# 否则,我们将使用OpenCV 2.4.X
else:
# 检测图像中的关键点
detector = cv2.FeatureDetector_create("SIFT")
kps = detector.detect(gray)
# 从图像中提取特征
extractor = cv2.DescriptorExtractor_create("SIFT")
(kps, features) = extractor.compute(gray, kps)
# 将关键点从KeyPoint对象转换为NumPy数组
kps = np.float32([kp.pt for kp in kps])
# 返回关键点和特征的元组
return (kps, features)
顾名思义,detectAndDescribe
方法接受图像,然后检测关键点并提取局部不变描述符。在我们的实现中,我们使用高斯差分(DoG)关键点检测器和SIFT特征提取器。
在第6行上,我们检查是否正在使用OpenCV 3.X.如果是这样,则使用cv2.xfeatures2d.SIFT_create
函数来实例化DoG关键点检测器和SIFT特征提取器。调用detectAndCompute
处理提取关键点和特征(第8和9行)。
请务必注意,必须在启用了opencv_contrib
支持的情况下编译OpenCV3.X。否则,将出现诸如以下错误:AttributeError: 'module' object has no attribute 'xfeatures2d'
。
如果我们使用的是OpenCV 2.4,则第12-19行处理。 cv2.FeatureDetector_create
函数实例化我们的关键点检测器(DoG)。进行detect
的调用将返回我们的关键点集。
我们需要使用SIFT
关键字初始化cv2.DescriptorExtractor_create
来设置我们的SIFT特征extractor
。调用extractor
的compute
方法将返回一组特征向量,这些特征向量将量化图像中每个检测到的关键点周围的区域。
最后,我们的关键点从KeyPoint
对象转换为NumPy
数组(第22行),并返回到调用方法(第25行)。
接下来,让我们看一下matchKeypoints
方法:
def matchKeypoints(self, kpsA, kpsB, featuresA, featuresB,
ratio, reprojThresh):
# 计算原始匹配项并初始化实际匹配项列表
matcher = cv2.DescriptorMatcher_create("BruteForce")
rawMatches = matcher.knnMatch(featuresA, featuresB, 2)
matches = []
# 循环原始匹配
for m in rawMatches:
# 确保距离在一定的比例内(即Lowe's ratio)
if len(m) == 2 and m[0].distance < m[1].distance * ratio:
matches.append((m[0].trainIdx, m[0].queryIdx))
matchKeypoints
函数需要四个参数:与第一张图像关联的关键点和特征向量,其次是与第二张图像关联的关键点和特征向量。还提供了David Lowe的比率测试变量和RANSAC重投影阈值。
匹配功能实际上是一个相当简单的过程。我们只需从两个图像中循环遍历描述符,计算距离,然后为每对描述符找到最小的距离。由于这是计算机视觉中非常普遍的做法,因此OpenCV具有一个称为cv2.DescriptorMatcher_create
的内置函数,可为我们构建功能匹配器。 BruteForce
值表明我们将穷举计算两个图像中所有特征向量之间的欧几里得距离,并找到距离最小的一对描述符。
第5行上的knnMatch
调用使用k = 2在两个特征向量集之间执行k-NN匹配(指示返回每个特征向量的前两个匹配项)。
我们想要排名前两名的匹配,而不仅仅是排名第一的原因是因为我们需要将David Lowe的比率测试应用于假阳性匹配修剪(false-positive match pruning)。
再次,第5行为每对描述符计算rawMatches
,但是配对中的一些可能会出现误报,这意味着图像补丁实际上并不是真正的匹配。为了修剪这些假阳性匹配,我们可以逐个遍历每个rawMatches
(第9行)并应用Lowe比率测试,该测试用于确定高质量的特征匹配。 Lowe比率的典型值通常在[0.7,0.8]范围内。
使用Lowe比率测试获得匹配后,我们可以计算两组关键点之间的单应性(homography):
# 计算单应性至少需要4个匹配项
if len(matches) > 4:
# 构造两组点
ptsA = np.float32([kpsA[i] for (_, i) in matches])
ptsB = np.float32([kpsB[i] for (i, _) in matches])
# 计算两组点之间的单应性
(H, status) = cv2.findHomography(ptsA, ptsB, cv2.RANSAC,
reprojThresh)
# 返回匹配以及单应矩阵和每个匹配点的状态
return (matches, H, status)
# 否则,将无法计算单应性
return None
计算两组点之间的单应性至少需要四个初始匹配项。为了获得更可靠的单应性估计,我们应该有不止四个匹配点。
最后,我们的Stitcher
方法中的最后一个方法drawMatches
用于可视化两个图像之间的关键点对应关系:
def drawMatches(self, imageA, imageB, kpsA, kpsB, matches, status):
# initialize the output visualization image
(hA, wA) = imageA.shape[:2]
(hB, wB) = imageB.shape[:2]
vis = np.zeros((max(hA, hB), wA + wB, 3), dtype="uint8")
vis[0:hA, 0:wA] = imageA
vis[0:hB, wA:] = imageB
# loop over the matches
for ((trainIdx, queryIdx), s) in zip(matches, status):
# only process the match if the keypoint was successfully
# matched
if s == 1:
# draw the match
ptA = (int(kpsA[queryIdx][0]), int(kpsA[queryIdx][1]))
ptB = (int(kpsB[trainIdx][0]) + wA, int(kpsB[trainIdx][1]))
cv2.line(vis, ptA, ptB, (0, 255, 0), 1)
# return the visualization
return vis
此方法要求我们传入两个原始图像,与每个图像关联的关键点集,应用Lowe比率测试后的初始匹配项以及最后由单应性计算提供的status
列表。使用这些变量,我们可以通过绘制从第一张图像中的关键点N到第二张图像中的关键点M的直线来可视化“内部”关键点。
2.2、stitch.py 编写
现在我们已经定义了Stitcher
类,让我们继续创建stitch.py
驱动程序脚本:
# 导入必要的包
from panorama import Stitcher
import imutils
import cv2
imageA = cv2.imread('./foto1B.jpg')
imageB = cv2.imread('./foto1A.jpg')
imageA = imutils.resize(imageA, width=400)
imageB = imutils.resize(imageB, width=400)
# 将图像拼接在一起以创建全景
stitcher = Stitcher()
(result, vis) = stitcher.stitch([imageA, imageB], showMatches=True)
# 显示图像
cv2.imshow("Image A", imageA)
cv2.imshow("Image B", imageB)
cv2.imshow("Keypoint Matches", vis)
cv2.imshow("Result", result)
cv2.waitKey(0)
加载图像,调整它们的大小(以便它们可以适合我们的屏幕)以及构建我们的全景图。加载图像并调整大小后,我们在第13行初始化Stitcher
类。然后调用stitch
方法,传入两个图像(同样,从左到右的顺序),并指出我们希望可视化关键点匹配两个图像之间。最后,第17-21行将我们的输出图像显示到屏幕上。
三、实验结果
在pycharm中点击运行,有如下运行结果:
在此图的顶部,我们可以看到两个输入图像(调整大小以适合我的屏幕,原始.jpg文件的分辨率要高得多)。在底部,我们可以看到两个图像之间匹配的关键点。
使用这些匹配的关键点,我们可以应用透视变换并获得最终的全景图:
两个图像已成功拼接在一起!
注意:在许多示例图像中,你经常会看到在拼接图像中心贯穿可见的“缝”。这是因为每次拍摄的焦点略有不同。当你对每张照片使用相同的焦点时,图像拼接和全景构造效果最佳。故,“缝”是由于拍摄照片时传感器属性的变化而引起的,并不是故意的。