OpenCV(二)—— 车牌定位

从本篇文章开始我们进入 OpenCV 的 Demo 实战。首先,我们会用接下来的三篇文章介绍车牌识别 Demo。

1、概述

识别图片中的车牌号码需要经过三步:

  1. 车牌定位:从整张图片中识别出牌照,主要操作包括对原图进行预处理、把车牌从整图中抠出
  2. 字符分割:将牌照中的字符进行切割
  3. 字符识别:识别单个字符,然后拼接成字符串

本节是 OpenCV 车牌识别的第一节课,主要完成了车牌定位的工作。具体流程:

2、项目搭建

Demo 使用 Visual Studio 开发,有关 Visual Studio 配置 OpenCV 项目的详细过程在上一篇文章中已经介绍过,这里就只是再简单提一下。

2.1 项目配置

在 Visual Studio 中创建一个 CMake 项目 LicensePlateRecognition,配置 CMakeLists.txt 如下:

cmake 复制代码
cmake_minimum_required (VERSION 3.8)

project ("LicensePlateRecognition")

# 指明 OpenCV 的头文件目录,编译时会去该目录下寻找 OpenCV 的头文件
include_directories("G:/Tools/OpenCV/build/include")

# 指明 OpenCV 的库文件目录,链接时会去该目录下寻找 OpenCV 的库文件
link_directories("G:/Tools/OpenCV/build/x64/vc15/lib")

# 将指定的源代码添加到此项目的可执行文件
add_executable (LicensePlateRecognition "LicensePlateRecognizer.cpp" "PlateLocator.cpp" "SobelLocator.cpp" "VLPR_1.cpp")

# 指明可执行文件或库文件依赖的库,opencv_world410d 在链接时会链接到目标 LicensePlateRecognition
target_link_libraries(LicensePlateRecognition opencv_world410d)

如果运行时说找不到 opencv_world410d.dll,请将库目录添加到环境变量并重启 VS 再试。

2.2 框架搭建

说一下被添加到 add_executable() 中编译的源码的功能:

  • LicensePlateRecognizer 是车牌识别器,传入一个车牌图像会返回车牌号:

    cpp 复制代码
    int main() {
    	Mat src = imread("C:/Users/69129/Desktop/Test/test2.jpg");
    	LicensePlateRecognizer lpr("C:/Users/69129/Desktop/Test/svm.xml",
    		"C:/Users/69129/Desktop/Test/train/ann/ann.xml", 
    		"C:/Users/69129/Desktop/Test/train/ann/ann_zh.xml");
        // 识别车牌,返回车牌号
    	string str_plate = lpr.recognize(src);
    	cout << "车牌号:" << str_plate << endl;
    	return 0;
    }
  • PlateLocator 是车牌定位器,用于定位车牌的。由于车牌定位有多种算法,因此具体的识别工作不由 PlateLocator 完成,而是交给使用了某一种算法的子类完成,如 SobelLocator(使用 Sobel 算法)或 ColorLocator(使用 HSV 颜色模型)

由于本节我们只进行车牌定位,因此文件暂时就这么多,后续随着功能的添加,源码文件也会随之增加。

注意事项与小技巧:

  • 头文件内不建议使用 # pragma once 的形式,兼容性不如宏定义的方式好
  • 使用 Visual Studio 时,如果在 CMakeLists 中通过 *.cpp 这种形式设置所有 cpp 文件都添加到可执行文件中,那么在新建 cpp 文件后,需要在 Visual Studio -> Project -> CMake 缓存 -> 删除缓存,然后在 CMakeLists 通过 Ctrl + S 重新生成可执行文件,否则新建的 cpp 不会自动被添加到可执行文件中

3、Sobel 算法定位车牌

我们使用 Sobel 算法实现 SobelLocator 定位器的 locate(),3.1 ~ 3.8 节的标题就是根据前面给出的流程图做出的实现步骤。

3.1 高斯模糊

高斯模糊算法本质上是一种数据平滑技术,图像处理恰好是一个直观的应用实例,具体内容可以参考阮一峰大神的博客:高斯模糊的算法

我们这里需要了解 OpenCV 的高斯模糊函数 GaussianBlur 如何使用:

cpp 复制代码
/** 使用高斯滤镜(滤波器)对图像进行模糊处理。该函数将源图像与指定的高斯核进行卷积。支持原地滤波。

@param src 输入图像;图像可以具有任意数量的通道,但是它们将独立处理,但是深度应为
			CV_8U、CV_16U、CV_16S、CV_32F 或 CV_64F
@param dst 输出图像,与 src 具有相同的大小和类型
@param ksize 高斯核大小。ksize.width 和 ksize.height 可以不同,但它们都必须是正奇数。
			或者,它们可以为零,然后它们将从 sigma 中计算得出
@param sigmaX X 向的高斯核标准差
@param sigmaY Y方向的高斯核标准差;如果 sigmaY 为零,则它被设置为与 sigmaX 相等,如果
			两个 sigma 都为零,则它们分别从 ksize.width 和 ksize.height 计算得出(有
			关详细信息,请参见 #getGaussianKernel);为了完全控制结果,无论将来可能对
			所有这些语义的修改如何,建议指定 ksize、sigmaX 和 sigmaY
@param borderType 素外推方法,参见 #BorderTypes
*/
CV_EXPORTS_W void GaussianBlur( InputArray src, OutputArray dst, Size ksize,
                                double sigmaX, double sigmaY = 0,
                                int borderType = BORDER_DEFAULT );

