赞
踩
OpenCV 入门系列:
OpenCV 入门(一)—— OpenCV 基础
OpenCV 入门(二)—— 车牌定位
OpenCV 入门(三)—— 车牌筛选
OpenCV 入门(四)—— 车牌号识别
OpenCV 入门(五)—— 人脸识别模型训练与 Windows 下的人脸识别
OpenCV 入门(六)—— Android 下的人脸识别
OpenCV 入门(七)—— 身份证识别
本节是车牌识别的最后一部分 —— 车牌字符识别,从一个完整的车牌图片到识别出车牌上的字符大致需要如下几步:
下面详解以上步骤。
图像识别的预处理工作一般都是灰度化、二值化这些图像“降噪”处理,当然我们这里还有一个特殊的处理,就是要祛除车牌图像上的铆钉,它是对识别准确度影响较大的一个因素。
新建一个类 AnnPredictor 用于进行字符识别:
#ifndef ANNPREDICTOR_H #define ANNPREDICTOR_H #define ANNPREDICTOR_DEBUG #include <opencv2/opencv.hpp> #include <string> #include <opencv2/ml.hpp> using namespace std; using namespace cv; using namespace ml; class AnnPredictor { public: AnnPredictor(const char* ann_model, const char* ann_zh_model); ~AnnPredictor(); // 字符识别 string predict(Mat plate); private: // 用于数字和英文字符识别 Ptr<ANN_MLP> ann; // 用于中文字符识别 Ptr<ANN_MLP> ann_zh; // ANN 的 HOG 特征 HOGDescriptor* annHog = nullptr; // 与 SvmPredictor 中的函数相同 void getHOGFeatures(HOGDescriptor* svmHog, Mat src, Mat& dst); // 去除铆钉 bool clearRivet(Mat &plate); // 验证单个字符尺寸 bool verifyCharSize(Mat src); // 获取城市汉字字符在排序后的矩形集合中的索引 int getCityIndex(vector<Rect> rects); // 获取中文字符所在的矩形 void getChineseRect(Rect cityRect, Rect& chineseRect); // 识别车牌字符保存到 str_plate 中 void predict(vector<Mat> plateCharMats, string& str_plate); // 汉字字符集合 static string ZHCHARS[]; // 数字与英文字符集合 static char CHARS[]; }; #endif // !ANNPREDICTOR_H
这样在 LicensePlateRecognizer 中调用 predict() 即可获取到车牌字符串:
LicensePlateRecognizer::LicensePlateRecognizer(const char* svm_model, const char* ann_model, const char* ann_zh_model) { ... annPredictor = new AnnPredictor(ann_model, ann_zh_model); } LicensePlateRecognizer::~LicensePlateRecognizer() { ... if (annPredictor) { delete annPredictor; annPredictor = nullptr; } } string LicensePlateRecognizer::recognize(Mat src) { // 1.车牌定位,使用 Sobel 算法定位 // 2.精选车牌定位得到的候选车牌图,找到最有可能是车牌的图 ... // 3.对车牌图进行字符识别 string str_plate = annPredictor->predict(plate); plate.release(); return str_plate; }
predict() 内,先进行预处理,像灰度化和二值化这些操作前面已出现过多次就不再赘述了:
string AnnPredictor::predict(Mat plate) { // 1.预处理 // 1.1 灰度化 Mat gray; cvtColor(plate, gray, COLOR_BGR2GRAY); // 1.2 二值化(非黑即白,对比更强烈) Mat shold; threshold(gray, shold, 0, 255, THRESH_OTSU + THRESH_BINARY); // 1.3 去铆钉 if (!clearRivet(shold)) { return string("未识别到车牌"); } ... }
主要说一下如何去掉车牌图片上的铆钉。
二值化后的图片仍能看到车牌上的铆钉,这是影响识别准确程度的一个干扰因素:
因此我们要去掉它,去掉后的效果:
去除铆钉的思路是,对车牌图像进行逐行扫描,如果这一行是铆钉,那么颜色跳变的次数应该为 4 次,远远少于正常字符的颜色跳变次数:
上面红线是扫描到铆钉的行,先是黑色,扫描到铆钉变为白色,离开铆钉再变为黑色,第二颗铆钉重复上述过程,因此有 4 次黑白之间的颜色跳变。而第二条红线扫描到正常字符,跳变次数远远大于 4,我们就用这个思路去除铆钉:
/** * 通过一行的颜色跳变次数判断是否扫描到了铆钉, * 一行最小跳变次数为 12,最大为 12 + 8 * 6 = 60。 * 如果该行是铆钉行,则将该行所有像素都涂成黑色(像素值为 0) */ bool AnnPredictor::clearRivet(Mat &plate) { // 1.逐行扫描统计颜色跳变次数保存到集合中 int minChangeCount = 12; vector<int> changeCounts; int changeCount; for (int i = 0; i < plate.rows; i++) { for (int j = 0; j < plate.cols - 1; j++) { int pixel_front = plate.at<char>(i, j); int pixel_back = plate.at<char>(i, j + 1); if (pixel_front != pixel_back) { changeCount++; } } changeCounts.push_back(changeCount); changeCount = 0; } // 2.计算字符高度,即满足像素跳变次数的行数 int charHeight = 0; for (int i = 0; i < plate.rows; i++) { if (changeCounts[i] >= 12 && changeCounts[i] <= 60) { charHeight++; } } // 3.判断字符高度 & 面积占整个车牌的高度 & 面积的百分比,排除不符合条件的情况 // 3.1 高度占比小于 0.4 则认为无法识别 float heightPercent = float(charHeight) / plate.rows; if (heightPercent <= 0.4) { return false; } // 3.2 面积占比小于 0.15 或大于 0.5 则认为无法识别 float plate_area = plate.rows * plate.cols; // countNonZero 返回非 0 像素点(即白色)个数,或者自己遍历找像素点为 255 的个数也可 float areaPercent = countNonZero(plate) * 1.0 / plate_area; // 小于 0.15 就是蓝背景白字车牌确实达不到识别标准,大于 0.5 是因为 // 黄背景黑子二值化会把背景转化为白色,由于前面的处理逻辑只能处理 // 蓝背景车牌,所以黄色车牌的情况也直接认为不可识别 if (areaPercent <= 0.15 || areaPercent >= 0.5) { return false; } // 4.将小于最小颜色跳变次数的行全部涂成黑色 for (int i = 0; i < plate.rows; i++) { if (changeCounts[i] < minChangeCount) { for (int j = 0; j < plate.cols; j++) { plate.at<char>(i, j) = 0; } } } return true; }
接下来开始分割字符,主要可以分为三部分:
下面详解。
先通过寻找轮廓的函数 findContours() 找到轮廓,生成轮廓矩形集合 vec_ann_rects:
string AnnPredictor::predict(Mat plate) { // 1.预处理 ... // 2.字符分割 // 2.1 找轮廓 // vector<Point>是点的集合,可以连成线,线的集合就是轮廓了 vector<vector<Point>> contours; findContours(shold, // 输入的图像 contours, // 轮廓,接收结果 RETR_EXTERNAL, // 轮廓检索模式:外轮廓 CHAIN_APPROX_NONE // 轮廓近似算法模式:不进行轮廓近似,保留所有的轮廓点 ); vector<Rect> vec_ann_rects; // 在原图上克隆一个用来画矩形 Mat src_clone = plate.clone(); for each (vector<Point> points in contours) { Rect rect = boundingRect(points); Mat rectMat = shold(rect); // rectangle(src_clone, rect, Scalar(0, 0, 255)); // 尺寸判断,符合规格的放入 vec_sobel_rects 集合中 if (verifyCharSize(rectMat)) { vec_ann_rects.push_back(rect); } } ... }
遍历 vec_ann_rects 生成轮廓矩形并生成与矩形对应的图片,这就是分割的字符图片。当然,在将它们存入集合前你需要过滤一下,因为不是所有轮廓都刚好是一个完整的字符。比如“渝”字,由于汉字相比于英文字符和数字,结构复杂,因此无法识别为整个字,而是识别出“渝”字中点或者某个局部部分:
因为我们导出的车牌宽度只有 136 像素,所以放大后并不清晰,但是能看出来,英文字母和数字的轮廓只有一个,而“渝”的轮廓有多个。面对这种情况,我们先用 verifyCharSize() 进行尺寸校验,将不符合规格的字符图片过滤掉:
bool AnnPredictor::verifyCharSize(Mat src) { // 最理想情况 车牌字符的标准宽高比 float aspect = 45.0f / 90.0f; // 当前获得矩形的真实宽高比 float realAspect = (float)src.cols / (float)src.rows; // 最小的字符高 float minHeight = 10.0f; // 最大的字符高 float maxHeight = 35.0f; // 1、判断高符合范围 2、宽、高比符合范围 // 最大宽、高比 最小宽高比 float error = 0.7f; float maxAspect = aspect + aspect * error;//0.85 float minAspect = 0.05f; int plate_area = src.cols * src.rows; float areaPercent = countNonZero(src) * 1.0 / plate_area; if (areaPercent <= 0.8 && realAspect >= minAspect && realAspect <= maxAspect && src.rows >= minHeight && src.rows <= maxHeight) { return true; } return false; }
汉字被识别为多个部分,无法通过 verifyCharSize() 的校验,会被过滤掉。也就是说,这一步中,我们只获取了英文和数字图片,汉字图片暂时还未获取。
思路是,先定位到汉字后面表示城市的那一位英文字符,再向左侧推导获取汉字轮廓。
首先,根据图片的横坐标对 vec_ann_rects 集合内的字符图片进行从左至右的排序:
string AnnPredictor::predict(Mat plate)
{
...
// 2.2 对矩形轮廓从左至右排序
sort(vec_ann_rects.begin(), vec_ann_rects.end(), [](const Rect& rect1, const Rect& rect2) {
return rect1.x < rect2.x;
});
...
}
然后获取到表示城市字符的轮廓索引:
string AnnPredictor::predict(Mat plate)
{
...
// 2.3 获取城市字符轮廓的索引
int cityIndex = getCityIndex(vec_ann_rects);
...
}
getCityIndex() 获取城市字符索引的思路是:车牌上共有 7 个字符,那么城市字符位于第 2 位,该字符中间横坐标一定在车牌水平方向的 1/7 ~ 2/7 之间:
/** * 寻找城市字符(7 位字符中的第 2 位)轮廓索引 */ int AnnPredictor::getCityIndex(vector<Rect> rects) { int cityIndex = 0; for (int i = 0; i < rects.size(); i++) { Rect rect = rects[i]; int midX = rect.x + rect.width / 2; // 如果字符水平方向中点坐标在整个车牌水平坐标的 // 1/7 ~ 2/7 之间,就认为是目标索引。136 是我们 // 训练车牌使用的素材的车牌宽度 if (midX < 136 / 7 * 2 && midX > 136 / 7) { cityIndex = i; break; } } return cityIndex; }
获取城市字符索引后,可以根据其横坐标推断出汉字字符的轮廓:
string AnnPredictor::predict(Mat plate)
{
...
// 2.4 推导汉字字符的轮廓
Rect chineseRect;
getChineseRect(vec_ann_rects[cityIndex], chineseRect);
...
}
getChineseRect() 会将推断出的矩形保存到 chineseRect 中:
/** * 通过城市字符的矩形,确定汉字字符的矩形 */ void AnnPredictor::getChineseRect(Rect cityRect, Rect& chineseRect) { // 把宽度稍微扩大一点以包含完整的汉字字符 // 还有一层理解,就是汉字与城市字符之间的空隙也要计算进去 float width = cityRect.width * 1.15; // 城市轮廓矩形的横坐标 int x = cityRect.x; // 用城市矩形的横坐标减去汉字宽度得到汉字矩形的横坐标 int newX = x - width; chineseRect.x = newX > 0 ? newX : 0; chineseRect.y = cityRect.y; chineseRect.width = width; chineseRect.height = cityRect.height; }
最后将 7 个字符的图片保存到 plateCharMats 集合中等待字符识别:
string AnnPredictor::predict(Mat plate) { ... // 2.5 将字符图像保存到集合中 // 先保存汉字字符图像 vector<Mat> plateCharMats; plateCharMats.push_back(shold(chineseRect)); // 再获取汉字之后的 6 个字符并保存 int count = 6; if (vec_ann_rects.size() < 6) { return string("未识别到车牌"); } for (int i = cityIndex; i < vec_ann_rects.size() && count; i++, count--) { plateCharMats.push_back(shold(vec_ann_rects[i])); } ... }
调用 predict() 传入字符图片集合 plateCharMats,识别的字符结果保存在 str_plate 中:
string AnnPredictor::predict(Mat plate) { ... // 3.字符识别 string str_plate; predict(plateCharMats, str_plate); for (Mat m : plateCharMats) { m.release(); } // 4.释放 Mat gray.release(); shold.release(); src_clone.release(); return str_plate; }
predict() 内遍历 plateCharMats,提取 HOG 特征后对汉字和数字英文分开识别,注意 ZHCHARS 与 CHARS 内的字符顺序要和训练样本存放顺序相同:
string AnnPredictor::ZHCHARS[] = { "川", "鄂", "赣", "甘", "贵", "桂", "黑", "沪", "冀", "津", "京", "吉", "辽", "鲁", "蒙", "闽", "宁", "青", "琼", "陕", "苏", "晋", "皖", "湘", "新", "豫", "渝", "粤", "云", "藏", "浙" }; char AnnPredictor::CHARS[] = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z' }; void AnnPredictor::predict(vector<Mat> plateCharMats, string& result) { for (int i = 0; i < plateCharMats.size(); i++) { Mat mat_plate_char = plateCharMats[i]; // 提取 HOG 特征 Mat features; getHOGFeatures(annHog, mat_plate_char, features); Mat sample = features.reshape(1, 1); Mat response; Point maxLoc; Point minLoc; if (i) { // 字母和数字 ann->predict(sample, response); minMaxLoc(response, 0, 0, &minLoc, &maxLoc); int index = maxLoc.x; result += CHARS[index]; } else { // 汉字 ann_zh->predict(sample, response); minMaxLoc(response, 0, 0, &minLoc, &maxLoc); int index = maxLoc.x; result += ZHCHARS[index]; } } }
至此代码结束,先来看一下效果:
对于【渝G 83666】的识别结果为【渝G 80666】,错了一位,这与识别的算法,还有训练样本的数量都有关系。总的来说,Demo 提供了一种车牌识别的思路,但是准确度还是有限的。
最后来说说样本是如何制作的。
识别车牌字符,需要所有字符训练的特征集合,即首位的汉字共 31 个字符、第二位英文字符 24 个(刨除 I 和 O 两个容易被误识别为 1 和 0)、以及后续位数中需要用到的 10 个数字。我们将训练样本分为两类:英文字符和数字的训练样本放入 ann 文件夹,汉字字符放入 ann_zh 文件夹。两个文件夹内都需要对字符进行编号:
这些字符的编号顺序需要记录在一个额外的文档中,内容如下:
"川", "鄂", "赣", "甘", "贵", "桂", "黑", "沪", "冀", "津", "京", "吉", "辽", "鲁", "蒙", "闽", "宁", "青", "琼", "陕", "苏", "晋", "皖", "湘", "新", "豫", "渝", "粤", "云", "藏", "浙"
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'
训练后可以得到两个特征集合文件 ann.xml(数字和英文字符)和 ann_zh.xml(汉字字符),正是初始化 AnnPredictor 的 ann 和 ann_zh 所加载的文件。目录结构如下:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。