# digital-image-processing-LPR **Repository Path**: zhang_qi_hao/digital-image-processing-lpr ## Basic Information - **Project Name**: digital-image-processing-LPR - **Description**: No description available - **Primary Language**: C/C++ - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2021-11-18 - **Last Updated**: 2021-12-31 ## Categories & Tags **Categories**: Uncategorized **Tags**: python3 ## README # digital-image-processing-LPR #### 介绍 此项目为《数字图像处理》课程期末作业,车牌检测与字符分割,使用Qt框架、python语言编写(也有一个半成品的c++版本) 使用了opencv自带的诸多图像处理算法(形态学处理、KMeans聚类、Canny边缘检测、霍夫线检测等),基本实现了对数字图像中车牌区域的定位,和车牌字符的分割。 但是,仍有一些缺陷待改进:一、由于每次都是随机选取聚类中心,KMeans的聚类效果不稳定,通过此方法进行颜色分割后的颜色区域不一定是车牌的蓝色区域;二、对于字符的分割不准确,对于“H”“7”“L”这一类的字符,容易只分割到部分区域。 #### 软件架构 ![](./image/arch.jpg) #### 安装教程 安装python3和如下python库 ```python opencv-python~=4.5.4.60 numpy~=1.21.4 Pillow~=8.4.0 PySide2~=5.15.2||PyQt5~=5.15.2 ``` #### 算法流程 ![](./image/process.png) ##### 1.kmeans聚类 Kmeans算法是最常用的聚类算法,主要思想是:在给定K值和K个初始类簇中心点的情况下,把每个点(亦即数据记录)分到离其最近的类簇中心点所代表的类簇中,所有点分配完毕之后,根据一个类簇内的所有点重新计算该类簇的中心点(取平均值),然后再迭代的进行分配点和更新类簇中心点的步骤,直至类簇中心点的变化很小,或者达到指定的迭代次数。 Kmeans算法的基本流程是:1.从数据集中随机选择k个点,作为中心点,即簇心;2.计算所有点到各个簇心之间的距离,将每个点归于与其最近的中心点的“簇”;3.计算每个簇的平均值(各个维度的平均),作为新的簇心;4.重复2、3直到达到最大迭代次数或者簇心不再改变。 彩色图片的一个像素有B、G、R三个维度的分量,一幅图片是大量有三个维度分量的数据点的集合,相似的颜色对应的像素点在色彩空间中的位置是相近的,这与其在图片中的空间位置无关,可以在色彩空间中,通过聚类算法,将像素点划分为一个一个的“簇”,然后用已知的目标标准颜色的三分量坐标值,求得与其最近的“簇”心,从而找到对应的“簇”。 ![](./image/Lab颜色空间90_140_70.png) kmeans聚类算法的python实现 ```python # 计算距离 def cal_distance(p1, p2, mode=0): p1 = p1.astype(np.float32) p2 = p2.astype(np.float32) if mode == 0: return sum(map(lambda x, y: np.power(x - y, 2), p1, p2)) # 欧氏距离 elif mode == 1: return sum(map(lambda x, y: np.abs(x - y), p1, p2)) # 曼哈顿距离 # 计算初始中心 def cal_centers(data, k): taboo = [] centers = [] count = 0 while count < k: index = random.randint(0, data.shape[0]-1) c = data[index] if centers: min_dist = cal_min_dist(centers, c)[0] if min_dist < 500: # 若与其他中心之间的最小距离小于500,舍弃该中心 continue if index in taboo: # 已经添加过的中心舍弃 continue taboo.append(index) centers.append(c) count += 1 centers = np.array(centers) return centers # 计算最小距离的点(返回最小距离和最小距离点的索引) def cal_min_dist(seq, p): min_dist = 1000000 min_index = 0 for i in range(len(seq)): d = cal_distance(seq[i], p) if d < min_dist: min_dist = d min_index = i return min_dist, min_index # 更新簇心 def update_centers(clusters): centers = [] for i in range(len(clusters)): cluster = np.array(clusters[i]) center = (pd.DataFrame(cluster).mean()).values centers.append(center) centers = np.array(centers, dtype=np.uint8) return centers # kmeans聚类,最大迭代次数100 def kmeans(data, k, max_iter=100): labels = np.zeros(data.shape[0]) # label用来标记各个点归属的簇 centers = cal_centers(data, k) # 计算初始簇心 clusters = [] for i in range(k): clusters.append([]) # 生成k个空簇的列表 for m in range(max_iter): for n in range(data.shape[0]): min_index = cal_min_dist(centers, data[n])[1] labels[n] = min_index clusters[min_index].append(data[n]) centers = update_centers(clusters) # 一遍结束后更新簇心 return centers, labels ``` 原图 ![](./image/test1.bmp) kmeans聚类 ![](./image/截图_matplotlib_20211229024933.bmp) 使用颜色阈值分割 ![](./image/截图_matplotlib_20211230200217.bmp) 两者相比,kmeans聚类精度更高,噪声点少 由于python语言效率较低,而以C++为基础的opencv提供了python的借口,所以这里使用opencv进行kmeans聚类,完整函数如下 ```python def k_means_cluster(src, k=16): h, w, n = src.shape dst = np.zeros_like(src) data = cv2.cvtColor(src, cv2.COLOR_BGR2Lab) # 转换到Lab色彩空间 data = np.float32(data.reshape((-1, 3))) # 需要降维忽略空间信息,并且转换数据类型 # 定义终止条件 (type,max_iter,epsilon) criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0) # 设置初始中心的选择 flags = cv2.KMEANS_PP_CENTERS # K-Means聚类 聚集成k类 compactness, labels, centers = cv2.kmeans(data, k, None, criteria, 10, flags) # blue = np.array([195, 78, 23]) # BGR # blue = np.array([112, 128, 128]) # HSV blue = np.array([90, 140, 70]) # Lab空间的蓝色中心 centers = np.uint8(centers) # 寻找与蓝色参考值距离最近的中心店的标记值 min_dist = 1000000 nearest_label = 0 for i in range(k): d = cal_distance(centers[i], blue) if min_dist > d: min_dist = d nearest_label = i labels = labels.reshape(-1) # 根据聚类结果将图像二值化,车牌区域为白色,其余为黑色 for i in range(h): for j in range(w): if labels[i * w + j] == nearest_label: dst[i][j] = np.array([255, 255, 255]) else: dst[i][j] = np.array([0, 0, 0]) return dst ``` ##### 2.Lab色彩空间 Lab是由一个亮度通道(channel)和两个颜色通道组成的。在Lab颜色空间中,每个颜色用L、a、b三个维度表示,其中L表示亮度,a表示从绿色到红色的分量,b表示从蓝色到黄色的分量。 Lab是基于人对颜色的感觉来设计的,更具体地说,它是感知均匀(perceptual uniform)的。Perceptual uniform的意思是,如果数字(即前面提到的L、a、b这三个数)变化的幅度一样,那么它给人带来视觉上的变化幅度也差不多。Lab相较于RGB与CMYK等颜色空间更符合人类视觉,也更容易调整:想要调节亮度(不考虑Helmholtz–Kohlrausch effect)就调节L通道,想要调节只色彩平衡就分别调a和b。 通常来说,L通道的动态范围是0到100,a和b是-128到127,但是在opencv中,这3个通道的范围都是0到255,对应的蓝色中心为(90,140,70)。由于L、a、b色彩空间中亮度与色彩分离,对于光照不均的图片,从Lab空间着手,比RGB空间效果更好。 原图 ![](./image/test16.bmp) 在Lab空间进行聚类分割 ![](./image/截图_main.py_20211230203334.bmp) 在RGB空间进行聚类分割 ![](./image/截图_main.py_20211230203258.bmp) ##### 3.车牌提取 车牌提取的精度取决于颜色分割的效果,kmeans聚类算法进行颜色分割效果良好,几乎没有噪声点,但为了保证分割精度,在处理之前,先进行形态学处理,去除噪声点,然后膨胀图形区域,使得投影更加连续。 形态学处理的python实现如下 ```python # 腐蚀 def erode(src, iterations=1): """ :param src: 二值图像 :param iterations: 迭代次数 :return: 腐蚀后的图像 """ h, w = src.shape[:2] dst = copy.deepcopy(src) tmp = copy.deepcopy(src) for k in range(iterations): for i in range(1, h-1): for j in range(1, w-1): if tmp[i][j] == 255: # 对图像点操作 mask = dst[i-1:i+2, j-1:j+2].reshape(-1) # 取(i, j)为中心,3 x 3的一个小方框(8邻域) z_count = 0 for m in range(9): if mask[m] == 0: z_count += 1 if z_count > 5: tmp[i][j] = 0 break dst = copy.deepcopy(tmp) # 之所以要用一个tmp来把值拷贝给dst,是因为要保持每次迭代的基础图像不变,否则,从上到下从左到右的腐蚀下来,上一行腐蚀的结果会影响下一行 return dst # 膨胀 def dilate(src, iterations=1): """ :param src: 二值图像 :param iterations: 迭代次数 :return: 膨胀后的图像 """ h, w = src.shape[:2] dst = copy.deepcopy(src) tmp = copy.deepcopy(src) for k in range(iterations): for i in range(1, h-1): for j in range(1, w-1): if tmp[i][j] == 0: # 对背景点操作 mask = dst[i-1:i+2, j-1:j+2].reshape(-1) # 取(i, j)为中心,3 x 3的一个小方框(8邻域),再降维为一维 nz_count = 0 for m in range(9): if mask[m] == 255: nz_count += 1 if nz_count > 2: tmp[i][j] = 255 break dst = copy.deepcopy(tmp) return dst # 开运算 def opening(src, iterations): dst = copy.deepcopy(src) for i in range(iterations): ero_img = erode(dst, 1) dst = dilate(ero_img, 1) return dst # 闭运算 def closing(src, iterations): dst = copy.deepcopy(src) for i in range(iterations): dil_img = dilate(dst, 1) dst = erode(dil_img, 1) return dst ``` opencv也提供了形态学处理的函数,由于车牌区域是矩形的,为了方便下一步的投影操作,选择矩形为结构元素。 由于第一步使用kmeans聚类的效果很好,几乎没有噪声点,所以最后可以多做几次膨胀运算。 ```python # 形态学处理 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) # 矩形结构元素 cv2.morphologyEx(bin_image, cv2.MORPH_OPEN, kernel, bin_image) # 开运算 cv2.morphologyEx(bin_image, cv2.MORPH_CLOSE, kernel, bin_image) # 闭运算 cv2.morphologyEx(bin_image, cv2.MORPH_DILATE, kernel, bin_image, iterations=3) # 膨胀3次 ``` 经过以上操作可以获得良好的车牌区域 ![](./image/截图_main.py_20211230211548.bmp) 然后可以分别进行水平和竖直投影,效果如下 由此可得出车牌区域的大致区域,进行剪切操作,python实现如下 ```python def project(src): # 颜色分割后的二值图像 bin_image = color_split(src) # 形态学处理 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) cv2.morphologyEx(bin_image, cv2.MORPH_OPEN, kernel, bin_image) cv2.morphologyEx(bin_image, cv2.MORPH_CLOSE, kernel, bin_image) cv2.morphologyEx(bin_image, cv2.MORPH_DILATE, kernel, bin_image, iterations=3) # 投影 h, w, n = bin_image.shape ver = np.zeros(w, dtype=np.int) hor = np.zeros(h, dtype=np.int) for i in range(h): for j in range(w): if bin_image[i][j][0] == 255: ver[j] += 1 hor[i] += 1 x1 = 0 x2 = 0 y1 = 0 y2 = 0 xflag = 0 yflag = 0 for i in range(w): if ver[i] > 20 and xflag == 0: # 水平方向上的门限为20 x1 = i xflag = 1 if (ver[i] < 5 and xflag == 1) or i == w - 1: x2 = i break for i in range(h): if hor[i] > 30 and yflag == 0: # 垂直方向上的门限为30,因为车牌宽比高大 y1 = i yflag = 1 if (hor[i] < 5 and yflag == 1) or i == w - 1: y2 = i break # 车牌区域的宽和高 new_w = x2 - x1 + 1 new_h = y2 - y1 + 1 split_image = np.zeros((new_h, new_w, n)).astype(np.uint8) # 新图像 for i in range(y1, y2 + 1): for j in range(x1, x2 + 1): split_image[i - y1][j - x1] = src[i][j] dst = cv2.resize(split_image, (440, 140), interpolation=cv2.INTER_LANCZOS4) # 车牌均重设为统一大小440x140 rect = (x1, y1, new_w, new_h) return dst, rect ``` ##### 4.车牌缩放 不同图片中车牌区域大小不同,为了更好的进行后续的处理,一般要将所得到的车牌区域缩放为相同大小,缩放会导致像素点增加,根据多出来的像素点的取值方法,有如下几种方案 (1)双线性插值 双线性插值是有两个变量的插值函数的线性插值扩展,其核心思想是在两个方向分别进行一次线性插值。未知函数 f 在点 P = (x, y) 的值,假设我们已知函数 f 在 Q11 = (x1, y1)、Q12 = (x1, y2), Q21 = (x2, y1) 以及 Q22 = (x2, y2) 四个点的值。最常见的情况,f就是一个像素点的像素值。 首先在 x 方向进行线性插值,得到 $$f(R_1)\approx \frac{x_2-x}{x_2-x_1}f(Q_{11}) + \frac{x-x_1}{x_2-x_1}f(Q_{21})$$ $$f(R_2)\approx \frac{x_2-x}{x_2-x_1}f(Q_{12}) + \frac{x-x_1}{x_2-x_1}f(Q_{22})$$ 再在y方向进行线性插值,得到 $$f(P) \approx \frac{y_2-y}{y_2-y_1}f(R_1) + \frac{y_-y_1}{y_2-y_1}f(R_2)$$ python调用双线性插值缩放的opencv接口为 ```python cv2.resize(src,dsize,dst=None,fx=None,fy=None,interpolation=cv2.INTER_LINEAR) ``` (2)最近邻域插值 python调用最近邻域插值缩放的opencv接口为 ```python cv2.resize(src,dsize,dst=None,fx=None,fy=None,interpolation=cv2.INTER_NEAREST) ``` (3)4x4像素邻域的双三次插值 python调用4x4像素邻域的双三次插值缩放的opencv接口为 ```python cv2.resize(src,dsize,dst=None,fx=None,fy=None,interpolation=cv2.INTER_CUBIC) ``` (4)8x8像素邻域的Lanczos插值 python调用8x8像素邻域的Lanczos插值缩放的opencv接口为 ```python cv2.resize(src,dsize,dst=None,fx=None,fy=None,interpolation=cv2.INTER_LANCZOS4) ``` (5)像素关系重采样 python调用像素关系重采样缩放的opencv接口为 ```python cv2.resize(src,dsize,dst=None,fx=None,fy=None,interpolation=cv2.INTER_AREA) ``` 插值方式的选取对结果影响不大,本文采用8x8像素邻域的Lanczos插值。 ##### 5.仿射变换 上述方法得到了标准化的车牌区域,下一步是更为精细的字符分割,为了字符分割的准确性,需要对得到的车牌区域进行校正操作。 仿射变换(Affine Transformormation)其实是另外两种简单变换的叠加:一个是**线性变换**,一个是**平移变换**。仿射变换是二维平面中一种重要的变换,在图像图形领域有广泛的应用,在二维图像变换中,一般表达为 ![](./image/截图_选择区域_20211230230333.bmp) 其中,R是线性变换,T是平移变换。 对于旋转变换,旋转矩阵(对应R)为 ![](./image/截图_选择区域_20211230230815.bmp) opencv的的仿射变换接口为 ```python m = cv2.getRotationMatrix2D((cx, cy), angle, 1.0) dst = cv2.warpAffine(src, m, (w, h), borderValue=(0, 0, 0)) ``` 实质上是先进行平移变换,将轴心`(cx,cy)`平移到原点,再进行仿射变换 ![](./image/截图_选择区域_20211230231835.bmp) 经过查阅资料,本文采用canny边缘提取+Hough先检测的方法,得到车牌区域的所有直线,再对所有倾斜角在-45到45度的直线计算斜率,提取角度,最后使用仿射变换的方法进行旋转,python实现如下 ```python # 直线矫正 def line_correction(src): gray = cv2.cvtColor(src, cv2.COLOR_BGR2GRAY) ret, bin_img = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) canny = cv2.Canny(bin_img, 50, 150, 3) # Canny提取边缘 lines = cv2.HoughLines(canny, 1, np.pi / 180, 0) # Hough直线检测 # rho = x0cos(theta) + y0sin(theta) rotate_angle = 0 # lines中的为(rho, theta)的数组,每一组rho theta代表一条直线 for line in lines[0]: rho, theta = line a = np.cos(theta) b = np.sin(theta) x0 = a * rho y0 = b * rho x1 = int(x0 + 1000 * (-b)) y1 = int(y0 + 1000 * (a)) x2 = int(x0 - 1000 * (-b)) y2 = int(y0 - 1000 * (a)) # 略过竖直线和水平线 if x1 == x2 or y1 == y2: continue k = (y2 - y1) / (x2 - x1) # 求得斜率 rotate_angle = math.degrees(math.atan(k)) # 由斜率求得倾斜角度 if rotate_angle > 45: rotate_angle -= 90 elif rotate_angle < -45: rotate_angle += 90 dst = rotate_image(src, rotate_angle) return dst def rotate_image(src, angle): h, w = src.shape[:2] cx, cy = w // 2, h // 2 # 旋转的中心 m = cv2.getRotationMatrix2D((cx, cy), angle, 1.0) # 二维旋转仿射变换矩阵 return cv2.warpAffine(src, m, (w, h), borderValue=(0, 0, 0)) # 旋转导致的边缘填充为0 ``` ##### 6.字符分割 通过上述步骤后,对车牌区域二值化,由于车牌检测时,车牌大多有一定的倾斜,对应的定位区域会多一段边框,对接下来的投影操作造成影响,向内剪切15像素,做水平和竖直投影,得到一系列跳变点,对于跳变点的极性、距离做限定可划分出若干区域,即可将字符分割开来。python实现如下 ```python def char_split(src): license_area = project(src)[0] # 投影法定位车牌位置 corrected_img = line_correction(license_area) # 旋转法矫正车牌 gray = cv2.cvtColor(corrected_img, cv2.COLOR_BGR2GRAY) ret, bin_img = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) # 超过阈值取0+大津阈值法 h, w = bin_img.shape bin_img = 255 - bin_img # 边缘裁剪 mask = np.zeros_like(bin_img) mask[15:h-15, 15:w-15] = 1 # 中间区域为保留区域 bin_img = bin_img * mask kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) cv2.morphologyEx(bin_img, cv2.MORPH_ERODE, kernel, bin_img, iterations=1) h_proj, v_proj = get_projection_array(bin_img) h_leap_points, h_polarity = h_scan(h_proj, 10) # 对最大高度的1/10处扫描 # h_leap_points, h_polarity = get_h_leap_points(h_proj) v_leap_points, v_polarity = v_scan(v_proj, 5) # 对最大高度的1/5处扫描 rect_borders = [] for j in range(len(v_leap_points) - 1): v_border = v_leap_points[j: j + 2] # 纵向的上下界 if v_border[1] - v_border[0] < 50: continue for i in range(len(h_leap_points) - 1): h_border = h_leap_points[i: i + 2] # 横向的左右界 if h_polarity[i] == 1 and h_polarity[i+1] == -1: # 相邻两个跳变点必须是先正后负 rect_borders.append([v_border, h_border]) dst = corrected_img i = 0 for border in rect_borders: left_up = (border[1][0], border[0][0]) right_down = (border[1][1], border[0][1]) cv2.rectangle(dst, left_up, right_down, (0, 0, 255)) i += 1 return dst # 输入二值图像,返回统计信息的数组 def get_projection_array(src, fgd=255): h, w = src.shape[:2] h_proj = np.zeros(w) v_proj = np.zeros(h) for i in range(h): for j in range(w): if src[i][j] == fgd: h_proj[j] += 1 v_proj[i] += 1 return h_proj, v_proj # 水平扫描,确定水平方向上的跳变点位置 def h_scan(data, lv): leap_points = [] # 记录跳变点的位置 leap_polarity = [] # 记录跳变点的极性 w = len(data) h = max(data) for i in range(0, w - 1): if leap_points: if i - leap_points[-1] < 8: # 相邻两次跳变的最小距离 continue x1 = i x2 = i + 1 y1 = data[x1] y2 = data[x2] r = sorted([y1, y2]) k = y2 - y1 k_abs = abs(k) if k_abs >= 1 and r[0] <= h / lv <= r[1]: # 斜率大于3且在lv处扫描 leap_points.append(i) leap_polarity.append(k / k_abs) return leap_points, leap_polarity # 竖直扫描,确定竖直方向上的跳变点位置 def v_scan(data, lv): leap_points = [] leap_polarity = [] w = len(data) h = max(data) for i in range(0, w - 1): if leap_points: if i - leap_points[-1] < 20: # 相邻两次跳变的最小距离 竖直方向上小于20的间隔舍去 continue x1 = i x2 = i + 1 y1 = data[x1] y2 = data[x2] r = sorted([y1, y2]) k = y2 - y1 k_abs = abs(k) if abs(k) >= 3 and r[0] <= h / lv <= r[1]: # 斜率大于5且在lv1处扫描 leap_points.append(i) leap_polarity.append(k/k_abs) return leap_points, leap_polarity def get_h_leap_points(data): lp = [] for i in range(70, 80): lp.extend(h_scan(data, i / 10)[0]) lp = np.array(lp, dtype=np.float32) criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 0.1) flags = cv2.KMEANS_PP_CENTERS compactness, labels, centers = cv2.kmeans(lp, 14, None, criteria, 10, flags) polarities = [1, -1] * 7 polarities = np.array(polarities) centers = centers.reshape(-1) centers = centers.astype(np.uint16) centers = sorted(centers) return centers, polarities ``` #### 最终效果 test18.bmp ![](./image/截图_main.py_20211230232739.bmp) test17.bmp ![](./image/截图_main.py_20211230232850.bmp) test16.bmp ![](./image/截图_main.py_20211230233005.bmp) test5.bmp ![](./image/截图_main.py_20211230233145.bmp) test7.bmp ![](./image/截图_main.py_20211230233305.bmp.bmp) 3.bmp ![](./image/截图_main.py_20211230233449.bmp.bmp) 4.bmp ![](./image/截图_main.py_20211230233534.bmp.bmp)