本篇簡介
本小節(jié)我們來實現(xiàn)一個相對復(fù)雜的應(yīng)用:無標(biāo)記AR——具體而言,在視頻流中實時跟蹤指定書本封面并繪制附著在之上的AR圖形。這樣描述可能有點抽象,我們先來看一下最終實現(xiàn)的效果:

也就是在采集到的場景中,找到《深入理解OpenCV》這本書的位置,并在它的封面上繪制一個表示這本書姿態(tài)的3D小方塊。
這里先額外說明幾點:
- 為什么這個應(yīng)用叫做無標(biāo)記AR?因為與之相對的有標(biāo)記AR,需要借助特殊的AR標(biāo)記物,例如二維碼,來在真實場景中繪制AR圖形。而無標(biāo)記AR僅需借助事先訓(xùn)練好的真實物體模型,就可以完成AR圖形的繪制
- 本篇的很多實現(xiàn)思路都參考了《深入理解OpenCV》相關(guān)章節(jié)的實現(xiàn),有興趣的話推薦大家把這本書找來翻一翻,里面有一些比較有意思的案例可以參考
基本原理及流程
整體流程
首先我們來梳理一下實現(xiàn)這樣一個應(yīng)用需要那些基本功能和步驟。就基本原理而言,我們使用基于特征的匹配方式來比對目標(biāo)模型和現(xiàn)實場景,因此整體流程分為以下幾個步驟:
- 對目標(biāo)物體進行建模,也就是需要對目標(biāo)物體進行特征提取
- 使用目標(biāo)物體的特征模型訓(xùn)練匹配器
- 使用訓(xùn)練好的匹配器將目標(biāo)模型與實時場景進行匹配、計算目標(biāo)物體在場景內(nèi)的姿態(tài)
- 根據(jù)匹配結(jié)果繪制對應(yīng)的AR圖形
從上述四個步驟可以看出,整個應(yīng)用的功能可以按階段劃分為訓(xùn)練階段和匹配階段。
在開始著手實現(xiàn)這兩個階段的具體功能前,我們先來準(zhǔn)備一個模式數(shù)據(jù)類作為存放基礎(chǔ)數(shù)據(jù)的載體,順便簡單講解一下接下來的實現(xiàn)中涉及到的一些基本概念。
實現(xiàn)模式數(shù)據(jù)類Pattern
模式數(shù)據(jù)類用于存放各類基礎(chǔ)模式數(shù)據(jù)。之后的實現(xiàn)中,目標(biāo)物體訓(xùn)練集的結(jié)果,將以這個數(shù)據(jù)類為載體進行存儲。其聲明如下:
#include <opencv2/core.hpp>
#include <vector>
using namespace cv;
using namespace std;
class Pattern
{
// 省略構(gòu)造方法和getter/setter
...
private:
Size m_size; // 圖像大小
Mat m_data; // 原始圖像
vector<KeyPoint> m_keypoints;
Mat m_descriptors;
vector<Point2f> m_points2d;
vector<Point3f> m_points3d;
};
可以看到模式數(shù)據(jù)類中,除了作為輔助信息的圖像大小和原始圖像外,還包含下面幾種重要的數(shù)據(jù):
- 特征點數(shù)組(key points)和其對應(yīng)的描述符(descriptors):基于特征的匹配方式最基礎(chǔ)的數(shù)據(jù)。特征點用于標(biāo)識目標(biāo)物體相對于一般場景最顯著的差異位置,而描述符則是對這些差異的具體描述。
- 描述目標(biāo)物體邊界的2D和3D點集(m_points2d和m_points3d)
簡單總結(jié)就是,通過邊界限定+特征點和描述的集合來實現(xiàn)對目標(biāo)物體的建模。
完成了基礎(chǔ)模式數(shù)據(jù)類的設(shè)計后,我們來根據(jù)上面整理的兩個階段,規(guī)劃下需要進一步實現(xiàn)的其他工具類??偨Y(jié)起來,我們需要實現(xiàn)下面幾個功能:
- 對目標(biāo)物體的建模
- 在檢測場景中檢測目標(biāo)物體
- 目標(biāo)物體的姿態(tài)估計和繪制
簡明起見,我們將前兩個功能整合到統(tǒng)一的模式檢測器(PatternDetector)工具類中,最后一個功能因為涉及到界面繪制,我們將它分離出來單獨實現(xiàn)一個模式跟蹤器(PatternTracker),并通過組合的方式將這三類功能串起來,統(tǒng)一由模式檢測器向操作界面提供功能接口。
模式檢測器的聲明如下,其具體的方法會在后文詳細(xì)講解,這里我們先只關(guān)注其中比較重要的成員變量:
#include <opencv2/core.hpp>
#include <opencv2/objdetect.hpp>
#include <opencv2/xfeatures2d.hpp>
#include "Pattern.h"
#include "PatternTracker.h"
using namespace cv;
using namespace std;
class PatternDetector : public QObject
{
Q_OBJECT
// 省略構(gòu)造和析構(gòu)方法
...
public:
void train(const Mat& img, Mat& featureImg);
bool findPatternFromScene(const Mat& sceneImg);
bool computePose(const CameraIntrinsic& intrinsic);
// 省略部分getter方法和私有方法
...
private:
// 特征檢測器
Ptr<FeatureDetector> m_detector;
// 描述符匹配器
Ptr<DescriptorMatcher> m_matcher;
// 模式跟蹤器
PatternTracker* m_patternTracker;
// 目標(biāo)模型模式數(shù)據(jù)(訓(xùn)練集)
Ptr<Pattern> m_pattern;
// 查詢集
Mat m_queryDescriptors;
// 單應(yīng)變換矩陣(緩存用)
Mat m_homography;
};
上面的聲明中,除了包含組合了訓(xùn)練集和查詢集的基礎(chǔ)數(shù)據(jù)外,還組合了OpenCV提供的工具特征檢測器和描述符匹配器,這兩個工具的具體使用我們放到后文講到對應(yīng)的處理流程時再詳細(xì)說明。而
上文所述的模式跟蹤器聲明如下:
#include <opencv2/core.hpp>
#include "Pattern.h"
#include "component/QCvCamera.h"
using namespace cv;
using namespace std;
class PatternTracker : public QObject
{
Q_OBJECT
// 省略構(gòu)造、析構(gòu)和gettter/setter方法
...
void draw2DContour(Mat& bgImg);
void draw3DCube(Mat& bgImg, const CameraIntrinsic& intrinsic);
private:
vector<Point2f> m_points2d;
Pose m_pose;
};
也就是提供了繪制目標(biāo)物體2D輪廓和3D姿態(tài)AR圖像的方法。需要特別說明的是,這里引用QCvCamera.h頭文件是為了使用我們在第五小節(jié)實現(xiàn)的標(biāo)定數(shù)據(jù)管理功能。
接下來我們就分別來詳細(xì)看看如何使用這些工具類實現(xiàn)訓(xùn)練和檢測階段所需要的各類功能。
訓(xùn)練階段
首先簡單講解一下我們對幾個關(guān)鍵組件,也就是特征檢測器和描述符匹配器類型的選擇:
m_detector = Ptr<FeatureDetector>(xfeatures2d::SURF::create());
m_matcher = Ptr<DescriptorMatcher>(new FlannBasedMatcher());
可以看到我們選取了SURF特征及其對應(yīng)的特征檢測器,而匹配器我們選擇了基于FLANN的匹配器,以實現(xiàn)快速近似臨近搜索。有關(guān)SURF特征值及FLANN的具體原理和特性,限于篇幅就不在這里展開說明了,有興趣的話可以參考文末的參考鏈接[1]、[2]和[3]。
而使用這兩個組件進行訓(xùn)練的過程實現(xiàn)如下:
void PatternDetector::train(const Mat& img, Mat& featureImg)
{
if (!img.empty())
{
featureImg = img.clone();
m_pattern = generatePattern(img);
// train matcher
m_matcher->clear();
m_matcher->add(m_pattern->descriptors());
m_matcher->train();
// generate retMat
for (size_t i = 0; i < m_pattern->keypoints().size(); i++)
{
cv::putText(featureImg, "+", m_pattern->keypoints()[i].pt, cv::FONT_HERSHEY_PLAIN, 1, cv::Scalar(0, 0, 255));
}
}
}
也就是分為了這么幾個步驟:
- 從訓(xùn)練圖像中生成模式數(shù)據(jù)(generatePattern)
- 使用模式數(shù)據(jù)中提取好的描述符訓(xùn)練匹配器
- 將提取得到的關(guān)鍵點繪制在訓(xùn)練圖像上供展示
這其中最關(guān)鍵的生成模式數(shù)據(jù)方法實現(xiàn)如下:
Ptr<Pattern> PatternDetector::generatePattern(const Mat& img)
{
// 轉(zhuǎn)化灰度圖像
Mat grayImg;
if (img.type() == CV_8UC1)
{
grayImg = img.clone();
}
else
{
cvtColor(img, grayImg, cv::COLOR_BGR2GRAY);
}
// 提取訓(xùn)練集特征
std::vector<cv::KeyPoint> keypoints;
cv::Mat descriptors;
extractFeature(img, keypoints, descriptors);
// 計算模型邊界
vector<Point2f> points2d = vector<Point2f>(4);
vector<Point3f> points3d = vector<Point3f>(4);
const float w = img.cols;
const float h = img.rows;
const float maxSize = std::max(w, h);
const float unitW = w / maxSize;
const float unitH = h / maxSize;
points2d[0] = Point2f(0, 0);
points2d[1] = Point2f(w, 0);
points2d[2] = Point2f(w, h);
points2d[3] = Point2f(0, h);
points3d[0] = Point3f(-unitW, unitH, 0);
points3d[1] = Point3f(unitW, unitH, 0);
points3d[2] = Point3f(unitW, -unitH, 0);
points3d[3] = Point3f(-unitW, -unitH, 0);
// 生成模式數(shù)據(jù)對象
cv::Size imgSize = Size(img.cols, img.rows);
cv::Mat origImg = img.clone();
return Ptr<Pattern>(new Pattern(imgSize, origImg,
keypoints, descriptors,
points2d, points3d));
}
這其中涉及到的主要步驟在上面的注釋中已經(jīng)寫了,就不再贅述了。需要說明的是,因為我們的實際場景是對書本封面的建模,所以這里計算模型邊界的過程很簡單,就是講書本封面這個平面的2D和3D坐標(biāo)計算出來就可以了,而對于3D坐標(biāo)系,我們將坐標(biāo)原點設(shè)置在了書本封面的中心點,比例尺則簡單做了一下歸一化計算。
上面的步驟中涉及到的提取特征過程放在了另一個方法extractFeature中實現(xiàn),具體如下:
void PatternDetector::extractFeature(const Mat& img, vector<KeyPoint>& keypoints, Mat& descriptors)
{
m_detector->detect(img, keypoints);
// 過濾重復(fù)的關(guān)鍵點,并僅保留特征最明顯的500個點
KeyPointsFilter::removeDuplicated(keypoints);
KeyPointsFilter::retainBest(keypoints, 500);
// 計算每個關(guān)鍵點的描述符
m_detector->compute(img, keypoints, descriptors);
}
可以看到OpenCV提供的檢測器工具除了提供基本的特征檢測和描述符計算功能外,還很提供了包括剔除重復(fù)點、設(shè)置保留個數(shù)在內(nèi)的便捷功能。
這樣整個訓(xùn)練階段的實現(xiàn)就完成了,接下來我們來看一看相對復(fù)雜一些的匹配階段。
匹配階段
如前文所述,整個匹配階段分為對目標(biāo)物體模式的匹配和場景中匹配到的模式姿態(tài)估計兩部分,接下來分別就這兩個步驟進行說明。
模式匹配
模式匹配過程通過下面的方法實現(xiàn):
bool PatternDetector::findPatternFromScene(const Mat& sceneImg)
{
if (m_pattern == NULL)
{
qWarning() << "No valid pattern!";
return false;
}
std::vector<cv::KeyPoint> keypoints;
// 提取查詢集特征
extractFeature(sceneImg, keypoints, m_queryDescriptors);
if (keypoints.size() >= m_pattern->keypoints().size())
{
vector<DMatch> matches;
// 計算匹配結(jié)果
getMatches(m_queryDescriptors, matches);
...
// 省略單應(yīng)計算內(nèi)容,后文詳述
}
else
{
return false;
}
}
在上面的實現(xiàn)中,我們首先使用上文介紹過的extractFeature方法提取場景中的特征形成查詢集,之后通過getMatches方法計算訓(xùn)練集與查詢集的匹配度。getMatches方法具體實現(xiàn)如下:
void PatternDetector::getMatches(const Mat& descriptors, vector<DMatch>& matches)
{
const float minRatio = 1.f / 1.5f;
vector<vector<DMatch>> knnMatches;
m_matcher->knnMatch(descriptors, knnMatches, 2);
for (size_t i = 0; i < knnMatches.size(); i++)
{
const DMatch& bestMatch = knnMatches[i][0];
const DMatch& betterMatch = knnMatches[i][1];
float distRatio = bestMatch.distance / betterMatch.distance;
if (distRatio < minRatio)
{
matches.push_back(bestMatch);
}
}
}
這里的實現(xiàn)我們采用了knn查找作為基本算法,并通過限制最佳匹配與次佳匹配比的閾值來剔除匹配中可能出現(xiàn)的離散值(false negative)。
模式姿態(tài)估計
在完成場景中目標(biāo)模式的匹配后,接下來需要計算模式在場景中的姿態(tài)。對此,首先我們要計算出訓(xùn)練集和查詢集中關(guān)鍵點集的單應(yīng)性,實現(xiàn)如下:
// 計算單應(yīng)性矩陣并細(xì)化
bool homographyFound = refineMatchesWithHomography(keypoints, matches, m_homography);
if (homographyFound)
{
refineHomography(sceneImg);
vector<Point2f> trackerPt2D;
perspectiveTransform(m_pattern->points2d(), trackerPt2D, m_homography);
m_patternTracker->setPoints2D(trackerPt2D);
return true;
}
else
{
return false;
}
其中,refineMatchesWithHomography方法實現(xiàn)如下:
bool PatternDetector::refineMatchesWithHomography(const vector<KeyPoint>& queryKeypoints, vector<DMatch>& matches, Mat& homography)
{
const int minMatchesAllowed = 8;
if (matches.size() < minMatchesAllowed)
{
return false;
}
vector<Point2f> srcPts(matches.size());
vector<Point2f> dstPts(matches.size());
for (size_t i = 0; i < matches.size(); i++)
{
srcPts[i] = m_pattern->keypoints()[matches[i].trainIdx].pt;
dstPts[i] = queryKeypoints[matches[i].queryIdx].pt;
}
vector<unsigned char> inliersMask(srcPts.size());
homography = cv::findHomography(srcPts, dstPts, CV_FM_RANSAC, 3, inliersMask);
vector<DMatch> inliers;
for (size_t i = 0; i < inliersMask.size(); i++)
{
if (inliersMask[i])
inliers.push_back(matches[i]);
}
matches.swap(inliers);
return matches.size() >= minMatchesAllowed;
}
也就是從匹配結(jié)果中分別取出訓(xùn)練集和查詢集的點id,并通過OpenCV的findHomography方法計算出單應(yīng)性矩陣。而計算出的單應(yīng)性矩陣,可以通過下面的方法進行進一步的細(xì)化:
void PatternDetector::refineHomography(const Mat& sceneImg)
{
Mat warppedImg;
warpPerspective(sceneImg, warppedImg,
m_homography, m_pattern->size(),
WARP_INVERSE_MAP | INTER_CUBIC);
vector<KeyPoint> warpedKeypoints;
vector<DMatch> refinedMatches;
extractFeature(warppedImg, warpedKeypoints, m_queryDescriptors);
getMatches(m_queryDescriptors, refinedMatches);
Mat refinedHomography;
bool homographyFound = refineMatchesWithHomography(warpedKeypoints,
refinedMatches,
refinedHomography);
if (homographyFound)
{
m_homography = m_homography * refinedHomography;
}
}
這里的基本原理是:利用計算得到的單應(yīng)性矩陣,將場景中匹配到的目標(biāo)模式進行反向透視投影,并與模式模型中的特征進行比對,從而實現(xiàn)單應(yīng)矩陣的細(xì)化。
上面的實現(xiàn)中,反向透視投影的計算通過OpenCV提供的warpPerspective方法完成。
完成了單應(yīng)矩陣的計算和細(xì)化后,我們就可以通過OpenCV的perspectiveTransform方法計算目標(biāo)模式在場景中對應(yīng)的邊界點位置,并計算結(jié)果提供給模式跟蹤器了。
最后,模式的姿態(tài)估計方法實現(xiàn)如下:
bool PatternDetector::computePose(const CameraIntrinsic& intrinsic)
{
if (m_pattern == NULL)
{
qWarning() << "No valid pattern!";
return false;
}
if (m_patternTracker->ponits2d().empty())
{
qWarning() << "Pattern tracker 2D perspective not valid!";
return false;
}
Mat rotation, translation;
if (!solvePnP(m_pattern->points3d(), m_patternTracker->ponits2d(),
intrinsic.cameraMat, intrinsic.distortCoeff,
rotation, translation))
{
qWarning() << "Failed solving PnP!";
return false;
}
Pose pose(rotation, translation);
m_patternTracker->setPose(pose);
return true;
}
簡而言之,就是使用上面計算的,場景中的邊界點(ponits2d),借助OpenCV提供的solvePnP方法,計算出目標(biāo)模式在場景中的旋轉(zhuǎn)(rotation)和平移(translation),并將結(jié)果存儲到模式跟蹤器中。
從上面的實現(xiàn)中可以看到,我們將相機的內(nèi)參(CameraIntrinsic)作為入?yún)魅肓诉M來參與了solvePnP的計算。有關(guān)相機內(nèi)參的獲取,可以參考第七小節(jié)的相關(guān)說明。
而模式跟蹤器得到這些計算好的結(jié)果后,只需要借助OpenCV和Qt提供的各類繪制功能將結(jié)果展現(xiàn)在圖像上就可以了,例如下面的繪制3D方塊的方法:
void PatternTracker::draw3DCube(Mat& bgImg, const CameraIntrinsic& intrinsic)
{
vector<Point3d> vispts3d;
vector<Point2d> vispts2d;
// axis
vispts3d.push_back(Point3d(0, 0, 0));
vispts3d.push_back(Point3d(1, 0, 0));
vispts3d.push_back(Point3d(0, 1, 0));
vispts3d.push_back(Point3d(0, 0, 1));
// cube
double cubeSize = 0.5;
vispts3d.push_back(Point3d(-cubeSize, -cubeSize, cubeSize));
vispts3d.push_back(Point3d(cubeSize, -cubeSize, cubeSize));
vispts3d.push_back(Point3d(cubeSize, cubeSize, cubeSize));
vispts3d.push_back(Point3d(-cubeSize, cubeSize, cubeSize));
vispts3d.push_back(Point3d(-cubeSize, -cubeSize, 0));
vispts3d.push_back(Point3d(cubeSize, -cubeSize, 0));
vispts3d.push_back(Point3d(cubeSize, cubeSize, 0));
vispts3d.push_back(Point3d(-cubeSize, cubeSize, 0));
// project 3D->2D
projectPoints(vispts3d, m_pose.rotation, m_pose.translation,
intrinsic.cameraMat, intrinsic.distortCoeff, vispts2d);
// draw cube
vector<Point> face{vispts2d[5], vispts2d[4], vispts2d[7],
vispts2d[11], vispts2d[10], vispts2d[9]};
vector<vector<Point>> faces{face};
fillPoly(bgImg, faces, Scalar(200, 128, 128, 100));
Scalar cubeColor = Scalar(200, 200, 200);
for (int i = 4; i <= 10; i++)
{
if (i <= 7)
{
cv::line(bgImg, vispts2d[i], vispts2d[i + 4], cubeColor, 3);
}
if (i != 7)
{
cv::line(bgImg, vispts2d[i], vispts2d[i + 1], cubeColor, 3);
}
}
cv::line(bgImg, vispts2d[7], vispts2d[4], cubeColor, 3);
cv::line(bgImg, vispts2d[11], vispts2d[8], cubeColor, 3);
// draw axis
cv::line(bgImg, vispts2d[0], vispts2d[1], Scalar(255, 0, 0), 3);
cv::line(bgImg, vispts2d[0], vispts2d[2], Scalar(0, 255, 0), 3);
cv::line(bgImg, vispts2d[0], vispts2d[3], Scalar(0, 0, 255), 3);
}
因為上面的計算結(jié)果大多使用OpenCV提供的數(shù)據(jù)結(jié)構(gòu)類存儲,因此這里為方便使用這些數(shù)據(jù),也使用了OpenCV提供的繪制工具進行繪制。之后在界面上展示時,使用我們前面幾講中實現(xiàn)好的轉(zhuǎn)換工具轉(zhuǎn)換為Qt支持的圖像格式即可。
至此我們的各類核心功能就實現(xiàn)完成了,接下來我們來實現(xiàn)操作界面將這些功能整合起來。
實現(xiàn)操作界面
依照慣例,首先我們實現(xiàn)連接視頻流控件和具體功能的濾波器類:
void QCvMatchResultFilter::execFilter(const Mat& inMat, Mat& outMat)
{
outMat = inMat.clone();
if (inMat.empty() || m_detector == NULL)
{
return;
}
else
{
Pose pose;
if (m_detector->findPatternFromScene(inMat))
{
const QCvCamera* camera = QCvMatFilter::camera();
if (camera != NULL && camera->isIntrinsicValid() &&
m_detector->computePose(camera->intrinsic()))
{
//draw 3d
m_detector->tracker()->draw3DCube(outMat, camera->intrinsic());
}
else
{
//draw 2d
m_detector->tracker()->draw2DContour(outMat);
}
}
}
}
經(jīng)過上面工具類的封裝,這里的實現(xiàn)就相對比較單純了。首先通過模式跟蹤器的模式匹配入口findPatternFromScene方法檢測目標(biāo)模式是否在場景中,然后判斷是否已加載了相機內(nèi)參數(shù)據(jù),如果加載了就繪制3D姿態(tài)方塊,否則只繪制2D輪廓。
實現(xiàn)了濾波器后,我們最后來著手實現(xiàn)最終的圖形界面,UI設(shè)計如下:

除了左側(cè)的展示區(qū)外,還包含了加入相機內(nèi)參數(shù)據(jù)的按鈕、加載訓(xùn)練圖片和訓(xùn)練按鈕,以及開始匹配跟蹤的按鈕。
各個按鈕的詳細(xì)實現(xiàn)限于篇幅就不詳細(xì)貼了,這里只說明一下比較關(guān)鍵的幾個實現(xiàn)步驟:
- 相機內(nèi)參數(shù)據(jù)讀取:只需要獲取內(nèi)參文件的文件名,然后調(diào)用QCvCamView封裝好的updateCalibrarion方法即可
- 訓(xùn)練模式模型:調(diào)用模式檢測器的train方法即可
- 開始匹配跟蹤:初始化視頻流控件時添加上面實現(xiàn)好的濾波器,在點擊開始按鈕時開啟視頻流即可
測試跟蹤效果
最終實現(xiàn)完成的測試效果如下:
-
加載訓(xùn)練圖片并訓(xùn)練模式模型,結(jié)果如下:
book_train.png
其中紅色的十字即為訓(xùn)練時檢測到的特征點。
-
在沒有加載相機內(nèi)參時,匹配跟蹤效果如下:
book2D.png -
加載了相機內(nèi)參后,匹配跟蹤結(jié)果如下:
book_track.png
那么關(guān)于這個相對復(fù)雜的無標(biāo)記AR范例就先講解到這里。
>>本篇參考代碼
>>返回系列索引
參考鏈接
[1] SURF Wikipedia
[2] FLANN官網(wǎng)
[3] OpenCV Flann匹配教程
[4] 圖像單應(yīng)性Wikipedia