调用 GaussianBlur() 对原图进行高斯模糊:

cpp 复制代码
/**
* 车牌定位,输入原图 src,输出候选图集合 dst_plates
*/
void SobelLocator::locate(Mat src, vector<Mat>& dst_plates)
{
	// 1.高斯模糊
	Mat blur;
	// 构造 Size 的宽高必须是正奇数,宽高越大越模糊
	GaussianBlur(src, blur, Size(5, 5), 0);
	imshow("src", src);
	imshow("blur", blur);
    ...
}

对比效果如下:

3.2 灰度化

实际上,色彩对于图像识别是有干扰的,因此需要通过灰度化对图像"降噪",为 Sobel 边缘检测算法(该算法只接受灰度图)做准备:

cpp 复制代码
void SobelLocator::locate(Mat src, vector<Mat>& dst_plates)
{
	// 1.高斯模糊
	...

	// 2.灰度化
	Mat gray;
	cvtColor(blur, gray, COLOR_BGR2GRAY);
	imshow("gray", gray);
    ...
}

灰度化效果:

可否先对原图灰度化后再高斯模糊呢?没有硬性规定,但是高斯模糊接收彩色图的模糊效果更好。

3.3 Sobel Derivatives 运算

Sobel Derivatives ------ Sobel 导数是一种用于计算图像梯度的算子。它是一种线性滤波器,用于检测图像中的边缘。Sobel 算子结合了水平和垂直方向的差分操作,从而可以同时计算图像在水平和垂直方向上的梯度。这使得 Sobel 算子在图像处理中广泛应用于边缘检测、图像增强和特征提取等任务中。

Sobel 算子的计算过程涉及对图像进行卷积操作,具体而言,它使用一个 3 × 3 的卷积核分别对图像进行水平和垂直方向的卷积。通过计算卷积结果的导数,可以得到图像在水平和垂直方向上的梯度强度。这些梯度信息可以用来检测图像中的边缘,因为边缘通常表示图像中灰度值的剧烈变化。

Sobel 算子在图像处理和计算机视觉领域具有广泛的应用,例如边缘检测、角点检测、图像平滑和模糊等。它是一种简单而有效的方法,可用于提取图像的结构信息并进行特征提取。更详细的信息与公式可参考 OpenCV 官方文档 Sobel Derivatives

我们通过 Sobel 运算可以得到图像一阶水平方向导数,目的是检测图像中的垂直边缘,便于区分车牌(注意 Sobel 运算只能对灰度图像有效,因此进行 Sobel 运算前必须先进行灰度化工作):

cpp 复制代码
void SobelLocator::locate(Mat src, vector<Mat>& dst_plates)
{
	// 1.高斯模糊
	// 2.灰度化
    ...

	// 3.Sobel 运算
	Mat sobel_16;
	// 输入的图像是 8 位的,而经过 Sobel 求导,导数可能会大于
	// 255 或小于 0,因此结果的数据深度要用 16 位,8 位不够
	// CV_16S 表示有符号 16 位整型
    // 最后两个参数 1 与 0 分别表示仅对 X 方向求导,Y 方向不用
	Sobel(gray, sobel_16, CV_16S, 1, 0);
	// sobel_16 无法显示,需要转回 8 位
	Mat sobel;
	convertScaleAbs(sobel_16, sobel);
	imshow("sobel", sobel);
    ...
}

Sobel 运算后的效果:

可以看到,经过 Sobel 运算后,物体轮廓要比灰度图明显了。

3.4 二值化

二值化的通俗说法就是非黑即白。对图像的每个像素做一个阈值处理,为后续的形态学操作准备。

具体来讲,就是灰度图中每个像素值是 0 ~ 255,表示灰暗程度。现在我们设定一个阈值 t,像素值小于 t 的设为 0,否则设为 1,这样所有的像素就只有 0 或 1 两个值。

在 OpenCV 中,二值化使用 threshold() 函数:

