OpenCV(四)—— 车牌号识别

本节是车牌识别的最后一部分 ------ 车牌字符识别,从一个完整的车牌图片到识别出车牌上的字符大致需要如下几步:

  • 预处理:将车牌图片灰度化、二值化,并去除识别时的干扰因素,比如车牌铆钉
  • 字符分割:将整个车牌图片按照每个字符分割成 7 个单独的字符图片保存到集合中
  • 字符识别:使用经过训练的数字、英文字符、中文字符的特征集合对字符图片进行识别,得到最终的车牌号

下面详解以上步骤。

1、预处理

图像识别的预处理工作一般都是灰度化、二值化这些图像"降噪"处理,当然我们这里还有一个特殊的处理,就是要祛除车牌图像上的铆钉,它是对识别准确度影响较大的一个因素。

1.1 AnnPredictor 预处理

新建一个类 AnnPredictor 用于进行字符识别:

cpp 复制代码
#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() 即可获取到车牌字符串:

cpp 复制代码
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() 内,先进行预处理,像灰度化和二值化这些操作前面已出现过多次就不再赘述了:

cpp 复制代码
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("未识别到车牌");
	}
    ...
}

主要说一下如何去掉车牌图片上的铆钉。

1.2 去掉车牌上的铆钉

二值化后的图片仍能看到车牌上的铆钉,这是影响识别准确程度的一个干扰因素:

因此我们要去掉它,去掉后的效果:

去除铆钉的思路是,对车牌图像进行逐行扫描,如果这一行是铆钉,那么颜色跳变的次数应该为 4 次,远远少于正常字符的颜色跳变次数:

上面红线是扫描到铆钉的行,先是黑色,扫描到铆钉变为白色,离开铆钉再变为黑色,第二颗铆钉重复上述过程,因此有 4 次黑白之间的颜色跳变。而第二条红线扫描到正常字符,跳变次数远远大于 4,我们就用这个思路去除铆钉:

cpp 复制代码
/**
* 通过一行的颜色跳变次数判断是否扫描到了铆钉,
* 一行最小跳变次数为 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;
}

2、字符分割

接下来开始分割字符,主要可以分为三部分:

  1. 找到各个字符的轮廓,并生成轮廓对应的图片
  2. 对汉字字符的轮廓图片进行特殊处理
  3. 将所有 7 个字符的轮廓图片保存到集合中为字符识别做准备

下面详解。

2.1 生成字符轮廓图片

先通过寻找轮廓的函数 findContours() 找到轮廓,生成轮廓矩形集合 vec_ann_rects:

cpp 复制代码
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() 进行尺寸校验,将不符合规格的字符图片过滤掉:

cpp 复制代码
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() 的校验,会被过滤掉。也就是说,这一步中,我们只获取了英文和数字图片,汉字图片暂时还未获取。

2.2 获取汉字轮廓

思路是,先定位到汉字后面表示城市的那一位英文字符,再向左侧推导获取汉字轮廓。

首先,根据图片的横坐标对 vec_ann_rects 集合内的字符图片进行从左至右的排序:

cpp 复制代码
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;
		});
    ...
}

然后获取到表示城市字符的轮廓索引:

cpp 复制代码
string AnnPredictor::predict(Mat plate)
{
    ...
    // 2.3 获取城市字符轮廓的索引
	int cityIndex = getCityIndex(vec_ann_rects);
    ...
}

getCityIndex() 获取城市字符索引的思路是:车牌上共有 7 个字符,那么城市字符位于第 2 位,该字符中间横坐标一定在车牌水平方向的 1/7 ~ 2/7 之间:

cpp 复制代码
/**
* 寻找城市字符(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;
}

获取城市字符索引后,可以根据其横坐标推断出汉字字符的轮廓:

cpp 复制代码
string AnnPredictor::predict(Mat plate)
{
    ...
    // 2.4 推导汉字字符的轮廓
	Rect chineseRect;
	getChineseRect(vec_ann_rects[cityIndex], chineseRect);
    ...
}

getChineseRect() 会将推断出的矩形保存到 chineseRect 中:

cpp 复制代码
/**
* 通过城市字符的矩形,确定汉字字符的矩形
*/
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;
}

2.3 保存所有字符图片

最后将 7 个字符的图片保存到 plateCharMats 集合中等待字符识别:

cpp 复制代码
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]));
	}
    ...
}

3、字符识别

3.1 识别过程

调用 predict() 传入字符图片集合 plateCharMats,识别的字符结果保存在 str_plate 中:

cpp 复制代码
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 内的字符顺序要和训练样本存放顺序相同:

cpp 复制代码
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 提供了一种车牌识别的思路,但是准确度还是有限的。

3.2 样本制作

最后来说说样本是如何制作的。

识别车牌字符,需要所有字符训练的特征集合,即首位的汉字共 31 个字符、第二位英文字符 24 个(刨除 I 和 O 两个容易被误识别为 1 和 0)、以及后续位数中需要用到的 10 个数字。我们将训练样本分为两类:英文字符和数字的训练样本放入 ann 文件夹,汉字字符放入 ann_zh 文件夹。两个文件夹内都需要对字符进行编号:

  • ann 中 0 号文件夹是数字 0 的训练素材,1 号文件夹是数字 1 的训练素材,以此类推,总共是 10 + 24 = 24 个文件夹
  • ann_zh 中 1 号文件夹是"川"字的训练素材,2 号文件夹是"鄂"字的训练素材,共计 31 个文件夹

这些字符的编号顺序需要记录在一个额外的文档中,内容如下:

txt 复制代码
"川", "鄂", "赣", "甘", "贵", "桂", "黑", "沪", "冀", "津", "京", "吉", "辽", "鲁", "蒙", "闽", "宁", "青", "琼", "陕", "苏", "晋", "皖", "湘", "新", "豫", "渝", "粤", "云", "藏", "浙"
'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 所加载的文件。目录结构如下:

相关推荐
小雨cc5566ru3 小时前
uniapp+Android面向网络学习的时间管理工具软件 微信小程序
android·微信小程序·uni-app
bianshaopeng4 小时前
android 原生加载pdf
android·pdf
hhzz4 小时前
Linux Shell编程快速入门以及案例(Linux一键批量启动、停止、重启Jar包Shell脚本)
android·linux·jar
火红的小辣椒5 小时前
XSS基础
android·web安全
勿问东西7 小时前
【Android】设备操作
android
五味香7 小时前
C++学习,信号处理
android·c语言·开发语言·c++·学习·算法·信号处理
图王大胜9 小时前
Android Framework AMS(01)AMS启动及相关初始化1-4
android·framework·ams·systemserver
x-cmd11 小时前
[241005] 14 款最佳免费开源图像处理库 | PostgreSQL 17 正式发布
数据库·图像处理·sql·安全·postgresql·开源·json
工程师老罗11 小时前
Android Button “No speakable text present” 问题解决
android
红米煮粥11 小时前
OpenCV-图像拼接
人工智能·opencv·计算机视觉