
简介这份资源是一套基于OpenCV与Python实现的车牌识别系统源码面向计算机视觉入门者、课程设计或毕业设计开发者帮助理解从图像预处理到字符识别的完整LPR流程。压缩包共49个文件约28.59MB包含6个py脚本、21张jpg与15张png测试图片、2个7z模型压缩包、2个dat分类器数据及说明文档覆盖主程序、图像识别、数学运算与配置等模块图片素材可用于验证不同光照与角度下的识别效果。项目按灰度化、二值化、降噪、边缘检测、轮廓分割、字符切分与SVM分类等步骤组织代码结构清晰便于逐环节调试与二次开发。目前已有11114人学习下载适合希望掌握OpenCV图像处理与机器学习分类实践、并需要可运行参考案例的读者。1. 车牌识别系统从零落地为什么我劝你先跑通再谈优化车牌识别系统在停车场闸机、小区门禁、高速卡口这些场景里几乎是标配但真正自己动手用 OpenCV Python 搭一套很多人卡在第一步——环境装完cv2导入报错或者图片一读进来全是噪点定位框乱飞。这个标题讲的就是用 OpenCV 做图像处理、用 Python 串起整个流程从一张车辆照片里把车牌区域找出来、把字符切出来、再识别成文本。它适合两类人一类是刚学完 Python 基础语法、想找一个能跑通的图像处理项目练手的新手另一类是已经会调库、但没系统走过「预处理→定位→矫正→分割→识别」全链路的熟手。整套方案不依赖深度学习大模型纯传统视觉方法就能跑出可用结果对算力要求低调试过程透明每个参数改了什么效果一眼能看见。下面按我实际搭过几版的顺序把选型理由、代码、参数和踩过的坑一次讲清。2. 环境搭建与图像预处理把 cv2 装明白再谈识别2.1 OpenCV 安装的三种路径与 cv2 导入失败排查新手最容易翻车的地方不是算法是环境。pip install opencv-python这条命令在大部分机器上能成但装完import cv2报ModuleNotFoundError: No module named opencv的情况我见过太多次。原因通常有三个一是 pip 装到了系统 Python 而不是你 VSCode 里选的解释器二是 Windows 上同时装了多个 Python 版本PATH 指向的和 pip 操作的不是同一个三是用了 conda 环境但没激活就 pip install。我一般会按这个顺序确认# 先确认当前 pip 属于哪个 Python python -c import sys; print(sys.executable) pip -V # 如果两者路径不一致用模块方式安装强制装到当前解释器 python -m pip install opencv-python opencv-contrib-python # 验证安装打印版本和编译信息 python -c import cv2; print(cv2.__version__); print(cv2.getBuildInformation()[:200])opencv-python是主包opencv-contrib-python带额外模块比如后面可能用到的cv2.text两个都装不会冲突但不要和opencv-python-headless混装headless 没有 GUI 相关函数cv2.imshow会直接报错。版本上 4.2 以上都够用我习惯锁 4.5.x 到 4.8.x 这一段API 稳定网上资料多。VSCode 里还要注意右下角选对解释器选错了终端里python能跑、编辑器里就是红线。Linux 下如果系统自带 Python建议用 venv 隔离python3 -m venv lpr_env source lpr_env/bin/activate pip install opencv-python numpy提示装完先跑cv2.__version__能打印出版本号再往下走别急着写识别代码。2.2 灰度、高斯模糊、Sobel 边缘预处理参数怎么定车牌定位的本质是「在图像里找一块矩形边缘密集区」。彩色图三通道信息冗余先转灰度减少计算量。转完灰度直接做边缘检测噪点会让边缘图全是碎点所以中间要加高斯模糊。这三步的顺序和参数直接决定后面定位准不准。import cv2 import numpy as np def preprocess(img_path): # 读图第二个参数 1 表示彩色后面转灰度 img cv2.imread(img_path) if img is None: raise FileNotFoundError(图片路径不对或格式不支持) # 转灰度三通道变单通道后续形态学操作都在单通道上做 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊ksize 必须是奇数5x5 适合 720p 左右的车牌图 # 太大如 11x11会把车牌字符边缘也糊掉太小3x3去噪不干净 blur cv2.GaussianBlur(gray, (5, 5), 0) # Sobel 水平方向求导车牌字符垂直边缘多水平梯度响应强 # ksize-1 表示用 3x3 的 Scharr 核比默认 Sobel 核更锐 sobel cv2.Sobel(blur, cv2.CV_8U, 1, 0, ksize-1) # 二值化OTSU 自动找阈值比手动定 127 稳 _, binary cv2.threshold(sobel, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) return img, gray, binaryGaussianBlur的ksize我试过 3、5、75 在多数 1280x720 的图上平衡最好。Sobel的dx1, dy0是只取水平梯度因为车牌字符是竖直排列的垂直边缘丰富。如果你拿到的图是斜拍或者车牌旋转角度大这一步响应会弱后面定位就漏。THRESH_OTSU适合光照均匀的图逆光或夜间图它会失效那种情况得先做直方图均衡或者换自适应阈值这是后面避坑章要讲的。预处理完的binary图应该能看到车牌区域是一块白色密集带如果整张图全是白点或者全黑说明模糊核或阈值有问题先调这两处再往下。3. 车牌定位与矫正形态学闭运算和轮廓筛选的实战参数3.1 闭运算把字符连成块kernel 尺寸与迭代次数二值化后的边缘图里车牌字符是一根根竖线彼此不相连直接找轮廓会得到几十个小框。形态学闭运算先膨胀后腐蚀能把相邻竖线连成一块矩形区域这就是车牌候选区。def locate_plate(binary): # 闭运算核宽 17 高 5 是经验值 # 宽大于高是因为车牌字符水平排列需要横向连接 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) # 迭代 2 次让字符间的缝隙充分闭合 closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations2) # 再腐蚀一次去掉细小的噪点连接 closed cv2.erode(closed, kernel, iterations1) closed cv2.dilate(closed, kernel, iterations1) # 找外轮廓 contours, _ cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) # 车牌宽高比经验范围 2.5 到 5.5太小太大都排除 ratio w / float(h) # 面积过滤太小的噪点框不要 if 2.5 ratio 5.5 and w 80 and h 20: candidates.append((x, y, w, h)) # 按面积从大到小排最大的通常是车牌 candidates.sort(keylambda b: b[2] * b[3], reverseTrue) return candidates, closedkernel的(17, 5)不是随便定的。车牌字符间距在 720p 图上大概 8 到 15 像素核宽 17 能跨过两个字符的间隙。核高 5 是为了不把上下无关的边缘也连进来。iterations2是我在几十张图上试出来的1 次连不上3 次会把车牌和保险杠边缘糊成一片。宽高比 2.5 到 5.5 覆盖了蓝牌、新能源绿牌、黄牌的大部分情况但双层黄牌宽高比会接近 2这种要单独处理。3.2 透视矫正用 minAreaRect 和 warpPerspective 摆正车牌定位到的框往往是斜的直接切出来字符是歪的分割和识别都会受影响。矫正分两步先用minAreaRect拿到最小外接旋转矩形再用warpPerspective做透视变换。def correct_plate(img, box): x, y, w, h box # 稍微外扩一点避免切掉车牌边缘 pad 5 roi img[max(0, y-pad):yhpad, max(0, x-pad):xwpad] gray cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) # 用 OTSU 在 ROI 内重新二值化比全图阈值更准 _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 找 ROI 内所有前景点拟合最小外接旋转矩形 coords np.column_stack(np.where(binary 0)) if len(coords) 50: return roi # 点太少放弃矫正直接返回 rect cv2.minAreaRect(coords) box_pts cv2.boxPoints(rect) box_pts np.int0(box_pts) # 确定目标宽高取旋转矩形的长边为宽 width int(rect[1][0]) height int(rect[1][1]) if width height: width, height height, width # 目标矩形四个角顺序要和 box_pts 对应 dst np.array([[0, 0], [width-1, 0], [width-1, height-1], [0, height-1]], dtypenp.float32) src box_pts.astype(np.float32) # 按左上、右上、右下、左下排序 src src order_points(src) M cv2.getPerspectiveTransform(src, dst) warped cv2.warpPerspective(roi, M, (width, height)) return warped def order_points(pts): # 按 xy 和 x-y 排序确定四个角顺序 rect np.zeros((4, 2), dtypenp.float32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] # 左上 rect[2] pts[np.argmax(s)] # 右下 diff np.diff(pts, axis1) rect[1] pts[np.argmin(diff)] # 右上 rect[3] pts[np.argmax(diff)] # 左下 return rectminAreaRect返回的rect[1]是 (宽, 高)但 OpenCV 不保证宽大于高所以要手动交换。order_points这个函数是矫正能不能对的关键顺序错了透视变换会把图翻过来。warpPerspective的输出尺寸用旋转矩形的长边做宽短边做高这样矫正后车牌是水平摆正的。注意如果 ROI 里混入了保险杠或车灯边缘minAreaRect会把它们一起框进去矫正后车牌只占一小块。这种情况要在定位阶段把宽高比卡得更严或者用颜色特征蓝牌 HSV 范围做二次筛选。4. 字符分割与识别垂直投影法和模板匹配的落地细节4.1 垂直投影切字符波峰波谷怎么找矫正后的车牌是水平条带字符竖直排列用垂直投影法分割最直接。把二值图按列求和字符所在列投影值高字符间隙投影值低找波谷就是切分点。def split_chars(plate_binary): # 统一高度到 40宽度按比例缩放方便后续模板匹配 h, w plate_binary.shape target_h 40 target_w int(w * target_h / h) resized cv2.resize(plate_binary, (target_w, target_h)) # 再二值化一次确保只有 0 和 255 _, binary cv2.threshold(resized, 127, 255, cv2.THRESH_BINARY) # 垂直投影每列白色像素个数 projection np.sum(binary 255, axis0) # 找波谷投影值小于阈值的列认为是间隙 threshold target_h * 0.15 # 高度 15% 以下算间隙 is_gap projection threshold chars [] start None for i, gap in enumerate(is_gap): if not gap and start is None: start i elif gap and start is not None: # 字符宽度过滤太窄的是噪点 if i - start 5: chars.append(binary[:, start:i]) start None # 处理最后一个字符贴着右边界的情况 if start is not None and target_w - start 5: chars.append(binary[:, start:target_w]) return charsthreshold target_h * 0.15这个系数我调过很多次0.1 会把「1」这种窄字符也当间隙切掉0.2 会把「川」这种中间有竖线的字切碎。0.15 在多数蓝牌上稳。字符宽度过滤 5是去掉边框残留的细线。分割完一般得到 7 个字符蓝牌但「·」和边框可能混进来所以后面识别阶段还要做一次数量校验不是 7 个就丢弃或重新分割。4.2 模板匹配识别归一化与匹配度阈值不依赖深度学习的情况下模板匹配是最容易跑通的识别方法。把 0-9、A-Z、各省简称做成 20x40 的二值模板每个分割出的字符和所有模板算匹配度取最高的。def match_char(char_img, templates): # char_img 和模板都统一到 20x40 char_resized cv2.resize(char_img, (20, 40)) _, char_binary cv2.threshold(char_resized, 127, 255, cv2.THRESH_BINARY) best_score -1 best_label ? for label, tmpl in templates.items(): # 用 TM_CCOEFF_NORMED对亮度差异不敏感 result cv2.matchTemplate(char_binary, tmpl, cv2.TM_CCOEFF_NORMED) score result[0][0] if score best_score: best_score score best_label label # 匹配度低于 0.4 认为不可信返回问号 if best_score 0.4: return ?, best_score return best_label, best_scoreTM_CCOEFF_NORMED返回 -1 到 1越接近 1 越像。阈值 0.4 是保守值实际蓝牌清晰图上正确字符匹配度通常在 0.6 以上低于 0.4 的基本是分割错了或者模板里没有这个字。模板制作时要注意所有模板必须和待识别字符用同样的二值化方式和同样的尺寸否则匹配度会整体偏低。模板匹配的局限很明显字体变化、磨损、污渍都会拉低匹配度。如果项目要求高准确率这一步可以换成轻量 CNN 或者调用现成 OCR但那就不是纯 OpenCV 方案了。我一般先用模板匹配跑通全流程确认定位和分割没问题再决定要不要换识别模块。5. 避坑与排查车牌识别系统最常见的 5 个翻车现场5.1 现象定位框把整个车头都框进去了原因闭运算 kernel 太大或者迭代次数太多把车牌和保险杠、车灯的边缘连成了一片。宽高比过滤没卡住因为整块区域的宽高比也可能落在 2.5 到 5.5 之间。解决把 kernel 从 (17,5) 降到 (13,5)迭代次数从 2 降到 1。同时在轮廓筛选里加一条候选框面积不能超过整图面积的 15%超过的丢弃。5.2 现象夜间或逆光图二值化后全是噪点原因OTSU 全局阈值在光照不均时失效暗区全黑、亮区全白Sobel 边缘图里车牌信号被淹没。解决换自适应阈值cv2.adaptiveThresholdblockSize 取 15 到 31 的奇数C 取 5 到 10。或者先做 CLAHE 直方图均衡再走原流程。夜间图还可以先做一次伽马校正把暗部提亮。5.3 现象字符分割出来多一块或少一块原因车牌边框没去干净或者「·」被当成字符切出来。垂直投影的间隙阈值设得不对也会导致「1」被切掉或「川」被切碎。解决分割前先做一次水平投影把车牌上下边框裁掉。分割后校验字符数量蓝牌不是 7 个就重新调阈值再分一次。对「·」可以在分割后判断宽度特别窄的小于 8 像素直接丢弃。5.4 现象模板匹配把「0」认成「O」「1」认成「I」原因模板库里 0 和 O、1 和 I 的模板太像二值化后几乎一样。车牌字符集里其实不含 I 和 O避免混淆但模板库如果按全字符集做的就会误匹配。解决模板库按车牌实际字符集做去掉 I 和 O。匹配时如果最高分和次高分差距小于 0.05标记为不确定输出两个候选。5.5 现象cv2.imshow 报错或窗口一闪而过原因装了 headless 版本或者代码里没写cv2.waitKey(0)。Linux 服务器上没有显示环境也会报错。解决确认装的是opencv-python不是opencv-python-headless。调试阶段加cv2.waitKey(0)和cv2.destroyAllWindows()。服务器上跑就把 imshow 换成cv2.imwrite存图看。6. 从能跑到好用三个提升识别率的进阶技巧第一个技巧是颜色预筛选。蓝牌在 HSV 空间里 H 大概 100 到 124S 大于 80V 大于 50。在形态学定位之前先做一次颜色掩膜把非蓝色区域置零定位准确率能提一大截尤其是背景复杂的场景。代码就三行hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) mask cv2.inRange(hsv, (100, 80, 50), (124, 255, 255)) binary cv2.bitwise_and(binary, binary, maskmask)绿牌和 yellow 牌改一下 HSV 范围就行新能源绿牌 H 在 35 到 85 之间。第二个技巧是倾斜校正后再做一次水平投影。有些车牌矫正后还有轻微上下倾斜字符基线不水平垂直投影的波谷会变浅。用cv2.minAreaRect拿到角度后做一次仿射旋转把角度归零分割准确率明显提升。角度小于 1 度可以跳过大于 5 度说明定位阶段就有问题回去查定位。第三个技巧是模板匹配得分做序列校验。单个字符匹配度低不一定要丢弃如果整串字符的匹配度均值高于 0.55且字符数量正确可以保留结果。反过来如果某个字符匹配度低于 0.3即使其他字符都很高也要标记整串为可疑。我一般会把每张图的定位框、矫正图、分割字符、匹配得分都存下来出问题时翻这些中间图比看代码快得多。这套方案我在树莓派 4B 上跑过1280x720 的图单帧处理大概 80 到 120 毫秒定位加分割加识别全流程能到 8 到 10 FPS做闸机抓拍够用。真正影响体验的不是算法速度是预处理参数有没有按你的实际场景调过。我现在的习惯是每换一个摄像头机位先拍 20 张不同光照的图把定位和分割的中间结果全存下来肉眼过一遍再定参数。这个笨办法比任何自动调参都靠谱。希望帮到你。本文还有配套的精品资源点击获取