最近学了Python感觉很强大,对计算机视觉有些兴趣,于是来做一个车牌识别项目,其中学习了OpenCV-Python、MATLAB等,收获颇丰,写一篇文章记录一下。
整体思路
车牌识别,LPR,License Plate Recognition,又叫ALPR。本项目使用蓝底白字,共7个字符加1个点的传统车牌。查阅资料发现实现的方式很多,本项目实现的流程为:
- 牌照检测:
这一步是从图片中找到车牌区域,用OpenCV-Python实现。 - 字符分割:
这里把牌照的每个字符分割开,用OpenCV-Python实现。 - 字符识别:
功能如其名。用Scikit-Learn实现。
下面具体来看:
牌照检测
首先我初始图片是这样的:
开始我用的是网上找的真车实拍图,但是处理到后面发现图片全糊了,原因应该是户外的光照和阴影吧,我太难了。。于是换了这种低级版。
第一步导入图片并灰度化,我也不知道为啥,大家到这么做:
# 导入图片
img_raw = cv.imread('C:/Users/Administrator/Desktop/pic3.jpg')
# 灰度化
img = cv.cvtColor(img_raw, cv.COLOR_BGR2GRAY)
我这个图片显示出来太大了,所以把它调小:
# 等比缩放
rows, cols = img.shape
ratio = rows/cols
img = cv.resize(img, (800,int(800*ratio)))
img_raw = cv.resize(img_raw, (800,int(800*ratio)))
接下来就是平滑图像,为啥要平滑图像呢,我查了资料的理解是:平滑图像的作用是消除图像噪声,图像噪声是一些由于某些因素随机生成的像素点,留着他们会对之后的效果有影响。这里用了高斯平滑,代码就一行:
# 平滑图像
img = cv.GaussianBlur(img, (5,5), 0)
接下来是很重要的一步了——二值化,先上代码:
# 二值化
img = cv.adaptiveThreshold(img, 255, cv.ADAPTIVE_THRESH_GAUSSIAN_C, cv.THRESH_BINARY, 11, 2)
官网提供了三种阈值化方法:简单阈值,自适应阈值和大津算法。
就我实验来看,在肉眼上自适应阈值的效果是最好的,但是并不代表对以后的操作就是友好的,开始在处理户外实拍照的时候用的是大津算法,在下文中还会用到简单阈值。
这里放一下二值化后的图像:
接下来是Canny边缘检测,官网上有一句话:It is a multi-stage algorithm and we will go through each stages.意思就是在这一步之前你需要做很多“前戏”,就是为什么前面要做那么多步骤,算解开了我的疑惑吧,下面是代码:
# Canny边缘检测
img = cv.Canny(img, 100, 200)
就一行,很简单,然后是效果图:
画风变成暗黑系了,接下来这一步很重要,可以帮我们找出车牌的轮廓,叫做形态学处理,还是先上代码和效果图:
# 形态学处理
kernel = np.ones((40,40), np.uint8)
img = cv.morphologyEx(img, cv.MORPH_CLOSE, kernel)
img = cv.morphologyEx(img, cv.MORPH_OPEN, kernel)
这部分内容官网讲的很清楚,大家可以看一下:Morphological Transformations。这里说一下,kernel很关键,如果是换了一张风格和现在差别很大的图片(比如变成了户外实拍图,车牌位置变得很小),kernel的值是一定要变换的,找到最好的效果。
开运算:先腐蚀后膨胀,去除周围小点。
闭运算:先膨胀后腐蚀,不会去除周围小点,使距离近的图形相连。
至于为啥是先闭运算后开运算,我看别人是这么做的。。
可以看到车牌的轮廓已经很明显了,下面的步骤就是将它选出来。
这里贴一张官网的截图,消除了我当初的疑惑:
主要是第一条和第三条:
- 为了更好的准确性,请使用二值图像。所以在查找轮廓之前,先阈值化或者Canny边缘检测。
- 在OpenCV里,找到轮廓是从黑色背景里找白色对象,所以要记住,要被查找的对象应该是白色的,背景应该是黑色的。
当初听说可以轮廓检测,就纳闷为啥不能直接拿生图去检测,非要经过前面一系列处理。
好了,再回来,官网提供了函数可以帮你选出所有轮廓:
# 找出所有轮廓
contours, hierarchy = cv.findContours(img, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)
返回值contours是个数组,包含找到的所有轮廓,每一个元素都是轮廓的点集。找出来之后当然也要画出来看一下:
# 绘制所有轮廓
img = cv.drawContours(img_raw, contours, -1, (0,0,255), 3)
contourIdx参数如果是0、1、2、3等,就是绘制contours里面的指定轮廓,如果是-1,就是绘制所有轮廓。
这个函数不仅可以绘制轮廓,还可以把边界点进行连线。
cv.drawContours函数中的contourId参数为0时:
前一个参数如果是轮廓的集合,则打印第一条直线。
前一个参数如果是点的集合,则打印第一个点,如果想打印所有点连成的直线,则要加[]。
咱看一下效果吧(我是在原图上绘制的,这样方便看框定的区域是否准确):
可以看到车牌区域是完完整整地被框在某一个轮廓内,效果还是可以的,但计算机并不知道哪一个才是车牌轮廓,我们要把它筛选出来,这里我写了一个函数,是返回车牌图片,如图:
先上代码,再说一下我的思路:
def ContoursSelect(img_raw, contours):
rectangle = []
for cnt in contours:
if cv.contourArea(cnt) > 15000:
# img = cv.drawContours(img_raw, [cnt], 0, (0,0,255), 3)
box_info = cv.minAreaRect(cnt)
width, height = box_info[1]
if width < height:
width,height = height,width
if 2 < width/height < 5:
box_float = cv.boxPoints(box_info)
box = np.intc(box_float)
rectangle.append(box)
if not len(rectangle) == 1:
print('筛选错误!')
return -1
else:
location_level = np.float32([[0,height],[0,0],[width,0],[width,height]])
location_tilt = np.float32([[width,height],[0,height],[0,0],[width,0]])
location = location_level if box_info[2] == 0 else location_tilt
M = cv.getPerspectiveTransform(box_float, location)
img_plate = cv.warpPerspective(img_raw, M, (int(width),int(height)))
return img_plate
- 找到车牌轮廓
官网上给出了很多轮廓特征我们可以参考一下,比如说算轮廓的长度(也就是围成区域的周长)、轮廓围成的区域的面积等。
这里我用的两个条件去筛选(其实第一个经过第一个条件之后就只剩一个轮廓了,就是车牌轮廓):
第一个是面积,首先我查看所有轮廓的面积,确定合适的阈值,就是15000。
第二个我用到了边界矩形中的旋转矩形,这个特征非常适合本项目,因为车牌就是个矩形嘛。
下面我来具体解释一下,它会用一个矩形框住轮廓,并且是可以旋转的,因此可以保证面积最小。这不仅可以帮助我们筛选(车牌的长宽比是一定比例的),对截取车牌也是很方便的。可以去官网详细地看一下Contour Features
在实践过程中我遇到了一个问题:在我计算长宽比的时候,发现有时会出现小于0的情况,说明函数对于长宽的定义有他自己的想法,具体原因在这里:python opencv minAreaRect 生成最小外接矩形
- 把车牌截出来并返回
上一步已经用一个矩形把车牌位置框出来,由于他有可能是倾斜的,我们要把它摆正然后截出来返回。用到了透视变换,官网有实现步骤Geometric Transformations of Images,需要的边界点、中心点上面的旋转矩形都提供了,还是很方便的。
在实践过程中也遇到了问题:如果我车牌的外接矩形本来就是水平的,没错可以这么巧,我就遇到了,那么四个边界的定义顺序就和倾斜的不一样(详情还是见上面那篇文章),所以要分开讨论。
这样,这个函数就写完了,会返回一个摆正的车牌图:
字符分割
这一步的实现方法有很多,比如用机器学习的聚类算法,但是我没做出来,我用的算法生硬且暴力,大家看一下就知道了。
首先我们还是要得到车牌二值图,但是这里二值化的方法和上面就不一样了,这里采用简单阈值法,阈值是自己实验选择出来的:
# 车牌二值化
img_plate = cv.cvtColor(img_plate_raw, cv.COLOR_BGR2GRAY)
img_plate = cv.GaussianBlur(img_plate, (5,5), 0)
ret, img_plate = cv.threshold(img_plate, 225, 255, cv.THRESH_BINARY)
看一下效果:
还行吧,接着我进行了一步开运算,因为我实验发现车牌号周围有许多小点:
这样看还不如不搞这一步。。
接下来就是切割了,我写了两个函数:
- LevelCut
这个函数的作用是把车牌号上下面的多余部分去掉。原理是找到图像中间的一行像素,从左向右检测,遇到白色就向上(下)移动,重复,直到有一行完全没有白色,认为是边界。上代码:
def LevelCut(img_plate):
rows, cols = img_plate.shape
line = rows//2 # line是图片中水平中线
boundaries = []
for boundary in range(line, 0, -1): # 向上扫描找到上边界
flag = 1
for point in img_plate[boundary]:
if point == 255:
flag = 0
break
if flag:
boundaries.append(boundary)
break
for boundary in range(line, rows-1, 1): # 向下扫描找到下边界
flag = 1
for point in img_plate[boundary]:
if all(point == [255,255,255]): # point == 255
flag = 0
break
if flag == 1:
boundaries.append(boundary)
break
img_plate_temp = img_plate[boundaries[0]:boundaries[1],:]
return img_plate_temp
效果如图:
其实这个算法可以优化,把“遇到白色就向上(下)移动”改为统计每一行的像素值和从而找到一个阈值,毕竟一行像素完全完全是黑色这个条件有点太苛刻了。
这一步操作之后要调整一下图片大小,因为下面操作会用到字符间和字符本身的宽度,不然阈值就用不了了。
- VerticalCut
垂直切割要考虑的情况更多,原理和水平切割类似,统计每一列的像素值和,相当于把图片垂直压缩成一行,如果前后两值从0变成非0为左边界,或从非0变0为右边界,每一对左右边界框定出一个字符。算法会遇到以下问题:
第一个问题是,省份简写中例如“沪”、“浙”、“津”等,在图片预处理的过程中很容易把偏旁腐蚀,这个算法就会把一个字的偏旁当做一个字符框起来,更不要说“川”这种字,算法会把它拆成三块,这里我解决的办法是判断两个字符的间距,如果小于某个阈值就认为是一个字,进行合并。
第二个问题是,车牌的组成里有个点,我的算法会把也会把它框出来当做一个字符,方法是,这个判断字符本身的宽度,很小的就剔除。
还会有其他细节问题,大家自行看代码:
def VerticalCut(img_plate_temp):
imgs = [] # 用于存放7张图片
boundaries = [] # 用于存放边界
img_one_dim = np.sum(img_plate_temp//255, axis=0) # 这是一个一维矩阵,表示二值图压缩成一行,每个元素表示一列像素的像素值之和(除255使矩阵中只有0和1,方便观察)
left_boundary = -1
for val in enumerate(img_one_dim):
if val[0] == 0:
before = val
continue
elif before[1] == 0 and val[1] != 0:
left_boundary = before[0]
elif before[1] != 0 and val[1] == 0:
right_boundary = val[0]
if not left_boundary == -1:
boundaries.append([left_boundary,right_boundary])
before = val
distance = [(boundaries[i+1][0]-boundaries[i][1]) for i in range(len(boundaries)-1)] # 查看每两组区域间的距离
for dis in enumerate(distance):
if dis[1] < 8: # 两组区域间距离小于8的认为是一片区域,进行合并
x = boundaries[dis[0]][0]
y = boundaries[dis[0]+1][1]
del boundaries[dis[0]], boundaries[dis[0]]
boundaries.insert(dis[0], [x,y])
length = [(j-i) for i,j in boundaries]
if len(boundaries) == 7:
imgs = Cut(img_plate_temp, boundaries)
else:
for i in range(len(boundaries)-7):
index = length.index(min(length))
del length[index], boundaries[index]
imgs = Cut(img_plate_temp, boundaries)
return imgs
def Cut(img_plate_temp, boundaries):
imgs = []
rows, cols = img_plate_temp.shape
for boundary in boundaries:
imgs.append(img_plate_temp[0:rows,boundary[0]:boundary[1]])
return imgs
在测试过程中我犯了个不细心的错误,这里记录一下:
a = [1,2,3,4,5,6,7] # 想删除前两个元素
del a[0],a[0] # 不要写成 del a[0],a[1]
上效果图:
看效果还是不错的,不过不得不说,我这算法太傻了,大家尽量别用。
还有一个问题自己遇到了,在这里提一下:有时候我喜欢去Jupyter里测试代码,比较方便,但是二值图保存到本地再在Jupyter里面读就不是二维矩阵了,又变成三维了。导致我有的代码在Jupyter里跑地好好的,移到主程序里就出错,都是教训啊!
字符识别
学习机器学习的时候做过MNIST手写数字识别,用的是神经网络算法,我觉得和这个很类似,决定如法炮制。
在网上找了个数据集如图,里面包括数字0到9,24个大写英文字母(I和O没有,太容易和1和0搞混吧)还有部分省份简称,大小是40*32,共5062张。
拿到图片之后就要打标签了,这个以前没做过,于是我打算看看MNIST数据集长什么样子,是一个mat文件,于是下载了MATLAB,打开后长这样:
我决定按它的格式复刻一份,5000多张图片挨个打标签肯定是不现实的,那就来写个脚本:
main_path = uigetdir('请选择文件夹:');
cd (main_path);
folders = dir('.');
for i = 1:length(folders);
if i >= 3;
cd (folders(i).name);
imgs = dir('*.bmp');
for j = 1:length(imgs);
path = fullfile((main_path), (folders(i).name), (imgs(j).name));
img = imread(path);
lb = str2num(folders(i).name);
img = img(:);
data = [data,img];
pause(1);
label = [label,lb];
end
cd ..;
end
end
我把我的数据集的文件夹做了下整理,一共是3层,第二层是40个文件夹,文件夹名就是标签,所以打标签的过程就完全不用人参与了,为了防止跑乱,中间加了1秒的延时。
第一次写MATLAB脚本遇到点问题,这里记录一下:
- cd 变量,一定要给变量加上括号,不然出错;
- 文件夹展开第三项才是第一个文件夹,不然就跑乱了;
- 打完标签后记得返回上一层。。
程序跑了一个多小时,成功了,还是很开心的,如下:
一模一样复制了一份,名字都没改,可见我求生欲有多强。下面就可以开始训练了:
from sklearn.datasets import fetch_mldata
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPClassifier
from sklearn.externals import joblib
ALPR = fetch_mldata('MLSET', data_home='C:\\Users\\Administrator\\Desktop\\datasets\\MLSET\\')
X = ALPR.data/255
y = ALPR.target
X_train, X_test, y_train, y_test = train_test_split(X, y, train_size = 4602, test_size=1000, random_state=62)
mlp_alpr = MLPClassifier(solver='lbfgs',hidden_layer_sizes=[100,100], activation='relu', alpha=1e-5, random_state=62)
mlp_alpr.fit(X_train, y_train)
joblib.dump(mlp_alpr, 'C:\\Users\\Administrator\\Desktop\\mlp_alpr.pkl')
print('测试数据集得分:{:.2f}%'.format(mlp_alpr.score(X_test,y_test)*100))
打印出来测试集得分99.5%,把我高兴坏了,但是实际上效果很差。下面是预测代码:
label = ['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',
'沪','京','闽','苏','粤','浙']
def Predict(nums):
mlp_alpr = joblib.load('C:\\Users\\Administrator\\Desktop\\mlp_alpr.pkl')
plate_number = ''
for num in nums:
num = num/255
arr = []
for j in range(32):
for i in range(40):
arr.append(num[i][j])
arr1 = np.array(arr).reshape(1,-1)
c = label[mlp_alpr.predict(arr1)[0]]
plate_number = plate_number + c
return plate_number
第一,要注意把测试图片resize成数据集中图片一样的大小,就是一样的特征数。
第二,图片的展开方式一定要相同,我的MATLAB脚本上是这样展开的:
但是测试图片如果这样展开就肯定错了:
好了,我的测试结果为:
(root) C:\Users\Administrator>D:/mypython/Anaconda3/python.exe c:/Users/Administrator/Desktop/ALPR.py
U6P7087
一共7个字符对了4个,结果挺失望的,问了下朋友,他建议数据集的数据自己采集。唉,不管怎么说这一条线也算走下来了。
如有错误,欢迎指正!