在深度學習在影像識別任務上大放異彩之前,詞袋模型Bag of Features一直是各類比賽的首選方法。首先我們先來回顧一下PASCAL VOC競賽歷年來的最好成績來介紹物體分類演算法的發展。
從上表我們可以發現,在2012年之前,詞袋模型是VOC競賽分類演算法的基本框架,幾乎所有演算法都是基於詞袋模型的,可以這麼說,詞袋模型在影像分類中統治了很多年。雖然現在深度學習在影像識別任務中的效果更勝一籌,但是我們也不要忘記在10年前,Bag of Features的框架曾經也引領過一個時代。那這篇文章就是要重溫BoF這個經典框架,並從實踐上看看它在影像物體分類中效果到底如何。
Bag of Features理論淺談
其實Bag of Features 是Bag of Words在影像識別領域的延伸,Bag of Words最初產生於自然處理領域,通過建模文件中單詞出現的頻率來對文件進行描述與表達。
詞包模型還有一個起源就是紋理檢測(texture recognition),有些影像是由一些重複的基礎紋理元素圖案所組成,所以我們也可以將這些圖案做成頻率直方圖,形成詞包模型。
詞包模型於2004年首次被引入計算機視覺領域,由此開始大量集中於詞包模型的研究,在各類影像識別比賽中也大放異彩,逐漸形成了由下面4部分組成的標準物體分類框架:
- 底層特徵提取
- 特徵編碼
- 特徵匯聚
- 使用SVM等分類器進行分類
2005年第一屆PASCAL VOC競賽 資料庫包含了4類物體:摩托車、自行車、人、汽車,訓練集加驗證集一共684張影像,測試集包含689張影像,資料規模相對較少。從方法上說,採用“興趣點-SIFT地城特徵描述-向量量化編碼直方圖-支援向量機”得到了最好的物體分類效能,這種方法也就是我們今天所講的Bag of Features方法。
為什麼要用BOF模型描述影像?
SIFT特徵雖然也能描述一幅影像,但是每個SIFT向量都是128維的,而且一幅影像通常都包含成百上千個SIFT向量,在進行相似度計算時,這個計算量是非常大的,更重要的是,每一幅圖提取到的SIFT特徵點數目都不一樣,所以我們要將這些特徵量化(比如生成統計直方圖),這樣才能進行相似度計算。通行的做法是用聚類演算法對這些向量資料進行聚類,然後用聚類中的一個簇代表BOF中的一個視覺詞,將同一幅影像的SIFT向量對映到視覺詞序列生成碼本,這樣每一幅影像只用一個碼本向量來描述,這樣計算相似度時效率就大大提高了。
搭建Bag-of-Features的步驟:
- 特徵提取(在這裡我們使用很穩定的SIFT運算元)
- K-means聚類。將第一步提取到的特徵向量及進行聚類,得出N個類心。
- 量化特徵,形成詞袋
- 統計每一類別的視覺單詞出現頻率,形成視覺單詞直方圖
5.訓練SVM分類器
實踐篇
要編碼實現BoF,其實只需嚴格按照上述講的步驟進行就可以了,而且OpenCV給我們準備了關於BoF的相關API,所以實現起來的難度進一步降低。現在我們要思考的的是,怎麼把opencv所提供的的這些API重新整合在一起,來構成一個分類能力還不錯的影像分類器。
今天還是以票據分類任務為例子講解BoF模型。
先觀察資料集,我們已經分出了訓練集和測試集
每一類圖片放在不同的資料夾下面,資料夾的名字就是這個類別的label
這是我們要分類的12種票據
一、特徵提取
對底層特徵,我們選擇的還是最為經典的SIFT特徵,用opencv做SIFT特徵提取只需要用到幾個API就可以了。
我們還是老套路,先準備好一些提取SIFT特徵的資料結構和描述SIFT的一些類。
//create Sift feature point extracter
static Ptr<FeatureDetector> detector1(new SiftFeatureDetector());
//create Sift descriptor extractor
static Ptr<DescriptorExtractor> extractor(new SiftDescriptorExtractor);
//To store the keypoints that will be extracted by SIFT
vector<KeyPoint> keypoints;
//To store the SIFT descriptor of current image
Mat descriptor;
//To store all the descriptors that are extracted from all the images
Mat featuresUnclustered;
//The SIFT feature extractor and descriptor
SiftDescriptorExtractor detector;
然後我們對我們的訓練樣本進行遍歷,對每一類的訓練圖片進行SIFT特徵提取,並將提取出來的特徵存進featuresUnclustered裡,用於接下來的k-means聚類。
/*第一步,計算目錄下所有訓練圖片的features,放進featuresUnclustered*/
printf("step1:sift features extracting...\n");
for (int num = 1; num < MAX_TRAINING_NUM; num++)
{
sprintf(filename, ".\\training\\%d\\train.txt", num);
//首先先檢查一下該類資料夾下有沒有用於train的特徵檔案,有的話就不需要提取特徵點了
if (_access(filename, 0) == -1)
{
printf("extracting features %d class\n", num);
for (int i = 1; i <= MAX_TRAINING_NUM; i++)
{
sprintf(filename, ".\\training\\%d\\%d.jpg", num, i);
//create the file name of an image
//open the file
input = imread(filename, CV_LOAD_IMAGE_GRAYSCALE); //Load as grayscale
if (input.empty())
{
break;
}
//resize:reduce keypoints numbers to accerlate
resize(input, input, Size(), 0.5, 0.5);
//detect feature points
detector.detect(input, keypoints);
printf("keypoints:%d\n", keypoints.size());
//compute the descriptors for each keypoint
detector.compute(input, keypoints, descriptor);
//save descriptor to file
char train_name[32] = { 0 };
sprintf(train_name, ".\\training\\%d\\train.txt", num);
WriteFeatures2File(train_name, descriptor);
//put the all feature descriptors in a single Mat object
featuresUnclustered.push_back(descriptor);
//train_features[num][i].push_back(descriptor);
}
}
else
{
Mat descriptor;
load_features_from_file(filename, descriptor);
featuresUnclustered.push_back(descriptor);
}
}
需要注意的是,我在特徵提取階段把每一類提取到的特徵都寫進了txt檔案中,只是為了以後增加類別時,我們不再需要再次遍歷提取特徵,而只需讀入我們原先存有特徵向量的txt檔案就可以了,這將大大加快訓練速度。
static int load_features_from_file(const string& file_name,Mat& features)
{
FILE* fp = fopen(file_name.c_str(), "r");
if (fp == NULL)
{
printf("fail to open %s\n", file_name.c_str());
return -1;
}
printf("loading file %s\n", file_name.c_str());
vector<float> inData;
while (!feof(fp))
{
float tmp;
fscanf(fp, "%f", &tmp);
inData.push_back(tmp);
}
//vector to Mat
int mat_cols = 128;
int mat_rows = inData.size() / 128;
features = Mat::zeros(mat_rows, mat_cols, CV_32FC1);
int count = 0;
for (int i = 0; i < mat_rows; i++)
{
for (int j = 0; j < mat_cols; j++)
{
features.at<float>(i, j) = inData[count++];
}
}
return 0;
}
static int WriteFeatures2File(const string& file_name,const Mat& features)
{
FILE* fp = fopen(file_name.c_str(), "a+");
if (fp == NULL)
{
printf("fail to open %s\n", file_name.c_str());
return -1;
}
for (int i = 0; i < features.rows; i++)
{
for (int j = 0; j < features.cols; j++)
{
int data = features.at<float>(i, j);
fprintf(fp, "%d\t", data);
}
fprintf(fp,"\n");
}
fclose(fp);
return 0;
}
二、特徵聚類
我們將上一步得到的訓練集的所有特徵進行聚類,聚類初始化方式選擇means++,類心數量選擇1000。這裡需要說明一下,聚類的類心數量是一個超引數,是一個需要反覆調整的引數,如果類心過少,那就表示BOF模型的視覺單詞數目很少,即該模型的表達能力很低,很可能在分類任務中不能區分出每一類物體(有點像Deep Learning中說的欠擬合);但類心過多,就會造成視覺單詞過於分散,很可能導致模型在泛化效果不佳(過擬合)。所以,選擇一個合理的類心數目很重要。
/*第二步,定義好聚類的中心數目,進行聚類,並得到詞典dictionary*/
printf("step2:clusting...\n");
int dictionarySize = 1000; //類心數目,即codebook num
//define Term Criteria
TermCriteria tc(CV_TERMCRIT_ITER, 1000, 0.001); //最大迭代1000次
//retries number
int retries = 1;
//necessary flags
int flags = KMEANS_PP_CENTERS; //kmeans++初始化
//Create the BoW (or BoF) trainer
BOWKMeansTrainer bowTrainer(dictionarySize, tc, retries, flags);
//cluster the feature vectors
Mat dictionary = bowTrainer.cluster(featuresUnclustered); //聚類
//store the vocabulary
FileStorage fs(".\\dictionary1.yml", FileStorage::WRITE); //將聚類後的結果寫入檔案
fs << "vocabulary" << dictionary;
fs.release();
cout << "Saving BoW dictionary\n";
這個聚類時間還是比較長的,大概需要20分鐘。
三、量化特徵,形成詞典直方圖
/*第三步,計算每個類別的詞典直方圖*/
printf("step3:generating dic histogram...\n");
//create a nearest neighbor matcher
Ptr<DescriptorMatcher> matcher(new FlannBasedMatcher);
//create Sift feature point extracter
Ptr<FeatureDetector> detector1(new SiftFeatureDetector());
//create Sift descriptor extractor
Ptr<DescriptorExtractor> extractor(new SiftDescriptorExtractor);
//create BoF (or BoW) descriptor extractor
BOWImgDescriptorExtractor bowDE(extractor, matcher);
//Set the dictionary with the vocabulary we created in the first step
bowDE.setVocabulary(dictionary);
cout << "extracting histograms in the form of BOW for each image " << endl;
Mat labels(0, 1, CV_32FC1);
Mat trainingData(0, dictionarySize, CV_32FC1);
int k = 0;
vector<KeyPoint> keypoint1;
Mat bowDescriptor1;
Mat img2;
//extracting histogram in the form of bow for each image
for (int num = 1; num <= MAX_TRAINING_NUM; num++)
{
for (int i = 1; i <= MAX_TRAINING_NUM; i++)
{
sprintf(filename, ".\\training\\%d\\%d.jpg", num,i);
//sprintf(filename, "%d%s%d%s", j, " (", i, ").jpg");
img2 = cvLoadImage(filename, 0);
if (img2.empty())
{
break;
}
resize(img2, img2, Size(), 0.5, 0.5);
detector.detect(img2, keypoint1);
bowDE.compute(img2, keypoint1, bowDescriptor1);
trainingData.push_back(bowDescriptor1);
labels.push_back((float)num);
}
}
四、訓練SVM
我們使用SVM作為分類器進行訓練,訓練好的資料以檔案的形式儲存下來,以後預測時直接讀檔案就可以還原模型了。
/*第四步,訓練SVM得到分類模型*/
printf("SVM training...\n");
CvSVMParams params;
params.kernel_type = CvSVM::RBF;
params.svm_type = CvSVM::C_SVC;
params.gamma = 0.50625000000000009;
params.C = 312.50000000000000;
params.term_crit = cvTermCriteria(CV_TERMCRIT_ITER, 1000, 0.000001);
CvSVM svm;
bool res = svm.train(trainingData, labels, cv::Mat(), cv::Mat(), params);
svm.save(".\\svm-classifier1.xml");
delete[] filename;
printf("bag-of-features training done!\n");
六、預測
首先我們需要載入我們訓練好的資料(svm-classifier1.xml和dictionary1.yml)
//字典檔案、SVM訓練檔案讀入記憶體
void TrainingDataInit()
{
FileStorage fs(".\\dictionary1.yml", FileStorage::READ);
Mat dictionary;
fs["vocabulary"] >> dictionary;
fs.release();
bowDE.setVocabulary(dictionary);
svm.load(".\\svm-classifier1.xml");
}
然後再寫一個預測函式,用SVM實現線上分類。
//實現發票影像的分類,返回值即預測的分類結果
int invoice_classify(Mat& img)
{
Mat img2 = img.clone();
resize(img2, img2, Size(), 0.5, 0.5);
cvtColor(img2, img2, CV_RGB2GRAY);
SiftDescriptorExtractor detector;
vector<KeyPoint> keypoint2;
Mat bowDescriptor2;
Mat img_keypoints_2;
detector.detect(img2, keypoint2);
bowDE.compute(img2, keypoint2, bowDescriptor2);
int it = svm.predict(bowDescriptor2);
return it;
}
現在開始測試,寫一個測試函式,讀入測試集進行預測,計算其準確率
void TestClassify()
{
int total_count = 0;
int right_count = 0;
string tag;
for (int num = 1; num < 30; num++)
{
for (int i = 1; i < 30; i++)
{
char path[128] = { 0 };
sprintf(path, ".\\test\\%d\\%d.jpg", num, i);
Mat img = imread(path,0);
if (img.empty())
{
continue;
}
int type = invoice_classify(img);
if (type == -1)
{
printf("reject image %s\n", path);
continue;
}
total_count++;
if (num == type)
{
tag = "CORRECT";
right_count++;
}
else
{
tag = "WRRONG";
}
printf("[%s] label: %d predict: %d, %s\n", path, num, type, tag.c_str());
}
}
printf("total image:%d acc:%.2f\n", total_count,(float)right_count/total_count);
}
完整的流程如下:先建立BoF模型,然後更新訓練資料,將訓練引數儲存至檔案。當線上預測時,先將訓練引數讀入記憶體,再利用模型對圖片進行分類。模擬測試程式碼如下:
#include "bof.h"
int main()
{
BuildDictionary(12,6);
TrainingDataInit();
TestClassify();
return 0;
}
訓練:
預測結果:
可以看出,BoF模型在這種簡單分類任務的效果還可以,更重要的是我每一類只用了6張訓練樣本(小樣本集)就可以有這個效果了,如果是採用深度學習做分類,這個估計不行了。
再優化
總體而言,2005年提出來的Bag-of-Features的分類效果並不是很好,尤其是一些比較像的類別,它的區分能力還是不足的。那能不能可以做哪些優化進一步提升分類準確率呢?我覺得可以從以下幾點入手試一試:
- kmeans類心數目調整
- 增加每一類訓練圖片的數目
- 可以加入顏色特徵,比如顏色直方圖。個人認為這個措施會有較大效果,因為SIFT特徵點提取時,圖片已經是灰度圖了,所以顏色這個很重要的特徵並沒有用上。
- 加入一些全域性特徵做特徵融合,因為SIFT是區域性特徵,所以如果有一些全域性特徵作為補充的話,效果會有比較好的提升。
- 空間域金字塔思路(CVPR2006)
完整的程式碼可以在我的github上獲取。
總結
在今天看來,曾經引領過一個時代的Bag-of-Features在普通分類任務上並沒有取得讓人滿意的效果,但我估計它在場景分類或影像檢索上還是會比較出色(比如地標)。現在已經全面進入深度學習的時代了,BoF的概念越來越淡出人們的視野,但BoF模型在某些應用場景還是很有潛力的。