cpp 复制代码
/** 对每个数组元素应用固定级别的阈值。

该函数对多通道数组应用固定级别的阈值处理。通常,该函数用于将灰度图像转换为
二值图像(也可以使用 #compare 函数实现此目的)或者用于去除噪声,即过滤掉
像素值过小或过大的像素。函数支持几种类型的阈值处理,这些类型由参数 type 决定。

此外,特殊值 #THRESH_OTSU 或 #THRESH_TRIANGLE 可以与上述类型的阈值组合使用。
在这些情况下,函数将使用 Otsu's 算法或 Triangle 算法确定最优阈值,并将其用于
替代指定的 thresh。

@note 目前,Otsu's 算法和 Triangle 算法仅适用于 8 位单通道图像。

@param src 输入数组(多通道、8 位或 32 位浮点数)。
@param dst 输出数组,与 src 具有相同的大小、类型和通道数。
@param thresh 阈值。
@param maxval 在 #THRESH_BINARY 和 #THRESH_BINARY_INV 阈值类型中使用的最大值。
@param type 阈值类型(参见 #ThresholdTypes)。
@return 如果使用了 Otsu's 算法或 Triangle 算法,则返回计算出的阈值。
 */
CV_EXPORTS_W double threshold( InputArray src, OutputArray dst,
                               double thresh, double maxval, int type );

使用 threshold() 进行二值化:

cpp 复制代码
void SobelLocator::locate(Mat src, vector<Mat>& dst_plates)
{
    // 1.高斯模糊
	// 2.灰度化
	// 3.Sobel 运算
	...

	// 4.二值化(非黑即白,对比更强烈)
	Mat shold;
	// OTSU 算法
	threshold(sobel, shold, 0, 255, THRESH_OTSU + THRESH_BINARY);
	imshow("shold", shold);
}

效果:

可以看出对比度更明显,轮廓更清晰了。

3.5 形态学操作(闭操作)

形态学操作的目的是将车牌字符连接成一个连通区域,便于取轮廓。

形态学操作的对象是二值化图像,操作有多种类型,腐蚀,膨胀是许多形态学操作的基础。我们先来了解部分操作类型:

  • 腐蚀(黑色腐蚀白色):让像素 x 位于模板的中心,根据模版的大小,遍历所有被模板覆盖的其他像素,修改像素 x 的值为所有像素中最小的值。实际上就是对于中心点像素 x,模板范围内没有黑色则保留,否则该像素涂黑:

假如按照从上至下、从左至右的顺序逐个检查像素点,现在检查到第一排最右侧,设该像素点为 X,让 X 位于模板中心:

那么原图像被模板覆盖的除了 X 还有 1、2、3 共 4 个像素点,只要有 1 个是黑色,X 就要被涂成黑色。整个原图中只有 A、B、C 三个像素作为中心的 3 * 3 矩形全部为白色,因此腐蚀后的效果就像右边的图那样。

  • 膨胀(白色膨胀占领黑色)与腐蚀操作相反:

  • 开操作是先腐蚀,再膨胀:

  • 闭操作是先膨胀,再腐蚀:

可以看到,闭操作将两个分开的部分融合成一个部分,这正是我们要做的把车牌字符的各个部分融合为整个车牌的轮廓:

cpp 复制代码
void SobelLocator::locate(Mat src, vector<Mat>& dst_plates)
{
	// 1.高斯模糊
	// 2.灰度化
	// 3.Sobel 运算
	// 4.二值化(非黑即白,对比更强烈)
	// 5.形态学操作中的闭操作
    Mat close;
	// 获取模板,模板类型是矩形
	Mat element = getStructuringElement(MORPH_RECT, Size(17, 3));
	// MORPH_CLOSE 表示进行闭操作
	morphologyEx(shold, close, MORPH_CLOSE, element);
	imshow("close", close);

}

效果如下:

可以看到原本车牌字符是分开的,现在都融合到一个车牌轮廓之中。

3.6 找轮廓

将连通域的外围画出来,便于形成外接矩形。这个矩形我们认为是可以有旋转角度的矩形,不一定是与 X 或 Y 正方向垂直的矩形。

