在深度學習在圖像識別任務上大放異彩之前,詞袋模型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模型在某些應用場景還是很有潛力的。