Opencv Python開發 第三章 圖像檢索

章節簡介

計算機不能像人眼一樣, 可以非常直接地匹配出兩張相似圖像之間的特徵點, 因此爲了讓計算機能夠檢測到圖像的主要特徵, 利用關鍵點將圖像拼接起來, 我們需要對圖像進行檢索和特徵匹配.

進一步的, 我們可以在已經提取出來的特徵上, 抽象出一個特徵類, 使其成爲圖像的描述符, 能夠應用於所有圖像的檢索.

本章將介紹OpenCV如何進行圖像特徵匹配和檢索, 重點是通過 單應性(homography) 來檢測這些特徵點是否和另一張圖片相匹配

往期博客

PythonOpencv開發 Python3.6.7+Opencv3.4.2.16環境配置

PythonOpenCV開發 前言

OpenCV Python開發 第一章 圖像處理基礎

OpenCV Python開發 第一章課後 自定義實現API

OpenCV Python開發 第二章 深度估計與分割

特徵的定義

粗略地講, 特徵就是圖像中那些對於我們來說有意義的, 我們感興趣關注的區域, 可以是一個像素點pixel, 也可以是一個超像素superpixel. 這些區域具有獨特性並且易於人眼識別.

角點, 高密度區域, 以及高梯度區域(高梯度意味着附近像素值變化特別高)都是很好的特徵. 比如我們能夠從很多個大小不同的圓的圖形中, 快速找到一個三角形, 卻不一定能夠找到一個被指定的圓.

而剩下來的大量的重複的模式, 或者低密度低梯度區域不是典型的特徵, 因爲他們變化太不明顯, 一個典型的例子就是背景. 我們對一副圖像進行檢索, 很少是爲了關注它的背景, 而不是它的內部的對象.

那麼, 在圖像中區分背景和對象的, 也就是邊緣, 從上面的理解來看, 邊緣也是非常好的特徵. 事實上Harris角點檢測的原理就用到了邊緣的概念.

小結一下, 圖像的特徵就是指, 我們感興趣的, 對我們有意義的圖像的一小部分, 通常是指角點, 高密度區域, 高梯度區域, 或者是圖像邊緣.

特徵檢測算法

目前主流的特徵檢測算法有很多, 但是專注的方向不同, OpenCV中常見的用來提取特徵的算法有:

  • Harris, FAST: 角點檢測
  • SIFT, SURF, BRIEF: 斑點檢測
  • ORB: 帶方向的FAST與旋轉不變性的BRIEF

而用來進行特徵匹配的有:

  • 暴力(Brute-Force)法
  • 基於FLANN(近似最鄰近快速庫, Fast Library for Approximate Nearest Neighbors)的匹配法

角點檢測

什麼是角點

我們人眼可以很明顯地分辨出哪些屬於角點, 比如樹尖, 針頭. 但是如何從數學角度去定義一個角點?

首先它要很小, 很尖, 這是生活中我們實際看到的角點的兩個基本特徵. 但是在圖像中, 大小是沒有多少參考意義的, 同樣的圖像, 我可以通過放大和縮小改變對象的大小. 即使圖裏是一根鐵杵, 我也能將其縮小, 直到它變成一根針.

那麼入手的角度就只剩下""這一個關鍵詞了. 萬幸這不是一個實現起來特別複雜的概念. 我們理解的尖, 是指一個物體, 兩個維度方向的長度完全不在一個量級. 比如針頭, 它可以很長, 長到10釐米, 但是它的橫截面積卻連1平方毫米都不到.

因此我們可以這麼定義角點: 該點處, 圖像存在至少兩個方向的梯度 fx,fy\Large f_x, f_y, 兩者絕對值相差特別大.

巧不巧? 剛好有一種算子, 可以計算水平和垂直方向上圖像的梯度, 稱爲Sobel算子, 因此Sobel算子是用來檢測角點的一個非常有用的工具. 下面分別是垂直和水平方向上的Sobel算子

[121000121][101202101] \left[ \begin{matrix} \large \large -1 & \large -2 & \large -1 \\ \large 0 & \large 0 & \large 0 \\ \large 1 & \large 2 & \large 1 \end{matrix} \right] \left[ \begin{matrix} \large \large -1 & \large 0 & \large 1 \\ \large -2 & \large 0 & \large 2 \\ \large -1 & \large 0 & \large 1 \end{matrix} \right]

Harris角點檢測

基本原理

Harris角點檢測的原理不算特別複雜, 這裏提一下基本原理, 想深入理解的同學點擊這裏.

Harris從角點的定義出發, 利用一個窗口在圖像上進行滑動, 過程中計算窗口內變化的梯度.

  • 如果這個特定的窗口在圖像各個方向上移動時,窗口內沒有發生明顯變化,那麼窗口內就不存在角點.
  • 如果窗口在某一個方向移動時,窗口內圖像的灰度發生了較大的變化,而在另一些方向上沒有發生變化,那麼,窗口內的圖像可能就是一條直線的線段, 也就是對象的邊緣.
  • 如果在各個方向上移動這個特徵的小窗口, 窗口內區域發生了較大的變化, 那麼就認爲在窗口內遇到了角點.

在這裏插入圖片描述

代碼實現

def myHarris(img, showImg=True, space='bgr'):
    """
    對img圖像進行Harris角點檢測
    :param img:  輸入圖像, BGR三通道
    :param showImg:  是否顯示結果, 默認爲是
    :param space:  圖像的色域空間
    :return:    Harris檢測結果圖像
    :rtype: np.array
    """
    img_orig = deepcopy(img)
    img_copy = deepcopy(img)
    if space not in ['gray', 'GRAY']:
        gray_img = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
    else:
        gray_img = deepcopy(img)
    gray_img = np.float32(gray_img)
    k1 = 0.04
    harris_detector_k1 = cv.cornerHarris(gray_img, 2, 23, k1)  # harris角點檢測
    dst_k1 = cv.dilate(harris_detector_k1, None)  # 膨脹harris結果
    thres_k1 = 0.01 * dst_k1.max()  # 設置閾值
    img[dst_k1 > thres_k1] = [0, 0, 255]  # 檢測出來的角點像素用紅色標出
    if showImg:
        for i in range(2):
            plt.subplot(1, 2, i + 1)
            if 0 == i:
                if space in ['bgr', 'BGR']:
                    plt.imshow(cv.cvtColor(img_orig, cv.COLOR_BGR2RGB))
                else:
                    plt.imshow(cv.cvtColor(img_orig, cv.COLOR_GRAY2RGB))
                plt.title("原圖")
            elif 1 == i:
                if space in ['bgr', 'BGR']:
                    plt.imshow(cv.cvtColor(img, cv.COLOR_BGR2RGB))
                else:
                    plt.imshow(cv.cvtColor(img, cv.COLOR_GRAY2RGB))
                plt.title("Harris角點檢測, k={}".format(k1))
            plt.xticks([]), plt.yticks([])  # 隱藏X,Y軸
        plt.show()
    return img