在找轮廓的过程中会用到如下几个 API:

  • findContours() 用于查找轮廓:

    cpp 复制代码
    /** @brief 在二值图像中查找轮廓
    
    该函数使用 @cite Suzuki85 算法从二值图像中提取轮廓。轮廓是形状分析和目标检测与识别的有用工具。
    请参阅 OpenCV 示例目录中的 squares.cpp。
    
    @note 自 OpenCV 3.2 起,此函数不会修改源图像。
    
    @param image 输入的 8 位单通道图像。非零像素被视为 1。零像素保持为 0,因此图像被视为二值图像。
    			您可以使用 #compare、#inRange、#threshold、#adaptiveThreshold、#Canny 等函数
    			将灰度图像或彩色图像转换为二值图像。如果 mode 等于 #RETR_CCOMP 或 #RETR_FLOODFILL,
    			则输入也可以是标签的 32 位整数图像(CV_32SC1)。
    @param contours 检测到的轮廓。每个轮廓以点的向量形式存储(例如 std::vector<std::vectorcv::Point>)。
    @param hierarchy 可选的输出向量(例如 std::vectorcv::Vec4i),包含关于图像拓扑的信息。它的元素个数
    				与轮廓的数量相同。对于每个 i-th 轮廓 contours[i],元素 hierarchy[i][0]、
    				hierarchy[i][1]、hierarchy[i][2] 和 hierarchy[i][3] 被设置为同一层次级别上下一个
    				轮廓、前一个轮廓、第一个子轮廓和父轮廓的0-based索引。如果轮廓 i 没有下一个、上一个、
    				父轮廓或嵌套轮廓,则 hierarchy[i] 的相应元素将为负数。
    @param mode 轮廓检索模式,参见 #RetrievalModes
    @param method 轮廓逼近方法,参见 #ContourApproximationModes
    @param offset 可选的偏移量,用于将每个轮廓点平移。如果轮廓是从图像 ROI 中提取的,
    			然后在整个图像上下文中进行分析,这将非常有用。
     */
    CV_EXPORTS_W void findContours( InputArray image, OutputArrayOfArrays contours,
                                  OutputArray hierarchy, int mode,
                                  int method, Point offset = Point());
    
    /** @overload */
    CV_EXPORTS void findContours( InputArray image, OutputArrayOfArrays contours,
                                  int mode, int method, Point offset = Point());
  • minAreaRect() 用于查找最小面积旋转矩形:

    cpp 复制代码
    /** @brief 寻找包围输入2D点集的最小面积旋转矩形。
    
    该函数计算并返回指定点集的最小面积边界矩形(可能是旋转的)。开发者需要注意,
    返回的旋转矩形在数据接近Mat元素边界时可能包含负索引。
    
    @param points 输入的 2D 点集向量,存储在 std::vector<> 或 Mat 中
     */
    CV_EXPORTS_W RotatedRect minAreaRect( InputArray points );

minAreaRect函数用于找到能够包围给定的 2D 点集的最小面积旋转矩形。

该函数计算并返回一个最小面积的包围矩形(可能是旋转的),用于指定的点集。开发者应该注意,返回的RotatedRect对象可能包含负索引,当数据接近Mat元素边界时。

函数接受一个输入参数points,表示 2D 点的输入向量或矩阵。这些点可以使用std::vector<>Mat来存储。

函数返回一个RotatedRect类型的对象,表示最小面积的旋转矩形。RotatedRect是一个包含旋转矩形相关信息的类,包括旋转矩形的中心坐标、宽度、高度和旋转角度等。

需要注意的是,当数据接近Mat元素边界时,返回的RotatedRect对象可能包含负索引。开发者在使用返回的旋转矩形时需要注意这一点。

cpp 复制代码
void SobelLocator::locate(Mat src, vector<Mat>& dst_plates)
{
	// 1.高斯模糊
	// 2.灰度化
	// 3.Sobel 运算
	// 4.二值化(非黑即白,对比更强烈)
	// 5.形态学操作中的闭操作
    ...

	// 6.找轮廓
	// vector<Point>是点的集合,可以连成线,线的集合就是轮廓了
	vector<vector<Point>> contours;
	findContours(close, // 闭操作后的图像
		contours, // 轮廓,接收结果
		RETR_EXTERNAL, // 轮廓检索模式:外轮廓
		CHAIN_APPROX_NONE // 轮廓近似算法模式:不进行轮廓近似,保留所有的轮廓点
	);

	// 一张图片会有多个轮廓,遍历将其画出
	RotatedRect rotatedRect;
	for each (vector<Point> points in contours) {
		rotatedRect = minAreaRect(points);
        // 在原图 src 上画一个红色的、包围旋转矩形的最小正立整数矩形
		rectangle(src, rotatedRect.boundingRect(), Scalar(0, 0, 255));
	}
	imshow("找轮廓", src);
    ...
}

可以看到,在原图中找出了大大小小 N 多个轮廓:

3.7 尺寸判断

从上图看出,经过轮廓查找,一张图片中可以找出很多个轮廓,但是有很多从大小上判断,就明显就不可能是车牌。所以我们通过尺寸判断的方式,初步筛选排除不可能是车牌的矩形(中国车牌的一般大小是 440mm * 140mm,宽高比为 3.14)。

由于尺寸判断对于车牌定位而言是一个通用操作,因此将其放在基类 PlateLocator 作为一个 protected 成员(方便特定算法子类覆盖):

