本文接着上一篇《自动车牌识别(ANPR)练习项目学习笔记2(基于opencv)》继续做笔记。
D. 车牌字符识别。
上一篇完成了车牌检测,接下来要进行车牌识别。OCR,就是光学字符识别。
在主函数中,最后一部分实现OCR.xml 的训练和识别。
OCR ocr("OCR.xml");
这句意思是实例化OCR类,通过调用它的构造函数?该函数是这样子的:
OCR::OCR(string trainFile){
DEBUG=false;
trained=false;
saveSegments=false;
charSize=20;
//Read file storage.
FileStorage fs;
fs.open("OCR.xml", FileStorage::READ);
Mat TrainingData;
Mat Classes;
fs["TrainingDataF15"] >> TrainingData;
fs["classes"] >> Classes;
train(TrainingData, Classes, 10);
}
实现功能:读取OCR.xml ;把数据装进 TrainingData 和 Classes. 最后训练。
train()函数定义:
void OCR::train(Mat TrainData, Mat classes, int nlayers){
Mat layers(1,3,CV_32SC1);
layers.at<int>(0)= TrainData.cols;
layers.at<int>(1)= nlayers;
layers.at<int>(2)= numCharacters;
ann.create(layers, CvANN_MLP::SIGMOID_SYM, 1, 1);
//Prepare trainClases
//Create a mat with n trained data by m classes
Mat trainClasses;
trainClasses.create( TrainData.rows, numCharacters, CV_32FC1 );
for( int i = 0; i < trainClasses.rows; i++ )
{
for( int k = 0; k < trainClasses.cols; k++ )
{
//If class of data i is same than a k class
if( k == classes.at<int>(i) )
trainClasses.at<float>(i,k) = 1;
else
trainClasses.at<float>(i,k) = 0;
}
}
Mat weights( 1, TrainData.rows, CV_32FC1, Scalar::all(1) );
//Learn classifier
ann.train( TrainData, trainClasses, weights );
trained=true;
}
神经网络分类器的训练先放一下,下一篇再写。
1. 先看OCR.xml 这是在trainOCR 这个project 中得到的。
trainOCR.cpp
#include <cv.h>
#include <highgui.h>
#include <cvaux.h>
#include "OCR.h"
#include <iostream>
#include <vector>
using namespace std;
using namespace cv;
//'0'图片有35张,‘1’图片有40张,...,'B'图片有30张...
const int numFilesChars[]={35, 40, 42, 41, 42, 33, 30, 31, 49, 44, 30, 24, 21, 20, 34, 9, 10, 3, 11, 3, 15, 4, 9, 12, 10, 21, 18, 8, 15, 7};
int main ( int argc, char** argv )
{
cout << "OpenCV Training OCR Automatic Number Plate Recognition\n";
cout << "\n";
char* path;
//Check if user specify image to process
if(argc >= 1 )
{
path= argv[1]; //这个路径里面有30个文件夹,每个文件夹以字符命名,即牌照上可能出现的字符。OCR:strCharacters[]里面的那些字符。
}else{
cout << "Usage:\n" << argv[0] << " <path to chars folders files> \n";
return 0;
}
Mat classes;
Mat trainingDataf5;
Mat trainingDataf10;
Mat trainingDataf15;
Mat trainingDataf20;
vector<int> trainingLabels;
OCR ocr;
for(int i=0; i< OCR::numCharacters; i++) //numCharacters=30
{
//图片数计数,比如numFilesChars[0]=35 表示“0”这个字符的图片总共有35张
int numFiles=numFilesChars[i];
for(int j=0; j< numFiles; j++){
//Character 0 file: 0, Character 0 file: 1
cout << "Character "<< OCR::strCharacters[i] << " file: " << j << "\n";
// path/0/0.jpg path/0/1.jpg path/0/2.jpg ... path/0/34.jpg
stringstream ss(stringstream::in | stringstream::out);
ss << path << OCR::strCharacters[i] << "/" << j << ".jpg";
Mat img=imread(ss.str(), 0); //读入待测字符图像的数据
//待测字符图像转换成5*5的小图像lowdata,读入该图像的水平直方图累积图、竖直直方图累积图、lowdata数据
Mat f5=ocr.features(img, 5);
Mat f10=ocr.features(img, 10);
Mat f15=ocr.features(img, 15);
Mat f20=ocr.features(img, 20);
//第一个for把同一个字符的图片数据都写进去,比如所有‘B’图片;第二个for把所有字符数据都写进去
trainingDataf5.push_back(f5);
trainingDataf10.push_back(f10);
trainingDataf15.push_back(f15);
trainingDataf20.push_back(f20);
trainingLabels.push_back(i);
}
}
trainingDataf5.convertTo(trainingDataf5, CV_32FC1);
trainingDataf10.convertTo(trainingDataf10, CV_32FC1);
trainingDataf15.convertTo(trainingDataf15, CV_32FC1);
trainingDataf20.convertTo(trainingDataf20, CV_32FC1);
Mat(trainingLabels).copyTo(classes);
FileStorage fs("OCR.xml", FileStorage::WRITE);
fs << "TrainingDataF5" << trainingDataf5; //671*65
fs << "TrainingDataF10" << trainingDataf10; //671*140
fs << "TrainingDataF15" << trainingDataf15; //671*265
fs << "TrainingDataF20" << trainingDataf20; //671*440
fs << "classes" << classes; //671*1
fs.release();
//671=numFileChars[]数组里面30个数的总和,即总共671个字符图片
return 0;
}
因为没有原始的训练图片,所以无法执行该project的main函数,通过代码分析一下原作者的处理流程。
可以看出原作者是准备了许多字符图片,采集了这些图片的特征数据作为训练数据写入OCR.xmlargv[1] 输入了一个文件夹路径,该路径下有30个文件夹,每个文件夹以字符命名,对应30个可能出现在车牌上的字符。每个文件夹下放了多少张图片呢?数组 const int numFilesChars[] 里面放的就是图片数量。比如图片“0“有35张,图片”1“有40张……总共有671张。
作者提取的特征参数有三个,水平方向累积直方图、竖直方向累积直方图和 lowdata . 其中 lowdata 有四种,分别是5*5, 10*10, 15*15, 20*20 的低分辨率图像,是由原图像缩小而来。后面还会比较采用不同缩小图的分类效果。
主要是两重for循环,外面一层循环30次,即30个可能字符;里面一层处理相应字符的N张图片,提取特征并放入Mat 容器中。循环结束后转换格式写入OCR.xml 中。
OCR.h
#ifndef OCR_h
#define OCR_h
#include <string.h>
#include <vector>
#include "Plate.h"
#include <cv.h>
#include <highgui.h>
#include <cvaux.h>
#include <ml.h>
using namespace std;
using namespace cv;
#define HORIZONTAL 1
#define VERTICAL 0
class CharSegment{
public:
CharSegment();
CharSegment(Mat i, Rect p);
Mat img;
Rect pos;
};
class OCR{
public:
bool DEBUG;
bool saveSegments;
string filename; //待识别的图像名
static const int numCharacters; //字符总数
static const char strCharacters[]; //字符
OCR(string trainFile);
OCR();
string run(Plate *input);
int charSize;
Mat preprocessChar(Mat in);
int classify(Mat f);
void train(Mat trainData, Mat trainClasses, int nlayers);
int classifyKnn(Mat f);
void trainKnn(Mat trainSamples, Mat trainClasses, int k);
Mat features(Mat input, int size);
private:
bool trained;
vector<CharSegment> segment(Plate input);
Mat Preprocess(Mat in, int newSize);
Mat getVisualHistogram(Mat *hist, int type);
void drawVisualFeatures(Mat character, Mat hhist, Mat vhist, Mat lowData);
Mat ProjectedHistogram(Mat img, int t);
bool verifySizes(Mat r);
CvANN_MLP ann;
CvKNearest knnClassifier;
int K;
};
#endif
主函数中用到了特征提取构造函数 ocr.features() 函数,该函数定义:
Mat OCR::features(Mat in, int sizeData){
//Histogram features
Mat vhist=ProjectedHistogram(in,VERTICAL); //得到垂直方向非零数
Mat hhist=ProjectedHistogram(in,HORIZONTAL); //得到水平方向非零数
//Low data feature
Mat lowData;
resize(in, lowData, Size(sizeData, sizeData) ); //图片缩小变换到指定大小,得到lowData矩阵
if(DEBUG)
drawVisualFeatures(in, hhist, vhist, lowData);
//Last 10 is the number of moments components
int numCols=vhist.cols+hhist.cols+lowData.cols*lowData.cols; //把这三种数据都写到一行里面去
Mat out=Mat::zeros(1,numCols,CV_32F);
//Asign values to feature
int j=0; // 最终j=numCols;
for(int i=0; i<vhist.cols; i++)
{
out.at<float>(j)=vhist.at<float>(i);
j++;
}
for(int i=0; i<hhist.cols; i++)
{
out.at<float>(j)=hhist.at<float>(i);
j++;
}
for(int x=0; x<lowData.cols; x++)
{
for(int y=0; y<lowData.rows; y++){
out.at<float>(j)=(float)lowData.at<unsigned char>(x,y);
j++;
}
}
if(DEBUG)
cout << out << "\n===========================================\n";
return out;
}
输入字符图片及低分辨率图片大小,输出一个行矩阵,Mat out=Mat::zeros(1,numCols,CV_32F);
列数int numCols=vhist.cols+hhist.cols+lowData.cols*lowData.cols; 及垂直方向非零数统计直方图、水平方向非零数统计直方图和缩小图像的所有像素灰度值。
缩小图像采用了 resize() 函数。
非零数的统计是由 ProjectedHistogram() 函数得到的:
// 得到一个Mat,统计了每一行(列)的非零数,并除以max的值
Mat OCR::ProjectedHistogram(Mat img, int t)
{
int sz=(t)?img.rows:img.cols;
Mat mhist=Mat::zeros(1,sz,CV_32F);
for(int j=0; j<sz; j++){
Mat data=(t)?img.row(j):img.col(j);
mhist.at<float>(j)=countNonZero(data);
}
//Normalize histogram
double min, max;
minMaxLoc(mhist, &min, &max);
if(max>0)
mhist.convertTo(mhist,-1 , 1.0f/max, 0);
return mhist; //一行n列
}
这里t = 0或1,表示垂直或水平方向,OCR.h 中宏定义过。扫描整个字符图像,按照行/列方向分别统计非零数,保存到行矩阵 mhist 中。并且每个值都除以最大值进行归一化。
这样就完成了OCR.xml 文件。
2. ocr.run()准备好原始数据之后,接下来要识别上一篇得到的Plate上的字符串。 ocr.run() 函数定义:
string OCR::run(Plate *input){
//Segment chars of plate
vector<CharSegment> segments=segment(*input);
for(int i=0; i<segments.size(); i++){
//Preprocess each char for all images have same sizes
Mat ch=preprocessChar(segments[i].img);
if(saveSegments){
stringstream ss(stringstream::in | stringstream::out);
ss << "tmpChars/" << filename << "_" << i << ".jpg";
imwrite(ss.str(),ch);
}
//For each segment Extract Features
Mat f=features(ch,15);
//For each segment feature Classify
int character=classify(f);
input->chars.push_back(strCharacters[character]);
input->charsPos.push_back(segments[i].pos);
}
return "-";//input->str();
}
处理流程是:输入 Plate 类型的牌照区域
--> 将字符分割 segment() 保存到 vector<CharSegment>中
--> 对每个分割后的字符片进行预处理 preprocessChar()
--> 保存字符片
--> 提取特征 features()
--> 判别 classify(),并保存plate.chars 和 plate.charPos.
segment() 函数定义:
vector<CharSegment> OCR::segment(Plate plate){
Mat input=plate.plateImg;
vector<CharSegment> output;
//Threshold input image
Mat img_threshold;
threshold(input, img_threshold, 60, 255, CV_THRESH_BINARY_INV);
if(DEBUG)
imshow("Threshold plate", img_threshold);
Mat img_contours;
img_threshold.copyTo(img_contours);
//Find contours of possibles characters
vector< vector< Point> > contours;
findContours(img_contours,
contours, // a vector of contours
CV_RETR_EXTERNAL, // retrieve the external contours
CV_CHAIN_APPROX_NONE); // all pixels of each contours
// Draw blue contours on a white image
cv::Mat result;
img_threshold.copyTo(result);
cvtColor(result, result, CV_GRAY2RGB);
cv::drawContours(result,contours,
-1, // draw all contours
cv::Scalar(255,0,0), // in blue
1); // with a thickness of 1
//Start to iterate to each contour founded
vector<vector<Point> >::iterator itc= contours.begin();
//Remove patch that are no inside limits of aspect ratio and area.
while (itc!=contours.end()) {
//Create bounding rect of object
Rect mr= boundingRect(Mat(*itc));
rectangle(result, mr, Scalar(0,255,0));
//Crop image
Mat auxRoi(img_threshold, mr);
if(verifySizes(auxRoi)){
auxRoi=preprocessChar(auxRoi);
output.push_back(CharSegment(auxRoi, mr));
rectangle(result, mr, Scalar(0,125,255));
}
++itc;
}
if(DEBUG)
cout << "Num chars: " << output.size() << "\n";
if(DEBUG)
imshow("SEgmented Chars", result);
return output;
}
输入是上一篇得到的Plate类型数据,使用plate.plateImg; 首先二值化threshold(); 查找所有外轮廓 findContours(),画出每个轮廓的外接矩形;把矩形区域作为ROI区域提取出来,这里有个陌生函数 auxRoi() (提取ROI的方法,比我以前的方法方便);判断该区域尺寸是否符合要求 verifySizes() , 若符合,把该区域压到 output 中,在图中标出矩形框。返回 output, 里面包含7个字符。
其中 verifySizes() 函数定义:
bool OCR::verifySizes(Mat r){
//Char sizes 45x77
float aspect=45.0f/77.0f;
float charAspect= (float)r.cols/(float)r.rows;
float error=0.35;
float minHeight=15;
float maxHeight=28;
//We have a different aspect ratio for number 1, and it can be ~0.2
float minAspect=0.2;
float maxAspect=aspect+aspect*error;
//area of pixels
float area=countNonZero(r);
//bb area
float bbArea=r.cols*r.rows;
//% of pixel in area
float percPixels=area/bbArea;
if(DEBUG)
cout << "Aspect: "<< aspect << " ["<< minAspect << "," << maxAspect << "] " << "Area "<< percPixels <<" Char aspect " << charAspect << " Height char "<< r.rows << "\n";
if(percPixels < 0.8 && charAspect > minAspect && charAspect < maxAspect && r.rows >= minHeight && r.rows < maxHeight)
return true;
else
return false;
}
字符大小的判断依据根据当地拍照标准以及车与摄像头的距离设定范围,计算长宽比与面积是否在该范围内。与第一篇判断牌照区域类似。第一篇不理解的数据现在明白了,车与摄像头的距离限定了牌照出现在图片上的大小。
preprocessChar() 函数定义:
Mat OCR::preprocessChar(Mat in){
//Remap image
int h=in.rows;
int w=in.cols;
Mat transformMat=Mat::eye(2,3,CV_32F); //[1,0,0; 0,1,0]
int m=max(w,h);
transformMat.at<float>(0,2)=m/2 - w/2;
transformMat.at<float>(1,2)=m/2 - h/2;
Mat warpImage(m,m, in.type());
warpAffine(in, warpImage, transformMat, warpImage.size(), INTER_LINEAR, BORDER_CONSTANT, Scalar(0) );
Mat out;
resize(warpImage, out, Size(charSize, charSize) );
return out;
}
这里做了一个仿射变换,变换后的效果是使得 in 图像变成一个正方形,且字符左右居中摆放.
仿射变换操作变换的是像素坐标,如果看不出仿射效果,可以取几个特殊点代进去看一下。这里就是往x方向平移了(h-w)/2 的距离,使得字符正好出现在h*h正方形左右居中的位置。(这里假设h>w).
保存该字符片:
Mat ch=preprocessChar(segments[i].img);
if(saveSegments){
stringstream ss(stringstream::in | stringstream::out);
ss << "tmpChars/" << filename << "_" << i << ".jpg";
imwrite(ss.str(),ch);
}
下一步提取特征,与trainOCR.cpp中提取特征一样 features() 函数
最后一步,识别。classify() 函数定义:
int OCR::classify(Mat f){
int result=-1;
Mat output(1, numCharacters, CV_32FC1);
ann.predict(f, output);
Point maxLoc;
double maxVal;
minMaxLoc(output, 0, &maxVal, 0, &maxLoc);
//We need know where in output is the max val, the x (cols) is the class.
return maxLoc.x;
}
主要是用到 ann.predict() 。这部分和ann.train() 一起放到下一篇。
到这里就实现了字符识别。
未完待续。