運行結果如下:

在這裏插入圖片描述

代碼分析

首先我們需要將輸入圖像img轉化爲灰度格式, 然後調用cornerHarris函數:

dst = cv.cornerHarris(img_gray, 2, 23, k)

這個函數原型如下:

cornerHarris(src, blockSize, ksize, k, dst=None, borderType=None)

其中重要的四個參數:

  • src: 輸入的圖像, float32類型
  • blockSize: 角點檢測中要考慮的領域大小
  • ksize: 求導中使用的窗口大小
  • k: 角點檢測方程中的自由參數,取值參數通常爲[0,04,0.06]

最重要的是第三個參數ksize, 它限定了cornerHarris使用的Sobel算子的中孔(aperture), Sobel算子通過對圖像的行, 列的變化來檢測邊緣. 簡單地說, 這個參數定義了角點檢測的敏感度, 取值通常爲3和31之間的奇數.

下面這行實現了膨脹(dilate), 可以將前景物體放大.

dst_k1 = cv.dilate(harris_detector_k1, None)

函數原型如下:

dilate(src, kernel, dst=None, anchor=None, iterations=None, borderType=None, borderValue=None)

其中src爲輸入的灰度圖像, kernel可以取None值, 也可以取以下的值:

kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))  # 橢圓結構
kernel = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3))  # 十字結構
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))  # 矩形結構

最後, 是對閾值的處理

img[dst_k1 > thres_k1] = [0, 0, 255]  # 檢測出來的角點像素用紅色標出

注意這裏的img爲沒有被轉化過的BGR原始圖像, 因此是三通道的. thres_k1爲設置的閾值, dst_k1爲膨脹處理過的輸出圖像, dst_k1 > thres_k1會返回一個bool型矩陣, 如果dst_k1對應的元素值大於thres_k1, 那麼結果中對應的元素爲True, 否則爲False. 像下面這樣

a = np.ones((4, 4))
a[1][2] = 0
print(a == 1)

"""
[[ True  True  True  True]
 [ True  True False  True]
 [ True  True  True  True]
 [ True  True  True  True]]
"""

之後, img[condition] = [0, 0, 255] 會將condition中, 值爲true的對應位置的元素賦值爲[0, 0, 255], 對應BGR中的紅色

FAST角點檢測

基本原理

FAST(Features from Accelerated Segment Test) 由Edward Rosten和Tom Drummond在2006年首先提出,是近年來一總倍受關注的基於模板和機器學習的角點檢測方法,它不僅計算速度快,還具有較高的精確度。

該算法的原理是取圖像中檢測點,以該點爲圓心的周圍的16個像素點判斷檢測點是否爲角點,通俗的講就是中心的的像素值比大部分周圍的像素值要亮一個閾值或者暗一個閾值則爲角點.

具體的算法介紹點擊這裏

在這裏插入圖片描述

代碼實現

def myFAST(img, showImg=True, space='bgr'):
    """
    對img圖像進行FAST角點檢測
    :param img:  輸入圖像, BGR三通道
    :param showImg:  是否顯示結果, 默認爲是
    :param space:  圖像的色域空間
    :return:    FAST檢測結果圖像
    :rtype: np.array
    """
    fast = cv.FastFeatureDetector_create()
    img_orig = deepcopy(img)
    # 找到所有關鍵點
    fast.setNonmaxSuppression(0)
    kp = fast.detect(img, None)
    img_colored = cv.drawKeypoints(img, kp, None, color=(0, 0, 255))
    if showImg:
        for i in range(2):
            plt.subplot(1, 2, i + 1)
            if 0 == i:
                if space in ['bgr', 'BGR']:
                    plt.imshow(cv.cvtColor(img_orig, cv.COLOR_BGR2RGB))
                else:
                    plt.imshow(cv.cvtColor(img_orig, cv.COLOR_GRAY2RGB))
                plt.title("原圖")
            elif 1 == i:
                if space in ['bgr', 'BGR']:
                    plt.imshow(cv.cvtColor(img_colored, cv.COLOR_BGR2RGB))
                else:
                    plt.imshow(cv.cvtColor(img_colored, cv.COLOR_GRAY2RGB))
                plt.title("FAST角點檢測")
            plt.xticks([]), plt.yticks([])  # 隱藏X,Y軸
        plt.show()
    return img_colored

運行結果如下:

在這裏插入圖片描述

FAST算法是一個非常不錯的算法, 速度非常快, 但缺點也非常明顯: 對噪音並不穩健. 容易把噪點誤判爲角點.

代碼分析

這裏用到了一個類對象fast = cv.FastFeatureDetector_create(), 詳細的成員函數和變量請參考官方API文檔

通常情況下, fast對象的一般用法爲:

  1. 創建對象fast = cv.FastFeatureDetector_create()
  2. 非極大抑制 fast.setNonmaxSuppression(0)
  3. 檢測關鍵點 kp = fast.detect(img, None)
  4. 將關鍵點標記在原圖像上 img_colored = cv.drawKeypoints(img, kp, None, color=(0, 0, 255))

不光FAST對象是這樣的流程, 後面介紹的SIFT, SURF, ORB, FLANN等對象都是這個邏輯和流程.

Harris和FAST角點檢測的缺點

通常情況下, 前面介紹的Harris和FAST都可以很好的檢測角點, 而且即使圖像經過了旋轉, 這兩個算法仍然具有理想的效果.