cpp 复制代码
/**
* 通过宽高比和面积两个方面校验传入的 RotatedRect 是否可能为车牌
*/
bool PlateLocator::verifySizes(RotatedRect rotatedRect)
{
    // 容错率
    float error = 0.75;
    // 理想宽高比(训练样本使用的车牌规格为 136,36,因此将其用作理想宽高比计算)
    float aspect = float(136) / float(36);
    // 利用容错率计算出最小宽高比与最大宽高比
    float aspectMin = (1 - error) * aspect;
    float aspectMax = (1 + error) * aspect;
    // 真实宽高比
    float realAspect = float(rotatedRect.size.width) / float(rotatedRect.size.height);
    if (realAspect < 1)
    {
        realAspect = float(rotatedRect.size.height) / float(rotatedRect.size.width);
    }

    // 真实面积
    float area = rotatedRect.size.width * rotatedRect.size.height;
    // 最小面积与最大面积,不符合的丢弃
    // 给个大概就行,可以随时调整,给大一点也没关系,这只是初步筛选
    int areaMin = 44 * aspect * 14;
    int areaMax = 440 * aspect * 140;

    // 刨除合法范围之外的
    if ((area < areaMin || area > areaMax) || (realAspect < aspectMin || realAspect > aspectMax))
    {
        return false;
    }
    return true;
}

在找轮廓的过程中会遍历形成矩形,用 verifySizes() 判断这些矩形是否符合规格,符合的存入 vec_sobel_rects 集合中:

cpp 复制代码
	// 6.找轮廓
	// vector<Point>是点的集合,可以连成线,线的集合就是轮廓了
	vector<vector<Point>> contours;
	findContours(close, // 闭操作后的图像
		contours, // 轮廓,接收结果
		RETR_EXTERNAL, // 轮廓检索模式:外轮廓
		CHAIN_APPROX_NONE // 轮廓近似算法模式:不进行轮廓近似,保留所有的轮廓点
	);

	// 一张图片会有多个轮廓,遍历将其画出
	RotatedRect rotatedRect;
	vector<RotatedRect> vec_sobel_rects;
	for each (vector<Point> points in contours) {
		rotatedRect = minAreaRect(points);
		// 在原图 src 上画一个红色的、包围旋转矩形的最小正立整数矩形
		rectangle(src, rotatedRect.boundingRect(), Scalar(0, 0, 255));
		// 7.尺寸判断,符合规格的放入 vec_sobel_rects 集合中
		if (verifySizes(rotatedRect)) {
			vec_sobel_rects.push_back(rotatedRect);
		}
	}
	imshow("找轮廓", src);

	// 用绿色矩形画出符合尺寸规格的轮廓
	for each (RotatedRect rect in vec_sobel_rects)
	{
		rectangle(src, rect.boundingRect(), Scalar(0, 255, 0));
	}
	imshow("尺寸判断", src);

经过尺寸判断,有可能为车牌的矩形被画成绿色:

3.8 矩形矫正

由于图片中的车牌不可能都像上面那样是正向的,也有可能是斜向的,比如下面这种:

那么在车牌定位阶段,就势必要对车牌轮廓的矩形进行旋转调整到水平正向位置,在旋转时还要注意不能超出原图的范围,最后还要将车牌图片调整到合适的大小方便后续识别。因此矩形矫正主要包括三方面内容:

  1. 获取一个范围安全(不会超过原图范围)的矩形
  2. 将偏斜的车牌调整为水平,为后面的车牌判断与字符识别提高成功率
  3. 调整车牌图片为合适大小为,确保候选车牌与导入机器学习模型之前尺寸一致,方便后续进行车牌字符识别

进入代码。上一步我们得到了尺寸校验合格的矩形集合 vec_sobel_rects,接下来就对这些矩形进行校正,并将校正结果存入 SobelLocator::locate() 的参数 dst_plates 中:

cpp 复制代码
void SobelLocator::locate(Mat src, vector<Mat>& dst_plates)
{
    ...
    // 8.矩形矫正
	tortuosity(src, vec_sobel_rects, dst_plates);
    // 查看校正结果
	for each (Mat m in dst_plates)
	{
		imshow("Sobel 定位候选车牌", m);
		// 通过输入任意按键查看每一个候选车牌
		waitKey();
	}
}

tortuosity() 是校正操作的入口,包含了上面提到的三种矫正方法:

cpp 复制代码
void PlateLocator::tortuosity(Mat src, vector<RotatedRect>& rects, vector<Mat>& dst_plates)
{
    // 遍历要处理的矩形
    for each (RotatedRect rect in rects)
    {
        // 矩形角度
        float angle = rect.angle;
        float r = float(rect.size.width) / float(rect.size.height);
        if (r < 1)
        {
            angle = 90 + angle;
        }

        // 1.让 rect 在一个安全范围内,不要超过 src
        Rect2f safe_rect;
        safeRect(src, rect, safe_rect);

        // 在原图片上画出一个矩形区域
        Mat src_rect = src(safe_rect);
        // 真正的候选车牌图
        Mat dst;
        
        // 2.旋转
        if (angle - 5 < 0 && angle + 5 > 0)
        {
            // 旋转角度在 X 轴顺时针 5° 到逆时针 5° 的范围内就不用旋转了
            dst = src_rect.clone();
        }
        else {
            // 矩形相对于 safe_rect 的中心点坐标
            Point2f ref_center = rect.center - safe_rect.tl();
            Mat rotated_mat;
            // 旋转,结果保存在 rotated_mat 中
            rotation(src_rect, rotated_mat, rect.size, ref_center, angle);
            dst = rotated_mat;
        }

        // 3.调整大小
        Mat plate_mat;
        plate_mat.create(36, 136, CV_8UC3);
        resize(dst, plate_mat, plate_mat.size());

        dst_plates.push_back(plate_mat);
        dst.release();
    }
}

