本文主要講述三個部分:
Feature extraction, Feature matching, Feature tracking.
另外還提到了透視變換:
Perspective transform.
內容的最后有我的完整代碼實現。
特征獲取(Feature extraction)
很多的低級特征,例如邊,角,團,脊會比一個像素的灰度值所帶有的信息多的多。在不同的應用,一些特征會比其它特征更加的有用。一旦想好我們想要的特征的構成,我們就要想辦法在圖片里找到我們想要的特征。
特征檢測(Feature detection)
在圖片里找到我們感興趣的區域的過程就叫做特征檢測。OpenCV中提供了多個特征檢測算法:
- Harris corner detection: 角點時在所有方向像素變化劇烈的點,Harris和Stephens提出了檢測這樣區域的快速的方法。opencv:cv2.cornerHarris
- Shi-Tomasi corner detection:通常比Harris方法更優,他們查找N個最強的角點。opencv:cv2.goodFeaturesToTrack
- Scale-Invariant Feature Transform(SIFT):在圖像大小改變時角點檢測的效果就不好了,Lowe提出了一個描述圖像里與角度大小無關的關鍵點的方法。在opencv3中,SIFT在contrib模塊里,ubuntu環境安裝opencv_contrib的方法見我寫的教程。windows的話,opencv3.2中集成了contrib,可以在這里找到對應的whl文件通過pip安裝即可。opencv2:cv2.SIFT,opencv3:cv2.xfeatures2d.SIFT_create()
- Speeded-Up Robust Features(SURF):SIFT是一個很好的方法,但是對于大部分應用來說,它不夠快。SURF將SIFT中的Laplacian of a Gaussian(LOG)用一個方框濾波(box filter)代替。opencv2:cv2.SURF,opencv3:cv2.xfeatures2d.SURF_create()
- OpenCV支持很多的特征描述,包括Features fromAccelerated Segment Test (FAST), Binary Robust IndependentElementary Features (BRIEF), Oriented FAST,Rotated BRIEF(ORB)。
使用SURF在圖片里檢測特征
SURF算法可以粗略分成兩個步驟:檢測興趣點,描述描述符。SURF依賴于Hessian角點檢測方法對于興趣點的探測,因此需要設置一個min_hessian的閾值。這個閾值決定了一個點要稱為興趣點,它對應的Hessian filter輸出至少要有多大。大的值輸出的數量比較少但是它們更為突出,相比之下輸出較小的值雖然多但是不夠突出(就是與普通差別不夠大)。文中代碼閾值設置為400:
def extract_features(self):
self.min_hessian = 400
self.SURF = cv2.xfeatures2d.SURF_create(self.min_hessian)
特征和描述符只需要一步就能獲得:
# detectAndCompute函數返回關鍵點和描述符,mask為None
# 注意,書中的query和train圖片和opencv官方的turorials里的是相反的
key_query, desc_query = self.SURF.detectAndCompute(self.img_query, None)
通過以下函數就能簡單的將關鍵點畫出:
imgOut = cv2.drawKeypoints(self.img, self.key_train, None, (255, 0, 0), 4)
注意:在獲得特征點后,要先檢查一下特征點的數量:len(key_query),以避免返回過多的特征點(太多則修改min_hessian)。
特征匹配
通過Fast Library for Approximate Nearest Neighbors(FLANN)方法將當前幀中像我們感興趣的對象給找出來:
good_matches = self.matchfeatures(desc_query)
找到幀和幀之間的一致性的過程就是在一個描述符集合(詢問集)中找另一個集合(相當于訓練集)的最近鄰。
通過FLANN方法進行特征匹配
可選的方法是利用近似k近鄰算法去尋找一致性,FLANN方法比BF(Brute-Force)方法快的多:
def matchfeatures(self, desc_frame):
# 函數返回一個訓練集和詢問集的一致性列表
matches = self.flann.knnMatch(self.desc_train, desc_frame, k=2)
用比值判別法(ratio test)刪除離群點
檢測出的匹配點可能有一些是錯誤正例(false positives)。
以為這里使用過的kNN匹配的k值為2(在訓練集中找兩個點),第一個匹配的是最近鄰,第二個匹配的是次近鄰。直覺上,一個正確的匹配會更接近第一個鄰居。換句話說,一個不正確的匹配,兩個鄰居的距離是相似的。因此,我們可以通過查看二者距離的不同來評判距匹配程度的好壞。比值檢測認為第一個匹配和第二個匹配的比值小于一個給定的值(一般是0.5),這里是0.7:
# 丟棄壞的匹配
good_matches = filter(lambda x: x[0].distance < 0.7*x[1].distance, matches)
通過cv2.drawMatchesKnn畫出匹配的特征點,再將好的匹配返回:
return good_matches
在復雜的環境中,FLANN算法不容易將對象混淆,而像素級算法則容易混淆。以下是書中的結果:
單應性估計
由于我們的對象是平面且固定的,所以我們就可以找到兩幅圖片特征點的單應性變換。得到單應性變換的矩陣后就可以計算對應的目標角點:
# 代碼與書中不同,做了一些修改
def detectcorner_points(self, key_frame, good_matches):
# 將所有好的匹配的對應點的坐標存儲下來
src_points = np.float32([self.key_train[m.trainIdx].pt for m in good_matches]).reshape(-1, 1, 2)
dst_points = np.float32([keyQuery[m.queryIdx].pt for m in good_matches]).reshape(-1, 1, 2)
H, mask= cv2.findHomography(src_points, dst_points, cv2.RANSAC, 5.0)
matches_mask = mask.ravel().tolist()
# 有了H單應性矩陣,我們可以查看源點被映射到query image中的位置
self.sh_train = self.img_train.shape[:2] # rows, cols
src_corners = np.float32([(0, 0), (self.sh_train[1], 0), (self.sh_train[1], self.sh_train[0]), (0,self.sh_train[0])]).reshape(-1, 1, 2)
# perspectiveTransform返回點的列表
dst_corners = cv2.perspectiveTransform(src_corners, H)
dst_corners = map(tuple, dst_corners[0])
# 將點向右移動img_train的寬度大小,方便我們同時顯示兩張圖片
dst_corners = [(np.int(dst_corners[i][0]+self.sh_train[1]), np.int(dst_corners[i][1])
for i in range(0,len(dst_corners)):
cv2.line(img_flann, dst_corners[i], dst_corners[(i+1) % 4],(0, 255, 0), 3)
結果圖:
彎曲圖片
我們可以將場景改變,使得看上去像正對著這本書。我們可以簡單的將單應性矩陣取逆:
Hinv = cv2.linalg.inverse(H)
但是,這會將書左上角的點變成新圖片的原點,書本左邊和上面的部分都會被剪斷。我們試圖僅僅大概的將書本放在圖片的中間。因此我們計算一個新的單應性矩陣。將場景點作為輸入,輸出的圖片中的書本要和模板圖片里的一樣大:
def warp_keypoints(self):
dst_size = img_in.shape[:2]
將書本大小縮小到dst_size大小的1/2,同時還要移動1/4的距離:
scale_row = 1./src_size[0]*dst_size[0]/2.
bias_row = dst_size[0]/4.
scale_col = 1./src_size[1]*dst_size[1]/2.
bias_col = dst_size[1]/4.
# 將每個點應用這樣的變換
src_points = [key_frame[good_matches[i].trainIdx].pt for i in range(len(good_matches))]
dst_points = [self.key_train[good_matches[i].queryIdx].pt for i in range(len(good_matches))]
dst_points = [[x*scale_row+bias_row, y*scale_col+bias_col] for x, y in dst_points]
Hinv, = cv2.findHomography(np.array(srcpoints), np.array(dst_points), cv2.RANSAC)
img_warp = cv2.warpPerspective(img_query, Hinv, dst_size)
特征跟蹤
如何保證一個幀里找到的圖在下一幀里再被找到。
在FearureMatching類的構造函數中,創建了一些記錄的變量。主要的想法是從一幀跑到下一幀時要加強一些連貫性。因此我們抓取了大約每秒10幀的圖,雖然上一幀里的圖和下一幀變化并不會太大,但是也不能因此而把新的一幀里的一些離群點認為是正確的。為了解決這個問題,我們保存了我們沒有找到合適結果的幀數量:self.num_frames_no_success,如果這個數量小于self.max_frames_no_success,我們將這些幀進行比較。如果大于閾值,我們就假定距離最后一次在幀中獲取結果的時間已經過去了很久,這種情況下就不需要再幀中比較結果了。
早期的離群點檢測與排除
我們可以將離群點的排除放在每次計算的步驟中,盡量保證獲取好的匹配。
def match(self, frame):
img_query = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
sh_query = img_query.shape[:2] # rows,cols
# 獲得好的matches
key_query, desc_query = self._extract_features(img_query)
good_matches = self._match_features(descQuery)
# 為了讓RANSAC方法可以盡快工作,至少需要4個好的匹配,否則視為匹配失敗
if len(good_matches) < 4:
self.num_frames_no_success=self.num_frames_no_success + 1
return False, frame
# 在query_image中找到對應的角點
dst_corners = self._detect_corner_points(key_query, good_matches)
# 如果這些點位置距離圖片內太遠(至少20像素),那么意味著我們沒有找到我們感興趣
# 的目標或者說是目標沒有完整的出現在圖片內,對于這兩種情況,我們都視為False
if np.any(filter(lambda x: x[0] < -20 or x[1] < -20
or x[0] > sh_query[1] + 20 or x[1] > sh_query[0] + 20, dst_corners)):
self.num_frames_no_success =
self.num_frames_no_success + 1
return False, frame
# 如果4個角點沒有圍出一個合理的四邊形,意味著我們可能沒有找到我們的目標。
# 計算面積
area = 0
for i in range(0, 4):
next_i = (i + 1) % 4
area = area + (dst_corners[i][0]*dst_corners[next_i]
[1]- dst_corners[i][1]*dst_corners[next_i][0])/2.
# 如果面積太大或太小,將它排除
if area < np.prod(sh_query)/16. or area > np.prod(sh_query)/2.:
self.num_frames_no_success=self.num_frames_no_success + 1
return False, frame
# 如果我們此時發現的單應性矩陣和上一次發現的單應性矩陣變化太大,意味著我們可能找到了
# 另一個對象,這種情況我們丟棄這個幀并返回False
np.linalg.norm(Hinv – self.last_hinv)
# 這里要用到self.max_frames_no_success的,作用就是距離上一次發現的單應性矩陣
# 不能太久時間,如果時間過長的話,完全可以將上一次的hinv拋棄,使用當前計算得到
# 的Hinv
recent = self.num_frames_no_success < self.max_frames_no_success
similar = np.linalg.norm(Hinv - self.last_hinv) < self.max_error_hinv
if recent and not similar:
self.num_frames_no_success = self.num_frames_no_success + 1
return False, frame
self.num_frames_no_success = 0
self.last_hinv = Hinv
img_out = cv2.warpPerspective(img_query, Hinv, dst_size)
img_out = cv2.cvtColor(img_out, cv2.COLOR_GRAY2RGB)
return True, imgOut
書中運行起來的效果如下:
warp image是變化不大的,近乎靜止:
如果算出了錯誤的單應性矩陣,因為self.max_frames_no_success的設置也能很快的恢復,使得算法精確且有效率。
整個程序的流程總結如下:
- 抽取每個視頻幀的感興趣的特征:_extract_features
- 將模板和視頻幀的特征點進行匹配,沒有匹配到則跳過:_match_features
- 檢測視頻幀中的對應模板圖片的角點,如果一些重要的角點在該幀之外則跳過:_detect_corner_points
- 計算四個角點構成的四邊形的面積,如果太小或太大則跳過
- 在當前幀大概畫出模板圖片的角點
- 計算將當前物體從當前幀移動到前額平行平面的透視變換,如果當前的結果與之前幀的結果差別很大,則跳過:_warp_keypoints
- 扭曲當前幀使得目標物體出現在中央并且正面朝上
實際運行代碼及結果
書里的代碼組合起來有些錯誤,下面是我修改過的整個FeatureMatching類的實現:
(注意幾個變動:
- 其中的train image和query image我是按照opencv的官方python教程來設置的,本書的train image和query image與之是相反的;
- 其中求四邊形的面積我改用的是行列式的方式;
- 書中使用的是SURF方法求特征點,但是就我的兩張圖片SURF得不到好的結果,故改用了SIFT方法;
)
import cv2
import numpy as np
from matplotlib import pyplot as plt
class FeatureMatching:
# 官方教程的目標圖片是query image
def __init__(self, query_image='data/query.jpg'):
# 創建SURF探測器,并設置Hessian閾值,由于效果不好,我改成了SIFT方法
# self.min_hessian = 400(surf方法使用)
# self.surf = cv2.xfeatures2d.SURF_create(min_hessian)
self.sift = cv2.xfeatures2d.SIFT_create()
self.img_query = cv2.imread(query_image, 0)
# 讀取一個目標模板
if self.img_query is None:
print("Could not find train image " + query_image)
raise SystemExit
self.shape_query = self.img_query.shape[:2] # 注意,rows,cols,對應的是y和x,后面的角點坐標的x,y要搞清楚
# detectAndCompute函數返回關鍵點和描述符
self.key_query, self.desc_query = self.sift.detectAndCompute(self.img_query, None)
# 設置FLANN對象
FLANN_INDEX_KDTREE = 0
index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)
search_params = dict(checks=50)
self.flann = cv2.FlannBasedMatcher(index_params, search_params)
# 保存最后一次計算的單應矩陣
self.last_hinv = np.zeros((3, 3))
# 保存沒有找到目標的幀的數量
self.num_frames_no_success = 0
# 最大連續沒有找到目標的幀的次數
self.max_frames_no_success = 5
self.max_error_hinv = 50.
# 防止第一次檢測到時由于單應矩陣變化過大而退出
self.first_frame = True
def _extract_features(self, frame):
# self.min_hessian = 400
# sift = cv2.xfeatures2d.SURF_create(self.min_hessian)
sift = cv2.xfeatures2d.SIFT_create()
# detectAndCompute函數返回關鍵點和描述符,mask為None
key_train, desc_train = sift.detectAndCompute(frame, None)
return key_train, desc_train
def _match_features(self, desc_frame):
# 函數返回一個訓練集和詢問集的一致性列表
matches = self.flann.knnMatch(self.desc_query, desc_frame, k=2)
# 丟棄壞的匹配
good_matches = []
# matches中每個元素是兩個對象,分別是與測試的點距離最近的兩個點的信息
# 留下距離更近的那個匹配點
for m, n in matches:
if m.distance < 0.7 * n.distance:
good_matches.append(m)
return good_matches
def _detect_corner_points(self, key_frame, good_matches):
# 將所有好的匹配的對應點的坐標存儲下來
src_points = np.float32([self.key_query[m.queryIdx].pt for m in good_matches]).reshape(-1, 1, 2)
dst_points = np.float32([key_frame[m.trainIdx].pt for m in good_matches]).reshape(-1, 1, 2)
H, mask = cv2.findHomography(src_points, dst_points, cv2.RANSAC, 5.0)
matchesMask = mask.ravel().tolist()
# 有了H單應性矩陣,我們可以查看源點被映射到img_query中的位置
# src_corners = np.float32([(0, 0), (self.shape_train[1], 0), (self.shape_train[1], self.shape_train[0]),
# (0, self.shape_train[0])]).reshape(-1, 1, 2)
h, w = self.img_query.shape[:2]
src_corners = np.float32([[0, 0], [0, h - 1], [w - 1, h - 1], [w - 1, 0]]).reshape(-1, 1, 2)
# perspectiveTransform返回點的列表
dst_corners = cv2.perspectiveTransform(src_corners, H)
return dst_corners, H, matchesMask
def _center_keypoints(self, frame, key_frame, good_matches):
dst_size = frame.shape[:2]
# 將圖片的對象大小縮小到query image的1/2(書里是train image,和官方命名相反而已)
scale_row = 1. / self.shape_query[0] * dst_size[0] / 2.
bias_row = dst_size[0] / 4.
scale_col = 1. / self.shape_query[1] * dst_size[1] / 2.
bias_col = dst_size[1] / 4.
# 將每個點應用這樣的變換
src_points = [self.key_query[m.queryIdx].pt for m in good_matches]
dst_points = [key_frame[m.trainIdx].pt for m in good_matches]
dst_points = [[x * scale_row + bias_row, y * scale_col + bias_col] for x, y in dst_points]
Hinv, _ = cv2.findHomography(np.array(src_points), np.array(dst_points), cv2.RANSAC, 5.0)
img_center = cv2.warpPerspective(frame, Hinv, dst_size, flags=2)
return img_center
def _frontal_keypoints(self, frame, H):
Hinv = np.linalg.inv(H)
dst_size = frame.shape[:2]
img_front = cv2.warpPerspective(frame, Hinv, dst_size, flags=2)
return img_front
def match(self, frame):
img_train = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
cv2.waitKey(0)
shape_train = img_train.shape[:2] # rows,cols
# 獲得好的matches
key_train, desc_train = self._extract_features(img_train)
good_matches = self._match_features(desc_train)
# 為了讓RANSAC方法可以盡快工作,至少需要4個好的匹配,否則視為匹配失敗
if len(good_matches) < 4:
self.num_frames_no_success += 1
return False, frame
# 畫出匹配的點
img_match = cv2.drawMatchesKnn(self.img_query, self.key_query, img_train, key_train, [good_matches], None,
flags=2)
plt.imshow(img_match), plt.show()
# 在query_image中找到對應的角點
dst_corners, Hinv, matchesMask = self._detect_corner_points(key_train, good_matches)
# 如果這些點位置距離圖片內太遠(至少20像素),那么意味著我們沒有找到我們感興趣
# 的目標或者說是目標沒有完整的出現在圖片內,對于這兩種情況,我們都視為False
dst_ravel = dst_corners.ravel()
if (dst_ravel > shape_train[0] + 20).any() and (dst_ravel > -20).any() \
and (dst_ravel > shape_train[1] + 20).any():
self.num_frames_no_success += 1
return False, frame
# 如果4個角點沒有圍出一個合理的四邊形,意味著我們可能沒有找到我們的目標。
# 通過行列式計算四邊形面積
area = 0.
for i in range(0, 4):
D = np.array([[1., 1., 1.],
[dst_corners[i][0][0], dst_corners[(i + 1) % 4][0][0], dst_corners[(i + 2) % 4][0][0]],
[dst_corners[i][0][1], dst_corners[(i + 1) % 4][0][1], dst_corners[(i + 2) % 4][0][1]]])
area += abs(np.linalg.det(D)) / 2.
area /= 2.
# 以下注釋部分是書中的計算方式,我使用時是錯誤的
# for i in range(0, 4):
# next_i = (i + 1) % 4
# print(dst_corners[i][0][0])
# print(dst_corners[i][0][1])
# area += (dst_corners[i][0][0] * dst_corners[next_i][0][1] - dst_corners[i][0][1] * dst_corners[next_i][0][
# 0]) / 2.
# 如果面積太大或太小,將它排除
if area < np.prod(shape_train) / 16. or area > np.prod(shape_train) / 2.:
self.num_frames_no_success += 1
return False, frame
# 如果我們此時發現的單應性矩陣和上一次發現的單應性矩陣變化太大,意味著我們可能找到了
# 另一個對象,這種情況我們丟棄這個幀并返回False
# 這里要用到self.max_frames_no_success的,作用就是距離上一次發現的單應性矩陣
# 不能太久時間,如果時間過長的話,完全可以將上一次的hinv拋棄,使用當前計算得到
# 的Hinv
recent = self.num_frames_no_success < self.max_frames_no_success
similar = np.linalg.norm(Hinv - self.last_hinv) < self.max_error_hinv
if recent and not similar and not self.first_frame:
self.num_frames_no_success += self.num_frames_no_success
return False, frame
# 第一次檢測標志置否
self.first_frame = False
self.num_frames_no_success = 0
self.last_hinv = Hinv
draw_params = dict(matchColor=(0, 255, 0), # draw matches in green color
singlePointColor=None,
matchesMask=matchesMask, # draw only inliers
flags=2)
img_dst = cv2.polylines(img_train, [np.int32(dst_corners)], True, (0, 255, 255), 5, cv2.LINE_AA)
img_dst = cv2.drawMatches(self.img_query, self.key_query, img_dst, key_train, good_matches, None,
**draw_params)
plt.imshow(img_dst)
plt.show()
img_center = self._center_keypoints(frame, key_train, good_matches)
plt.imshow(img_center)
plt.show()
# 轉換成正面視角
img_front = self._frontal_keypoints(frame, Hinv)
plt.imshow(img_front)
plt.show()
return True, img_dst
測試:
import cv2
from feature_matching import FeatureMatching
img_train = cv2.imread('data/BM_left1.jpg')
matching = FeatureMatching(query_image='data/query.jpg')
flag = matching.match(img_train)