移動端的視訊指紋實現
在上篇《移動端圖片相似度演算法選型》中,我們測試了感知雜湊、卷積神經網路、以及基於區域性不變特徵三種計算圖片相似度方式。發現基於區域性不變特徵做相似度計算準確度優於傳統感知雜湊演算法,對旋轉不變性的支援優於卷積神經網路。同時又詳細比較了用 SIFT 和 Hessian-Affine 做特徵點檢測的檢索效率差異,並且加上了“最小特徵點數”限制,最終用 “Hessian-Affine特徵點檢測+SIFT特徵點描述” 的方式,獲得了一個能兼顧抗干擾能力和檢索效率的有效檢索演算法。
本篇文章,我們將從工程側角度,進一步介紹如何將此演算法移植到端上,並且如何針對移動端做優化,實現端上視訊指紋的生成。整個工程基於 工程[1] 來實現,這裡我們主要分如下幾部分來介紹:
-
視訊抽幀:------------------------------------將視訊相似度問題轉換為圖片相似度問題
-
圖片特徵提取演算法移植到端上:----------解決依賴庫等問題
-
針對移動端的優化:-------------------------速度和包大小優化
-
基於Bloom Filter的檢索系統:-----------針對視訊的檢索效率測試
-
遇到的坑
1.視訊抽幀
我們將視訊提取多個幀,再提取這些幀的特徵點描述,來作為整個視訊的特徵資訊,這樣將視訊指紋問題,轉化為圖片特徵提取問題。
為了減少計算量,我們每個視訊最多提取10個關鍵幀,並且中間用皮爾遜相關係數排除掉相似的幀。
static float pearson_relative(unsigned char* inputX, unsigned char* inputY, int length) { //採用皮爾遜相關係數計算相關性 double totalX = 0.0, totalY = 0.0; double totalMul = 0.0, totalSqrtX = 0.0, totalSqrtY = 0.0; for(int i = 0; i < length; ++i) { totalX += inputX[i]; totalY += inputY[i]; } totalX /= length; totalY /= length; for(int i = 0; i < length; ++i) { totalMul += (inputX[i] - totalX) * (inputY[i] - totalY); totalSqrtX += (inputX[i] - totalX) * (inputX[i] - totalX); totalSqrtY += (inputY[i] - totalY) * (inputY[i] - totalY); } return totalMul / (sqrt(totalSqrtX) * sqrt(totalSqrtY)); }
當相關性大於0.9時,則認為兩個關鍵幀過於相似,只取其中一個。
2 圖片特徵提取演算法移植到端上
將視訊抽取關鍵幀以後,接下來就要對關鍵幀提取特徵,我們需要將 工程[1] 裡的圖片特徵提取演算法移植到端上。圖片特徵提取過程大致流程如下:
其中 特徵處理 對應的程式碼為:
檔案:
indexer/global_descriptors/gdindex.cc
函式:
void generate_point_indexed_descriptor(const FeatureSet* feature_set, const int verbose_level, vector<uint>& feat_assgns, vector<float>& feat_assgn_weights, vector < vector < float > >& feat_residuals);
Hash提取對應的程式碼為:
檔案:
bloom_filters/point_indexed/binarize_residuals.cc
函式:
void binarize_residual(const vector < vector < float > >& feat_residuals, vector<uint>& feat_residuals_binarized)
特徵點檢測和特徵點描述:
上方提到,我們經過測試決定採用 Hessian-Affine + SIFT 的方式,但是 工程[1] 只提供了 Linux 和 Mac 的可執行檔案,並沒有提供原始碼,因此我們需要尋找相關程式碼。
2.1 Hessian Affine + SIFT 特徵提取原始碼
經過網上尋找,我們找到 VISE[2] 工程,其中裡面的 detect_points
和 compute_descriptors
分別對應特徵點檢測和特徵點描述。
這裡面提供了 Hessian-Affine特徵點檢測和 SIFT特徵點描述。因此我們提取裡面相關程式碼,並且做適當改造。
首先是 特徵點檢測 :
檔案:
src/external/KMCode_relja/exec/detect_points/detect_points.cpp
函式:
void detect_points_hesaff(std::string jpg_filename, std::vector<ellipse> ®ions)
然後是 特徵點描述 :
檔案:
src/external/KMCode_relja/exec/compute_descriptors/compute_descriptors.cpp
函式:
void compute_descriptors_sift(std::string jpg_filename, std::vector<ellipse> ®ions, uint32_t& feat_count, float scale_multiplier, bool upright, float *&descs )
檢視兩個函式我們可以發現,兩個函式接收到入參 jpg_filename
後,都是讀取並解析圖片,因此這裡可以合併到外部。最終,結合 工程[1] 的Hash提取程式碼,我們的程式碼大致為:
DARY *image = new ImageContent(inputStr.c_str()); if(image == NULL || image->bValid == false) return -1; image->toGRAY(); image->char2float(); uint32_t numFeats; std::vector<ellipse> regions; float *descs; vector< CornerDescriptor* > descriptors; initPatchMask(PATCH_SIZE); KM_detect_points::detect_points_hesaff(image, regions, 500); KM_compute_descriptors::compute_descriptors_sift(image, regions, numFeats, 3.0, false, descs, descriptors); vector < uint > feat_assgns; vector <uint> result_vector = generate_point_indexed_descriptor(descriptors, numFeats, feat_assgns);
得到的 feat_assgns
和 result_vector
即最終產物。(這裡的 result_vector
是 binarize_residual
函式得到的結果,我們將 binarize_residual
函式合併到了 generate_point_indexed_descriptor
裡)
2.2 libjpeg、blas 的包依賴問題
移植過程中,我們還會遇到 jpeg
圖片解析庫和 blas
計算庫的依賴問題。
JPEG庫:
VISE[2]工程直接依賴計算機的 jpeg
庫,因此我們需要找一個端上可用的 jpeg
庫來做替代。這裡我們使用 libjpeg-turbo
[3] 這個工程,分別編譯IOS和Android可用的庫。
BLAS庫:
工程[1]依賴一個yael子工程,這個子工程依賴 BLAS 科學計算庫,因此我們還需要解決端上 BLAS 庫依賴問題。起初我們找到了一個適合端上使用的 OpenBLAS
[4] 工程,不過後面我們發現可以直接精簡掉 BLAS 庫,用一個函式來替代。這個我們在後面會再詳細介紹。
2.3 個別函式不同平臺的相容問題
工程[1]裡 common/yael_v260_modif/yael/vector.c
的 fvec_new
函式,需要處理不同平臺相容問題:
float *fvec_new (long n) { #if defined(__custom__linux__) float *ret = (float *) malloc (sizeof(*ret) * n); #elif defined __IPHONE_OS_VERSION_MIN_REQUIRED float *ret = (float *) malloc (sizeof(*ret) * n); #else float *ret = (float *) memalign (16, sizeof (*ret) * n); #endif if (!ret) { fprintf (stderr, "fvec_new %ld : out of memory\n", n); abort(); } return ret; }
3 針對移動端的優化
3.1 速度優化
3.1.1流程優化:優化讀寫檔案操作
原始流程裡,生成特徵點描述以後,會將特徵點儲存成 siftgeo 字尾的檔案,後面再讀取這檔案,進一步經過Hash提取,將一個視訊的多個幀儲存成一個檔案。這裡我們可以直接去掉IO操作,將資料儲存在記憶體。
3.1.2特徵點控制:每張圖片最多提取500個特徵點
我們發現,很多圖片經常能檢測到上千個特徵點(有的達到5000~6000),而特徵點的個數會直接影響到在端上的計算速度,因此我們需要控制檢測的特徵點個數。
我們在權衡計算速度和效果的情況下,選取500為最大的特徵點數。
通過修改 src/external/KMCode_relja/descriptor/CornerDetector.cpp
檔案的 harris_lap 函式即可控制檢測到的特徵點數。
控制特徵點後,在端上的計算速度可以加快數倍,我們測試的單張圖片(1700個特徵點左右)在手機上的計算時間可以從50~60秒減少到16秒左右。
3.1.3NEON指令優化
編譯的時候可以開啟NEON指令優化,加快端上的計算速度。具體方法可以搜尋網上教程,這裡不再贅述。
3.1.4多執行緒
一個視訊會提取多個幀,每個幀都需要進行圖片特徵提取,因此我們可以在任務粒度上開啟多個執行緒來加快整個視訊的特徵提取速度。我們採用8個執行緒,每個執行緒一次處理一個圖片幀。
3.1.5部分演算法優化
VISE[2]工程裡的 src/external/KMCode_relja/gauss_iir/gauss_iir.cpp
裡面有很多高斯模糊的卷積運算,實際執行我們會發現個別函式有時候有很多 0 值在參與運算,我們可以加快這部分運算。
同時裡面有非常多的這種運算:
rpix[r][c] = 0.030 * pix[r-3][c] + 0.105 * pix[r-2][c] + 0.222 * pix[r-1][c] + 0.286 * pix[r][c] + 0.222 * pix[r+1][c] + 0.105 * pix[r+2][c] + 0.030 * pix[r+3][c];
我們可以利用乘法結合律改造成:
rpix[r][c] = 0.030 * (pix[r-3][c] + pix[r+3][c]) + 0.105 * (pix[r-2][c] + pix[r+2][c]) + 0.222 * (pix[r-1][c] + pix[r+1][c]) + 0.286 * pix[r][c];
3.1.6 預置引數檔案改成原始碼
在 videosearch/indexer/global_descriptors/trained_parameters/
目錄下,有部分預訓練的引數是以二進位制檔案儲存,這裡我們為了減少IO操作,可以將我們用到的引數檔案修改成標頭檔案。 我們使用到的引數檔案有三個:
sift.pre_alpha.0.50.desc_covariance sift.pre_alpha.0.50.desc_eigenvectors sift.pre_alpha.0.50.pca.32.gmm.512
修改後長這樣:
3.2 包大小優化
3.2.1yael工程簡化:sgemm函式替代OpenBLAS庫
上面我們提到過,我們使用OpenBLAS[4]來作為端上的科學計算庫。然而實際上我們發現,我們有用到的這個計算庫裡的函式只有一個矩陣相乘的函式:sgemm。因此,我們自己實現了一個sgemm函式,用來替代整個 OpenBLAS,這樣可以減少包大小。
void sgemm(char *transa, char *transb, const int M, const int N, const int K, float alpha, const float* a, int lda, const float* b, int ldb, float beta, float* c, int ldc) { //定義陣列a,陣列b和陣列c的大小 const int area_a = M * K; const int area_b = N * K; const int area_c = M * N; //給轉置後的陣列分配大小 float input_a[M][K]; float input_b[K][N]; //如果a矩陣輸入'T'則轉置,否則保持不變 if(M == lda) { for(int i = 0; i < M; ++i) { for(int j = 0; j < K; ++j) input_a[i][j] = a[j * M + i]; } } else { int cnt = 0; for(int i = 0; i < M; ++i) for(int j = 0; j < K; ++j) input_a[i][j] = a[cnt++]; } //如果b矩陣輸入'T'則轉置,否則保持不變 if(K == ldb) { for(int i = 0; i < K; ++i) { for(int j = 0; j < N; ++j) input_b[i][j] = b[j * K + i]; } } else { int cnt = 0; for(int i = 0; i < K; ++i) for(int j = 0; j < N; ++j) input_b[i][j] = b[cnt++]; } float sum; for(int m = 0; m < M; ++m) for(int n = 0; n < N; ++n) { sum = 0; for(int k = 0; k < K; ++k) sum += input_a[m][k] * input_b[k][n]; const int index = n * M + m; c[index] = alpha * sum + beta * c[index]; } }
3.2.2刪除無用檔案
我們使用到了VISE[2]的特徵點檢測和特徵點描述,工程[1]的Hash生成,這兩個工程裡有較多我們並不需要的程式碼。因此我們從我們需要的函數出發,儘可能精簡地提取裡面的程式碼,刪掉無用程式碼,以減少我們的包大小。因為涉及到較多檔案,這裡不做詳細說明。
4 基於Bloom Filter的檢索系統
整個工程可以執行以後,我們需要針對視訊來做檢索效率測試。檢索系統主體是基於 工程[1] 裡的 videosearch/bloom_filters/
部分。
視訊指紋儲存、檢索原理大致如下:
視訊指紋庫是一個三維list,我們給它取名為 A。
儲存視訊指紋的時候:
假設有一個id為100的視訊,每個提取的關鍵幀都提取好了特徵點Hash(一個點對應兩個int,一個是0~到512的Hash函式id,一個是Hash值)。我們將 Frame 0 的第一個特徵點描述拿出來看,假設對應的 Hash函式id為2,雜湊值為 156324。那麼將這個特徵點儲存到視訊指紋庫的時候,用id為2的Hash函式,計算“156324”的Hash值,假設得到的值為1465879456,那麼找到 A[2][1465879456] 所對應的list,將“100”(視訊id)存入這個list。
在檢索某個視訊的時候:
假設該視訊的某一個特徵點表示也是 2 和 156324 ,那麼通過同樣的方式,可以找到 A[2][1465879456] 對應的list,這個時候假設list裡有2個值:100和3,即表示視訊id為100和3的這兩個視訊,有特徵點和當前檢索的特徵點匹配上,那麼我們為這兩個視訊加上一定的“分數”。最後遍歷完所檢索視訊的所有特徵點以後,即可以按照匹配到的分數,得到跟其他視訊的匹配程度。
在這裡,每個匹配到的特徵點,所加上的“分數”大小,利用TF-IDF(Term Frequency-Inverse Document Frequency, 詞頻-逆檔案頻率)來決定,基本思想就是一個特徵點在一個視訊中出現次數越多,同時在所有視訊中出現次數越少,則越能夠代表該視訊。
測試結果:
我們以959個視訊作為視訊庫(原本是1000個,手動剔除了部分重複或者相似視訊),取其中283個視訊作為待查詢視訊,最後得到的檢索效率結果:
召回率:0.978799 準確率:0.975352 F值(2PR/(P+R)):0.977
5 遇到的坑
PatchMask初始化問題
在測試中我們發現,同一個視訊,第一次獲取到的視訊指紋,總是和後面獲取到的不一致。最後排查發現,是 initPatchMask
初始化時機太晚導致。 src/external/KMCode_relja/descriptor/Corner.cpp
裡面有個全域性的 patch_mask
,而第一次使用到它的時候,還沒執行 initPatchMask
,導致第一次結果計算出來是錯誤的。後面再次執行的時候,因為已經初始化過,所以能得到正確的結果。最終我們將 initPatchMask
方法的執行時機調前,解決了這個問題。
6 小結
由於計算能力的差異,端上實現視訊指紋需要很好的解決計算速度與包大小問題,因此需要在計算量和效果之間做平衡。我們針對移動端做了各種優化,但是仍然有很大的空間可以探索。比如可以嘗試利用OpenCL加速運算、進一步精簡一些流程(如去掉特徵提取後的SVD奇異值分解)、在演算法方面,還可以考慮結合音訊資訊,來更完整的表達視訊資訊等等,歡迎一起交流探討。
參考
[1] https://github.com/andrefaraujo/videosearch
[2] https://github.com/ox-vgg/vise
[3] https://github.com/libjpeg-turbo/libjpeg-turbo