136 * 36 是我们训练车牌识别模型时使用的车牌样本图片宽高,为了提高识别率,我们在最后输出车牌定位结果时,也将图片宽高设置为 136 * 36。

计算范围安全的矩形:

cpp 复制代码
/**
* 获取一个范围安全(不会超过 src)的矩形
*/
void PlateLocator::safeRect(Mat src, RotatedRect rotatedRect, Rect2f& safe_rect)
{
    // RotatedRect 不含坐标信息,转换为带坐标的 Rect2f
    Rect2f boundRect = rotatedRect.boundingRect2f();

    // 左上角坐标为 (t1_x,t1_y)
    float t1_x = boundRect.x > 0 ? boundRect.x : 0;
    float t1_y = boundRect.y > 0 ? boundRect.y : 0;

    // 右下角坐标为 (br_x,br_y)
    float br_x = boundRect.x + boundRect.width < src.cols ?
        boundRect.x + boundRect.width - 1
        : src.cols - 1;
    float br_y = boundRect.y + boundRect.height < src.rows ?
        boundRect.y + boundRect.height - 1
        : src.rows - 1;

    // 计算转换后的矩形宽高
    float width = br_x - t1_x;
    float height = br_y - t1_y;
    if (width <= 0 || height <= 0)
    {
        return;
    }

    // 创建结果矩形给接收参数
    safe_rect = Rect2f(t1_x, t1_y, width, height);
}

旋转:

cpp 复制代码
void PlateLocator::rotation(Mat src, Mat& dst, Size rect_size, Point2f center, double angle)
{
    // 获得以 center 为中心、angle 为角度、不进行缩放的旋转矩阵
    Mat rot_mat = getRotationMatrix2D(center, angle, 1);

    // 旋转后的结果
    Mat mat_rotated;

    // 校正后大小会不一样,但是对角线一定能容纳
    int max = sqrt(pow(src.cols, 2) + pow(src.rows, 2));

    // 运用仿射变换
    warpAffine(src, mat_rotated, rot_mat, Size(max, max), INTER_CUBIC);

    if (debug)
    {
        imshow("旋转前", src);
        imshow("旋转后", mat_rotated);
    }

    // 截取,尽量把车牌多余的区域截取掉
    getRectSubPix(mat_rotated, Size(rect_size.width, rect_size.height), center, dst);
    if (debug)
    {
        imshow("截取后", dst);
    }

    // 注意,在调试时留一个 waitKey() 就可以通过输入任意按键查看所有
    // 备选矩形,因为我们这个 rotation 是在 tortuosity 的循环中调用的
    if (debug)
    {
        waitKey();
    }

    // 释放
    mat_rotated.release();
    rot_mat.release();
}

旋转过程用到了仿射变换,可以参考 OpenCV 官方的仿射变换教程

至此,车牌定位完成,我们可以运行 Demo 来看一下效果:

四张图从左至右依次是原图、旋转前、旋转后、截取后的图片效果,可以看到第一组并没有得到正确的车牌。结果一共有三组,后两组效果如下:

可以看到第二组是正确地定位了车牌的。

4、HSV 颜色模型定位车牌

我们使用手中现有的车牌图片进行车牌定位,发现 HSV 颜色模型的定位准确性要比 Sobel 好一些,因此我们也介绍一下这种定位方式。

利用 HSV 模型定位的要点:识别车牌的蓝色底色,将蓝色亮度调到最高,其余颜色亮度调低,以实现车牌定位的效果。

4.1 HSV 简介

为了找出图像中的蓝色部分,需要检查 RGB 分量中的 Blue 分量就可以了。一般 Blue 分量是 0~255 的值,即便蓝色分量是 255 了,由于另外两个分量的影响,需要考虑各个分量的配比问题,RGB 作为颜色判断很难实现,于是就有了 HSV 模型和 Photoshop 中的 HSB 模型。

