OpenCV(二)—— 车牌定位

news2024/12/27 12:07:30

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

1、概述

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

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

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

流程

2、项目搭建

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

2.1 项目配置

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

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 是车牌识别器,传入一个车牌图像会返回车牌号:

    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 如何使用:

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

@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() 对原图进行高斯模糊:

/**
* 车牌定位,输入原图 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);
    ...
}

对比效果如下:

2024-4-2.高斯模糊效果

3.2 灰度化

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

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

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

灰度化效果:

2024-4-2.灰度化效果

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

3.3 Sobel Derivatives 运算

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

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

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

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

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 运算后的效果:

2024-4-2.Sobel计算效果

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

3.4 二值化

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

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

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

/** 对每个数组元素应用固定级别的阈值。

该函数对多通道数组应用固定级别的阈值处理。通常,该函数用于将灰度图像转换为
二值图像(也可以使用 #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() 进行二值化:

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);
}

效果:

2024-4-2.二值化效果

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

3.5 形态学操作(闭操作)

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

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

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

在这里插入图片描述

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

在这里插入图片描述

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

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

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

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

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

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);

}

效果如下:

2024-4-2.求轮廓(闭操作)效果

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

3.6 找轮廓

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

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

  • findContours() 用于查找轮廓:

    /** @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() 用于查找最小面积旋转矩形:

    /** @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对象可能包含负索引。开发者在使用返回的旋转矩形时需要注意这一点。

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 多个轮廓:

2024-4-2.找轮廓效果

3.7 尺寸判断

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

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

/**
* 通过宽高比和面积两个方面校验传入的 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 集合中:

	// 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);

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

2024-4-2.尺寸判断效果

3.8 矩形矫正

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

2024-4-2.斜向车牌

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

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

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

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() 是校正操作的入口,包含了上面提到的三种矫正方法:

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。

计算范围安全的矩形:

/**
* 获取一个范围安全(不会超过 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);
}

旋转:

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 来看一下效果:

2024-4-2.车牌定位效果图1

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

2024-4-2.车牌定位效果图2

2024-4-2.车牌定位效果图3

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

4、HSV 颜色模型定位车牌

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

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

4.1 HSV 简介

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

HSV 色轮模型图

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:

hsv范围

4.2 代码实现

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

HSV 的前三步为:

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

参考代码如下:

#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、凸显蓝色的效果图如下:

2024-4-3.HSV凸显蓝色效果

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

2024-4-3.OpenCV的split

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

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

2024-4-3.两种算法闭操作对比

2024-4-3.两种算法找轮廓对比

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1638642.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

碳纤维复合材料的纳米纤维膜

碳纤维复合材料的纳米纤维膜是一种具有良好性能和应用前景的新材料。以下是关于这种材料的详细介绍&#xff1a; 制备方法&#xff1a;碳纤维复合材料的纳米纤维膜可以通过多种方法制备&#xff0c;包括化学气相沉积法、固相合成法、模板法等。其中&#xff0c;化学气相沉积法是…

Docker——部署LNMP架构

目录 一、LNMP架构概述 1.项目环境 2.服务器环境 3.需求 二、搭建Linux系统基础镜像 三、部署Nginx 1.建立工作目录 2.编写Dockerfile脚本 3.准备Nginx.conf配置文件 4.生成镜像 5.创建自定义网络 6.启动镜像容器 7.验证Nginx 三、部署Mysql 1.建立工作目录 2.编…

MathType打开的窗口太多 MathType说打开窗口太多无法复制怎么解决

在数学文档编辑中&#xff0c;MathType作为一款常用的数学公式编辑工具&#xff0c;使用过程中&#xff0c;我们常常会遇到一些问题&#xff0c;比如MathType打开的窗口过多导致软件运行缓慢甚至崩溃&#xff0c;以及在复制过程中出现“打开窗口太多&#xff0c;无法复制”的提…

2024年教你怎么将学浪视频保存到本地

你是否曾为无法将学浪视频保存到本地而烦恼&#xff1f;现在&#xff0c;我们将在2024年教给你如何解决这个问题&#xff01;只需简单几步操作&#xff0c;即可轻松将学浪视频保存到您的本地设备&#xff0c;随时随地想看就看&#xff01; 我已经将下载学浪的工具打包好了&…

使用FPGA实现并行乘法器

介绍 并行乘法器&#xff0c;那么它的输入输出就都是并行的数据了&#xff0c;相对来说&#xff0c;内部的结构就更复杂了&#xff0c;占用的资源就更多了。以后有需要完成这部分操作的话都可以调用IP核。 乘法器模块 这是一个纯组合逻辑电路&#xff0c;我们也知道&#xff0…

C++--const成员及const取地址操作符重载

前言 今天我们来了解一下const成员的基本使用,以及const取地址重载的运用 来开始今天的学习 const成员 1.基本定义, 将const修饰的“成员函数”称之为const成员函数&#xff0c;const修饰类成员函数&#xff0c;实际修饰该成员函数 隐含的*this指针&#xff0c;表明在该成员函…

队列以及信号量

什么是队列 队列又称消息队列&#xff0c;是一种常用于任务间通信的数据结构&#xff0c;队列可以在任务与任务间、中断和任 务间传递信息。 为什么不使用全局变量&#xff1f; 如果使用全局变量&#xff0c;兔子&#xff08;任务1&#xff09;修改了变量 a &#xff0c;等待树…

LeetCode LCR 179. 和为s的两个数字

原题链接&#xff1a;LCR 179. 查找总价格为目标值的两个商品 - 力扣&#xff08;LeetCode&#xff09; 题目的意思&#xff1a;通过给定的数组&#xff0c;找出两个值&#xff0c;相加并等于目标值。 第一种思路&#xff0c;暴力枚举&#xff0c;伪代码如下&#xff1a; for (…

【Linux】详解信号的保存信号屏蔽字的设置

一、信号处理的一些常见概念 实际执行信号的处理动作称为信号递达(Delivery)。信号从产生到递达之间的状态,称为信号未决(Pending)。进程可以选择阻塞 (block )某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。注意&#xff1a;阻…

传感器测试脉冲电源 —测试传感器性能的电源设备

传感器测试脉冲电源是一种专门用于测试传感器性能的电源设备。传感器测试脉冲电源可以输出不同幅值、频率和形状的脉冲信号&#xff0c;以模拟传感器在实际应用中可能遇到的各种电压和电流波形。通过这种电源&#xff0c;可以对传感器进行全面的性能测试&#xff0c;包括动态响…

信号,信号列表,信号产生方式,信号处理方式

什么是信号 信号在我们的生活中非常常见&#xff1b;如红绿灯&#xff0c;下课铃&#xff0c;游戏团战信号&#xff0c;这些都是信号&#xff1b;信号用来提示接收信号者行动&#xff0c;但接收信号的人接收到信号会进行一系列的行为&#xff0c;完成某个动作&#xff1b;这就…

qt学习篇---界面按键关联(信号和槽)

目录 1.qt基础 2.做一个界面 创建project UI界面设计 信号和槽 1.控件改名字 2.什么是信号和槽 3.怎么关联信号和槽 自动关联 手动关联 1.qt基础 qt可移植性强&#xff0c;不久会用到MCU。很有意义学习 2.做一个界面 创建project 不要中文路径 选择QWidget .pro文件…

字符串函数与字符函数运用(1)

字符串与字符函数介绍1 前言一、字符分类函数字符函数练习 二、字符函数转换1.引入库2.代码改进 字符串函数strlen函数strcpy 结尾 前言 字符串函数大概有以下这几种 strcpy、strcat 、strcmp、strncpy、strncat、strncmp、strstr、strtok、strerror 这些函数可以很好的解决你…

数据结构的队列(c语言版)

一.队列的概念 1.队列的定义 队列是一种常见的数据结构&#xff0c;它遵循先进先出的原则。类似于现实生活中排队的场景&#xff0c;最先进入队列的元素首先被处理&#xff0c;而最后进入队列的元素则要等到前面的元素都被处理完后才能被处理。 在队列中&#xff0c;元素只能…

什么,你的EasyExcel导出一万条数据就OOM了?

前言 前段时间在做一个导出的功能&#xff0c;本以为是平平无奇的一个功能。就用公司内部的一个导出工具类三下五除二就写完了&#xff0c;做法是直接查全量数据&#xff0c;然后直接往Excel里写。一开始没多少数据也没什么问题&#xff0c;但是当数据量逐渐多了起来后&#x…

【OpenCV • c++】图像平滑处理(2) —— 方框滤波 | 盒滤波 | 源码分析

文章目录 前言一、方框滤波代码演示 二、源码分析 前言 前文我们了解了什么是图像平滑处理、图像滤波、邻域算子与线性邻域滤波、以及如何使用方框滤波&#xff0c;本文我们来分析一下方框滤波的源码。 一、方框滤波 void boxFilter(InputArray src, OutputArray dst, int ddep…

面试常见 | 项目上没有亮点,如何包装?

很多技术人在公司用的老技术&#xff0c;而且很多都是搬业务代码且做枯燥乏味的CRUD&#xff0c;在面试提交简历或做自我介绍的时候并不突出&#xff0c;这种情况&#xff0c;如何破局&#xff1f; 首先不管你做的啥项目&#xff0c;全世界不可能只有你自己在做&#xff0c;比…

Android Handler用法

Android Handler用法 为什么要设计Handler机制&#xff1f;Handler的用法1、创建Handler2、Handler通信2.1 sendMessage 方式2.2 post 方式 Handler常用方法1、延时执行2、周期执行 HandlerThread用法主线程-创建Handler子线程-创建Handler FAQMessage是如何创建主线程中Looper…

Agent AI智能体的未来

未来社会中的智能使者&#xff1a;Agent AI智能体的可能性与挑战 随着科技的迅速进步&#xff0c;人工智能已深入我们生活的各个领域&#xff0c;而Agent AI智能体作为与人工智能紧密相关的一个分支&#xff0c;其未来发展无疑是值得期待的。Agent AI智能体&#xff0c;或称为…

JAVA Coding 规范

Coding 规范 文章目录 Coding 规范一.文件规范1.1 声明1.2 缩进1.3 空行1.4 空格1.5 对齐1.6 小括号1.7 花括号1.8 代码长度 二.命名规范2.1 命名总则2.2 命名空间2.3 类与接口2.4 方法命名2.5 属性命名2.6 常量命名2.7 变量命名 三.语句规范3.1 语句总则3.2 循环语句3.3 Switc…