但是, 如果隊圖像進行縮放處理(以縮小爲例), 可能會檢測到比原圖更多的角點. 因此這兩個算法對圖像大小是及其敏感的(稱爲特徵損失), 下圖是一個示例

在這裏插入圖片描述

特徵提取與描述

由於特徵損失的存在, 我們就需要一種與圖像比例無關的角點檢測方法來解決. SIFT可以很好的解決這個問題.

SIFT特徵提取

基本原理

SIFT(Scale-Invariant Feature Transform)有David Lowe於1999年提出的, 該算法會對不同的圖像大小輸出相同的結果(前提是遵循尺度不變特徵變換). 需要注意的是, SIFT並不直接檢測關鍵點, 但會通過一個特徵向量來描述關鍵點周圍區域的情況.

SIFT使用 DoG(Difference of Gaussians) 來檢測關鍵點. DoG是對同一圖像使用不同高斯濾波器所得到的結果. SIFT對象會使用DoG來檢測關鍵點, 並且對每個關鍵點周圍的取餘計算特徵向量. 返回值是關鍵點信息和描述符, 同上文的fast對象一樣, 需要我們手動把這些描述符標註到原圖像上.

SIFT特性

  • 對旋轉、尺度縮放、亮度變化等保持不變性
  • 對視角變換、仿射變化、噪聲也保持一定程度的穩定性
  • 獨特性好,信息量豐富,適用於海量特徵庫進行快速、準確的匹配;
  • 多量性,即使是很少幾個物體也可以產生大量的SIFT特徵;
  • 高速性,經優化的SIFT匹配算法甚至可以達到實時性的要求;
  • 擴展性,可以很方便的與其他的特徵向量進行聯合

代碼實現

def mySIFTSURF(img, showImg=True, space='bgr', select='sift', thres=None):
    """
    對輸入圖像img做SIFT或者SURF處理
    :param img: 輸入圖像
    :param showImg: 是否顯示結果, 默認爲是
    :param space: 圖像色域空間
    :param select: 選擇SIFT還是SURF
    :param thres: SURF的閾值
    :return: 處理後的圖像, 關鍵點, 以及描述符
    :rtype: np.array, np.array, np.array
    """
    height, width = img.shape[:2]
    small_size = 0.6
    img_orig = deepcopy(img)  # 原圖

    if select in ['sift', 'SIFT']:
        algo = cv.xfeatures2d.SIFT_create()
    else:
        algo = cv.xfeatures2d.SURF_create(thres)

    if space in ['bgr', 'BGR']:
        kp, descri = algo.detectAndCompute(cv.cvtColor(img, cv.COLOR_BGR2GRAY), None)
    else:
        kp, descri = algo.detectAndCompute(img, None)
    img_processed = cv.drawKeypoints(deepcopy(img), outImage=img, keypoints=kp,
                                     flags=cv.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)

    if showImg:
        ttls = ['原圖', '原圖{}'.format(select)]
        imgs = [img_orig, img_processed]
        for i in range(2):
            plt.subplot(1, 2, i + 1)
            if space in ['bgr', 'BGR']:
                plt.imshow(cv.cvtColor(imgs[i], cv.COLOR_BGR2RGB))
            else:
                plt.imshow(imgs[i])
            plt.title(ttls[i])
            plt.xticks([]), plt.yticks([])  # 隱藏X,Y軸
        plt.show()
    return img_processed, kp, descri

運行結果如下, SIFT會以關鍵點爲圓心, 畫一個圓, 並指出特徵向量的方向.

在這裏插入圖片描述

下面是局部的圖像

在這裏插入圖片描述

代碼分析

和前文的FAST算法是一樣的流程, 只不過創建的類不同罷了, 函數參數上也有一些小的變動.

這裏我們先是創建了一個SIFT對象 sift = cv.xfeatures2d.SIFT_create(), 然後計算關鍵點 kp, descri= sift.detectAndCompute(img, None), 最後再在原圖上標出這些關鍵點 img_sift = cv.drawKeypoints(image=deepcopy(img), outImage=img, keypoints=kp, flags=cv.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)

上面的 flags=cv.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS 其實可以用 flags=4來代替

另外稍微提一下, 關鍵點既然包含了座標和方向兩個屬性, 那麼很容易想到它應該是一個對象類, 而不是基本數據類型.

事實上, 從官方的文檔會發現, 關鍵點keypoint類有以下成員變量

  • pt: 表示圖像中關鍵點的x,y座標
  • size: 特徵的直徑
  • angle: 特徵的方向
  • response: 關鍵點的強度. 某些特徵會通過SIFT來分類, 因爲他得到的特徵比其它特徵更好, 通過查看response屬性可以品谷特徵強度
  • octave: 表示特徵所在金字塔的層級. SIFT算法用到了高斯金字塔.

驗證尺度和旋轉不變性

前文提到過, SIFT具有尺度和旋轉的不變形, 下面我們來寫兩個demo驗證一下.

我們創建一個0.6倍率縮小的原圖的副本, 進行同樣的SIFT操作, 然後將兩者拼接起來, 相匹配的關鍵點我們用線連接起來

同理我們再創建一個逆時針旋轉45度的副本, 進行同樣的操作.

首先我們定義一個旋轉圖片的函數 rotateImage(img, angle, resize=1.0), 由於直接使用opencv的API會導致旋轉後的圖片缺少一塊, 所以我們要自定義一個函數. 實現將旋轉後的圖片的大小重新調整, 使得整張圖片得到保存.

