1. 为什么你的车牌识别总是不准先搞定“歪脖子”车牌我做了这么多年车牌识别项目发现新手最容易栽跟头的地方往往不是复杂的深度学习模型而是最基础的图像预处理。你想想一个车牌在图像里歪着、斜着或者被边框、铆钉干扰后面的字符识别模型再厉害也像是让一个近视眼去读一本放歪了的书怎么可能看得准很多朋友拿到车牌定位后的图像直接就扔给字符分割模块结果分割出来的字符要么缺胳膊少腿要么把两个字符粘在一起识别率自然上不去。问题的根源十有八九出在“矫正”和“清洗”这两个环节没做好。今天我就把自己踩过坑、试过有效的方法掰开揉碎了讲给你听重点就是两个核心操作透视变换矫正和跳变次数法分割。简单来说透视变换就是帮你把“歪脖子”车牌给“掰正”让它变成一个规规矩矩的长方形。而跳变次数法则像是一个精细的“清洁工”负责把车牌上那些没用的边框、固定用的铆钉以及各种小噪点给清理掉只留下干干净净的字符区域。这两个步骤做好了后面的字符分割和识别才能事半功倍。我会用最直白的语言和完整的代码带你走一遍这个流程保证你跟着做就能出效果。2. 透视变换把任何角度的车牌“掰正”2.1 透视变换到底是个啥你可以把透视变换想象成给一张照片“换一个视角”。比如你从侧面拍一个长方形相框它在照片里会变成一个梯形。透视变换的作用就是把这个梯形再“变回”我们正对着它看时的长方形。在车牌识别里摄像头安装角度、车辆停放位置都会导致拍到的车牌图像发生透视形变可能是水平倾斜也可能是垂直倾斜或者两者都有。这种形变如果不纠正字符的宽度、高度、间距都会失真后续分割根本无从谈起。透视变换的数学公式看起来有点唬人但它的核心思想很简单找到原图中车牌的四个角点然后告诉计算机“我想把这四个点围成的区域映射到一个标准的长方形上。”这个标准的长方形就是我们期望的、正对的车牌样子。对于国内常见的蓝牌小车它的标准尺寸是440像素宽140像素高。所以我们变换的目标就是把这个区域映射到一个440x140的矩形上。2.2 实战如何找到车牌的四个“角”理论好说做起来难。第一步也是最关键的一步就是精准定位倾斜车牌的四个顶点。原始文章里提到了利用三角形相似原理来计算这对理解很有帮助。在实际编程中我们通常是在车牌定位步骤后得到一个最小外接矩形cv2.minAreaRect。这个矩形就包含了车牌的倾斜角度和中心位置。但是这个外接矩形的四个顶点并不总是严格对应车牌本身的四个角尤其是当车牌边框不明显或者图像有噪声时。所以我们需要一些策略来修正。我的经验是结合车牌的颜色特征和边缘信息来辅助定位。比如我们先通过颜色阈值蓝色、黄色、绿色等大致框出车牌区域然后提取这个区域的轮廓。对于这个轮廓我们求它的最小外接矩形。这个矩形的倾斜角度就告诉我们车牌是顺时针歪了还是逆时针歪了。接下来根据这个角度我们去分析轮廓的极点最上、最下、最左、最右的点或者利用霍夫变换找车牌的长边直线通过这些线的交点来更精确地确定四个角点。这里有个小技巧有时候车牌边框不清晰角点检测会跑偏。我通常会加一个“校验”步骤比如判断一下我们找到的四个点构成的四边形它的长宽比是不是接近车牌的标准比例大约3.14:1。如果差得太远就说明角点找错了可能需要回退到用最小外接矩形的顶点或者结合边缘检测的结果重新计算。2.3 手把手代码实现透视矫正光说不练假把式我们直接上代码。假设我们已经通过之前的步骤得到了一个疑似车牌的图像区域plate_img并且计算出了它的四个角点src_pts这是一个包含四个(x,y)坐标的NumPy数组顺序是左上、右上、右下、左下但这个顺序需要根据你的检测结果调整。import cv2 import numpy as np def perspective_correct(plate_img, src_pts): 对车牌图像进行透视变换矫正。 :param plate_img: 原始倾斜的车牌图像 :param src_pts: 原始图像中车牌的四个角点顺序为 [左上 右上 右下 左下] :return: 矫正后的正车牌图像 # 定义目标矩形的四个角点标准车牌尺寸 440x140 width, height 440, 140 dst_pts np.array([ [0, 0], # 左上 [width - 1, 0], # 右上 [width - 1, height - 1], # 右下 [0, height - 1] # 左下 ], dtypefloat32) # 确保输入点也是float32类型 src_pts np.array(src_pts, dtypefloat32) # 计算透视变换矩阵 M cv2.getPerspectiveTransform(src_pts, dst_pts) # 应用透视变换 warped cv2.warpPerspective(plate_img, M, (width, height)) return warped # 示例假设我们通过某种方法得到了角点 # 注意这里的坐标是示例你需要用自己检测到的真实角点替换 example_src_pts [[50, 20], [380, 45], [370, 130], [40, 150]] corrected_plate perspective_correct(plate_img, example_src_pts) cv2.imshow(Original Tilted Plate, plate_img) cv2.imshow(Corrected Plate, corrected_plate) cv2.waitKey(0)这段代码就是透视变换的核心。cv2.getPerspectiveTransform函数根据源点和目标点计算出变换矩阵M然后cv2.warpPerspective应用这个矩阵把歪斜的图像“拉正”。效果立竿见影原来倾斜的车牌瞬间就变得方方正正为后续处理打下了完美的基础。3. 跳变次数法给车牌做个“深度清洁”3.1 边框和铆钉字符分割的“头号杀手”车牌矫正后你以为就能直接切字符了太天真了车牌周围那一圈边框还有固定车牌用的那几个亮闪闪的铆钉它们还牢牢地粘在图像上。在二值化图像里字符是白色背景是黑色这些边框和铆钉也是白色的。如果你直接做垂直投影分割字符这些非字符的白色区域会严重干扰投影结果导致字符边界找不准很可能把边框当成字符的一部分切进来。所以我们必须先把这些“杂质”去掉。跳变次数法就是我试过多种方法后觉得最直观、最有效的一种。它的原理基于一个非常简单的观察在车牌字符区域一行或一列像素中黑白颜色变化的次数会非常多。因为字符笔画是白色的背景是黑色的从左到右扫描遇到笔画开始颜色从黑变白这是一次跳变笔画结束颜色从白变黑这又是一次跳变。一个复杂的汉字或字母一行里可能有十几次甚至几十次跳变。相反在只有边框或者纯背景的区域颜色很均匀跳变次数极少可能就边框边缘有1-2次跳变。铆钉区域虽然也是白色但它通常是一个小圆点在某一列上可能只造成一次由黑到白再到黑的跳变总共2次。我们就是利用这个“跳变次数”的显著差异来把字符区域和非字符区域区分开。3.2 四步扫描精确定位字符区域具体怎么做呢就像扫地一样我们从上、下、左、右四个方向对二值化后的车牌图像进行扫描。确定上下边界行扫描从上往下扫统计每一行像素中黑白跳变的次数。当连续若干行比如3行的跳变次数都超过一个阈值比如10次我们就认为进入了字符区域记录这一行作为字符区域的上边界。从下往上扫同样的逻辑找到字符区域的下边界。为什么是连续几行这是为了容错防止因为某个单独的噪声点或边框的局部突起造成误判。确定左右边界列扫描在已经确定的上下边界范围内我们再进行列的扫描。从左往右扫统计每一列像素中黑白跳变的次数。找到跳变次数超过阈值这个阈值要设得低一些比如2因为数字“1”可能只产生2次跳变的列且连续几列都超过就认为是字符区域的左边界。从右往左扫同理找到右边界。阈值为什么是2这是为了保护像数字“1”这样笔画简单的字符。数字“1”在垂直方向上可能只在中间有一竖那么在一列中它只产生一次“黑-白”和一次“白-黑”的跳变总共2次。如果阈值设为3就会把“1”给过滤掉那就出大问题了。找到这四个边界后边界之外的区域我们就可以放心大胆地全部涂成黑色背景色这样就彻底清除了边框和铆钉的干扰。下面我们看看代码是怎么实现这个逻辑的。3.3 代码实战实现跳变次数法去噪假设我们已经有了一张矫正后的、二值化的车牌图像binary_plate白色字符黑色背景。def remove_frame_rivet_by_jump(binary_plate, row_threshold10, col_threshold2, continuity3): 使用跳变次数法去除车牌边框和铆钉。 :param binary_plate: 二值化的车牌图像255为白0为黑 :param row_threshold: 行跳变次数阈值用于区分字符行和边框行 :param col_threshold: 列跳变次数阈值用于区分字符列和边框/铆钉列 :param continuity: 连续行/列判定条件 :return: 去除边框和铆钉后的干净车牌图像 height, width binary_plate.shape clean_plate binary_plate.copy() # 步骤1扫描行确定上下边界 jump_count_rows [] for row in range(height): jumps 0 for col in range(1, width): if binary_plate[row, col] ! binary_plate[row, col-1]: jumps 1 jump_count_rows.append(jumps) # 找上边界 top_boundary top_boundary 0 for i in range(height - continuity): if all(jc row_threshold for jc in jump_count_rows[i:icontinuity]): top_boundary i break # 找下边界 bottom_boundary bottom_boundary height - 1 for i in range(height - 1, continuity - 1, -1): if all(jc row_threshold for jc in jump_count_rows[i-continuity1:i1]): bottom_boundary i break # 步骤2在上下边界内扫描列确定左右边界 jump_count_cols [] for col in range(width): jumps 0 for row in range(top_boundary 1, bottom_boundary): if binary_plate[row, col] ! binary_plate[row-1, col]: jumps 1 jump_count_cols.append(jumps) # 找左边界 left_boundary left_boundary 0 for i in range(width - continuity): if all(jc col_threshold for jc in jump_count_cols[i:icontinuity]): left_boundary i break # 找右边界 right_boundary right_boundary width - 1 for i in range(width - 1, continuity - 1, -1): if all(jc col_threshold for jc in jump_count_cols[i-continuity1:i1]): right_boundary i break # 步骤3将边界外的区域置为黑色0 # 清除上下边框 clean_plate[0:top_boundary, :] 0 clean_plate[bottom_boundary1:height, :] 0 # 清除左右边框 clean_plate[:, 0:left_boundary] 0 clean_plate[:, right_boundary1:width] 0 return clean_plate, (top_boundary, bottom_boundary, left_boundary, right_boundary) # 使用示例 clean_binary_plate, bounds remove_frame_rivet_by_jump(binary_plate) cv2.imshow(With Frame/Rivet, binary_plate) cv2.imshow(Cleaned Plate, clean_binary_plate) cv2.waitKey(0)运行这段代码你会看到边框和铆钉神奇地消失了图像里只剩下清晰的字符。这个bounds元组也非常有用它记录了字符区域的实际范围后面字符分割可以直接在这个区域内进行效率更高。4. 字符分割把连体字一个个“切开”4.1 垂直投影法找到字符间的“空隙”清理干净之后终于到了分割字符的环节。最经典、最直观的方法就是垂直投影法。它的思想很简单把二值图像在垂直方向“压扁”统计每一列上有多少个白色像素点字符点。想象一下你把一束光从车牌图像的顶部照下来字符笔画厚的地方投下的影子就宽白色像素多字符笔画细或者没有字符的地方影子就窄甚至没有白色像素少。这样我们就得到了一个“投影直方图”。在这个直方图上字符区域会形成波峰字符之间的间隙会形成波谷。我们的任务就是找到这些波谷的位置它们就是切割字符的最佳位置。通常车牌有7个字符省份汉字字母5位数字字母混合所以我们应该找到6个明显的波谷将图像切成7份。4.2 处理粘连与断裂实际项目中的挑战理论很美好现实却很骨感。直接投影经常会遇到两个麻烦字符粘连比如“京”和“A”靠得太近投影的波谷很浅甚至没有导致切不开。字符断裂比如数字“1”比较细或者图像质量差导致笔画中间有断裂投影可能会把它误判成两个小波峰从而多切一刀。对于粘连我常用的办法是结合字符的宽高比先验知识。我们知道每个字符的宽度大致在一个范围内。如果投影切出来的某个区域宽度明显大于这个范围那它很可能是两个粘连的字符。这时候可以尝试在这个区域中间寻找一个局部最小值点作为分割点或者使用更精细的连通域分析进行二次分割。对于断裂可以在投影前先对图像进行一次轻微的形态学闭运算先膨胀后腐蚀。这能弥合字符笔画中细小的断裂但又不会让独立的字符粘在一起。闭运算的核大小需要仔细调整我一般用一个1x3或3x1的竖长条形核只弥合垂直方向上的断裂避免水平方向粘连。4.3 完整的分割与归一化代码让我们把垂直投影法和一些简单的优化结合起来实现一个鲁棒性更强的字符分割函数。def segment_chars(clean_binary_plate, char_height20, char_width20): 使用垂直投影法分割字符并归一化到统一尺寸。 :param clean_binary_plate: 去除边框后的干净二值车牌图像 :param char_height: 归一化后的字符高度 :param char_width: 归一化后的字符宽度 :return: 分割并归一化后的字符图像列表 height, width clean_binary_plate.shape # 计算垂直投影直方图 vertical_projection np.sum(clean_binary_plate 255, axis0) # 可视化投影可选用于调试 # import matplotlib.pyplot as plt # plt.plot(vertical_projection) # plt.title(Vertical Projection) # plt.show() char_images [] in_char False start_idx 0 min_char_width 5 # 字符最小宽度用于过滤噪声 for i in range(width): if vertical_projection[i] 0 and not in_char: # 进入一个字符区域 in_char True start_idx i elif vertical_projection[i] 0 and in_char: # 离开一个字符区域 in_char False end_idx i char_width_current end_idx - start_idx # 过滤掉太窄的区域可能是噪声或残留边框 if char_width_current min_char_width: # 提取单个字符区域 char_img clean_binary_plate[:, start_idx:end_idx] # 归一化到统一尺寸 char_img cv2.resize(char_img, (char_width, char_height), interpolationcv2.INTER_CUBIC) # 二值化确保结果干净 _, char_img cv2.threshold(char_img, 127, 255, cv2.THRESH_BINARY) char_images.append(char_img) # 处理最后一个字符如果图像右边没有纯黑列 if in_char: char_img clean_binary_plate[:, start_idx:width] char_img cv2.resize(char_img, (char_width, char_height), interpolationcv2.INTER_CUBIC) _, char_img cv2.threshold(char_img, 127, 255, cv2.THRESH_BINARY) char_images.append(char_img) # 后处理如果分割出的字符数量不对通常应为7个可能需要检查投影阈值或预处理 print(f分割出 {len(char_images)} 个字符) if len(char_images) ! 7: print(警告字符数量可能不正确请检查图像质量或分割参数。) # 这里可以添加更复杂的逻辑比如基于宽度重新合并或分割 return char_images # 使用示例 char_list segment_chars(clean_binary_plate) # 显示分割结果 for i, char_img in enumerate(char_list): cv2.imshow(fChar {i}, char_img) cv2.waitKey(0) cv2.destroyAllWindows()这个函数会输出一个包含7个理想情况下归一化字符图像的列表。每个字符都被统一缩放到20x20像素这是为了匹配后面字符识别神经网络的输入尺寸。归一化非常重要它消除了因为拍摄距离不同导致的字符大小差异让识别模型有一个稳定的输入。5. 避坑指南与经验之谈走完上面三步一个车牌从歪歪扭扭到方方正正再到被干干净净地分割成单个字符整个预处理流程就算完成了。但在实际项目中我还遇到过不少坑这里分享给你希望能帮你少走弯路。第一个坑是关于透视变换的角点检测。在光照不均、车牌脏污或者有部分遮挡的情况下角点检测很容易失败。我的经验是不要只依赖一种方法。我会用cv2.goodFeaturesToTrack来检测角点再结合轮廓分析找到的极点最后用RANSAC之类的算法来筛选出最可能是车牌四角的四个点。如果实在找不到稳定的四个点有时候退而求其次用仿射变换cv2.getAffineTransform代替透视变换也是个办法。仿射变换只要求三个点它能校正倾斜但无法处理透视形变比如一边大一边小对于摄像头角度不是特别偏的情况也够用了。第二个坑是跳变次数法的阈值选择。row_threshold行阈值和col_threshold列阈值不是固定值。对于不同颜色蓝牌、黄牌、绿牌、不同光照条件下二值化的图像最佳的阈值是不同的。我通常会在系统初始化时用一个小的验证集来自动调整这个阈值。比如遍历一组阈值选择那个能让绝大多数车牌正确去除边框同时又不损伤字符“1”的阈值。更高级的做法可以尝试用自适应阈值或者基于连通域分析的方法来去除边框鲁棒性更好但跳变次数法胜在简单直观速度快。第三个坑是字符分割的鲁棒性。垂直投影法对字符间距均匀的情况很好用但遇到车牌字体特殊、字符间距不规则时就容易出错。一个增强的方法是加入字符宽度的先验知识。我们知道车牌的第一个字符汉字通常较宽后面的字母数字宽度较窄且相对均匀。当投影法切分后我们可以检查每个切分块的宽度。如果某个块宽度异常大可能是两个字符粘连了如果某个块宽度异常小可能是噪声或断裂。然后针对性地进行处理比如对宽块进行二次投影或使用滴水算法分割。最后预处理的所有步骤都需要可视化检查。我习惯在代码的关键节点比如矫正后、去边框后、分割后都把图像显示出来看看。把这些中间结果保存下来对于后期调试和优化参数有巨大的帮助。别怕麻烦前期多花时间把预处理管道调稳后面识别模型的准确率提升会让你觉得这一切都是值得的。预处理没做好识别模型就得替它背锅模型再升级效果也有限。