雜記:Visual Tracking + Object Proposals + Features + Coding

這篇文章主要是做一個筆記式的講解最近關注的一些東西,包括基於核相關濾波器的目標跟蹤算法EdgeBoxes目標假設算法、Color Names特徵描述方法,最後再講講我對如何進行高效率編程的理解。

    上面左圖是一幀圖像並給出了初始目標位置,右圖紅色框部分對應目標樣本,然後我們對樣本在兩個不同方向上分別做循環移位,產生了右圖中顯示的其餘樣本。現在我們想擬合一個迴歸器,希望真樣本的迴歸值最大,然後其它衍生樣本的迴歸值隨着循環移位長度的增加而遞減,如下圖所示:

        上面右圖顯示的是期望的迴歸值,一般是一個高斯分佈。
        試想一下,有了這麼一個迴歸器(擬合函數),當輸入目標樣本圖像時,它給出最高響應值,當輸入一個由目標樣本循環移位得到的衍生樣本時,它給出一個相對較低的響應值,並且響應值隨着移位量的增加而減小。那麼這個迴歸器就可以用來做目標檢測了,從而實現基於檢測的跟蹤。

        上圖給出了線性峯度迴歸模型及其閉式解,其中大寫X表示有所有樣本組成的一個大矩陣。lambda是控制結構風險與經驗風險比例的一個調節因子,lambda等於0時,該峯度迴歸器退化爲一個線性迴歸器,即常說的最小二乘擬合。
        在求解該回歸器的時候,需要對超大矩陣X和Y進行一系列運算,特別是矩陣求逆運算,非常耗時。X的每一行,即一個小x,它對應了一個圖像樣本,當樣本採用HOG等高維特徵表示時,x的元素個數非常多。
        所以從計算上來說,直接求解迴歸器不現實。但是循環矩陣理論爲這一問題提供瞭解決方法。圖中所示循環矩陣C(x)是一個由1維向量x衍生出的,其每一行是上一行的一個循環位移。循環矩陣的一個重要性質就是其特徵值對應了xbar中各個元素,其中xbar表示x的離散傅里葉變換向量。而C(x)的特徵向量對應離散傅里葉變換矩陣F的列向量。這裏:xbar = F * x,即將向量x與矩陣F相乘就得到了x的傅里葉變換,關於離散傅里葉變換理論,大家可以查閱相關資料。
        當每個樣本x_i都是由基本樣本x經過循環移位得到時,大X就是一個循環矩陣:X=C(x)。在這種情況下,可以對迴歸器進行化簡:

        上圖是化簡結果,可以看出在循環樣本假設下,迴歸器可以在頻率域快速求解,所有衍生樣本x_i不再出現在迴歸器公式中,只需要知道基本樣本小x的離散傅里葉變換向量即可。然後帶核的峯度迴歸器也能快速求解,具體可以參見KCF論文。求解出迴歸器以後,理論上可以對每個樣本分別求取對應的迴歸值,選出迴歸值最高的樣本作爲檢測(跟蹤)結果。但是在循環樣本假設下,檢測過程可以同樣非常快,因爲我們只需評估一個樣本的迴歸值,然後其它樣本可以視爲該基本樣本的循環移位,對應的迴歸值可以通過傅里葉技巧得到。檢測部分的細節也請參考原文。

        上面是一組用EdgeBoxes在圖像中找到的目標假設,只畫出了得分最高的前7個框。EdgeBoxes先檢測圖像中的邊緣,然後將邊緣連接成輪廓,最後在圖像中尋找那些包含完整輪廓的個數較多的矩形窗作爲檢測輸出。可以看出該方法能發現圖像中的顯著目標,但是也傾向於尋找尺寸更大的框。
        這裏我們關注的不是該檢測方法本身,而是它所依賴的高效率、高可靠性的邊緣檢測方法:Fast Edge Detection Using Structured Forests (Structured Edge Detector) PAMI15
        結構隨機森林就是一個隨機森林分類器,但是其輸出的是結構信息(e.g.一個字符串,一個mask等)。隨機森林是由多個決策樹並聯得到的,下面講講這個基本的決策樹分類器Decision Forests. A Unified Framework for Classification, Regression, Density Estimation, Manifold Learning and Semi-Supervised Learning. Microsoft 2011

        一個決策樹由一系列節點組成,包括最頂端的根節點、最末端的葉子節點,以及中間層的分離節點,根節點本身也屬於分離節點。決策樹執行分類任務的過程是簡單明瞭的,如上面左圖所示,樣本輸入到根節點,然後每個節點不斷地重複着:問問題 -> 決定樣本是該到左邊還是右邊。
      決策樹是一顆二叉樹,樣本在樹的節點之間流動,最終到達葉子節點,一個葉子節點對應一個決策結果,不同的葉子節點可以給出相同的決策。

      比起使用決策樹,其訓練過程稍微複雜一點:準備了一組樣本集和標籤,如何確定每個節點的參數,使得最終能把樣本集正確地分離開。
      決策樹的訓練過程就是依次確定各個分離節點的過程。一個分離節點有一個分離函數,常用形式有樁函數(stump),和差值函數(difference function),其中樁函數從樣本向量中抽取一個維度(標量)然後用來和一個閾值tau做比較,來判斷樣本該去往左邊還是右邊,上圖中[ ]是指示函數,輸出0或1. 差值函數則從樣本中抽取兩個維度,然後作差並把差值和閾值做比較。
      分離節點的訓練過程就是確定函數參數的過程,爲了找到最佳的參數,需要定義一個目標函數,訓練過程應該使得目標函數取得最大或最小值。常用的目標函數是信息增益,它是在香農熵的基礎上定義的。熵是一個數據集純淨度的數值衡量——數據中類別數越多且各個類別比例越均衡,則熵越大,如果數據集只有一個類別,則熵最小。一個分離節點應該使得離開這個節點的兩個樣本子集的熵之和到達該該節點的訓練集的熵更小。在訓練的時候可以用窮搜索方式依次測試各個不同參數來確定最佳分離函數,當然實際中會用基於概率的隨機方法或梯度下降法來確定分離函數。
            上面介紹了決策樹的概念以及分離節點的訓練方法。依次訓練好各個分離節點就訓練好了一顆決策樹,依次訓練好若干個決策樹就得到了一個決策森林。決策森林的幾個要素:單顆樹的最大深度;樹的個數。決策樹訓練過程是在最大化信息增益,當信息增益達不到低於一個閾值時,應該終止對一個節點的繼續分割。在有些時候數據無論如何無法分離時,不能讓決策樹深度無限增長,所以要限制最大深度。一個決策森林由多顆樹組成,這些樹是並行的,每顆樹的訓練方式是類似的。
        試想一下,如果所有的數據和所有的節點都被同時用來訓練每一顆樹,則會導致訓練的每顆樹都是一樣的,這個森林就退化了。決策森林的推廣能力和魯棒性依賴於各個樹之間是decorrelated!即樹與樹之間相關度較低,越低越好。這就要求在訓練每顆樹的時候增加一些變數,常用的方法有兩個:1.隨機地從樣本集中採樣一個子集來訓練每顆樹;2.隨機地從所有特徵中採樣一個子集(確定一組特徵維度)來訓練不同的樹。文章說第二種方法往往效果更好。
        隨機森林的葉子節點代表什麼?一個樣本集輸入到一個決策森林,然後樣本在森林的各個節點之間流動,最終每個樣本到達一個節點。可以統計到達每個節點的各個類別的樣本個數,即確定到達這個節點的樣本集的概率分佈。在測試時,一個節點在每顆樹種到達一個節點,得到這個樣本屬於各個類別的可能性(後驗概率),聯合所有的決策樹,算出一個平均概率,最後把樣本劃分到概率最大的那一類,從而完成對樣本的分類任務。
        介紹完了隨機森林分類器,再回過頭來看這篇文章:Fast Edge Detection Using Structured Forests (Structured Edge Detector) PAMI15

        經典的邊緣檢測方法比如Canny算子,一般是通過計算像素梯度的方式完成的,P'Dollar大神的這篇文章另闢蹊徑,用分類器來檢測邊緣。其問題模型如上圖所示:我們有很多圖片,還有對應的mask,一個mask是一張二值圖像,其中非0像素對應原圖中的邊緣像素(edge)。爲了形式化這個問題,我們把圖像劃分成許多個32×32的小塊,每個圖像小塊都有一個對應的mask,這裏只取圖像小塊正中間16×16大小的區域對應的mask,這個mask就是分類器需要學習和預測的標籤。所以這是一個結構學習問題。
        思考如何在隨機森林框架下着手這個結構學習問題,我們關注如何訓練一個基本的split function。首先,split function應該對樣本集怎麼分割才能是最好的?如上圖所示,我們希望相似的mask被分到一起。那問題來了,怎樣定義mask之間的相似性?這是文中提出的思路:

      我們把結構標籤(mask)進行聚類,把結構學習問題轉化爲多類分類問題。爲了定義y(即一個mask)之間的相似性,我們考慮y中任意兩個點之間是否屬於一個連通區域,如果是則得到一個1,如果不是,則得到一個0,y中有16的平方個點,所以一共有32640個點對,得到一個32640長度的二值向量,假設這個向量空間就是Z。
      通過定義過渡空間Z,將結構元素y映射到歐式空間,從而可以用歐式距離或者漢明距離等距離度量來定義兩個z之間的相似性。但是z還是太長了,計算效率低,所以文中提出隨機地從z中抽取256個維度,再對這個256長度向量進行PCA降維,最終的向量長度不超過5.
      有了距離度量方法,就可以對所有的mask進行聚類,從而可以知道怎樣對mask分割達到期望的效果,即確定了訓練每個分離節點的目標函數。然後分離函數的形式一般沒什麼變化,就是stump或者difference function,訓練時優化分離函數的參數即可。其它的方面已經和一般的隨機森林訓練無異,不再多言。
      再講下我們的樣本表示。訓練樣本時32×32的圖像小塊,樣本標籤是16×16的mask(通過複雜的映射變成1個類標籤,scalar)。但是圖像小塊肯定還是要轉換成樣本向量的,我們先對圖像小塊提取HOG+LAB+Magnitude共13維特徵(P'Dollar: Channel features),將訓練樣本圖像轉化爲32×32×13的密集特徵表示,設爲x。然後用5×5的平滑濾波器濾波,再對濾波結果降採樣,得到16×16×13大小的密集特徵,設爲x1;再用8×8的平滑濾波器對x進行濾波,將濾波結果resize到5×5×13大小,設爲x2,計算x2中任意兩個像素(x2有25個像素)之間的向量距離,得到25*24/2=300個值。最終一共有300 + 16*16*13 = 7228個特徵。也就是說一個32×32的圖像小塊,對應一個7228維的特徵向量。
      訓練過程講完了,再講下邊緣檢測:

      檢測採用滑動窗口的方法,拿一個32×32的窗口在圖像當中滑動,提取對應的圖像特徵,輸入到隨機森林分類器,預測窗口正中間16×16子窗口部分的edge mask。由於窗口的重疊,圖像中每個像素會得到很多次預測結果,最終算平均值作爲這個像素位置是否是edge的一個概率。


      上面是一些檢測結果。值得一說的是,作者在PASCAL VOC數據集上訓練的邊緣檢測器顯示出很強的推廣能力,可以作爲通用邊緣檢測算法。大概是因爲,雖然圖像可以千差萬別,但是二維圖像中邊緣的樣式是非常有限的。
三、Color Names顏色特徵 Learning Color Names for Real-World Applications TransIP09 (Color Names)
      顏色特徵往往被忽視,近年來,主流的目標檢測和跟蹤算法都使用HOG特徵——確切地說,是Felzenswalb在DPM中用的fhog,原始HOG特徵有128維,fhog是模仿PCA-HOG得到的,只有31維,效果得到了各種算法和應用的檢驗。HOG是一種有效的形狀描述子,而顏色特徵卻往往因爲對光照、噪聲等變化的敏感而不被人看好。
      但是一個魯棒的顏色描述卻改變了這一現狀,這始於09年在IEEEE Transaction on Image Processing的文章,如今color names已廣泛用於目標識別、跟蹤等領域,更常見的做法是將HOG特徵與color names合在一起用。
      寫得有點多有點乏了,貼兩張PPT大家自己看吧,有興趣的還是閱讀以下原文。



---------------------------------------------------------------
      以上文字和圖片均摘我自己的PPT,其中有些拼圖是從文獻中摘抄的。理解不到位或者說錯的地方請讀者批評指正。下面講點更接地氣的東西:編程。
      我只講基於C++的視覺計算,其它編程語言,以及界面編程等都不在我的關心範圍之內。從出發點來說,我希望我的程序是跨平臺的,能移植到任何有C++編譯器的平臺上,所以我儘量使用開源庫,不使用系統函數。
      目前對GPU編程還不熟,所以主要是基於CPU的方法。
---------------------------------------------------------------
1. OpenCV & Matlab
     做視覺研究的人一般都用過Matlab,通用性和跨平臺支持實在太好了,各種函數支持,且效率非常高。而OpenCV作爲一個C++開源庫,已經被應用到很多商業軟件上。但是有些人可能還不知道,OpenCV和Matlab的內存安排方式其實迥然不同:Matlab是列優先,OpenCV是行優先。內存的安排方式決定了數據讀取方式,而高效率的函數必定會適應其矩陣內存安排方式。通過實踐,我覺得列優先方式適合並行,e.g.Matlab各個通道是分離的,可以對每個通道同時進行操作;行優先的方式在實現上更簡單,OpenCV中各個通道數據是交錯的,用一個指針遍歷整個矩陣時每到一個位置可以一次讀取R/G/B值。Matlab要想讀取圖像中一個位置的RGB值時,最好用3個指針分別指向R平面,G平面,B平面,然後分別讀取。
2. 基於OpenCV的C++視覺計算
      一個計算程序的計算量其實主要是由算法決定的,如果算法很好了,那麼計算量很難通過編程的手段來消減,所以進一步提速的關鍵是讓計算速度更快。視覺計算的特點是需要訪問大量的數據,所以一個影響很大的因素就是數據訪問速度。
以索引一個RGB圖像爲例,三個階段:
(1) mat.at<>(); 
(2) (float*)mat.data+y*mat.cols*mat.channels()+x*mat.cols*mat.channels+c
(3) float *p = (float*)mat.data; &B = *p++, &G = *p++, &R = *p++;
      opencv提供了.at()函數來訪問矩陣的單個元素,但是它其實非常慢,最好的方法是自己用指針去定位各個元素。opencv是行優先,所以你指針定位時先要定位到相應行的首地址,然後再把指針移動到相應元素位置。但是指針跳跳跳去也是比較慢的,那麼最快的方式就是讓指針不要跳來跳去,讓它一步步走。上面寫的遍歷一張彩圖的最快的第3個階段就是說用一個指針在內存塊中逐個訪問,這是最快的方式了。
OpenCV使用建議
(1) cv::Mat提供大量的高效率基礎操作
(2) 基本函數如高斯模糊、積分圖像等操作可以適量使用
(3) 高級函數如HOG特徵和多種目標檢測、目標跟蹤算法等都儘量不用,自己實現效率更高
      個人以爲,opencv並不適合做高質量的計算程序,特別是它的許多高級功能(比如FFT變換)其實效率很低,而且由於使用頻率低,官方對這些代碼的穩定性並沒有充分驗證。我之前曾用opencv2.4.11生成漢寧窗,結果生成1*33大小的窗口時,前一半值是對的,後一半值是錯的,生成1*32大小的窗口就沒問題。
      Intel奔騰系列以後的CPU以及近來所有的AMD的CPU都支持SIMD指令集,這種指令集允許同時進行4個float的加減乘除運算,相當於計算速度提升3倍,而且這部分計算是CPU自帶功能,根本不會像開多線程一樣增加CPU的佔用率。
      SIMD指令集中一個基本變量類型是__m128,它相當於float x[4],即4個浮點數的一個數列,但是要求這個數列的首地址是16字節對齊的,即
__m128 x;   <span style="color:#ff0000;">=</span>   float x[4]; + !(size_t(x)&15)
      OpenCV的函數大量使用了SIMD指令,所以OpenCV的矩陣數據在分配時都是要保證(矩陣每一行)首地址是16字節對齊的。開發者自己如何分配16字節對齊的數據?其實很簡單,其一是使用std庫自帶的函數_mm_malloc()函數,其二是自己在分配內存的時候多分配15個額外的字節的空間:char *buf = malloc(N+15); 那麼buf~(buf+15)當中肯定有一個地址是16字節對齊的(因爲指針其實是4字節的unsigned)。當然還有基於malloc函數的一步到位的內存分配方法,大家自己去查。
      如何使用SIMD指令集?我剛知道SIMD的妙處後開始大量使用它,但是寫的程序出現了大量的諸如_mm_add_ps()之類的複雜指令而不易閱讀,直到最近我才發現只要重載SIMD指令集的運算指令就可以將普通的函數編程一個基於SIMD指令集計算的函數:
namespace yu
{
	__m128  operator+ (const __m128 &x, const __m128 &y) {return _mm_add_ps(x, y);}

	__m128  operator- (const __m128 &x, const __m128 &y) {return _mm_sub_ps(x, y);}

	__m128  operator* (const __m128 &x, const __m128 &y) {return _mm_mul_ps(x, y);}

	__m128  operator/ (const __m128 &x, const __m128 &y) {return _mm_div_ps(x, y);}

	void  operator+= (__m128 &x, const __m128 &y) {x = x + y;}

	void  operator-= (__m128 &x, const __m128 &y) {x = x - y;}

	void  operator*= (__m128 &x, const __m128 &y) {x = x * y;}

	void  operator/= (__m128 &x, const __m128 &y) {x = x / y;}	

	__m128  operator+ (const __m128 &x, const float &y) {return _mm_add_ps(x, _mm_set_ps1(y));}

	__m128  operator- (const __m128 &x, const float &y) {return _mm_sub_ps(x, _mm_set_ps1(y));}

	__m128  operator* (const __m128 &x, const float &y) {return _mm_mul_ps(x, _mm_set_ps1(y));}

	__m128  operator/ (const __m128 &x, const float &y) {return _mm_div_ps(x, _mm_set_ps1(y));}

	void  operator+= (__m128 &x, const float &y) {x = x + y;}

	void  operator-= (__m128 &x, const float &y) {x = x - y;}

	void  operator*= (__m128 &x, const float &y) {x = x * y;}

	void  operator/= (__m128 &x, const float &y) {x = x / y;}

	__m128  inv(__m128 x) {return _mm_rcp_ps(x);}

	float  inv(float x) {return 1.f / x;}
};
    重載了操作符以後,就可以利用模板編程來定義函數,比如如下的實現兩個矩陣加法的函數:
namespace yu
{
	/// C = A + B, element-wise sum of two matrices.
	/// Parameter "A": input matrix, m-by-n-by-d.
	/// Parameter "B": input matrix, same size as A.
	/// Parameter "C": output matrix, same size as A.
	template<typename T>
	void  add_(Mat_<T> &A, Mat_<T> &B, Mat_<T> &C)
	{
		if(A.rows!=B.rows || A.cols!=B.cols || A.channs!=B.channs){
			printf("Check error in yu::add()!");
			throw("Check error in yu::add()!");
		}

		C.create(A.rows, A.cols, A.channs);

		T *pa = A.data;
		T *pb = B.data;
		T *pc = C.data;

		for(int k=A.rows*A.cols*A.channs; k--;)
			*(pc++) = *(pa++) + *(pb++);
	}
	void  add(Matf &A, Matf &B, Matf &C) {add_(A, B, C);}
	void  add(Matm &A, Matm &B, Matm &C) {add_(A, B, C);}
}
      我自己定義了Matf和Matm兩種不同的矩陣,它們在形式上是一樣的,唯一不同的是Matf的元素是float,而Matm的元素是__m128,具體定義如下:
namespace yu
{
	///----------------------------------------Part 1. matrix definition
	template<typename T>
	struct  Mat_
	{
		int rows, cols, channs; // size of the matrix
		T *data; // imitating openCV
		int *refcount; // reference count
		Mat_() // default construction
		{
			rows = cols = channs = 0;
			refcount = 0; data = 0;
		}
		Mat_(const Mat_ &p) // copy construction
		{
			rows = p.rows; cols = p.cols; channs = p.channs;
			data = p.data; refcount = p.refcount;
			if(refcount) (*refcount)++;
		}
		void operator = (const Mat_ &p)
		{
			dealloc();
			rows = p.rows; cols = p.cols; channs = p.channs;
			data = p.data; refcount = p.refcount;
			if(refcount) (*refcount)++;
		}
		~Mat_() 
		{
			dealloc();
			rows = cols = channs = 0;
		}
		void  alloc()
		{
			dealloc();
			if(rows && cols && channs){
				int total = rows*cols*channs*sizeof(T);
				char *buf = (char*)_mm_malloc(total+sizeof(int), 16);
				if(size_t(buf) & 15){
					printf("Allocation error in yu::Mat::alloc()!");
					throw("Allocation error in yu::Mat::alloc()!");
				}
				data = (T*)buf; refcount = (int*)(buf + total); *refcount = 1;
			}
		}
		void  dealloc()
		{
			if(refcount) (*refcount)--;
			if(data && refcount && !(*refcount)) _mm_free(data); // delete [] (char*)data;
			data = 0;  refcount = 0;		
		}
		void  create(int rows_, int cols_, int channs_)
		{
			bool flag = (rows_*cols_*channs_) != (rows*cols*channs);
			rows = rows_; cols = cols_; channs = channs_;
			if(flag)
				alloc();
		}
		void  copyTo(Mat_ &p)
		{
			p.create(rows, cols, channs);
			memcpy(p.data, data, rows*cols*channs*sizeof(T));
		}

		void  copyROI(Mat_ &p, int rect[4]) // rect[4]={x,y,width,height}
		{
			int x = rect[0], y = rect[1], w = rect[2], h = rect[3];
			if(x<0 || y<0 || w<1 || h<1 || x+w>cols || y+h>rows){
				printf("Check error in yuMat::copyROI()!");
				throw("Check error in yuMat::copyROI()!");
			}
			p.create(h, w, channs);
			if(!x && !y && w==cols && h==rows){
				memcpy(p.data, data, rows*cols*channs*sizeof(T));
				return;
			}
			int step1 = cols * channs, step2 = w * channs;
			float *pt1 = data + y*step1 + x*channs, *pt2 = p.data;
			while(h--){
				memcpy(pt2, pt1, step2*sizeof(T));
				pt1 += step1; pt2 += step2;
			}
		}
	};

	typedef Mat_<__m128> Matm;
	typedef Mat_<float> Matf;
}
      上面是我自己模仿OpenCV定義的矩陣類型,它使用了智能指針,所以我可以像以往基於OpenCV編程時那樣,只管申請和使用矩陣,不用擔心數據泄露問題。
4. 使用宏
      將重複的代碼定義爲宏,減少代碼行數,不易出錯,缺點是程序更難理解.

 
5. 寫程序的一些建議
      1.《C++ Primer》:程序的可閱讀性比程序的運行效率更加重要:變量命名、代碼分段、註釋、模塊化函數、模板編程;
      2. 要形成穩定的編程風格.


---------------------------------------------------------------
      最後,貼一個應用程序,LayeredKCF——我將KCF進行了拓展,在僅有估算平移能力的KCF的基礎上增加了一個一維的尺度濾波器和一個一維的旋轉濾波器,從而得到一個具有跟蹤平移+尺度+旋轉能力的跟蹤器。在我的資源頁下載:http://download.csdn.net/detail/yu_xianguo/9485839
      除了採用OpenCV來讀圖像以外,該程序未使用任何其它庫以及系統函數,基本上所有函數都是我自己寫的,大量使用了SSE指令集,沒有使用多線程和多核加速,僅僅用CPU計算,跟蹤速度非常快,有興趣的可以下載下來玩玩。
      我會繼續改進這個算法,主要是增加全場檢測能力,從而可以在丟失目標後重新捕獲。另跟蹤程序只用到了HOG特徵來表示目標,我會繼續爲其添加color names特徵描述。還有一點,代碼中用到的雙線性插值函數是自己寫的,效果勉強,但是當插值窗口比較大時,插值圖像的artificats就很明顯,主要對尺度跟蹤部分影響較大,我準備重寫這一塊,但是應該是以後有時間再說了。
    另外傅里葉變換這一塊是我自己寫的,也是計算量最多的地方,如果可以使用Intel MKL提供的傅里葉變換或者FFTW庫的傅里葉變換函數,應該可以把速度再提升一個量級。



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