def rotateImage(img, angle, resize=1.0):
    """
    將img逆時針旋轉angle角度
    :param img: 輸入圖像
    :param angle:  逆時針旋轉角度
    :param resize: 縮放大小
    :return: 旋轉後的圖像
    :rtype: np.array
    """
    (h, w) = img.shape[:2]
    (cX, cY) = (w // 2, h // 2)

    M = cv.getRotationMatrix2D((cX, cY), angle, resize)
    cos = np.abs(M[0, 0])
    sin = np.abs(M[0, 1])
    nW = int((h * sin) + (w * cos))
    nH = int((h * cos) + (w * sin))
    M[0, 2] += (nW / 2) - cX
    M[1, 2] += (nH / 2) - cY
    img_rotated = cv.warpAffine(img, M, (nW, nH))
    return img_rotated

接着我們定義一個用FLANN進行特徵匹配的函數, FLANN具體會在後面介紹, 這裏只是先調用一下.

def FlannMatch(img1, img2, ttl='FLANN匹配', showImg=True, space='bgr'):
    """
    將im1和img2進行flann匹配
    :param img1: 輸入圖像1
    :param img2: 輸入圖像2
    :param ttl: 最終顯示圖片的名稱
    :param showImg: 是否顯示匹配結果, 默認爲是
    :param space: img1和img2的色域空間
    :return: 匹配結果圖像, 匹配的關鍵點
    :rtype: np.array, list
    """
    sift = cv.xfeatures2d.SIFT_create()
    FLANN_INDEX_KDTREE = 0
    index_params = dict(algorithm=FLANN_INDEX_KDTREE, tree=5)
    searchparams = dict(checks=50)
    flann = cv.FlannBasedMatcher(index_params, searchparams)
    cv.FlannBasedMatcher_create()
    kp1, des1 = sift.detectAndCompute(img1, None)
    kp2, des2 = sift.detectAndCompute(img2, None)
    matches = flann.knnMatch(des1, des2, k=2)
    good = []
    for m, n in matches:
        if m.distance < 0.7 * n.distance:
            good.append([m])
    img5 = cv.drawMatchesKnn(img1, kp1, img2, kp2, good, None, flags=2)
    if showImg:
        if space in ['bgr', 'BGR']:
            plt.imshow(cv.cvtColor(img5, cv.COLOR_BGR2RGB))
        else:
            plt.imshow(img5)
        plt.xticks([]), plt.yticks([])
        plt.title(ttl)
        plt.show()
    return img5, good

最後我們定義一個驗證SIFT尺度和旋轉不變性的函數

def checkSIFT(img, space='bgr'):
    """
    檢查SIFT的尺度和旋轉不變性
    :param img: 輸入圖像
    :param space: 圖像的色域空間
    """
    height, width = img.shape[:2]
    small_size = 0.6
    img_orig = deepcopy(img)
    img_small = cv.resize(deepcopy(img), (int(small_size * width), int(small_size * height)),
                          interpolation=cv.INTER_CUBIC)
    img_rotate = rotateImage(deepcopy(img), 45)

    FlannMatch(deepcopy(img_orig), deepcopy(img_small), ttl='尺度不變性驗證')
    FlannMatch(deepcopy(img_orig), deepcopy(img_rotate), ttl='旋轉不變性驗證')
    return

運行checkSIFT()結果如下

在這裏插入圖片描述
在這裏插入圖片描述

可以看到, 兩張圖片中的每個關鍵點都有對應的關鍵點進行匹配. 這就是SIFT的尺度和旋轉不變性.

快速Hessian和SURF特徵提取

與SIFT相比, SURF(Speeded Up Robust Features) 是一種更快速的算法, 由Herbert Bay於2006年提出, 吸收了SIFT的思想, 但是速度上比SIFT快好幾倍

Hessian矩陣

黑塞矩陣(Hessian Matrix) 是一個多元函數的二階偏導數構成的方陣,描述了函數的局部曲率。黑塞矩陣常用於牛頓法解決優化問題,利用黑塞矩陣可判定多元函數的極值問題。在工程實際問題的優化設計中,所列的目標函數往往很複雜,爲了使問題簡化,常常將目標函數在某點鄰域展開成泰勒多項式來逼近原函數,此時函數在某點泰勒展開式的矩陣形式中會涉及到黑塞矩陣。

說白了, 就是多元函數的泰勒展開式需要用到Hessian矩陣, 除此之外, Hessian矩陣還涉及到很多數學相關的知識點,比如極值判斷、矩陣特徵值及特徵向量、二次型等

具體的介紹請點擊這裏

SURF基本原理

SURF與SIFT構造的金字塔有很大不同, 也正是因此, SURF比SIFT快得多. SIFT採用的是DoG圖像,而SURF採用的是Hessian矩陣行列式近似值圖像。以下是圖像中某個像素點的Hessian矩陣:
H(x,σ)=[Lxx(x,σ)Lxy(x,σ)Lxy(x,σ)Lyy(x,σ)] \large H(x, \sigma) = \left[ \begin{matrix} \large \large L_{xx}(x, \sigma) & \large L_{xy}(x, \sigma) \\ \large L_{xy}(x, \sigma) & \large L_{yy}(x, \sigma) \end{matrix} \right]
其中 Lxx(x,σ)\large L_{xx}(x, \sigma) 是是高斯二階微分在像素點(x, y)處與圖像函數 I(x,y)\large I(x,y) 的卷積

此外, SURF還用到了一個圖像積分的概念. 所謂的圖像積分, 對於像素點(x, y)的積分 S(x,y)\large S(x,y), 其值等於所有位於該像素點左上角的所有像素的和. 寫成公式如下:
S(x,y)=i=0xj=0yI(i,j) \large S(x, y) = \sum_{i=0}^x \sum_{j=0}^y I(i, j)

代碼實現

SURF的代碼與SIFT的事實上只有一行不同, 就是創建的對象不同. 因此我們完全可以改一下SIFT的代碼, 沒必要再寫一個函數

def mySIFTSURF(img, showImg=True, space='bgr', select='sift', thres=None):
    """
    對輸入圖像img做SIFT或者SURF處理
    :param img: 輸入圖像
    :param showImg: 是否顯示結果, 默認爲是
    :param space: 圖像色域空間
    :param select: 選擇SIFT還是SURF
    :param thres: SURF的閾值
    :return: 處理後的圖像, 關鍵點, 以及描述符
    :rtype: np.array, np.array, np.array
    """
    height, width = img.shape[:2]
    small_size = 0.6
    img_orig = deepcopy(img)  # 原圖

    if select in ['sift', 'SIFT']:
        algo = cv.xfeatures2d.SIFT_create()
    else:
        algo = cv.xfeatures2d.SURF_create(thres)

    if space in ['bgr', 'BGR']:
        kp, descri = algo.detectAndCompute(cv.cvtColor(img, cv.COLOR_BGR2GRAY), None)
    else:
        kp, descri = algo.detectAndCompute(img, None)
    img_processed = cv.drawKeypoints(deepcopy(img), outImage=img, keypoints=kp,
                                     flags=cv.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)

    if showImg:
        ttls = ['原圖', '原圖{}'.format(select)]
        imgs = [img_orig, img_processed]
        for i in range(2):
            plt.subplot(1, 2, i + 1)
            if space in ['bgr', 'BGR']:
                plt.imshow(cv.cvtColor(imgs[i], cv.COLOR_BGR2RGB))
            else:
                plt.imshow(imgs[i])
            plt.title(ttls[i])
            plt.xticks([]), plt.yticks([])  # 隱藏X,Y軸
        plt.show()
    return img_processed, kp, descri

運行結果如下

在這裏插入圖片描述

基於ORB的特徵檢測和匹配

ORB(Oriented Fast and Rotated BRIEF) 算法是基於FAST特徵檢測與BRIEF特徵描述子匹配實現,相比BRIEF算法中依靠隨機方式獲取而值點對,ORB通過FAST方法,FAST方式尋找候選特徵點方式是假設灰度圖像像素點A周圍的像素存在連續大於或者小於A的灰度值. 與SIFT和SURF相比, ORB的速度更快.

在ORB的論文中, 作者得到了如下的成果:

  • 向FAST增加一個快速,準確的方向分量(component)
  • 高效計算帶方向的BRIEF特徵
  • 基於帶方向的BRIEF特徵的方差分析和相關分析
  • 在旋轉不變性條件下學習一種不相關的BRIEF特徵, 使得算法在KNN的應用中得到較好的性能.

ORB旨在優化和加快速度, 包括以 旋轉感知(rotate-aware) 的方式使用BRIEF, 這樣即使在訓練圖像與查詢圖像之間旋轉差別很大的情況下也能夠提高匹配效果.

ORB基本原理

BRIEF

ORB是基於FAST和BRIEF算法的, FAST已經在前文介紹過了, 這裏簡單介紹一下BRIEF.

BRIEF是2010年的一篇名爲《BRIEF: Binary Robust Independent Elementary Features》的文章中提出,BRIEF是對已檢測到的特徵點進行描述,它是一種二進制編碼的描述子,擯棄了利用區域灰度直方圖描述特徵點的傳統方法,大大的加快了特徵描述符建立的速度,同時也極大的降低了特徵匹配的時間,是一種非常快速,很有潛力的算法。

由於BRIEF僅僅是特徵描述子,所以事先要得到特徵點的位置,可以利用FAST, Harris, SIFT, SURF等算法檢測特徵點的位置。接下來在特徵點鄰域利用BRIEF算法建立特徵描述符。

BRIEF是目前最快的特徵描述符, 相應地理論也非常複雜.

BF暴力匹配

暴力匹配(Brute-Force) 是一種描述符匹配方法, 該方法會比較兩個描述符, 併產生匹配結果的列表list. 稱爲暴力匹配的原因是該算法不進行任何優化, 兩個描述符集一一進行比較. 每次比較兩個描述符.

OpenCV提供了BFMatcher對象來實現暴力匹配.

代碼實現

我們選取兩張儘可能相似的圖片, 比如不同時間點, 同一景點的照片, 這樣建築整體是相似的, 人羣是唯一的不確定因素.

這裏我選取以下兩張故宮的照片.

在這裏插入圖片描述
在這裏插入圖片描述

def myORB(img1, img2, showImage=True, select='ORB', k=None):
    """
    對img1和img2進行ORB或者KNN匹配
    :param img1: 輸入圖像1, grayscale類型
    :param img2: 輸入圖像2, grayscale類型
    :param showImage: 是否顯示結果, 默認爲是
    :param select: 選擇暴力BF還是KNN匹配
    :param k: KNN匹配的k
    :return: 匹配的結果和匹配的關鍵點
    :rtype: np.array, list
    """
    orb = cv.ORB_create()
    kp1, des1 = orb.detectAndCompute(deepcopy(img1), None)
    kp2, des2 = orb.detectAndCompute(deepcopy(img2), None)
    bf = cv.BFMatcher_create(cv.NORM_HAMMING, crossCheck=True)
    if select in ['orb', 'ORB']:
        matches = bf.match(des1, des2)
        matched = sorted(matches, key=lambda x: x.distance)
        img3 = cv.drawMatches(img1, kp1, img2, kp2, matches, None, flags=2)
    else:
        matches = bf.knnMatch(des1, des2, k=k)
        img3 = cv.drawMatchesKnn(img1, kp1, img2, kp2, matches, None, flags=2)
    if showImage:
        plt.imshow(cv.cvtColor(img3, cv.COLOR_BGR2RGB))
        plt.xticks([]), plt.yticks([])
        plt.title('ORB+{}匹配'.format(select))
        plt.show()
    return img3, matches

運行結果如下, 可以看到效果不是特別理想.

在這裏插入圖片描述

代碼分析

直到 kp2, des2 = orb.detectAndCompute(img2, None), 都和前面SIFT, SURF的邏輯是一樣的.

接下來我們接觸到了第一個匹配類型的對象BFMatcher

bf = cv.BFMatcher_create(cv.NORM_HAMMING, crossCheck=True)

這行行我們創建類BFMatcher對象, 函數原型爲:

def BFMatcher_create(normType=None, crossCheck=None)

參數說明:

  • normType:用來指定要使用的距離測試類型。默認值爲cv2.Norm_L2。這很適合SIFT和SURF等. 對於使用二進制描述符的ORB、BRIEF和BRISK算法等,要使用cv2.NORM_HAMMING,這樣就會返回兩個測試對象之間的漢明距離. 如果ORB算法的參數設置爲WTA_K==3或4,normType就應該設置成cv2.NORM_HAMMING2.
  • crossCheck:針對暴力匹配, 可以使用交叉匹配的方法來過濾錯誤的匹配. 默認值爲False. 如果設置爲True, 匹配條件就會更加嚴格,只有到A中的第i個特徵點與B中的第j個特徵點距離最近,並且B中的第j個特徵點到A中的第i個特徵點也是最近時纔會返回最佳匹配(i, j),即這兩個特徵點要互相匹配才行。

這句其實可以用 bf = cv.BFMatche(cv.NORM_HAMMING, crossCheck=True)代替, 兩者的效果是一樣的.

接着我們對計算出來的描述符進行匹配:

matches = bf.match(des1, des2)
matches = sorted(matches, key=lambda x:x.distance)

BFMatcher對象其實有兩個匹配算法, 一個是 bf.match(des1, des2), 另一個是 bf.knnMatch(des1, des2), 兩者的區別想必看名字就知道了, 前者返回最佳匹配, 對一個匹配目標只返回一個最佳的匹配結果. 而後者通過KNN算法, 返回k個最佳匹配.

之後由於暴力匹配複雜度實在太高, 我們先對匹配出來的結果按照距離進行排序, 再標註到原圖上:

img3 = cv.drawMatches(img1, kp1, img2, kp2, matches, None, flags=2)

cv.drawMatches() 函數之前的函數 cv.drawKeypoints() 特別像, 容易搞混. 前者是將兩張圖像上匹配的關鍵點連線標出, 後者是在單張圖像上繪製出關鍵點.

KNN最近鄰匹配

熟悉機器學習, 特別是sklearn庫的同學可能對KNN這個算法很瞭解.

KNN(K-Nearest Neighbors)可能是機器學習中最簡單的分類算法之一了, 背後的理論也很簡單.

基本原理

以二維平面上的不同點集爲例. 假設有以下三個經過分類的圓形點集(紫, 黃, 青), 給定幾個樣本(藍色三角標出), 要求分別給這幾個樣本進行分類.

在這裏插入圖片描述

KNN的思想是, 對於每個樣本, 分別計算其與已知類別的距離(本例中是歐氏距離), 然後選擇最小的K個結果返回.

以下是結果, 可以看到所有原本是藍色的三角都被很好地分到了對應的類中.

在這裏插入圖片描述

代碼實現

前面ORB中提到過, BFMatcher對象提供了暴力匹配 bf.match() 和KNN匹配 bf.knnMatch() 兩個算法, 因此只需改動一下代碼就好了. 注意KNN匹配的結果無需再另外排序

def myORB(img1, img2, showImage=True, select='ORB', k=None):
    """
    對img1和img2進行ORB或者KNN匹配
    :param img1: 輸入圖像1, grayscale類型
    :param img2: 輸入圖像2, grayscale類型
    :param showImage: 是否顯示結果, 默認爲是
    :param select: 選擇暴力BF還是KNN匹配
    :param k: KNN匹配的k
    :return: 匹配的結果和匹配的關鍵點
    :rtype: np.array, list
    """
    orb = cv.ORB_create()
    kp1, des1 = orb.detectAndCompute(deepcopy(img1), None)
    kp2, des2 = orb.detectAndCompute(deepcopy(img2), None)
    bf = cv.BFMatcher_create(cv.NORM_HAMMING, crossCheck=True)
    if select in ['orb', 'ORB']:
        matches = bf.match(des1, des2)
        matched = sorted(matches, key=lambda x: x.distance)
        img3 = cv.drawMatches(img1, kp1, img2, kp2, matches, None, flags=2)
    else:
        matches = bf.knnMatch(des1, des2, k=k)
        img3 = cv.drawMatchesKnn(img1, kp1, img2, kp2, matches, None, flags=2)
    if showImage:
        plt.imshow(cv.cvtColor(img3, cv.COLOR_BGR2RGB))
        plt.xticks([]), plt.yticks([])
        plt.title('ORB+{}匹配'.format(select))
        plt.show()
    return img3, matches

運行結果如下:

在這裏插入圖片描述

FLANN匹配

最後, 介紹FLANN匹配.

FLANN(Fast Library for Approximate Nearest Neighbors), 從命名可以看出, FLANN求得的是近似的最近鄰, 因此條件比SIFT, SURF以及KNN寬鬆很多, 相應的結果也就不會特別準確. 犧牲準確度以換取速度.

FLANN網站有一段話:

FLANN is a library for performing fast approximate nearest neighbor searches in high dimensional spaces. It contains a collection of algorithms we found to work best for nearest neighbor search and a system for automatically choosing the best algorithm and optimum parameters depending on the dataset.

FLANN is written in C++ and contains bindings for the following languages: C, MATLAB, and Python.

簡單地說, 就是FLANN具有一種內部機制, 能夠自動根據傳遞的參數, 選擇最合適的算法來處理數據. FLANN處理的速度是其它最近鄰軟件的幾倍以上.

需要注意的是, FLANN並不是一個算法, 它是一個庫(命名中有Library), FLANN使用的還是其它的算法. 只是很好地將其他算法封裝成了一個抽象類.

代碼實現

前面已經定義過FLANN匹配的函數了, 這裏再複製一遍

def FlannMatch(img1, img2, ttl='FLANN匹配', showImg=True, space='bgr'):
    """
    將im1和img2進行flann匹配
    :param img1: 輸入圖像1
    :param img2: 輸入圖像2
    :param ttl: 最終顯示圖片的名稱
    :param showImg: 是否顯示匹配結果, 默認爲是
    :param space: img1和img2的色域空間
    :return: 匹配結果圖像, 匹配的關鍵點
    :rtype: np.array, list
    """
    sift = cv.xfeatures2d.SIFT_create()
    FLANN_INDEX_KDTREE = 0
    index_params = dict(algorithm=FLANN_INDEX_KDTREE, tree=5)
    searchparams = dict(checks=50)
    flann = cv.FlannBasedMatcher(index_params, searchparams)
    cv.FlannBasedMatcher_create()
    kp1, des1 = sift.detectAndCompute(img1, None)
    kp2, des2 = sift.detectAndCompute(img2, None)
    matches = flann.knnMatch(des1, des2, k=2)
    good = []
    for m, n in matches:
        if m.distance < 0.7 * n.distance:
            good.append([m])
    img5 = cv.drawMatchesKnn(img1, kp1, img2, kp2, good, None, flags=2)
    if showImg:
        if space in ['bgr', 'BGR']:
            plt.imshow(cv.cvtColor(img5, cv.COLOR_BGR2RGB))
        else:
            plt.imshow(img5)
        plt.xticks([]), plt.yticks([])
        plt.title(ttl)
        plt.show()
    return img5, good

運行結果在前面驗證SIFT尺度不變性的時候調用過了, 這裏重複貼一下

在這裏插入圖片描述

代碼分析

整體上FLANN的流程和邏輯和ORB還是相似的, 先創建對象

FLANN_INDEX_KDTREE = 0
index_params = dict(algorithm=FLANN_INDEX_KDTREE, tree=5)
searchparams = dict(checks=50)
flann = cv.FlannBasedMatcher(index_params, searchparams)

然後將兩張圖像的描述符進行匹配

matches = flann.knnMatch(des1, des2, k=2)
    good = []
    for m, n in matches:
        if m.distance < 0.7 * n.distance:
            good.append([m])

最後將匹配結果在原圖上標出

img5 = cv.drawMatchesKnn(img1, kp1, img2, kp2, good, None, flags=2)

不過, FLANN對象在創建的時候有點不同. 可以看到最上面三行都是創建FLANN對象時用到的參數.

FlannBasedMatcher()原型如下:

class FlannBasedMatcher(__cv2.DescriptorMatcher):
    def create(self): # real signature unknown; restored from __doc__
        """
        create() -> retval
        """
        pass

    def __init__(self, *args, **kwargs): # real signature unknown
        pass

    @staticmethod # known case of __new__
    def __new__(*args, **kwargs): # real signature unknown
        """ Create and return a new object.  See help(type) for accurate signature. """
        pass

    def __repr__(self, *args, **kwargs): # real signature unknown
        """ Return repr(self). """
        pass

FlannBasedMatcher()在調用時接收多個參數, 從__init__()中的 *args 和 **kwargs可以看出. 上面給出的參數都是dict字典類型的, 如下所示

FLANN_INDEX_KDTREE = 0
index_params = dict(algorithm=FLANN_INDEX_KDTREE, tree=5)
searchparams = dict(checks=50)
print(index_params, searchparams)
"""
{'algorithm': 0, 'tree': 5} {'checks': 50}
"""

而熟悉python的同學應該知道, 用字典傳遞的參數, 只能是**kwargs中的參數. 出於好奇, 我查了相關的資料, 想弄清楚這個__init__() 到底接收多少參數, 但是可以查到的資料幾乎沒有說明參數的. 這個坑留給以後填吧…

FLANN的單應性匹配

什麼是單應性

A relation between two figures, such that to any point of the one corresponds one and but one point in the other, and vise versa. Thus, a tangent line rolling on a circle cuts two fixed tangents of the circle in two sets of points that are homographic.

翻譯過來就是說, 單應性是這麼一種條件, 該條件表明當兩幅圖像中的一副出現投影畸變(perspective distortion) 時, 他們還能彼此匹配.

所謂的投影畸變, 就是指視覺上的投影. 比如我們從側面看一個正方形的時候, 看到的是一個斜着的長方形.

代碼實現

首先我們要明確, 單應性匹配的輸入和輸出是什麼.

  • 輸入: 兩幅圖像, 一副正視圖, 一副側視圖
  • 輸出: 輸入圖像的匹配結果.

首先我們需要定義一個對圖像進行投影運算的函數:

def perspectImage(img):
    """
    將輸入圖像投影后返回
    :param img: 輸入圖像
    :return: 投影后的圖像
    :rtype: np.array
    """
    h, w = img.shape[:2]
    src = np.array([[0, 0], [w - 1, 0], [0, h - 1], [w - 1, h - 1]], np.float32)
    dst = np.array([[50, 50], [w / 3, 50], [50, h - 1], [w - 1, h - 1]], np.float32)
    P = cv.getPerspectiveTransform(src, dst)  # 計算投影矩陣
    r = cv.warpPerspective(img, P, (w, h), borderValue=125)
    return r

效果如下:

在這裏插入圖片描述

接着, 我們定義函數來驗證FLANN的單應性:

def checkFLANN(img):
    """
    對FLANN進行單應性驗證
    :param img: 輸入圖像
    """
    MIN_MATCH_COUNT = 15  # 小於這個數量則認爲兩張圖像不是同一個對象
    img1 = deepcopy(img)
    img2 = perspectImage(img)

    # 創建SIFT對象
    sift = cv.xfeatures2d.SIFT_create()
    kp1, des1 = sift.detectAndCompute(img1, None)
    kp2, des2 = sift.detectAndCompute(img2, None)

    # 創建FLANN對象
    FLANN_INDEX_KDTREE = 0
    index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)
    search_params = dict(checks=50)
    flann = cv.FlannBasedMatcher(index_params, search_params)

    matches = flann.knnMatch(des1, des2, k=2)    # 進行描述符匹配

    # 保存有效的匹配點
    good = []
    for m, n in matches:
        if m.distance < 0.7*n.distance:
            good.append(m)	# 注意這裏不是good.append([m])

    if len(good) < MIN_MATCH_COUNT:
        print("Not enough matches are found, {} detected while {} required".
              format(len(good), MIN_MATCH_COUNT))
        return

    src_pts = np.float32([ kp1[m.queryIdx].pt for m in good ]).reshape(-1, 1, 2)
    dst_pts = np.float32([ kp2[m.trainIdx].pt for m in good ]).reshape(-1, 1, 2)
    M, mask = cv.findHomography(src_pts, dst_pts, cv.RANSAC, 5.0)
    matchesMask = mask.ravel().tolist()

    h, w = img.shape[:2]
    pts = np.float32([ [0,0], [0,h-1], [w-1,h-1], [w-1,0] ]).reshape(-1, 1, 2)
    dst = cv.perspectiveTransform(pts, M)

    img2 = cv.polylines(img2, [np.int32(dst)], True, 255, 3, cv.LINE_AA)
    draw_params = dict(matchColor=(0,255,255), singlePointColor=None, 
                       matchesMask=matchesMask, flags=2)
    img3 = cv.drawMatches(img1, kp1, img2, kp2, good, None, **draw_params)

    plt.imshow(cv.cvtColor(img3, cv.COLOR_BGR2RGB))
    plt.xticks([]), plt.yticks([])
    plt.show()