HSV(Hue, Saturation, Value)模型和 HSB(Hue, Saturation, Brightness)模型是描述颜色的模型,它们在表示颜色的方式上是相似的,但在数学计算上有细微的差异。

  1. HSV 模型是根据颜色的直观特性由 A. R. Smith 在 1978 年创建的一种颜色空间,也称六角锥体模型(Hexcone Model)。这个模型中颜色的参数分别是:色调(H),饱和度(S),明度(V):

    • Hue(色调):表示颜色的基本属性,即我们通常所说的颜色名称,如红色、绿色、蓝色等。在 HSV 模型中,色相用角度衡量,取值范围是 0 到 360 度,对应着色轮的角度。从红色开始按逆时针方向计算,红色为 0°,绿色为120°,蓝色为240°;它们的补色是:黄色为 60°,青色为 180°,品红为 300°
    • Saturation(饱和度):表示颜色的纯度或者灰度的程度。饱和度越高,颜色越鲜艳纯净;饱和度越低,颜色越接近灰色。在 HSV 模型中,饱和度的取值范围是 0 到 1 之间,其中 0 表示灰度,1 表示最大饱和度。表示颜色接近光谱色的程度。一种颜色,可以看成是某种光谱色与白色混合的结果。其中光谱色所占的比例愈大,颜色接近光谱色的程度就愈高,颜色的饱和度也就愈高。饱和度高,颜色则深而艳。光谱色的白光成分为 0,饱和度达到最高。通常取值范围为 0%~100%,值越大,颜色越饱和。
    • Value(亮度):表示颜色的亮度或者明暗程度。在 HSV 模型中,亮度的取值范围是 0 到 1 之间,其中 0 表示黑色,1 表示最大亮度。
  2. HSB 模型:

    • Hue(色调):与 HSV 模型中的色相相同,表示颜色的基本属性。
    • Saturation(饱和度):与 HSV 模型中的饱和度相同,表示颜色的纯度或者灰度的程度。
    • Brightness(亮度):与 HSV 模型中的明度相同,表示颜色的亮度或者明暗程度。在 HSB 模型中,明度的取值范围是 0 到 1 之间,其中 0 表示黑色,1 表示最大亮度。

HSV 模型和 HSB 模型在实际应用中可以互换使用,但需要注意它们之间的命名差异和数值范围的不同。

OpenCV 中 HSV 数据与原始定义略有不同,数据类型为 8UC,取值分别为 0 ~ 180、0 ~ 255、0 ~ 255,蓝色的范围是 100 ~ 124:

4.2 代码实现

Sobel 算法实现车牌定位的前三步为高斯模糊、灰度化、Sobel 运算,然后进行二值化运算。HSV 与 Sobel 的不同之处就在于前三步,从第四步二值化开始的后续步骤是相同的。

HSV 的前三步为:

  1. 预处理:将原图从 BGR 颜色空间转换为 HSV 颜色空间
  2. 遍历 HSV 像素点,凸显背景车牌颜色。我们这里是使用蓝色车牌举例的
  3. 分离 V 分量,为二值化做准备

参考代码如下:

cpp 复制代码
#include "ColorLocator.h"

