综述
最近在研究基于PPOCR算法的车牌识别(LPR),部署模型后发现之前关于OCR文本定位的后处理策略在车牌识别中存在定位精度不够高,文本框偏移的问题,如:
经分析发现是之前的OCR后处理策略存在一定局限:即获取最小外接矩形难以应对侧拍导致的车牌形状平行四边形化和梯形化问题。需要优化这一后处理策略。
改进策略
经过我一段时间的调研、代码研究与测试,需要对这一策略做如下改进:
- 1、修改车牌定位模型推理提取文本区域概率图轮廓提取算法中的method以保证轮廓点的连续性:CHAIN_APPROX_SIMPLE->CHAIN_APPROX_NONE
关键代码:
//获取轮廓集合
std::vector<std::vector<cv::Point>> contours;
std::vector<cv::Vec4i> hierarchy;
cv::findContours(bitmap, contours, hierarchy, cv::RETR_LIST,cv::CHAIN_APPROX_NONE);
- 2、在进行一次轮廓点最小外接矩形提取后,以提取到的最小外接矩形上下两边的中心点和宽高乘以一个权重,来构造两个新的外接矩形,并且要保证分别把上下两边附近的大部分轮廓点包含进来,但是一个矩形区域不能同时包含上下两边的轮廓点
关键代码:
//获取最小外接矩形轮廓
float ssid;
cv::RotatedRect box = cv::minAreaRect(contours[_i]);
auto array = get_mini_boxes(box, ssid);
if (ssid < min_size) {
continue;
}
//获取轮廓分数
float score = box_score_fast(array, pred);
if (score < boxScoreThresh) {
continue;
}
// 构造新的外接矩形,为后续拟合直线做准备
std::vector<cv::Point> sortedcurve = {cv::Point(array[0][0],array[0][1]),cv::Point(array[1][0],array[1][1]),cv::Point(array[2][0],array[2][1]),cv::Point(array[3][0],array[3][1])};
std::vector<cv::RotatedRect> masks = make_RotateRectMask(box.size.width>box.size.height?box.size.width:box.size.height, box.size.width>box.size.height?box.size.height:box.size.width, sortedcurve);
- 3、针对第一次轮廓点最小外接矩形进行一次多边形加权扩张与重新外接矩形调整,记录新的外接矩形左右两侧顶点坐标,以最小二乘法算法求取上一步中构造的两个矩形区域内所有点的拟合直线,并记录拟合直线的斜率值
关键代码:
//加权最小外接矩形轮廓拟合
float perimeter = 2.f * (box.size.width + box.size.height);
cv::RotatedRect clipbox = unclip(array, unClipRatio, perimeter);
//轮廓坐标顺序调整
auto cliparray = get_mini_boxes(clipbox, ssid);
if (ssid < min_size + 2) {
continue;
}
//求取车牌文本热力图边缘直线
//记录旋转矩形框内待拟合散点坐标集合
std::vector<std::vector<cv::Point>> edgeslines = Actual_line(rotateRectMasks, contours[_i]);
//根据散点坐标集合使用最小二乘法拟合对应的直线
cv::Vec4f line1_2 = fit_edge_line(edgeslines[0]);
cv::Vec4f line3_4 = fit_edge_line(edgeslines[1]);
//提取拟合直线斜率值
float k12 = line1_2[1]/line1_2[0];
float k34 = line3_4[1]/line3_4[0];
- 4、根据上一步求取的上下两边斜率值来估算文本区域两侧边哪一边为长边,哪一边为短边,取长边一侧的对应顶点坐标和上下两边拟合直线的斜率构造两条新的直线,且该直线须经过较长侧边的一个顶点
关键代码:
//记录车牌左右两端边缘顶点坐标
std::vector<cv::Vec4f> col_line_points = {
{cliparray[0][0], cliparray[0][1], cliparray[3][0], cliparray[3][1]},
{cliparray[1][0], cliparray[1][1], cliparray[2][0], cliparray[2][1]}
};
//构造车牌边缘所在直线
cv::Vec3f line1_2_, line3_4_;
if(k12 > k34) {
//当拟合的两条直线呈现左方向开口状时,取左侧边缘顶点作为构造直线经过点
//上边缘直线构造
line1_2_[0] = k12;
line1_2_[1] = col_line_points[0][0];
line1_2_[2] = col_line_points[0][1];
//下边缘直线构造
line3_4_[0] = k34;
line3_4_[1] = col_line_points[0][2];
line3_4_[2] = col_line_points[0][3];
}else {
//当拟合的两条直线呈现右方向开口状时,取右侧边缘顶点作为构造直线经过点
//上边缘直线构造
line1_2_[0] = k12;
line1_2_[1] = col_line_points[1][0];
line1_2_[2] = col_line_points[1][1];
//下边缘直线构造
line3_4_[0] = k34;
line3_4_[1] = col_line_points[1][2];
line3_4_[2] = col_line_points[1][3];
}
- 5、将较短侧边线以二维像素坐标系直线的方式表示出来,求取该直线与上一步构造的两条直线的交点,作为短侧边新的顶点坐标,将短侧边的两个顶点坐标与之前长侧边两个顶点的坐标组成一组新的定位框坐标
关键代码:
// 重新计算车牌定位框四个顶点位置
std::vector<cv::Point> vectices(4);
//左上
vectices[0] = Line_intersection_coordinates_(line1_2_, col_line_points[0]);
//右上
vectices[1] = Line_intersection_coordinates_(line1_2_, col_line_points[1]);
//右下
vectices[2] = Line_intersection_coordinates_(line3_4_, col_line_points[1]);
//左下
vectices[3] = Line_intersection_coordinates_(line3_4_, col_line_points[0]);
- 6、根据车牌位置、拍摄角度与成像的特点,车牌左右两端边线大多数情况下是竖直方向的,所以在保持左右两边边长不变的情况下以左右两边中点为轴分别旋转左右两边,使其竖直化,并同步变更定位坐标
关键代码:
//计算左边缘线中心点坐标
int leftMiddleX = (textBox.boxPoint[0].x + textBox.boxPoint[3].x) / 2;
int leftMiddleY = (textBox.boxPoint[0].y + textBox.boxPoint[3].y) / 2;
//计算左边缘线长度
int imgCropHeightLeft = int(sqrt(pow(textBox.boxPoint[0].x - textBox.boxPoint[3].x, 2) + pow(textBox.boxPoint[0].y - textBox.boxPoint[3].y, 2)));
//计算右边缘线中心点坐标
int rightMiddleX = (textBox.boxPoint[1].x + textBox.boxPoint[2].x) / 2;
int rightMiddleY = (textBox.boxPoint[1].y + textBox.boxPoint[2].y) / 2;
//计算右边缘线长度
int imgCropHeightRight = int(sqrt(pow(textBox.boxPoint[1].x - textBox.boxPoint[2].x, 2) + pow(textBox.boxPoint[1].y - textBox.boxPoint[2].y, 2)));
//以左右两侧边缘中心点横坐标作为新的边缘横坐标,以中心点纵坐标加减一半对应边缘线长度作为新的边缘纵坐标
cv::Point npoint0 = cv::Point(leftMiddleX < 0 ? 0 : leftMiddleX, leftMiddleY - imgCropHeightLeft/2 < 0 ? 0 : leftMiddleY - imgCropHeightLeft/2);
cv::Point npoint3 = cv::Point(leftMiddleX < 0 ? 0 : leftMiddleX, leftMiddleY + imgCropHeightLeft/2 > inputHeight ? inputHeight : leftMiddleY + imgCropHeightLeft/2);
cv::Point npoint1 = cv::Point(rightMiddleX > inputWidth ? inputWidth : rightMiddleX, rightMiddleY - imgCropHeightRight/2 < 0 ? 0 : rightMiddleY - imgCropHeightRight/2);
cv::Point npoint2 = cv::Point(rightMiddleX > inputWidth ? inputWidth : rightMiddleX, rightMiddleY + imgCropHeightRight/2 > inputHeight ? inputHeight : rightMiddleY + imgCropHeightRight/2);
以上修改方案是基于原有后处理策略基础上来制定的,关于原有处理策略的介绍可以看我的另一篇文章安卓端部署PPOCR的ncnn模型——模型部署
下面我贴出较完整代码:
主要后处理代码:
......
//将推理的结果数据构造成cv::Mat数据
cv::Mat pred_map = cv::Mat::zeros(pred_height, pred_width, CV_32FC1);
memcpy(pred_map.data, pred, pred_size * sizeof(float));
//阈值boxThresh的二值化处理
cv::Mat norfMapMat;
norfMapMat = pred_map > boxThresh;
//拷贝为单通道cv::Mat
cv::Mat cbuf_map;
norfMapMat.convertTo(cbuf_map, CV_8UC1);
//膨胀处理
cv::Mat mask_map;
cv::Mat dilation_kernel = cv::Mat::ones(2, 2, CV_8UC1);
cv::dilate(cbuf_map, mask_map, dilation_kernel);
//提取标注框
std::vector<TextBox> boxes = boxes_from_bitmap_(pred_map, mask_map, originBitmap, boxScoreThresh, unClipRatio);
......
提取车牌标注框代码:
const int min_size = 5;//标注框最小边长
const int max_candidates = 1000;//最大轮廓数
int width = bitmap.cols;//原图宽
int height = bitmap.rows;//原图高
//获取轮廓集合
std::vector<std::vector<cv::Point>> contours;
std::vector<cv::Vec4i> hierarchy;
cv::findContours(bitmap, contours, hierarchy, cv::RETR_LIST,cv::CHAIN_APPROX_NONE);
int num_contours = (int)(contours.size() >= max_candidates ? max_candidates : contours.size());
std::vector<TextBox> rsBoxes;
rsBoxes.clear();
if(num_contours == 0) return rsBoxes;
for (int _i = 0; _i < num_contours; _i++) {
//获取最小外接矩形轮廓
float ssid;
cv::RotatedRect box = cv::minAreaRect(contours[_i]);
auto array = get_mini_boxes(box, ssid);
if (ssid < min_size) {
continue;
}
//获取轮廓分数
float score = box_score_fast(array, pred);
// end box_score_fast
if (score < boxScoreThresh) {
continue;
}
// 获取旋转矩形顶点
std::vector<cv::Point> sortedcurve = {cv::Point(array[0][0],array[0][1]),cv::Point(array[1][0],array[1][1]),cv::Point(array[2][0],array[2][1]),cv::Point(array[3][0],array[3][1])};
std::vector<cv::RotatedRect> masks = make_RotateRectMask(box.size.width>box.size.height?box.size.width:box.size.height, box.size.width>box.size.height?box.size.height:box.size.width, sortedcurve);
//最小外接矩形轮廓拟合
float perimeter = 2.f * (box.size.width + box.size.height);
cv::RotatedRect clipbox = unclip(array, unClipRatio, perimeter);
//轮廓坐标顺序调整
auto cliparray = get_mini_boxes(clipbox, ssid);
if (ssid < min_size + 2) {
continue;
}
//轮廓位置还原至推理输出矩阵宽高比例
int dest_width = pred.cols;
int dest_height = pred.rows;
std::vector<cv::Point> clipBox;
clipBox.clear();
if((abs(cliparray[0][0]-cliparray[3][0])/abs(cliparray[0][1]-cliparray[3][1]) < 0.1)
&& (abs(cliparray[1][0]-cliparray[2][0])/abs(cliparray[1][1]-cliparray[2][1]) < 0.1)){
//当左右两侧上下顶点坐标没有明显偏差时,使用原有后处理方式计算的顶点坐标
for (int num_pt = 0; num_pt < 4; num_pt++) {
clipBox.emplace_back(int(clampf(roundf(cliparray[num_pt][0] / float(width) *
float(dest_width)),
0, float(dest_width))),
int(clampf(roundf(cliparray[num_pt][1] /
float(height) * float(dest_height)),
0, float(dest_height))));
}
}else {
//当左右两侧上下顶点坐标存在明显偏差时,使用原有后处理方式计算的顶点坐标
//构造左右边缘顶点坐标集合
std::vector<cv::Vec4f> col_line_points = {
{cliparray[0][0], cliparray[0][1], cliparray[3][0], cliparray[3][1]},
{cliparray[1][0], cliparray[1][1], cliparray[2][0], cliparray[2][1]}
};
//计算新的标注框顶点坐标
std::vector<cv::Point> polygoncurve = LprVertices(masks, contours[_i], col_line_points);
auto **polygonArray = new float *[4];
for (int i = 0; i < 4; ++i) {
polygonArray[i] = new float[2];
}
polygonArray[0][0] = polygoncurve[0].x < 0 ? 0 : polygoncurve[0].x;
polygonArray[0][1] = polygoncurve[0].y < 0 ? 0 : polygoncurve[0].y;
polygonArray[1][0] = polygoncurve[1].x > width ? width : polygoncurve[1].x;
polygonArray[1][1] = polygoncurve[1].y < 0 ? 0 : polygoncurve[1].y;
polygonArray[2][0] = polygoncurve[2].x > width ? width : polygoncurve[2].x;
polygonArray[2][1] = polygoncurve[2].y > height ? height : polygoncurve[2].y;
polygonArray[3][0] = polygoncurve[3].x < 0 ? 0 : polygoncurve[3].x;
polygonArray[3][1] = polygoncurve[3].y > height ? height : polygoncurve[3].y;
for (int num_pt = 0; num_pt < 4; num_pt++) {
clipBox.emplace_back(int(clampf(roundf(polygonArray[num_pt][0] / float(width) *
float(dest_width)),
0, float(dest_width))),
int(clampf(roundf(polygonArray[num_pt][1] /
float(height) * float(dest_height)),
0, float(dest_height))));
}
}
rsBoxes.emplace_back(TextBox{clipBox, score});
}
if(!rsBoxes.empty()) {
reverse(rsBoxes.begin(), rsBoxes.end());
}
return rsBoxes;
根据构造的矩形、原左右边缘顶点及原始轮廓点计算新的车牌边缘顶点
/**
* 生成局部提取轮廓线区域
* @param rotateRectWidth 旋转矩形长边
* @param rotateRectHeight 旋转矩形短边
* @param p1 旋转矩形长边端点1
* @param p2 旋转矩形长边端点2
* @return 长边提取矩形区域
*/
cv::RotatedRect RotateRectMask(const int rotateRectWidth, const int rotateRectHeight, cv::Point &p1, cv::Point &p2){
float angle = cv::fastAtan2((float)(p1.y - p2.y), (float)(p1.x - p2.x));
cv::Point center;
center.y = (p1.y + p2.y) / 2;
center.x = (p1.x + p2.x) / 2;
int rotate_rect_width = rotateRectWidth * 3 / 4;
int rotate_rect_height = (rotateRectHeight / 2 - 1) * 2;
cv::RotatedRect rotateRect(center, cv::Size(rotate_rect_width , rotate_rect_height), angle);
return rotateRect;
}
/**
* 生成关于文本热力图外接矩形每条边的旋转矩形局部区域
* @param rotateRectWidth 文本热力图外接矩形长边
* @param rotateRectHeight 文本热力图外接矩形短边
* @param sortedcurve 文本热力图外接矩形顶点坐标
* @return 长边提取矩形区域集合
*/
std::vector<cv::RotatedRect> make_RotateRectMask(const int rotateRectWidth, const int rotateRectHeight, std::vector<cv::Point> sortedcurve){
std::vector<cv::RotatedRect> rotate_rect(2);
rotate_rect[0] = RotateRectMask(rotateRectWidth, rotateRectHeight, sortedcurve[0], sortedcurve[1]);
rotate_rect[1] = RotateRectMask(rotateRectWidth, rotateRectHeight, sortedcurve[2], sortedcurve[3]);
return rotate_rect;
}
//辅助顶点排序代码
static float **Mat2Vec(cv::Mat mat) {
auto **array = new float *[mat.rows];
for (int i = 0; i < mat.rows; ++i) {
array[i] = new float[mat.cols];
}
for (int i = 0; i < mat.rows; ++i) {
for (int j = 0; j < mat.cols; ++j) {
array[i][j] = mat.at<float>(i, j);
}
}
return array;
}
//快速顶点排序代码
static void quickSort(float **s, int l, int r) {
if (l < r) {
int i = l, j = r;
float x = s[l][0];
float *xp = s[l];
while (i < j) {
while (i < j && s[j][0] >= x) {
j--;
}
if (i < j) {
float* temp=s[i];
s[i]=s[j];
s[j]=temp;
i++;
}
while (i < j && s[i][0] < x) {
i++;
}
if (i < j) {
float* temp=s[j];
s[j]=s[i];
s[i]=temp;
j--;
}
}
s[i] = xp;
quickSort(s, l, i - 1);
quickSort(s, i + 1, r);
}
}
/**
* 求取旋转矩形区域内的轮廓点集
* @param rotateRectMask 旋转矩形
* @param contour 文本区域热力图轮廓点集
* @return
*/
std::vector<cv::Point> make_RotateRectMaskInnerPoints(cv::RotatedRect &rotateRectMask, std::vector<cv::Point> &contour){
std::vector<cv::Point> innerPoints;
cv::Mat tempPoints;
cv::boxPoints(rotateRectMask, tempPoints);
auto array = Mat2Vec(tempPoints);
quickSort(array, 0, 3);
float *idx1 = array[0], *idx2 = array[1], *idx3 = array[2], *idx4 = array[3];
if (array[3][1] <= array[2][1]) {
idx2 = array[3];
idx3 = array[2];
} else {
idx2 = array[2];
idx3 = array[3];
}
if (array[1][1] <= array[0][1]) {
idx1 = array[1];
idx4 = array[0];
} else {
idx1 = array[0];
idx4 = array[1];
}
array[0] = idx1;
array[1] = idx2;
array[2] = idx3;
array[3] = idx4;
std::vector<cv::Point> rotateRectMaskPoints = {
cv::Point(array[0][0], array[0][1]),
cv::Point(array[1][0], array[1][1]),
cv::Point(array[2][0], array[2][1]),
cv::Point(array[3][0], array[3][1]),
};
for(auto &point : contour){
auto innerFlag = cv::pointPolygonTest(rotateRectMaskPoints, point, false);
if(innerFlag>=0){
innerPoints.push_back(point);
}
}
return innerPoints;
}
/**
* 提取文本热力图上下边缘轮廓待拟合直线点集
* @param rotateRectMasks 上下边缘附近构造旋转矩形
* @param contour 文本区域热力图轮廓点集
* @return 待拟合上下边缘直线点集
*/
std::vector<std::vector<cv::Point>> Actual_line(std::vector<cv::RotatedRect> &rotateRectMasks, std::vector<cv::Point> &contour){
std::vector<std::vector<cv::Point>> Actual_lines;
for(auto mask : rotateRectMasks){
std::vector<cv::Point> innerPoints = make_RotateRectMaskInnerPoints(mask, contour);
Actual_lines.push_back(innerPoints);
}
return Actual_lines;
}
/**
* 最小二乘法拟合直线
* @param linePoints 待拟合直线点集
* @return 拟合直线表达信息集(直线x轴方向变量、直线y轴方向变量、直线上某个点的横坐标、直线上某个点的纵坐标)
*/
cv::Vec4f fit_edge_line(std::vector<cv::Point> &linePoints){
cv::Vec4f line;
cv::fitLine(linePoints, line, cv::DIST_L2, 0, 0.01, 0.01);
return line;
}
/**
* 估算车牌上下边缘直线与车牌左右边缘直线的交点
* @param row_line 上下边缘直线
* @param col_line 左右边缘直线
* @return 两直线交点坐标
*/
cv::Point Line_intersection_coordinates_(const cv::Vec3f& row_line, const cv::Vec4f& col_line){
// 先检查两直线的夹角情况 line(vx, vy, x0, y0)
float k1 = row_line[0];
float k2 = 9999;
if(col_line[0]!=col_line[2]) {
k2 = (col_line[3]-col_line[1]) / (col_line[2]-col_line[0]);
}
float x1 = row_line[1];
float y1 = row_line[2];
float x2 = col_line[2];
float y2 = col_line[3];
cv::Point intersection;
if(k2!=9999) {
intersection.x = (k1 * x1 - k2 * x2 + y2 - y1) / (k1 - k2);
intersection.y = k1 * (k2 * (x1 - x2) + y2 - y1) / (k1 - k2) + y1;
}else{
intersection.x = x2;
intersection.y = k1 * (x2 -x1) + y1;
}
return intersection;
}
/**
* 计算新的文本定位框顶点集合
* @param rotateRectMasks 构造的旋转矩形
* @param contour 文本热力图轮廓点集
* @param col_line_points 左右边缘顶点集合
* @return 新的文本定位框顶点集
*/
std::vector<cv::Point> LprVertices(std::vector<cv::RotatedRect> &rotateRectMasks, std::vector<cv::Point> &contour, std::vector<cv::Vec4f>& col_line_points){
//求取车牌文本热力图边缘直线
std::vector<std::vector<cv::Point>> edgeslines = Actual_line(rotateRectMasks, contour);
cv::Vec4f line1_2 = fit_edge_line(edgeslines[0]);
cv::Vec4f line3_4 = fit_edge_line(edgeslines[1]);
//根据长边边缘直线斜率和后处理定位矩形框短边位置重新调整长边位置
float k12 = line1_2[1]/line1_2[0];
float k34 = line3_4[1]/line3_4[0];
cv::Vec3f line1_2_, line3_4_;
if(k12 > k34) {
line1_2_[0] = k12;
line1_2_[1] = col_line_points[0][0];
line1_2_[2] = col_line_points[0][1];
line3_4_[0] = k34;
line3_4_[1] = col_line_points[0][2];
line3_4_[2] = col_line_points[0][3];
}else {
line1_2_[0] = k12;
line1_2_[1] = col_line_points[1][0];
line1_2_[2] = col_line_points[1][1];
line3_4_[0] = k34;
line3_4_[1] = col_line_points[1][2];
line3_4_[2] = col_line_points[1][3];
}
// 重新计算车牌定位框四个顶点位置
std::vector<cv::Point> vectices(4);
//左上
vectices[0] = Line_intersection_coordinates_(line1_2_, col_line_points[0]);
//右上
vectices[1] = Line_intersection_coordinates_(line1_2_, col_line_points[1]);
//右下
vectices[2] = Line_intersection_coordinates_(line3_4_, col_line_points[1]);
//左下
vectices[3] = Line_intersection_coordinates_(line3_4_, col_line_points[0]);
return vectices;
}
为了便于大家理解这一改进策略,我以实例图像来进行说明:
假设我们需要对以下车牌图进行车牌定位:
1、车牌定位模型推理提取文本区域概率图(如下图的白色区域)
2、提取概率图轮廓和最小外接矩形(如下图的蓝色线条和红色线条)
3、新的旋转矩形构造与直线拟合
4、获取原始策略下的矩形标注框位置
5、计算更新策略下的矩形标注框位置
效果评价
经自测试定位精度有了明显提升,同时也为之后的车牌字符识别打下良好基础,无论是定位框位置精度还是校正图的正方向性都有了明显的改善,如有错误,还请各位大佬批评指正,如有更好的解决方案,可以一起研究讨论。