運行結果如下:

在這裏插入圖片描述

代碼分析

前半部分有一個小細節, 就是 good.append(m), 之前我們都是 good.append([m]), 這裏我們用前者, 因爲我們需要的是一個一維的描述符列表, 而不是二維的. 之前都是二維的.

直到if len(good) < MIN_MATCH_COUNT前, 都是我們非常熟悉的模式.

從這開始, 就是新內容了, 記下來, 很重要!
首先, 我們需要確保圖像中至少有一定數目的點是匹配的, 不能說兩張毫不相干的圖像強行進行匹配. 這裏我們設置這個數值爲15. 當匹配的描述符數量小於15時, 認爲這兩張圖片不是同一個對象. 直接退出函數.

接着, 我們來看下面這兩句.

src_pts = np.float32([ kp1[m.queryIdx].pt for m in good ]).reshape(-1, 1, 2)
dst_pts = np.float32([ kp2[m.trainIdx].pt for m in good ]).reshape(-1, 1, 2)

乍一看這寫的啥啊, 又是kp又是pt的, 查了網上也沒有太好的詳解.

碰到這種查不到的問題怎麼辦?
根據上下文情景, 再結合變量和函數的命名, 以及自身的知識儲備和直覺, 大膽地猜!

首先m.queryIdx中有Id的字眼, 而Id是用來唯一標識一個對象的, 再加上query查詢的字樣? 還不明顯嘛? 我們就理解爲通過一個id來查找對應的描述符m(有點類似超像素的id). 那麼很容易聯想到 kp1[m.queryIdx] 的作用就是根據 m.queryIdx返回的這個id, 在kp1列表中找到相應位置的keypoint關鍵點. 那麼再聯想一下, 關鍵點有什麼屬性? 至少得有座標這個屬性吧? 再結合後面np.reshape(-1, 1, 2)可知最後的結果是一個n行2列的二維矩陣, 類似下面這樣