void ColorLocator::locate(Mat src, vector<Mat>& dst_plates)
{
	// 1.预处理
	Mat hsv;
	// 将 BGR 颜色空间转换成 HSV 颜色空间
	cvtColor(src, hsv, COLOR_BGR2HSV);
	if (debug)
	{
		imshow("src", src);
		imshow("hsv", hsv);
	}
	
	// 2.遍历 HSV 图片像素点,凸显出蓝色像素点
	// 获取 HSV 通道数,实际为 3 个,即 H、S、V 各一个
	int channels = hsv.channels();
	// HSV 图片高度为像素行数,宽度为像素列数 * 每个像素的通道数
	int height = hsv.rows;
	int width = hsv.cols * channels;
	// 假如是连续存储,可以将多行多列转换为一行多列来处理
	if (hsv.isContinuous())
	{
		width *= height;
		height = 1;
	}
	// 开始遍历 HSV 图像矩阵
	uchar* p;
	// 被检查的像素点是否为蓝色
	bool isBlue = false;
	for (int i = 0; i < height; i++)
	{
		// 获取第 i 行的数据
		p = hsv.ptr<uchar>(i);
		// 遍历像素点,由于每个像素点有 HSV 3 个通道,因此每个点要 +3
		for (int j = 0; j < width; j += 3)
		{
			int h = p[j];
			int s = p[j + 1];
			int v = p[j + 2];
			// 检查 H、S、V 分量是否符合蓝色特征值
			if (h >= 100 && h <= 124 && s >= 43 && s <= 255 && v >= 46 && v <= 255)
			{
				isBlue = true;
			}
			else
			{
				isBlue = false;
			}
			// 如果是蓝色,就将其凸显,否则变黑
			if (isBlue)
			{
				p[j] = 0;
				p[j + 1] = 0;
				p[j + 2] = 255;
			}
			else
			{
				p[j] = 0;
				p[j + 1] = 0;
				p[j + 2] = 0;
			}
		}
	}
	if (debug)
	{
		imshow("凸显蓝色", hsv);
	}

	// 3.分离 V 分量,达到 Sobel 二值化之前的效果
	vector<Mat> hsv_split;
	// 对图像按通道进行分离
	split(hsv, hsv_split);
	if (debug)
	{
		imshow("分离V分量", hsv_split[2]);
	}

	// 4.二值化,从这里开始步骤与 Sobel 相同了
	Mat shold;
	// OTSU:大律法,自适应阈值;THRESH_BINARY:正二值化;THRESH_BINARY_INV:反二值化
	// 蓝色车牌是背景深、字符浅,如果是黄色车牌黑色字,那就是背景浅字符深,就要用反二值化
	threshold(hsv_split[2], shold, 0, 255, THRESH_OTSU + THRESH_BINARY);
	if (debug)
	{
		imshow("shold", shold);
	}

	// 5.形态学操作中的闭操作
	Mat close;
	// 获取模板,模板类型是矩形
	Mat element = getStructuringElement(MORPH_RECT, Size(17, 3));
	// MORPH_CLOSE 表示进行闭操作
	morphologyEx(shold, close, MORPH_CLOSE, element);
	if (debug)
	{
		imshow("HSV闭操作", close);
	}

	// 6.找轮廓
	// vector<Point>是点的集合,可以连成线,线的集合就是轮廓了
	vector<vector<Point>> contours;
	findContours(close, // 闭操作后的图像
		contours, // 轮廓,接收结果
		RETR_EXTERNAL, // 轮廓检索模式:外轮廓
		CHAIN_APPROX_NONE // 轮廓近似算法模式:不进行轮廓近似,保留所有的轮廓点
	);

	// 一张图片会有多个轮廓,遍历将其画出
	RotatedRect rotatedRect;
	vector<RotatedRect> vec_sobel_rects;
	// 使用 vector 迭代器遍历,这里与 Sobel 方式不同
	vector<vector<Point>>::iterator it = contours.begin();
	while (it != contours.end())
	{
		rotatedRect = minAreaRect(*it);
        // 在原图 src 上画一个红色的、包围旋转矩形的最小正立整数矩形
		rectangle(src, rotatedRect.boundingRect(), Scalar(0, 0, 255));
        // 7.尺寸判断,符合规格的放入 vec_sobel_rects 集合中
		if (verifySizes(rotatedRect)) {
			vec_sobel_rects.push_back(rotatedRect);
			++it;
		}
		else
		{
			it = contours.erase(it);
		}
	}

	if (debug)
	{
		imshow("HSV找轮廓", src);
	}

	// 用绿色矩形画出符合尺寸规格的轮廓
	for each (RotatedRect rect in vec_sobel_rects)
	{
		rectangle(src, rect.boundingRect(), Scalar(0, 255, 0));
	}
	if (debug)
	{
		imshow("尺寸判断", src);
	}

	// 8.矩形矫正
	tortuosity(src, vec_sobel_rects, dst_plates);
	for each (Mat m in dst_plates)
	{
		imshow("HSV 定位候选车牌", m);
		// 通过输入任意按键查看每一个候选车牌
		waitKey();
	}

	// 释放
	hsv.release();
	for each (Mat m in hsv_split)
	{
		m.release();
	}
	shold.release();
	close.release();
	element.release();
}

原图、转换为 HSV、凸显蓝色的效果图如下:

对图像按通道进行分离可以是对 BGR 进行分离,也可以是对 HSV 进行分离。以 BGR 为例,分离的示意图如下:

对于 HSV 而言,分离结果就是将像素中每个点的 H、S、V 分量提取到各自的数组中,代码中的 hsv_split[2] 就是 V 分量的数组。

虽然 HSV 后续步骤与 Sobel 相同,但是 HSV 的定位效果却要比 Sobel 好。HSV 的闭操作、找轮廓都要比 Sobel 更准确,并且最后得出的候选车牌,Sobel 有三组,而 HSV 直接就是车牌的那一组:

相关推荐
GitLqr39 分钟前
Android - 云游戏本地悬浮输入框实现
android·开源·jitpack
周周的Unity小屋40 分钟前
Unity实现安卓App预览图片、Pdf文件和视频的一种解决方案
android·unity·pdf·游戏引擎·webview·3dwebview
单丽尔3 小时前
Gemini for China 大更新,现已上架 Android APP!
android
JerryHe4 小时前
Android Camera API发展历程
android·数码相机·camera·camera api
Synaric6 小时前
Android与Java后端联调RSA加密的注意事项
android·java·开发语言
程序员老刘·6 小时前
如何评价Flutter?
android·flutter·ios
JoyceMill8 小时前
Android 图像效果的奥秘
android
取名真难.9 小时前
人脸检测(Python)
python·opencv·计算机视觉
想要打 Acm 的小周同学呀9 小时前
ThreadLocal学习
android·java·学习
天下是个小趴菜10 小时前
蚁剑编码器编写——中篇
android