C++ 實現運動目標的追蹤
一、介紹
本次實驗將使用利用 OpenCV 來實現對視頻中動態物體的追蹤。
涉及的知識點
- C++ 語言基礎
- g++ 的使用
- 圖像基礎
- OpenCV 在圖像及視頻中的應用
- Meanshift 和 Camshift 算法
實驗效果
本次實驗要實現的效果是追蹤太陽系中運動的行星(圖中選擇了淺綠顏色軌道上的土星,可以看到追蹤的目標被紅色的橢圓圈住):
二、環境搭建
進行本節的實驗時,您需要先完成 C++實現太陽系行星運行系統 的相關實驗,才能進行下面的相關學習。
2.1 創建視頻文件
在實驗樓環境中暫時還不支持連接用戶計算機的攝像頭進行實時取景,我們首先需要創建實驗前的視頻文件。
首先安裝屏幕錄製工具:
sudo apt-get update && sudo apt-get install gtk-recordmydesktop
安裝完成後,我們能夠在應用程序菜單中找到:
運行之前完成的太陽系行星系統程序 ./solarsystem
並使用 RecordMyDesktop
錄製程序運行畫面(10~30s 即可),並保存到 ~/Code/camshift
路徑下,命名爲 video
:
時間足夠後停止錄製便能得到一個.ogv
結尾的視頻文件video.ogv
:
2.2 圖像基礎
OpenCV 是一個開源的跨平臺計算機視覺庫,與 OpenGL 所提供的圖像繪製不同,OpenCV 實現了圖像處理和計算機視覺方面的很多通用的算法。在學習 OpenCV 之前,我們需要先了解一下圖像、視頻在計算機中的一些基本概念。
首先,我們要理解圖片在計算機中的表示方式:圖像在顯示過程中以連續變化的形式而存在,但在計算機中,有兩種常見的存儲圖片的方式:一種是矢量圖,一種則是像素圖。
矢量圖,也稱爲面向對象的圖像或繪圖圖像,在數學上定義爲一系列由線連接的點。矢量文件中的圖形元素稱爲對象。每個對象都是一個自成一體的實體,它具有顏色、形狀、輪廓、大小和屏幕位置等屬性。
而更常見的則是像素圖,比如常說的一張圖片的尺寸爲1024*768,這就意味着這張圖片水平方向上有1024個像素,垂直方向上有768個像素。
像素就是表示像素圖的基本單位,通常情況下一個像素由三原色(紅綠藍)混合而成。由於計算機的本質是對數字的識別,一般情況下我們把一種原色按亮度的不同從 0~255 進行表示,換句話說,對於原色紅來說,0表示最暗,呈現黑色,255表示最亮,呈現純紅色。
這樣,一個像素就可以表示爲一個三元組(B,G,R)
,比如白色可以表示爲(255,255,255)
,黑色則爲(0,0,0)
,這時我們也稱這幅圖像是 RGB 顏色空間中的一副圖像,R、G、B 分別成爲這幅圖像的三個通道,除了 RGB 顏色空間外,還有很多其他的顏色空間,如 HSV、YCrCb 等等。
像素是表示像素圖的基本單位,而圖像則是表示視頻的基本單位。一個視頻由一系列圖像組成,在視頻中我們稱其中的圖像爲幀。而通常我們所說的視頻幀率,意味着這個視頻每秒鐘包含多少幀圖像。比如幀率爲 25,那麼這個視頻每秒鐘就會播放25幀圖像。
1秒鐘共有1000毫秒,因此如果幀率爲 rate
那麼每一幀圖像之間的時間間隔爲 1000/rate
。
2.3 圖像顏色直方圖
顏色直方圖是描述圖像的一種工具,它和普通的直方圖類似,只是顏色直方圖需是根據某一幅圖片計算而來。
如果一副圖片是 RGB 顏色空間,那麼我們可以統計 R 通道中,顏色值爲 0~255 這 256 種顏色出現的次數,這邊能獲得一個長度爲 256 的數組(顏色概率查找表),我們再將所有的值同時除以這幅圖像中像素的總數,將這之後所得的數列轉化爲直方圖,其結果就是顏色直方圖。
2.4 直方圖反向投影
人們已經證明,在 RGB 顏色空間中,對光照亮度變化較爲敏感,爲了減少此變化對跟蹤效果的影響,就需要對直方圖進行反向投影。這一共分爲三個步驟:
- 首先將圖像從RGB空間轉換到HSV空間。
- 然後獲取其中的 H 顏色通道的直方圖。
- 將圖像中每個像素的值用顏色概率查找表中對應的概率進行替換,就得到了顏色概率分佈圖。
這個過程就叫反向投影,顏色概率分佈圖是一個灰度圖像。
2.5 OpenCV 初步使用
首先安裝 OpenCV:
sudo apt-get install libopencv-dev
我們已經熟悉了 C++的基本語法,幾乎在寫過的每一個程序中都有使用到 #include <iostream>
和 using namespace std;
或者 std::cout
。OpenCV 也有它自己的命名空間。
使用 OpenCV,只需要包含這一個頭文件:
#include <opencv2/opencv.hpp>
我們可以使用
using namespace cv;
來啓用 OpenCV 的名稱空間,也可以在 OpenCV 提供的 API 前使用 cv::
來使用它提供的接口。
由於我們是初次接觸 OpenCV,對 OpenCV 所提供的接口還不熟悉,所以我們推薦先使用 cv::
前綴來進行接下來的學習。
我們先寫一個程序來讀取之前錄製的視頻:
//
// main.cpp
//
#include <opencv2/opencv.hpp> // OpenCV 頭文件
int main() {
// 創建一個視頻捕獲對象
// OpenCV 提供了一個 VideoCapture 對象,它屏蔽了
// 從文件讀取視頻流和從攝像頭讀取攝像頭的差異,當構造
// 函數參數爲文件路徑時,會從文件讀取視頻流;當構造函
// 數參數爲設備編號時(第幾個攝像頭, 通常只有一個攝像
// 頭時爲0),會從攝像頭處讀取視頻流。
cv::VideoCapture video("video.ogv"); // 讀取文件
// cv::VideoCapture video(0); // 使用攝像頭
// 捕獲畫面的容器,OpenCV 中的 Mat 對象
// OpenCV 中最關鍵的 Mat 類,Mat 是 Matrix(矩陣)
// 的縮寫,OpenCV 中延續了像素圖的概念,用矩陣來描述
// 由像素構成的圖像。
cv::Mat frame;
while(true) {
// 將 video 中的內容寫入到 frame 中,
// 這裏 >> 運算符是經過 OpenCV 重載的
video >> frame;
// 當沒有幀可繼續讀取時,退出循環
if(frame.empty()) break;
// 顯示當前幀
cv::imshow("test", frame);
// 錄製視頻幀率爲 15, 等待 1000/15 保證視頻播放流暢。
// waitKey(int delay) 是 OpenCV 提供的一個等待函數,
// 當運行到這個函數時會阻塞 delay 毫秒的時間來等待鍵盤輸入
int key = cv::waitKey(1000/15);
// 當按鍵爲 ESC 時,退出循環
if (key == 27) break;
}
// 釋放申請的相關內存
cv::destroyAllWindows();
video.release();
return 0;
}
將這個文件和 video.ogv
同樣置於 /home/shiyanlou/Code/camshift/
下,使用 g++ 編譯 main.cpp
:
g++ main.cpp `pkg-config opencv --libs --cflags opencv` -o main
運行,可以看到成功播放視頻:
./main
提示
你可能注意到運行後,命令行會提示:
libdc1394 error: Failed to initialize libdc1394
這是 OpenCV 的一個 Bug,雖然它並不影像我們程序的運行,但是如果你有強迫症,那麼下面這條命令可以解決這個問題:
sudo ln /dev/null /dev/raw1394
三、Meanshift 和 Camshift 算法
本小節主要介紹的內容爲:Meanshift 和 Camshift 算法。
3.1 Meanshift
Meanshift 和 Camshift 算法是進行目標追蹤的兩個經典算法,Camshift 是基於 Meanshift 的,他們的數學解釋都很複雜,但想法卻非常的簡單。所以我們略去那些數學事實,先介紹 Meanshift 算法。
假設屏幕上有一堆紅色的點集,這時候藍色的圓圈(窗口)必須要移動到點最密集的地方(或者點數最多的地方):
如圖所示,把藍色的圓圈標記爲 C1
,藍色的矩形爲圓心標記爲 C1_o
。但這個圓的質心卻爲 C1_r
,標記爲藍色的實心圓。
當C1_o
和C1_r
不重合時,將圓 C1
移動到圓心位於 C1_r
的位置,如此反覆。最終會停留在密度最高的圓 C2
上。
而處理圖像來說,我們通常使用圖像的反向投影直方圖。當要追蹤的目標移動時,顯然這個移動過程可以被反向投影直方圖反應。所以 Meanshift 算法最終會將我們選定的窗口移動到運動目標的位置(收斂,算法結束)。
3.2 Camshift
經過之前的描述,我們可以看到 Meanshift 算法總是對一個固定的窗口大小進行追蹤,這是不符合我們的需求的,因爲在一個視頻中,目標物體並不一定是很定大小的。
所以 Camshift 就是爲了改進這個問題而產生的。這一點從 Camshift 的全稱(Continuously Adaptive Meanshift)也能夠看出來。
它的基本想法是:首先應用 Meanshift 算法,一旦 Meanshift 的結果收斂後,Camshift 會更新窗口的大小,並計算一個帶方向的橢圓來匹配這個窗口,然後將這個橢圓作爲新的窗口應用 Meanshift 算法,如此迭代,便實現了 Camshift。
OpenCV 提供了 Camshift 算法的通用接口:
RotatedRect CamShift(InputArray probImage, Rect& window, TermCriteria criteria)
其中第一個參數 probImage 爲目標直方圖的反向投影,第二個參數 window 爲執行 Camshift 算法的搜索窗口,第三個參數爲算法結束的條件。
四、實現
理解了 Camshift 算法的基本思想之後我們就可分析實現這個代碼主要分爲幾個步驟了:
- 設置選擇追蹤目標的鼠標回調事件;
- 從視頻流中讀取圖像;
- 實現 Camshift 過程;
下面我們繼續修改 main.cpp
中的代碼:
4.1 選擇追蹤目標區域的鼠標回調函數
與 OpenGL 不同,在 OpenCV 中,對鼠標的回調函數指定了五個參數,其中前三個是我們最需要的:通過 event
的值我們可以獲取這次回調鼠標發生的具體事件,如左鍵被按下(CV_EVENT_LBUTTONDOWN
)、左鍵被擡起(CV_EVENT_LBUTTONUP
) 等。
bool selectObject = false; // 用於標記是否有選取目標
int trackObject = 0; // 1 表示有追蹤對象 0 表示無追蹤對象 -1 表示追蹤對象尚未計算 Camshift 所需的屬性
cv::Rect selection; // 保存鼠標選擇的區域
cv::Mat image; // 用於緩存讀取到的視頻幀
// OpenCV 對所註冊的鼠標回調函數定義爲:
// void onMouse(int event, int x, int y, int flag, void *param)
// 其中第四個參數 flag 爲 event 下的附加狀態,param 是用戶傳入的參數,我們都不需要使用
// 故不填寫其參數名
void onMouse( int event, int x, int y, int, void* ) {
static cv::Point origin;
if(selectObject) {
// 確定鼠標選定區域的左上角座標以及區域的長和寬
selection.x = MIN(x, origin.x);
selection.y = MIN(y, origin.y);
selection.width = std::abs(x - origin.x);
selection.height = std::abs(y - origin.y);
// & 運算符被 cv::Rect 重載
// 表示兩個區域取交集, 主要目的是爲了處理當鼠標在選擇區域時移除畫面外
selection &= cv::Rect(0, 0, image.cols, image.rows);
}
switch(event) {
// 處理鼠標左鍵被按下
case CV_EVENT_LBUTTONDOWN:
origin = cv::Point(x, y);
selection = cv::Rect(x, y, 0, 0);
selectObject = true;
break;
// 處理鼠標左鍵被擡起
case CV_EVENT_LBUTTONUP:
selectObject = false;
if( selection.width > 0 && selection.height > 0 )
trackObject = -1; // 追蹤的目標還未計算 Camshift 所需要的屬性
break;
}
}
4.2 從視頻流中讀取圖像
我們已經在之前實現了讀取視頻流的基本結構,下面我們進一步細化:
int main() {
cv::VideoCapture video("video.ogv");
cv::namedWindow("CamShift at Shiyanlou");
// 1. 註冊鼠標事件的回調函數, 第三個參數是用戶提供給回調函數的,也就是回調函數中最後的 param 參數
cv::setMouseCallback("CamShift at Shiyanlou", onMouse, NULL);
cv::Mat frame; // 接收來自 video 視頻流中的圖像幀
// 2. 從視頻流中讀取圖像
while(true) {
video >> frame;
if(frame.empty()) break;
// 將frame 中的圖像寫入全局變量 image 作爲進行 Camshift 的緩存
frame.copyTo(image);
// 如果正在選擇追蹤目標,則畫出選擇框
if( selectObject && selection.width > 0 && selection.height > 0 ) {
cv::Mat roi(image, selection);
bitwise_not(roi, roi); // 對選擇的區域圖像反色
}
imshow("CamShift at Shiyanlou", image);
int key = cv::waitKey(1000/15.0);
if(key == 27) break;
}
// 釋放申請的相關內存
cv::destroyAllWindows();
video.release();
return 0;
}
提示
ROI(Region of Interest),在圖像處理中,被處理的圖像以方框、圓、橢圓、不規則多邊形等方式勾勒出需要處理的區域,稱爲感興趣區域,ROI。
4.3 實現 Camshift 過程
計算追蹤目標的反向投影直方圖爲需要先使用 cvtColor
函數,這個函數可以將 RGB 顏色空間的原始圖像轉換到 HSV 顏色空間。計算直方圖必須在選擇初始目標之後,因此:
int main() {
cv::VideoCapture video("video.ogv");
cv::namedWindow("CamShift at Shiyanlou");
cv::setMouseCallback("CamShift at Shiyanlou", onMouse, NULL);
cv::Mat frame;
cv::Mat hsv, hue, mask, hist, backproj;
cv::Rect trackWindow; // 追蹤到的窗口
int hsize = 16; // 計算直方圖所必備的內容
float hranges[] = {0,180}; // 計算直方圖所必備的內容
const float* phranges = hranges; // 計算直方圖所必備的內容
while(true) {
video >> frame;
if(frame.empty()) break;
frame.copyTo(image);
// 轉換到 HSV 空間
cv::cvtColor(image, hsv, cv::COLOR_BGR2HSV);
// 當有目標時開始處理
if(trackObject) {
// 只處理像素值爲H:0~180,S:30~256,V:10~256之間的部分,過濾掉其他的部分並複製給 mask
cv::inRange(hsv, cv::Scalar(0, 30, 10), cv::Scalar(180, 256, 10), mask);
// 下面三句將 hsv 圖像中的 H 通道分離出來
int ch[] = {0, 0};
hue.create(hsv.size(), hsv.depth());
cv::mixChannels(&hsv, 1, &hue, 1, ch, 1);
// 如果需要追蹤的物體還沒有進行屬性提取,則對選擇的目標中的圖像屬性提取
if( trackObject < 0 ) {
// 設置 H 通道和 mask 圖像的 ROI
cv::Mat roi(hue, selection), maskroi(mask, selection);
// 計算 ROI所在區域的直方圖
calcHist(&roi, 1, 0, maskroi, hist, 1, &hsize, &phranges);
// 將直方圖歸一
normalize(hist, hist, 0, 255, CV_MINMAX);
// 設置追蹤的窗口
trackWindow = selection;
// 標記追蹤的目標已經計算過直方圖屬性
trackObject = 1;
}
// 將直方圖進行反向投影
calcBackProject(&hue, 1, 0, hist, backproj, &phranges);
// 取公共部分
backproj &= mask;
// 調用 Camshift 算法的接口
cv::RotatedRect trackBox = CamShift(backproj, trackWindow, cv::TermCriteria( CV_TERMCRIT_EPS | CV_TERMCRIT_ITER, 10, 1 ));
// 處理追蹤面積過小的情況
if( trackWindow.area() <= 1 ) {
int cols = backproj.cols, rows = backproj.rows, r = (MIN(cols, rows) + 5)/6;
trackWindow = cv::Rect(trackWindow.x - r, trackWindow.y - r,
trackWindow.x + r, trackWindow.y + r) & cv::Rect(0, 0, cols, rows);
}
// 繪製追蹤區域
ellipse( image, trackBox, cv::Scalar(0,0,255), 3, CV_AA );
}
if( selectObject && selection.width > 0 && selection.height > 0 ) {
cv::Mat roi(image, selection);
bitwise_not(roi, roi);
}
imshow("CamShift at Shiyanlou", image);
int key = cv::waitKey(1000/15.0);
if(key == 27) break;
}
cv::destroyAllWindows();
video.release();
return 0;
}
五、總結本節實現的代碼
下面的代碼實現僅供參考:
#include <opencv2/opencv.hpp>
bool selectObject = false;
int trackObject = 0;
cv::Rect selection;
cv::Mat image;
void onMouse( int event, int x, int y, int, void* ) {
static cv::Point origin;
if(selectObject) {
selection.x = MIN(x, origin.x);
selection.y = MIN(y, origin.y);
selection.width = std::abs(x - origin.x);
selection.height = std::abs(y - origin.y);
selection &= cv::Rect(0, 0, image.cols, image.rows);
}
switch(event) {
case CV_EVENT_LBUTTONDOWN:
origin = cv::Point(x, y);
selection = cv::Rect(x, y, 0, 0);
selectObject = true;
break;
case CV_EVENT_LBUTTONUP:
selectObject = false;
if( selection.width > 0 && selection.height > 0 )
trackObject = -1;
break;
}
}
int main( int argc, const char** argv )
{
cv::VideoCapture video("video.ogv");
cv::namedWindow( "CamShift at Shiyanlou" );
cv::setMouseCallback( "CamShift at Shiyanlou", onMouse, 0 );
cv::Mat frame, hsv, hue, mask, hist, backproj;
cv::Rect trackWindow;
int hsize = 16;
float hranges[] = {0,180};
const float* phranges = hranges;
while(true) {
video >> frame;
if( frame.empty() )
break;
frame.copyTo(image);
cv::cvtColor(image, hsv, cv::COLOR_BGR2HSV);
if( trackObject ) {
cv::inRange(hsv, cv::Scalar(0, 30, 10), cv::Scalar(180, 256, 256), mask);
int ch[] = {0, 0};
hue.create(hsv.size(), hsv.depth());
cv::mixChannels(&hsv, 1, &hue, 1, ch, 1);
if( trackObject < 0 ) {
cv::Mat roi(hue, selection), maskroi(mask, selection);
calcHist(&roi, 1, 0, maskroi, hist, 1, &hsize, &phranges);
normalize(hist, hist, 0, 255, CV_MINMAX);
trackWindow = selection;
trackObject = 1;
}
calcBackProject(&hue, 1, 0, hist, backproj, &phranges);
backproj &= mask;
cv::RotatedRect trackBox = CamShift(backproj, trackWindow, cv::TermCriteria( CV_TERMCRIT_EPS | CV_TERMCRIT_ITER, 10, 1 ));
if( trackWindow.area() <= 1 ) {
int cols = backproj.cols, rows = backproj.rows, r = (MIN(cols, rows) + 5)/6;
trackWindow = cv::Rect(trackWindow.x - r, trackWindow.y - r,
trackWindow.x + r, trackWindow.y + r) &
cv::Rect(0, 0, cols, rows);
}
ellipse( image, trackBox, cv::Scalar(0,0,255), 3, CV_AA );
}
if( selectObject && selection.width > 0 && selection.height > 0 ) {
cv::Mat roi(image, selection);
bitwise_not(roi, roi);
}
imshow( "CamShift at Shiyanlou", image );
char c = (char)cv::waitKey(1000/15.0);
if( c == 27 )
break;
}
cv::destroyAllWindows();
video.release();
return 0;
}
六、結果
重新編譯 main.cpp
:
g++ main.cpp `pkg-config opencv --libs --cflags opencv` -o main
運行:
./main
在視頻中用鼠標拖拽選擇一個要追蹤的物體,即可看到追蹤的效果:
圖中選擇了淺綠顏色軌道上的土星,可以看到追蹤時的窗口呈現紅色的橢圓狀。
七、參考資料
- OpenCV 官方教程. http://docs.opencv.org/2.4/.
- 學習 OpenCV. http://shop.oreilly.com/product/0636920044765.do
- Gary, Bradsky. Computer Vision Face Tracking for Use in a Perceptual User Interface. http://opencv.jp/opencv-1.0.0_org/docs/papers/camshift.pdf