[[1, 1], [2, 2], [3, 3]]

這不就是座標構成的列表嘛? 再說了, 我前面可是明確告訴你關鍵點kp有哪些成員變量的…其中第一個就是座標pt… 忘了的往前翻…

至於a = [func(x) for x in list] 這種寫法是Python中很常見的了. 就是對list中的每個元素作爲參數傳遞給函數func(), 將結果保存在列表a中.

因此這兩句的含義就很明顯了: 分別查詢原始圖像和訓練圖像中發現的關鍵點的座標, 並保存到列表src_pts和dst_pts中.

接着, 我們通過cv.findHomography()計算多個二維點對之間的最優單映射變換矩陣 H(3行3列), 使用最小均方誤差或者RANSAC方法

該函數原型如下:

def findHomography(srcPoints, dstPoints, method=None, ransacReprojThreshold=None, 
                   mask=None, maxIters=None, confidence=None)

參數說明:

  • srcPoints: 源平面中點的座標矩陣,可以是CV_32FC2類型,也可以是list類型
  • dstPoints: 目標平面中點的座標矩陣,可以是CV_32FC2類型,也可以是list類型
  • method: 計算單應矩陣所使用的方法。不同的方法對應不同的參數,具體如下:
    • 0 - 利用所有點的常規方法
    • RANSAC - RANSAC-基於RANSAC的魯棒算法
    • LMEDS - 最小中值魯棒算法
    • RHO - PROSAC-基於PROSAC的魯棒算法
  • ransacReprojThreshold: 將點對視爲內點的最大允許重投影錯誤閾值(僅用於RANSAC和RHO方法)
  • mask: 可選輸出掩碼矩陣,通常由魯棒算法(RANSAC或LMEDS)設置。 請注意,輸入掩碼矩陣是不需要設置的。
  • maxIters: RANSAC算法的最大迭代次數,默認值爲2000。
  • confidence: 可信度值,取值範圍爲0到1.

