图像配准多图配准/不同特征提取算法/匹配器比较测试
Posted zstar-_
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了图像配准多图配准/不同特征提取算法/匹配器比较测试相关的知识,希望对你有一定的参考价值。
前言
本文首先完成之前专栏前置博文未完成的多图配准拼接任务,其次对不同特征提取器/匹配器效率进行进一步实验探究。
各类算法原理简述
看到有博文[1]指出,在速度方面SIFT<SURF<BRISK<FREAK<ORB,在对有较大模糊的图像配准时,BRISK算法在其中表现最为出色,后面考虑选取其中SIFT、BRISK、ORB三种算法进行验证。
在此之前,先对后续算法的原理做一些初步了解。
SIFT算法
在前文【图像配准】SIFT算法原理及二图配准拼接已经对此做过分析,这里不作赘述。
BRISK算法
BRISK算法是2011年ICCV上《BRISK:Binary Robust Invariant Scalable Keypoints》文章中,提出来的一种特征提取算法。
BRISK算法通过利用简单的像素灰度值比较,进而得到一个级联的二进制比特串来描述每个特征点,之后采用了邻域采样模式,即以特征点为圆心,构建多个不同半径的离散化Bresenham同心圆,然后再每一个同心圆上获得具有相同间距的N个采样点。
更详细的内容可参考文献[3]对论文的解读。
ORB算法
ORB(Oriented FAST and rotated BRIEF)是OpenCV实验室开发的一种特征检测与特征描述算法,将 FAST 特征检测与 BRIEF 特征描述结合并进行了改进,具有尺度不变性和旋转不变性,对噪声有较强的抗干扰能力[4]。
ORB算法在图像金字塔中使用FAST算法检测关键点,通过一阶矩计算关键点的方向,使用方向校正的BRIEF生成特征描述符。
更详细的内容可参考文献[4]。
AKAZE算法
Alcantarilla等人提出了AKAZE(Accelerated-KAZE)算法,即加速KAZE算法,加速了非线性尺度空间的构造,效率较KAZE有所提升,以各向异性的非线性滤波来构造尺度空间,将整个尺度空间进行分割,利用局部自适应分级获得细节和噪声,保留较多的边缘细节信息,但该算法关键点检测能力不足,且鲁棒性不强[5]。
多图配准
无论何种算法,图像配准无非是这样几个步骤->图像灰度化->提取特征->构建匹配器->计算变换矩阵->图像合并。
那么多图配准,实际上可以分解为多个双图配准。
以下代码主要参考了这个仓库:https://github.com/799034552/concat_pic
下面按处理顺序对各部分内容进行分块拆解:
图像读取
首先是读取图像再进行灰度化转换。
这里进行了一个判断,判断传入的是否是图像的文本路径,这一步主要是为了后面多图拼接的便利性,因为后面多图拼接会把拼接好的部分图像直接放在内存中,这里若不是路径,就直接赋值给变量,相当于用整张大图去和另外一张小图去做拼接。
# 读取图像-转换灰度图用于检测
# 这里做一个文本判断是为了后面多图拼接处理
if isinstance(path2, str):
imageA = cv2.imread(path2)
else:
imageA = path2
if isinstance(path1, str):
imageB = cv2.imread(path1)
else:
imageB = path1
imageA_gray = cv2.cvtColor(imageA, cv2.COLOR_BGR2GRAY)
imageB_gray = cv2.cvtColor(imageB, cv2.COLOR_BGR2GRAY)
构建特征提取器
OpenCV对各种算法都进行了较好的封装,这里主要对比测试了sift,brisk,orb,akaze这几种算法,所用opencv-python版本为4.7.0,值得注意的是,OpenCV4以后的版本,cv2.SURF_create()
无法使用,只能用老版本的cv2.xfeatures2d.SURF_create()
来实现SURF,因此这里没有对SURF算法进行比较测试。
# 选择特征提取器函数
def detectAndDescribe(image, method=None):
if method == 'sift':
descriptor = cv2.SIFT_create()
elif method == 'surf':
descriptor = cv2.xfeatures2d.SURF_create() # OpenCV4以上不可用
elif method == 'brisk':
descriptor = cv2.BRISK_create()
elif method == 'orb':
descriptor = cv2.ORB_create()
elif method == 'akaze':
descriptor = cv2.AKAZE_create()
(kps, features) = descriptor.detectAndCompute(image, None)
return kps, features
提取特征/特征匹配
# 提取两张图片的特征
kpsA, featuresA = detectAndDescribe(imageA_gray, method=feature_extractor)
kpsB, featuresB = detectAndDescribe(imageB_gray, method=feature_extractor)
# 进行特征匹配
if feature_matching == 'bf':
matches = matchKeyPointsBF(featuresA, featuresB, method=feature_extractor)
elif feature_matching == 'knn':
matches = matchKeyPointsKNN(featuresA, featuresB, ratio=0.75, method=feature_extractor)
if len(matches) < 10:
return None, None
这里比较了两种匹配器,一种是暴力匹配器(BFMatcher),函数接口为cv2.BFMatcher
,主要有下面两个参数可以设置:
- normType:距离类型,可选项,默认为欧式距离NORM_L2。
- NORM_L1:L1范数,曼哈顿距离。
- NORM_L2:L2范数,欧式距离。
- NORM_HAMMING:汉明距离。
- NORM_HAMMING2:汉明距离2,对每2个比特相加处理。
- crossCheck:交叉匹配选项,可选项,默认为False,若为True,即两张图像中的特征点必须互相都是唯一选择
注:对于SIFT、SURF描述符,推荐选择欧氏距离L1和L2范数;对于ORB、BRISK、BRIEF描述符,推荐选择汉明距离NORM_HAMMING;对于ORB描述符,当WTA_K=3或4时,推荐使用汉明距离NORM_HAMMING2。
对于该函数更详细的内容,可参考博文[6]。
另一个是FLANN匹配器,Flann-based matcher 使用快速近似最近邻搜索算法寻找,FlannBasedMatcher接受两个参数:index_params和search_params:
- index_params:可用不同的数值表示不同的算法,有下表这些可选项(表中数据来源文章[7])
- search_params(int checks=32, float eps=0, bool sorted=true)
checks为int类型,是遍历次数,一般只改变这个参数
# 创建匹配器
def createMatcher(method, crossCheck):
"""
不同的方法创建不同的匹配器参数,参数释义
BFMatcher:暴力匹配器
NORM_L2-欧式距离
NORM_HAMMING-汉明距离
crossCheck-若为True,即两张图像中的特征点必须互相都是唯一选择
"""
if method == 'sift' or method == 'surf':
bf = cv2.BFMatcher(cv2.NORM_L2, crossCheck=crossCheck)
elif method == 'orb' or method == 'brisk' or method == 'akaze':
# 创建BF匹配器
# bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=crossCheck)
index_params = dict(algorithm=1, trees=5)
search_params = dict(checks=50)
# 创建Flann匹配器
bf = cv2.FlannBasedMatcher(index_params, search_params)
return bf
二者的区别在于BFMatcher总是尝试所有可能的匹配,从而使得它总能够找到最佳匹配,这也是Brute Force(暴力法)的原始含义。
而FlannBasedMatcher中FLANN的含义是Fast Library forApproximate Nearest Neighbors,从字面意思可知它是一种近似法,算法更快但是找到的是最近邻近似匹配,所以当我们需要找到一个相对好的匹配但是不需要最佳匹配的时候往往使用FlannBasedMatcher。当然也可以通过调整FlannBasedMatcher的参数来提高匹配的精度或者提高算法速度,但是相应地算法速度或者算法精度会受到影响[8]。
特征匹配也有两种方式,可以直接进行暴力检测,也可以采用KNN进行检测,不同检测方式的代码如下:
# 暴力检测函数
def matchKeyPointsBF(featuresA, featuresB, method):
start_time = time.time()
bf = createMatcher(method, crossCheck=True)
best_matches = bf.match(featuresA, featuresB)
rawMatches = sorted(best_matches, key=lambda x: x.distance)
print("Raw matches (Brute force):", len(rawMatches))
end_time = time.time()
print("暴力检测共耗时" + str(end_time - start_time))
return rawMatches
# 使用knn检测函数
def matchKeyPointsKNN(featuresA, featuresB, ratio, method):
start_time = time.time()
bf = createMatcher(method, crossCheck=False)
# rawMatches = bf.knnMatch(featuresA, featuresB, k=2)
# 上面这行在用Flann时会报错
rawMatches = bf.knnMatch(np.asarray(featuresA, np.float32), np.asarray(featuresB, np.float32), k=2)
matches = []
for m, n in rawMatches:
if m.distance < n.distance * ratio:
matches.append(m)
print(f"knn匹配的特征点数量:len(matches)")
end_time = time.time()
print("KNN检测共耗时" + str(end_time - start_time))
return matches
计算视角变换矩阵/透视变换
匹配完关键点后,就可以计算视角变换矩阵,然后一幅图不动,另一幅图进行透视变换,这里的具体方式和前文较为类似。
# 计算视角变换矩阵
def getHomography(kpsA, kpsB, matches, reprojThresh):
start_time = time.time()
# 将各关键点保存为Array
kpsA = np.float32([kp.pt for kp in kpsA])
kpsB = np.float32([kp.pt for kp in kpsB])
# 如果匹配点大于四个点,再进行计算
if len(matches) > 4:
# 构建出匹配的特征点Array
ptsA = np.float32([kpsA[m.queryIdx] for m in matches])
ptsB = np.float32([kpsB[m.trainIdx] for m in matches])
# 计算视角变换矩阵
(H, status) = cv2.findHomography(ptsA, ptsB, cv2.RANSAC, reprojThresh)
end_time = time.time()
print("透视关系计算共耗时" + str(end_time - start_time))
return matches, H, status
else:
return None
M = getHomography(kpsA, kpsB, matches, reprojThresh=4)
if M is None:
print("Error!")
(matches, H, status) = M
# 将图片A进行透视变换
result = cv2.warpPerspective(imageA, H, ((imageA.shape[1] + imageB.shape[1]) * 2, (imageA.shape[0] + imageB.shape[0]) * 2))
resultAfterCut = cutBlack(result)
图片黑边裁剪
在做透视变换时,往往会采取一个比较大的背景,以确保图片能够不遗漏的拼接上去,比如这里图片的尺寸设定为(imageA.shape[1] + imageB.shape[1]) * 2, (imageA.shape[0] + imageB.shape[0]) * 2)
,这样会产生一些背景黑边,需要进行裁切。
之前的文章提到过一种通过膨胀方式来找到最大内接矩形,这里的代码处理方式更为巧妙,直接采用像素点搜索的方式,找到图像的最大外接矩形。
# 去除图像黑边
def cutBlack(pic):
rows, cols = np.where(pic[:, :, 0] != 0)
min_row, max_row = min(rows), max(rows) + 1
min_col, max_col = min(cols), max(cols) + 1
return pic[min_row:max_row, min_col:max_col, :]
图片位置检查
由于无法提前知道两张图片的位置关系,对于透视变换,可能图片会映射到整个选取区域的左边,这样的话,无法正常显示图片,因此,要对透视变换后的图片进行面积检查,如果比原来的图片面积小太多,就用另一张图片来进行透视变换[9]。
if np.size(resultAfterCut) < np.size(imageA) * 0.95:
print("图片位置不对,将自动调换")
# 调换图片
kpsA, kpsB = swap(kpsA, kpsB)
imageA, imageB = swap(imageA, imageB)
if feature_matching == 'bf':
matches = matchKeyPointsBF(featuresB, featuresA, method=feature_extractor)
elif feature_matching == 'knn':
matches = matchKeyPointsKNN(featuresB, featuresA, ratio=0.75, method=feature_extractor)
if len(matches) < 10:
return None, None
matchCount = len(matches)
M = getHomography(kpsA, kpsB, matches, reprojThresh=4)
if M is None:
print("Error!")
(matches, H, status) = M
result = cv2.warpPerspective(imageA, H,
((imageA.shape[1] + imageB.shape[1]) * 2, (imageA.shape[0] + imageB.shape[0]) * 2))
图像融合
图像融合这里处理得也比较巧妙,对图片接壤部分选取最大值,这样确保了色调的统一性。
# 合并图片-相同的区域选取最大值,从而实现融合
result[0:imageB.shape[0], 0:imageB.shape[1]] = np.maximum(imageB, result[0:imageB.shape[0], 0:imageB.shape[1]])
result = cutBlack(result) # 结果去除黑边
多图拼接
最后是拼接多幅图像,反复调用拼接双图即可。
# 合并多张图
def handleMulti(*args):
l = len(args)
assert (l > 1)
# isHandle用于标记图片是否参与合并
isHandle = [0 for i in range(l - 1)]
nowPic = args[0]
args = args[1:]
for j in range(l - 1):
isHas = False # 在一轮中是否找到
matchCountList = []
resultList = []
indexList = []
for i in range(l - 1):
if isHandle[i] == 1:
continue
result, matchCount = handle(nowPic, args[i])
if not result is None:
matchCountList.append(matchCount) # matchCountList存储两图匹配的特征点
resultList.append(result)
indexList.append(i)
isHas = True
if not isHas: # 一轮找完都没有可以合并的
return None
else:
index = matchCountList.index(max(matchCountList))
nowPic = resultList[index]
isHandle[indexList[index]] = 1
print(f"合并第indexList[index] + 2个")
return nowPic
完整代码
utils.py
import cv2
import numpy as np
import time
# 选择特征提取器函数
def detectAndDescribe(image, method=None):
if method == 'sift':
descriptor = cv2.SIFT_create()
elif method == 'surf':
descriptor = cv2.xfeatures2d.SURF_create() # OpenCV4以上不可用
elif method == 'brisk':
descriptor = cv2.BRISK_create()
elif method == 'orb':
descriptor = cv2.ORB_create()
elif method == 'akaze':
descriptor = cv2.AKAZE_create()
(kps, features) = descriptor.detectAndCompute(image, None)
return kps, features
# 创建匹配器
def createMatcher(method, crossCheck):
"""
不同的方法创建不同的匹配器参数,参数释义
BFMatcher:暴力匹配器
NORM_L2-欧式距离
NORM_HAMMING-汉明距离
crossCheck-若为True,即两张图像中的特征点必须互相都是唯一选择
"""
if method == 'sift' or method == 'surf':
bf = cv2.BFMatcher(cv2.NORM_L2, crossCheck=crossCheck)
elif method == 'orb' or method == 'brisk' or method == 'akaze':
# 创建BF匹配器
# bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=crossCheck)
index_params = dict(algorithm=1, trees=5)
search_params = dict(checks=50)
# 创建Flann匹配器
bf = cv2.FlannBasedMatcher(index_params, search_params)
return bf
# 暴力检测函数
def matchKeyPointsBF(featuresA, featuresB, method):
start_time = time.time()
bf = createMatcher(method, crossCheck=True)
best_matches = bf.match(featuresA, featuresB)
rawMatches = sorted(best_matches, key=lambda x: x.distance)
print("Raw matches (Brute force):", len(rawMatches))
end_time = time.time()
print("暴力检测共耗时" + str(end_time - start_time))
return rawMatches
# 使用knn检测函数
def matchKeyPointsKNN(featuresA, featuresB, ratio, method):
start_time = time.time()
bf = createMatcher(method, crossCheck=False)
# rawMatches = bf.knnMatch(featuresA, featuresB, k=2)
# 上面这行在用Flann时会报错
rawMatches = bf.knnMatch(np.asarray(featuresA, np.float32), np.asarray(featuresB, np.float32), k=2)
matches = []
for m, n in rawMatches:
if m.distance < n.distance * ratio:
matches.append(m)
print(f"knn匹配的特征点数量:len(matches)")
end_time = time.time()
print("KNN检测共耗时" + str(end_time - start_time))
return matches
# 计算视角变换矩阵
def getHomography(kpsA, kpsB, matches, reprojThresh):
start_time = time.time()
# 将各关键点保存为Array
kpsA = np.float32([kp.pt for kp in kpsA])
kpsB = np.float32([kp.pt for kp in kpsB])
# 如果匹配点大于四个点,再进行计算
if len(matches) > 4:
# 构建出匹配的特征点Array
ptsA = np.float32([kpsA[m.queryIdx] for m in matches])
ptsB = np.float32([kpsB[m.trainIdx] for m in matches])
# 计算视角变换矩阵
(H, status) = cv2.findHomography(ptsA, ptsB, cv2.RANSAC, reprojThresh)
end_time = time.time()
print("透视关系计算共耗时" + str(end_time - start_time))
return matches, H, status
else:
return None
# 去除图像黑边
def cutBlack(pic):
rows, cols = np.where(pic[:理解图像配准中的LMedsM-estimators与RANSAC算法