在本节中我们将检测和跟踪任意大小的对象,这些对象可能是在不同角度或者在部分遮挡的情况下观察到的。
为此我们将运用特征描述子(Feature Descriptor),这是捕获感兴趣对象的重要属性的一种方式。我们这样是为了即使将对象嵌入繁忙的视觉场景中也可以对其进行定位。我们将算法应用到网络摄像头的实时流中,并保持算法的健壮性。
目的是开发一个程序,该应用程序可以检测和跟踪网络摄像头视频流的目标对象---即使是不同角度或有遮挡。
准备工作
在github中导入SURF。
列出应用程序执行的任务
特征提取:
使用SUFRF描述感兴趣的对象,该算法用于查找图像中尺度不变,旋转度不变的独特关键点。这些关键点能够帮助我们确保在多个帧上跟踪准确的对象,因为对象的外观能够准确变化。找到不依赖于对象观察距离或视角的关键点很重要。
特征匹配:
使用FLANN在关键点之间建立对应关系,以查看帧是否包含与我们感兴趣的对象的关键点相似的关键点。如果找到,则在每个帧上标记。
特征跟踪:
将使用各种形式的早期异常值检测和异常值剔除来逐帧跟踪感兴趣的目标,以加快算法的速度。
透视变化:
我们将通过扭曲透视来反转对象已经历的所有平移和旋转,以使对象在屏幕中央垂直显示,即使对象周围的整个场景绕其旋转时,对象似乎被冻结在一个位置上。
规划应用
最终的应用程序将包括一个用于检测、匹配和跟踪图像特征的Python类,以及一个用于访问网络摄像头并显示每个处理过的帧的脚本。
feature_matching : 用于特征提取、特征匹配和特征跟踪的算法
feature_matching.FeatureMatching: 此类实现了整个特征匹配流程。它接受蓝色绿色红色摄像头帧,并尝试在其中找到感兴趣的对象。
chapter3: 这是本节应用程序的主1脚本
chapter3.main:这是启动应用程序、访问摄像头,将要处理的每一帧发送到FeatureMatching类的实例并显示结果的主函数例程。
设计应用程序
执行main()函数的步骤
(1) 该函数将首先使用VideoCapture方法访问网络摄像头,他将传递 0 作为参数,这是对默认网络摄像头的引用。
import cv2 as cv
from feature_matching import FeatureMatching
def main():
capture = cv.VideoCapture(0)
assert capture.isOpened(),"Cannot connect to camera"
(2) 设置所需的视频流大小和每秒帧数
capture.set(cv.CAP_PROP_FPS,10) #设置了视频流的帧率(Frames Per Second)为10帧/秒
capture.set(cv.CAP_FRAME_WIDTH,640) #设置了视频流的宽度为640像素
capture.set(cv.CAP_FRAME_HEIGHT,480) #设置了视频流的高度为480像素
(3) 使用描述感兴趣对象的模板文件的路径初始化FeatureMatching类的实例
matching = FeatureMatching(train_image = 'train.png')
(4) 处理摄像头中的帧,我们从capture.read中创建一个迭代器
for success,frame in iter(capture.read,(False,None)):
cv.imshow("frame",frame)
match_succsess,img_warped,img_flann = matching.match(frame)
在上面的代码中,FeatureMatching.match方法将处理BGR图像。如果在当前帧中检测到对象,则match方法将报告match_success = True并返回扭曲还原之后的图像以及说明匹配的图像img_frann。
接下来将介绍如何显示匹配方法返回的结果。
显示结果
实际上,仅当使用match方法返回一个结果时,我们才能显示匹配的结果
if match_succsess:
cv.imshow("res",img_warped)
cv.imshow("flann",img_flann)
if cv.waitKey(1) & 0xff ==27:
break
处理流程
FeatureMatching类,可以提取。匹配和跟踪特征。但是我们还需要学习一下SURF和FLANN
初始化
class FeatureMatching:
def __init__ (self,train_image: str = "train.png")->None:
(1) 设置SURF检测器,我们将使用该检测器从图像中检测和提取特征,其Hessian阈值在300-500,即400
self.f_extractor = cv.xfeatures2d_SURF.create(hessianThreshold = 400)
(2) 加载我们感兴趣的模板对象
self.img_obj = cv.imread(train_image,cvv.CV_8UC1)
assert self.img_obj is not None,f"Could not find train image
{train_image}"
(3) 为了方便起见,还可以存储图像的形状
self.sh_train = self.img_obj.shape[:2]
我们将模板图像称为训练图像,因为我们要使用它来训练算法以找到图像,而每个传入帧都将成为查询图像,因为我们将这些图像来查询训练图像
(4) 我们将SURF算法应用于感兴趣的对象,这可以通过方便的函数来完成,该函数既返回关键点列表,又返回描述子
self.key_train,self.desc_train = \
self.f_extractor.detectAndCompute(self.img_obj,None)
我们将对每个传入的帧进行相同的操作,然后比较图像之间的特征列表。
(5) 我们设置了一个FLANN对象,该对象将用于匹配训练图像和查询图像的特征。这要求字典指定一些其他参数。例如使用哪种算法以及并行运行多少树。
index_params = {"algorithm":0,"trees":5}
search_params = {"checks":50}
self.flann = cv.FlannBaseMatcher(index_params,search_params)
(6) 初始化一些其他簿记变量,当我们想要使特征跟踪既快又准确时,这些变量将派上用场,例如我们可以跟踪最新的已经计算的单应矩阵,以及已经使用但是没有找到感兴趣对象的帧
self.last_hinv = np.zeros((3,3))
self.max_error_hinv = 50
self.num_frames_no_success = 0
self.max_frames_no_success = 5
然而大部分FeatureMatching.match方法完成,此方法遵循以下过程。
(1) 从每个传入的视频帧中提取感兴趣的图像特征
(2) 匹配模板图像和视频帧之间的特征。这是在FeatureMatching.match_features中完成的。如果找不到匹配项则跳到下一帧。
(3) 在视频帧中找到模板图像的角点。这是在detect_corner_points函数中完成的,如果有任何一个角位于帧之外,则跳到下一帧。(角点:两条边缘相交的点)
(4) 计算4个角点跨越的四边形的面积,如果该区域太小或太大,则跳至下一帧
(5) 使用框线画出当前帧模板中模板图像的角点
(6) 找到所需的透视变换,将已定位的对象从当前帧带到frontoparallel平面。如果结果与先前帧中获得的结果明显不同,则跳至下一帧。
(7) 扭曲当前帧中的透视图,以使感兴趣的对象垂直居中
接下来将讨论特征提取。
特征提取
特征通常是一个数据降维的过程,它将产生包含丰富信息的数据元素描述,一旦确定了我们喜欢的特征,则需先想出一种方法来检查图像是否不包含此类特征。另外还需要找出包含它们的位置,然后创建出特征的描述子。
特征检测
找到感兴趣的区域的过程称为特征检测,在后台,对于图像中的每个点,特征检测算法都会确定图像是否包含感兴趣的特征
OpenCV提供了范围广泛的特征检测算法
Harris角点检测:cv2.cornerHarris
Shi-Tomasi角点检测:通过找到N个最强角点,该算法通常比Harris角点检测更好,cv2.goodFeatureToTrack.
尺度不变特征变化:将图像的尺度发生变化时,仅有角点检测是不够的,为此开发了一种方法来描述图像中的关键点,这些关键点与方向和大小无关,cv2.xfeatures2d_SIFT.
SURF:SIFT已被证明效果非常好,但是对于大多数应用程序而言,它的速度还不够块,SURF因此应运而生,它用盒式滤波器代替了SIFT中计算开销大的高斯的拉普拉斯算子,cv2.xfeatures2d_SURF.
使用SURF检测图像中的特征
使用SURF算法大致可以分为两个不同的步骤:检测兴趣点和制定描述子。
SURF依赖Hessian角点检测器进行兴趣点检测,这需要设置最小的minhessianThreshold此阈值决定了将点用作兴趣点必须使用Hessian滤波器的输出大小。
当该值较大时,将获得较少的兴趣点,但是从理论上来讲,它们更加明显,反之亦然。
本节将400作为选择值,就像之前在FeatureMatching.__init__中所设置的那样,我们使用以下代码创建SURF描述子:
self.f_extractor = cv2.xfeatures2d_SURF.create(hessianThreshold = 400)
#Hessian 阈值是一个用于确定图像中关键点是否显著的参数,较高的阈值会减少检测到的关键点数量,但可能会提高特征的质量。
图像中的关键点可以通过一个步骤获得
key_query = self.f_extractor.detect(img_query)
在这里,key_query是cv2.KeyPoint实例的列表,并且具有检测到的关键点数量的长度,每个KeyPoint包含有关位置,大小的信息,以及有关兴趣点的其他有用信息。
现在也可以是使用drawkeypoints函数轻松绘制关键点
img_keypoints = cv2.drawKeyPoints(img_query,key_query,None,
(255,0,0),4)
cv2.imshow("keypoints",img_keypoints)
根据图像的不同,检测到的关键点数量可能很大,并且在可视化时不清楚。这可使用len(keyQuery)进行检查。
使用SURF获取特征描述子
使用SURF通过OpenCV从图像中提取特征的过程也是一个步骤,这是通过我们的特征提取器的compute方法完成的
key_query,desc_query = self.f_extractor.compute(img_query,key_query)
在这里desc_query是一个形状为(num_keypoints,descriptor_size)的Numpy ndarray。每个描述子都是一个n维空间中的向量。每个向量都描述了相应的关键点,并提供了有关完整图像的一些有意义的信息。
通过这种方式,我们已经完成了特征提取算法,该算法必须以降维的方式提供有关图像的有意义的信息,这取决于算法的创建者决定描述子向量中包含哪种信息,但是至少这些向量应该使得它们比看起来不同的关键点更接近相似的关键点
我们的特征提取算法还提供了一种方便的方法来组合特征检测和描述子创建过程
key_query,desc_query = self.f_extractor.dectAndComputer(img_query,,None)
它可以在单个步骤中同时返回关键点和描述子,并接受感兴趣区域的蒙版。
特征匹配
我们先查找当前帧中感兴趣的对象的区域。
good_matches = self.match_features(desc_query)
查找帧到帧的步骤可以公式化为:从一组描述子中为另一组描述子的每一个元素搜索最近的邻居。
接下来我们将学习如何使用FLANN算法匹配图像的特征。
使用FLANN算法匹配特征
我们利用FLANN库中的k最近临算法来查找对应关系。
def match_features(self,desc_frame:np.ndarray) ->List[cv2.DMatch]:
matches = self.flann.knnMatch(self.desc_train,desc_frame,k = 2)
flann.knnMatch函数返回的结果是两个描述子的集合之间对应关系的列表,这两个描述子的集合都包含在matches变量中。这两个描述子的集合正是训练集和查询集
现在我们找到了特征的最近邻,接下来还需要删除异常值。
执行比率检验以消除异常值
找到的正确匹配越多,则图案出现在图像中的机会就越高。但是某些匹配也可能是假阳性。
直观地看,正确的匹配应该第一个匹配项要比第二个匹配项近得多,反过来将,不正确的匹配就是两个最接近的邻居距离相近。
因此我们可以通过查看距离之间的差异来发现匹配的良好程度。比率检验认为,只有在第一个匹配项和第二个匹配项之间的距离比率小于给定数字(通常为0.5左右)时,该匹配才是最好的。、
good_matcher = [x[0] for x in matches
if x[0].distance < 0.7 * x[1].distance]
要删除所有不满足此要求的匹配,可以过滤匹配项列表,并将良好匹配项存储在good_matches列表中。
然后,将找到的匹配项传递给FeatureMatching.match,以便对其做进一步的处理。
return good_matches
接下来,我们做可视化特征匹配、
可视化特征匹配
在OpenCV中,我们可以用cv2.drawMatches轻松绘制匹配的特征
def draw_good_matches(img1: np.ndarray,
kp1: Sequence[cv2.KeyPoint],
img2: np.ndarray,
kp2: Sequence[cv2.KeyPoint],
matches: Sequence[cv2.DMatch]) -> np.ndarray:
该函数接受两幅图像,就是指感兴趣的图像和当前视频帧。还接受图像和匹配的关键点,它将在单幅图像上绘制彼此相邻的图像,在图像上显示匹配。
(1) 创建一幅新的图像,该图像大小应该可以将两幅图像放在一起,使它为三通道以便在图像上绘制彩色线条。
rows1 , cols1 = img1.shape[:2]
rows2 , cols2 = img2.shape[:2]
out = np.zeros((max([rows1,rows2]),cols1 + cols2 ,3),
dtype = 'uint8'
(2) 将第一幅图像放置在新图像的左侧,将第二幅图像放置在新图像的右侧。
out[:rows1, :cols1,:] = img1[...,None]
out[:rows2, cols1:cols1 + cols2, :] = img2[...,None]
numpy的广播规则:
1.如果两个数的维度不同,那么小维度数组的形状将会在最左边补1。
2.如果两个数组的形状在任何一个维度都不匹配,那么数组的形状会沿着维度为1的维度扩展以匹配另外一个数组的形状。
3.如果两个数组的形状在任何一个维度上都不匹配并且没有任何一个维度等于1,则会引发异常。
(3) 对于两幅图像之间的每个匹配点对,我们要在每幅图像上绘制一个小蓝色圆圈,并将两个圆圈用一条连线连接起来。为此,可使用for循环遍历匹配的关键点列表,从相应的关键点中提取中心坐标,并移动第二个中心的坐标以进行线条绘制。
for m in matches:
c1 = tuple(map(int,kp1[m.queryIdx].pt))
c2 = tuple(map(int,kp2[m.queryIdx].pt))
c2 = c2[0] + cols1,c2[1]
(4) 在同一个循环中,绘制半径为4像素,颜色为蓝色,轮廓线粗细为1像素的圆,然后用直线将圆圈连接起来。
radius =4
BLUE = (255,0,0)
thickness = 1
#在两个坐标上各绘制一个很小的圆
cv2.circle(out, c1, radius, BLUE, thickness)
cv2.circle(out, c2, radius, BLUE, thickness)
#在亮点之间绘制一条直线
cv2.line(out, c1, c2, BLUE, thickness)
(5) 返回结果图像
return out
现在我们有了一个很方便的函数,可以使用以下代码来指示特征匹配
cv2.imshow('imgFlann',draw_good_matches(self.img_train,
self.key_train,img_query,key_query,good_matches))
蓝色线条可以将对象(左)中的特征连接应用到场景(右)中的特征,
这种方法确实好用,但当应用场景中还有其他对象时,结果又会怎么样呢
结果证明,在对象有字母场景中还有其他对象时,也能应用。
现在我们已经可以特征匹配了,接下来使用这些结果来突出显示感兴趣对象,可以在单应性估计的帮助下执行此操作。
映射单应性估计
由于我们假设感兴趣的对象是平面且是刚性的,因此我们可以找到两幅图像的特征点之间的单应性变换。
在一下步骤中,我们将讨论如何通过单应性来计算透视变换,当对象图像中的匹配特征点与当前图像帧中的相应特征点放置在同一平面时,需要这种透视变换。
(1) 为了方便起见,我们将所有匹配良好的关键点图像坐标存储在列表中,如下
train_points = [self.key_train[good_match.queryIdx].pt
for good_match in good_matches]
query_points = [key_query[good_match.trainIdx].pt
for good_match in good_matches]
(2) 将角点检测的逻辑封装在一个单独的函数中
def detect_corner_points(src_points: Sequence[Point],
dst_points: Sequence[Point],
sh_src: Tuple[int,int]) -> np.ndarray:
上面的代码显示了两个点序列和源图像的形状,该函数将返回这些点的角,这可以通过以下步骤完成。
1.找到给定的两个坐标序列的透视变换,这实际上是单应矩阵H:
H,_ = cv2.findHomography(np.array(src_points),
np.array(dst_points),cv2.RANSAC)
cv2.findHomography函数将使用随机样本共识。
2.如果该方法找不到单应矩阵,则将引发一个异常,稍后将在我们的应用出现中捕获该异常。
if H is None:
raise Outliter("Homography not found")
3.给定源图像的形状,可将其角的坐标存储在一个数组中
height,width - sh_src
src_corners = np.array([(0,0),(width,0),
(width,height),
(0,height)],dtype = np.float=32)
4. 单应矩阵可用于将图案中的任何点转化到应用的场景中,例如将训练图像中的角点转化为查询图像中的角点,这意味着我们可以通过变换训练图像的角度的角点来在查询图像中绘制书籍封面的轮廓
为此,可以获取训练图像的角点列表并通过执行透视变换将其投影到查询图像中:
return cv2.perspectiveTransform(src_corners[None,:,:],H)[0]
该结果将立即返回,即图像点的数组
(3) 现在我们已经定义了函数,可以调用他来检测角点
dst_corners = detect_corner_points(
train_points,query_points,self.sh_train)
(4) 我们需要做的就是在dst_corners中的每个点与下一个点之间画一条线,这样在应用到场景中将看一个轮廓。
dst_corners[:,0] += self.sh_train[1]
cv2.polylines(
img_flann,
[dst_corners.atype(np.int)],
isClosed = True,
color = (0,255,0),
thickness = 3)
为了绘制图像点,首先要将点的X坐标偏移,偏移量就是模式图像的宽度(因为我们将两幅图像彼此相邻显示)。然后我们将图像的点视为的多段线,并使用cv2.polilines对其进行绘制
(5) 书籍封面的轮廓绘制如下图
部分遮挡也可以识别
扭曲图像
我们也可以执行单应性估计/变换的相反曹祖,方法是从已经探测到的应用场景还原到训练模式坐标。本示例中,这意味着可以将书的封面变成正面。
可以使用简单地单应矩阵的逆来获得逆矩阵。
Hinv = cv2.linalg.inverse(H)
但是这样做会图书封面的左上角映射到新图像的原点,这将切断图书封面左侧和上方的所有内容,而我们希望在新图像中大致将图书封面居中。因此还需计算一个新的单应矩阵。
(1) 找到缩放比例因子和偏差,然后应用线性缩放比例并转换坐标。
@staticmetod
def scale_and_offset(points:Sequence[Point],,
source_size: Tuple[int,int],
dst_size: Tuple[int,int],
factor: float = 0.5)->List[Point]:
dst_size = np.array(dst_size)
scale = 1/np.array(source_size) * dst_size * factor
bias = dst_size * (1-factor) / 2
return [tuple(np.array(pt) * scale + bias)for pt in points]
(2) 作为输出,我们希望图像具有与模式图像相同的形状。
train_points_scaled = self.scale_and_offset(
train_points,self.sh_train,sh_query)
(3) 可以在查询图像中的点与训练图像变换后的点之间找到单应矩阵
Hinv ,_ = cv2.findHomography(
np.array(query_points),np.array(train_points_scaled),cv.RANSAC)
(4) 可以使用过单应矩阵来变换图像中的每个像素,该操作也称为扭曲透视
img_warp = cv2.warpPerspective(img_query,Hinv,(sh_query[1],sh_query[0]))
接下来我们来了解特征跟踪
在FeatureMatching __init__ 中我们创建了一些簿记变量,这些变量将用于特征跟踪,其主要思想是在从上一帧到下一帧是增强一些连贯性。
当然我们必须注意不要卡在我们认为合理但实际上是异常值的结果上。为了解决此问题我们可以跟踪找不到合适结果的帧数。我们使用self.num_frames_no_success来保存帧数的值。如果此值小于某个阈值,则需要在帧之间比较。
理解早期异常值检测和剔除
我们可以将异常值排除的概念扩展到计算的每个步骤,目标是最大限度地减少工作量,同时最大限度地图稿我们获得良好结果的可能值。
def match(self.frame):
#创建帧的有效副本
#并存储其形状方便日后使用
img_query = cv2.cvtColor(frame,cv2.COLOR_BGR2GRAY)
sh_query = img_query.shape#行数和列数
以下步骤演示了匹配过程
(1) 在模式的特征描述子和查询图像之间找到良好的匹配,然后存储来自训练图像和查询图像的相应点坐标。
key_query,desc_query = self.f_extractor.detetAndCompute(
img_query,None)
good_matches = self.match_features(desc_query)
train_points = [self.key_train[good_match.queryIdx].pt
for good_match in good_matches]
query_points = [key]
为了使RANSAC能够在下一个操作步骤中正常有效,至少需要进行4个匹配项,如果匹配项小于4个,则承认失败,并使用自定义消息抛出Outlier异常。我们将异常值检测包装在try模块中。
try:
#早期异常值检测和剔除
if len(good_matches)< 4:
raise Outilter("Too few matches")
(2) 在查询图像(dst_corners)中找到图案的角点
def_corners = detect_corner_points(
train_points,query_points,self.sh_train)
如果这些点的任何一个点位于图像的外部,则意味着我没有看到感兴趣的对象,或者感兴趣的对象并不完全在图像中。在这两种情况下,都无须继续处理,并可创建Outlier的实例并抛出异常。
if np.any((dst_corners<-20)|(dst_corners > np.array(sh_query) + 20)):
raise Outliter("Out of image")
(3) 如果4个还原的角点未跨越合理的四边形,则意味着我们可能还没有看到感兴趣的对象。可以使用以下代码来计算四边形的面积。
for prev,nxt in zip(dst_corners,np.roll(dst_corners,-1,axis = 0)):
area += (prev[0] * nxt[1] - prev[1] * nxt[0])/2
如果面积大小不合理,则丢弃该帧并抛出异常。
if not np.prod(sh_query) / 16.< area <np.prod(sh_query)/2.:
raise Outlier("Area is unreasonably small or large")
(4) 缩放训练图像上的比较好的点,并找到单应矩阵以将对象带到正向平面
train_points_scaled = self.scale_and_offset(
train_points,self.sh_train,sh_query)
Hinv,_ = cv2.findHomogrgaphy(
np.array(query_points),np.array(train_points_scaled),cv2.RANSAC)
(5) 如果还原的单应矩阵与上次还原的单应矩阵有很大不同,则意味着我们可能看到的是不同对象。当然如果是在最近的帧,则仅考虑self.last)hinv:
similar = np.linalg.norm(
Hinv - self.last_hinv) < self.max_error_hinv
recent = self.num_frames_no_success < self.max_frames_no_success
if recent and not similar:
raise Outlier("Not similar transformation")
这将有助于我们随着时间的流逝,跟踪相同的关注对象,如果某种原因使我们失去对模式图像的跟踪超过self.max_features_no_success帧,则跳过此条件,并接收到那时为止还原的所有单应矩阵。这样可以确保我们不会被self.last_hinv矩阵卡住,此时self.last_hinv矩阵实际上是一个异常值。
如果在异常值检测过程中检测到异常值,则会递增self.num_frame_no_success并返回False.还可以输出异常值的消息,以查看确切的时间。
expect Outlier as e:
print(f"Outlier:{e}")
self.num_frames_no_success += 1
return False, None, None
如果未检测到异常值,则可以确定已成功在当前帧中找到了感兴趣的对象,这时我们首先存储异常值的消息,以查看确切时间:
else:
#重置计数器并更新Hinv
self.num_features_no_success = 0
self.last_h = Hinv
以下代码将显示图像的扭曲透视
img_warped = cv2.warpPerspective(img_query,
Hinv,(sh_query[1],sh_query[0]))
cv2.warpPerspective
是 OpenCV 库中的一个函数,它根据指定的单应性矩阵(或透视变换矩阵)对图像进行透视变换。这种变换能够模拟相机视角的变化,常用于图像校正、图像拼接、3D 场景的2D投影等场景。
(6) 绘制良好的匹配和角点,并返回结果
img_flann = draw_good_matches(
self.img_obj,
self.key_train,
img_query,
key_query,
good_matches
)
#调整角点的x坐标(col)
#以便可以在训练图像旁边绘制它们
dst_corners[:,0] += self.sh_train[1]
cv2.polylines(
img_flann,
[dst_corners.astype(np.int)],
isClosed = True,
color = (0,255,0),
thickness = 3)
return True,img_wraped,img_flann
上面的代码中我们将角点的x坐标进行了偏移,偏移量就是训练图像的宽度,以为查询图像出现在训练图像旁边。我们还将角点的数据类型更改为整数。这是由polilines方法接受整数作为坐标。
有了这个功能强大的算法,现在我们就可以设计出先进的特征跟踪。图像拼接或增强现实应用程序。