matchesMask = mask.ravel().tolsit(), 則是得到一個matchesMask, 最後用來繪製匹配圖.

然後, 我們需要對第二張投影過的圖計算相對於原始圖像的投影畸變

h, w = img.shape[:2]
pts = np.float32([ [0,0], [0,h-1], [w-1,h-1], [w-1,0] ]).reshape(-1, 1, 2)
dst = cv.perspectiveTransform(pts, M)
img2 = cv.polylines(img2, [np.int32(dst)], True, 255, 3, cv.LINE_AA)

第一行之所以寫成h, w = img.shape[:2], 而不寫成h, w = img.shape的原因是, 如果img是三通道圖像, 那麼第二種寫法會報錯. 第一種寫法默認只返回img.shape元組中的前兩個參數, 也就是圖像的高度和寬度.

最後的繪圖過程於之前的例子是一樣的. 不再贅述.

章節總結

本章介紹瞭如何檢測圖像特徵以及如何爲描述符提取特徵, 探討了如何通過OpenCV提供的API完成這個任務.

下一章將在本章的基礎上, 介紹 級聯(cascade) 的概念和自定義特徵模型.

本章新的API

"""
OpenCV
"""
cv.getPerspectiveTransform(src, dst)  # 計算投影矩陣
cv.warpPerspective(img, P, (w, h), borderValue=125) # 進行仿射投影
cv.getRotationMatrix2D((cX, cY), angle, resize) # 計算旋轉矩陣
cv.warpAffine(img, M, (nW, nH)) # 進行圖像旋轉
cv.cornerHarris(gray_img, 2, 23, k1)  # harris角點檢測
cv.dilate(harris_detector_k1, None)  # 膨脹操作
# 創建對象
cv.FastFeatureDetector_create()
cv.xfeatures2d.SIFT_create()
cv.xfeatures2d.SURF_create(thres)
cv.FlannBasedMatcher(index_params, searchparams)

kp, des = object.detectAndCompute(img, None)	# 檢測並計算關鍵點和描述符, object可以是SIFT, SURF等
cv.drawKeypoints(img, kp, None, color=(0, 0, 255)) # 標出關鍵點

本章完整代碼

已上傳至個人資源, 審覈通過後, 點擊此處 即可下載, (我也不知道怎麼回事…提交的時候是無需積分的, 但是過段時間就變成需要2積分或者5積分了)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章