ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

利用Python和OpenCV实现车牌识别:从定位到识别的全流程解析

利用Python和OpenCV实现车牌识别:从定位到识别的全流程解析 简介利用Python与OpenCV构建的车牌识别项目资源包面向计算机视觉入门者和人工智能应用开发者适合课程设计、毕业设计或项目移植。内容包含车牌搜索识别、对比识别、数据库认证、图文搜索、文件图片识别、网络图片地址识别、实时截图识别、摄像头拍照识别等场景并借助百度API与HyperLPR提升识别效果兼顾在线与本地方式。压缩包共139个文件约73.86MB含50张jpg、32张png、19张jpeg样本图以及18个Python脚本、yml配置、dat模型数据、Docker部署文件可快速搭建环境。目前已有1608人学习下载。读者可获得从图片输入、车牌定位、字符识别到多场景检索的完整示例并了解车牌数据库组织与外部识别接口调用方式是入门到进阶的高性价比参考资料。1. 利用 Python 和 OpenCV 做车牌识别先别急着识别定位才是第一道坎晚上十点的停车场出口道闸摄像头拍到一辆车系统却迟迟不抬杆后台人工确认才发现是车牌没识别出来。这类场景里十次有七八次不是字符识别算法不行而是压根没在画面里找到车牌。利用 Python opencv 进行车牌识别看起来是“识别”两个字实际是一条流水线定位车牌区域、矫正角度、分割字符、逐字识别。OpenCV 只提供图像处理原语它不认识“车牌”是什么需要你用颜色、边缘、形状这些特征把它从画面里挑出来。这篇笔记面向刚学完 OpenCV 基础想做完整项目的人也面向准备在道闸、门禁里做原型验证的从业者。先把定位这一步做扎实后面每一步都会顺很多。2. 车牌定位的两种主流路线HSV 颜色阈值与形态学边缘法怎么选2.1 为什么定位是整条流水线最容易翻车的一步车牌在画面里通常只占几十分之一的面积周围全是车身的线条、灯光的反射、地面的纹理。车牌本身有两个稳定特征一是底色中国大陆车牌主要是蓝底、黄底和新能源的绿底二是矩形边框宽高比固定。但这两个特征单独拿出来都不够稳健蓝色车身、白色路牌、树影里的矩形广告牌都会干扰。常见做法是同时跑两条路线颜色定位和边缘定位得到一批候选区域后再用面积、宽高比、内部纹理做校验。不要指望一次就精准框住宁可多给几个候选让后面的校验去淘汰也别漏掉真正的车牌。2.2 路线 A用 HSV 颜色空间筛出车牌底色RGB 里蓝色受光照影响很大同一个蓝牌在晴天和路灯下三个通道的值能差出一大截。HSV 把色相 H 单独拆出来对光照相对不敏感所以颜色定位基本都用 HSV。我的经验是把 S 和 V 的下限放宽一些宁可把深色车身的像素也纳进来再靠形态学操作和面积过滤去清理。下面这段是颜色定位的完整函数输入一张 BGR 图输出候选车牌轮廓。import cv2 import numpy as np def locate_by_color(img_bgr): hsv cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) # 蓝牌H 100~130S 下限放低是为了覆盖夜间偏灰的蓝色 blue_lower (100, 80, 40) blue_upper (130, 255, 255) mask cv2.inRange(hsv, blue_lower, blue_upper) # 开运算去掉细小噪点闭运算把零散的车牌字符连成一个整体 kernel_open cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) kernel_close cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_open) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_close) cnts, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] for cnt in cnts: x, y, w, h cv2.boundingRect(cnt) area w * h ratio w / h # 中国车牌宽高比约 3.14按 2.0~4.5 过滤留出倾斜余量 if area 1500 and 2.0 ratio 4.5: candidates.append((x, y, w, h)) return candidates闭运算的核尺寸是关键参数。我常用的 (17, 5) 来自车牌本身的长宽比例17 对应横向5 对应纵向为了让字符之间的空隙被横向“填满”又不会把上下两条边框外的区域吞进来。如果你处理的图片分辨率很高或很低这个核要按比例缩放否则小图上 17 像素的核可能把半张车头都连进去。S 通道下限我给 80是因为夜间车牌颜色偏灰蓝色饱和度会掉很多教程写 150 以上白天能跑傍晚直接全灭。2.3 路线 BCanny 边缘加形态学闭运算拧出一个矩形框颜色定位对绿牌和掉色严重的旧蓝牌容易失效边缘法这时候能兜底。车牌外框是一个矩形Canny 把它变成一圈白色边缘再用闭运算让矩形边缘“闭合”成一个大连通域。车身线条虽然多但很难刚好形成一个宽高比接近 3 的闭合块。def locate_by_edge(img_bgr): gray cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) gray cv2.GaussianBlur(gray, (5, 5), 0) # Canny 两个阈值控制边缘密度50/150 对白天和傍晚都比较稳 edges cv2.Canny(gray, 50, 150) # 横向 17 像素闭运算把断续的边框补成整块 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel) cnts, _ cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] for cnt in cnts: rect cv2.minAreaRect(cnt) w rect[1][0] h rect[1][1] if w 0 or h 0: continue ratio max(w, h) / min(w, h) area w * h if area 2000 and 2.0 ratio 4.5: candidates.append(rect) return candidatesCanny 的阈值不要一开始就追求“完美边框”。阈值调低了车身线条全部变成噪声闭运算会把整辆车连成一块阈值调高了车牌边框本身太淡断裂后补不回来。我的经验是先固定 50/150看闭运算结果里有没有一个面积占比适中的矩形块没有再去微调。边缘法比颜色法更容易受车灯、地面反光影响所以两套结果一般合并起来统一交给下一步校验。2.4 候选区校验与透视矫正把斜着的车牌掰正定位到轮廓只是第一步道闸摄像头往往从斜上方俯拍车牌是透视变形的。直接用矩形裁剪会把旁边的车身带进来字符分割时容易多出许多噪声。常见做法是用 minAreaRect 拿到带角度的最小外接矩形再做透视变换归一化成 440×140 的标准尺寸。def order_points(pts): # 按坐标和排序成 左上、右上、右下、左下 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).ravel() rect[1] pts[np.argmin(diff)] rect[3] pts[np.argmax(diff)] return rect def crop_plate(img_bgr, rect): box cv2.boxPoints(rect) box order_points(box) dst np.array([[0, 0], [440, 0], [440, 140], [0, 140]], dtypenp.float32) M cv2.getPerspectiveTransform(box, dst) plate cv2.warpPerspective(img_bgr, M, (440, 140)) return plateorder_points 这个排序函数看着简单但少写它的人特别多。boxPoints 返回的四个点顺序在不同角度下是乱的不排序直接做透视变换轻则图像上下颠倒重则毛巾形扭曲。排序的原理是“和最小的是左上和最大的是右下”减法的极值是右上、左下。透视变换后最好再加一道校验算一下矫正后的图里非零像素的占比如果低于某个阈值说明原候选区可能只是块纯色车身直接丢弃。这条校验能过滤掉不少误检。3. 字符分割把定位好的车牌切成 7 个独立字符3.1 二值化方向先搞清楚车牌是深底浅字还是浅底深字车牌区域裁出来后字符是白字或黑字底色是蓝、绿或黄。二值化的目标是把字符变成白色、背景变成黑色但方向错了整段全反。蓝牌是深底白字绿牌是深底黑字黄牌是黄底黑字所以统一处理时需要根据车牌类型选择正反。最简单的办法是固定使用 THRESH_BINARY_INV因为蓝牌和新能源绿牌的比例最大先把蓝色、绿色这类深色底变黑白字变白。如果是黄牌黑字二值化后正好反了这时再做一次反色即可。def preprocess_plate(plate_bgr): gray cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) gray cv2.GaussianBlur(gray, (3, 3), 0) # 用 OTSU 自动找阈值避免手动调 _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 如果白色像素占比过高说明底浅字深做一次反色 if np.sum(binary 255) 0.5 * binary.size: binary cv2.bitwise_not(binary) return binaryOTSU 在光照均匀时很好用但遇到反光、阴影它会跟着大块背景走。一个补救办法是直接固定阈值比如 100 到 120但这又回到手动调参的老路。我一般先跑 OTSU再用白色像素占比判断是否反色简单粗暴但足够稳。二值化这块要多准备几张傍晚和强光下的车牌图做回归因为出错率最大的往往就在这一步。3.2 垂直投影法把字符从“柱子”里切出来字符在竖直方向上是连成块的把每列像素和算出来字符列的和小间隔列的和接近于零。垂直投影就是利用这个波形把字符段找出来。代码里有个隐藏细节车牌第二和第三个字符之间有个小圆点它的横坐标恰好落在字符间隔里投影法容易把它和左右字符的“山谷”连起来导致多切一段。def split_by_projection(binary): col_sum np.sum(binary 255, axis0) segments [] start None for i in val in enumerate(col_sum): if val 2 and start is None: start i elif val 2 and start is not None: segments.append((start, i)) start None if start is not None: segments.append((start, len(col_sum))) # 合并间隔小于等于3列的相邻段解决小圆点造成的多切 merged [] for seg in segments: if merged and seg[0] - merged[-1][1] 3: merged[-1] (merged[-1][0], seg[1]) else: merged.append(list(seg)) # 过滤掉高度太矮的噪声段 height_threshold binary.shape[0] * 0.3 result [] for (s, e) in merged: block binary[:, s:e] if np.sum(block 255) height_threshold: result.append((s, e)) return result这里的 val 2 是按“有字符像素”判定的阈值因为二值化后车牌上下边框还在它们会形成一片恒定的小值。如果边框没清干净投影曲线会有个缓坡合并逻辑会把这些边框段当成字符段。遇到这种情况先裁掉车牌图最上和最下面各 5 到 10 像素把边框排除再投影。合并距离 3 列是我在 440 宽的车牌图上实验出来的图片分辨率变了这个值也要跟着缩放。3.3 连通域分割一个更省心的备选方案垂直接影在字符粘连时很难切开连通域法更直接把二值图里的每个白色连通块当成一个候选字符按坐标从左到右排序。OpenCV 的 connectedComponentsWithStats 一次把标签、外接框、面积全部算好代码比投影法还要短。def split_by_connected(binary): num_labels, labels, stats, centroids cv2.connectedComponentsWithStats( binary, connectivity8, ltypecv2.CV_32S ) chars [] for i in range(1, num_labels): x, y, w, h, area stats[i] if h binary.shape[0] * 0.3: continue # 字符高宽比约 2:1过滤掉横向长条和竖线 ratio w / h if 0.3 ratio 1.5: chars.append((x, y, w, h)) chars.sort(keylambda b: b[0]) return chars注意连通域会把“京”这种笔画复杂的汉字拆出好几块也会把磨损后断开的数字拆开。我的处理是先做一次膨胀把断笔接上再跑连通域如果发现块数多于 7 或 8就降级用垂直投影法重切。两种方法各有翻车场景我把它们写成一个函数各跑一遍看哪个切出的块数最接近预期字符数就用哪个结果。3.4 分割结果一致性检查进识别前先拒绝坏图这一步很多人跳过但它是识别率提升最快的一步。蓝牌固定 7 个字符新能源绿牌 8 个黄牌大车 7 个。分割完如果数量不对不管后面识别器多强都白搭。常见做法是切出来的块数不等于预期值就换另一套分割算法重试两套算法都拿不出正确的块数就认定这不是有效车牌直接放弃这个候选区。字符宽度也要检查正常情况下每个字符宽度约占车牌的七分之一到八分之一超过这个范围的块说明是边框或噪声。def validate_chars(chars, expected7): if len(chars) ! expected: return False plate_w 440 mean_w np.mean([b[2] for b in chars]) if mean_w plate_w / 12 or mean_w plate_w / 5: return False return True这个检查成本几乎为零但能拦下一大批误检。宁可这次没识别出来也不要给下游一个错误结果因为道闸系统最怕的是识别成别人的车牌。4. 字符识别模板匹配、Tesseract 与 OCR 推理库的取舍4.1 模板匹配一个勉强能用的 baseline字符被切出来后最简单的是模板匹配。把每个字符块缩放到固定尺寸和模板库里所有字符图像算相似度取最高分的作为结果。模板匹配对小规模、字体统一的数字字母识别不错尤其是数字和字母但对汉字很不友好汉字笔画结构复杂模板稍有出入就错。import os def match_template(char_img, template_dirtpl): char_img cv2.resize(char_img, (20, 40)) best_score -1 best_label for f in os.listdir(template_dir): tpl cv2.imread(os.path.join(template_dir, f), 0) if tpl is None: continue tpl cv2.resize(tpl, (20, 40)) score cv2.matchTemplate(char_img, tpl, cv2.TM_CCOEFF_NORMED)[0][0] if score best_score: best_score score best_label f.split(.)[0] return best_label, best_scoreTM_CCOEFF_NORMED 对光照变化不太敏感是匹配里最常用的度量。但模板匹配的坑在大学项目里特别典型白天测试 95 分晚上出停车场全是 0/1 混淆原因就是车牌的字体是机打 FE 字体手写模板和它不匹配还有字符块边缘有残留边框相似度被拉低。我的建议是模板匹配只用来验证流水线是否跑通真正要上线还是得换后面的方案。4.2 Tesseract 做单行识别注意字符白名单Tesseract 是开源 OCR 引擎训练数据集庞大但对车牌这种短字符场景反而容易“自由发挥”。它默认会把 0 识别成 O、1 识别成 l因为自然语言里字母更常见。用 pytesseract 时强制指定 PSM 7单行模式和字符白名单能压住一部分问题。import pytesseract text pytesseract.image_to_string( char_img, config--psm 7 -c tessedit_char_whitelist0123456789ABCDEFGHJKLMNPQRSTUVWXYZ )白名单里故意去掉了 I 和 O因为中国车牌不适用这两个字母数字 1 和 0 的误识会显著减少。Tesseract 对单个字符块的识别速度不快而且如果每个字符都单独调用一次整体耗时会被拖到几百毫秒级别实时场景不太敏感时勉强能用。更关键的是它对汉字省份简称支持一般所以它通常只用来做数字字母段的后备方案。4.3 用 ddddocr 或 RapidOCR 替代传统 OCR轻量推理的新选择现在很多项目开始直接用 ddddocr 替代 Tesseract。ddddocr 是专门为验证码设计的 OCR 模型但它对印刷体单字符的识别能力也很强尤其在扭曲、低分辨率情况下比 Tesseract 稳。它基于 ONNX Runtime纯 CPU 也能跑单张车牌全流程识别大约几十毫秒到一百多毫秒比 Tesseract 快一个量级。使用前需要 pip install ddddocr常见报错“ ddddocr 未安装”就是没把它的 onnxruntime 依赖装上。import ddddocr ocr ddddocr.DdddOcr(show_adFalse) def recognize_char(char_img): # ddddocr 对输入尺寸不敏感但二值化后的图效果通常更好 ok, buf cv2.imencode(.png, char_img) if not ok: return result ocr.classification(buf.tobytes()) return resultddddocr 的代价是它训练集偏向验证码字体和车牌机打字体有差异个别数字会识别成奇怪结果。给字符块加两像素白色边框再送进去识别率会提升一点算是我血泪经验里的一个小技巧。如果不想一个个字符识别可以直接用 RapidOCR 这类基于 PaddleOCR 的 ONNX 推理库跑整块车牌它对整行文字识别效果好但偶尔会多识别出一些不存在的字符需要在后处理里用正则把“幻觉”删掉。4.4 后处理校验用规则把“大概率对”变成“必然对”车牌结构有很强的先验第一位是省份汉字第二位是城市字母后面是数字字母混合。不管用哪种识别器结果都应该过一遍这层规则。正则是最便宜的后处理手段。import re def post_process(text): text text.upper().replace( , ) # 蓝牌7位汉字 字母 5位字母数字 m re.fullmatch(r[\u4e00-\u9fa5][A-Z][A-Z0-9]{5}, text) return m.group(0) if m else 如果识别结果过不了正则我一般会让模板匹配和 OCR 各给一个结果两个一致就采纳不一致时按“第二个位置必须是字母、后面必须字母数字混合”这一条来裁决。遇到过最典型的情况是模板匹配把 5 认成 SOCR 把 S 认成 5后处理规则会根据候选车牌的常见组合把两个都列为可疑这时宁可多拍一次重新识别也不硬选一个。5. 车牌识别常见问题排查从环境安装到 HSV 阈值玄学5.1 ModuleNotFoundError: No module named cv2三个最典型的原因现象在终端里 pip install opencv-python 成功但运行脚本时 import cv2 直接报 No module named cv2。 原因最常见是装了多个 Python 环境pip 装到了 A 环境VSCode 或命令行运行用的是 B 环境其次是没激活虚拟环境还有一种是“安装成功却找不到 cv2”多为 opencv 的 wheel 和当前 Python 版本不匹配pip 给你装了源码包但编译失败。 解决先确认当前解释器路径和 pip 路径是否一致然后强制重装 wheel。which python python -m pip install --force-reinstall opencv-python opencv-contrib-python python -c import cv2; print(cv2.__version__)用 python -m pip 而不是裸的 pip是因为裸 pip 可能指向另一个环境。VSCode 里排查时注意右下角解释器选择别让 Jupyter kernel 和终端解释器各用一套 Python。树莓派上如果你非要编译安装 opencv请做好折腾两小时的准备能别编译就别编译用 pip 的预编译 wheel 最省事性能差异不大。5.2 OpenCV 版本变化导致的代码报错findContours 返回值坑现象网上教程抄来的代码在 OpenCV 4.4.0 上直接报 cv2.error错误堆栈里能看到 findContours 相关字样比如 “not enough values to unpack (expected 3, got 2)”。 原因OpenCV 3.x 时代 findContours 返回三个值image, contours, hierarchyOpenCV 4.x 改为返回两个值contours, hierarchy。老代码里_, cnts, _ cv2.findContours(...)自然全崩。 解决按新写法解包。cnts, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)这条排查办法能救回大量从旧博客抄来的代码。还有一类 OpenCV(4.4.0) 报错堆栈里带着 c:\Users\appveyor... 这种路径通常是 Windows 上源码编译 opencv 时出的问题别去硬编译换 whl 包直接装。5.3 蓝牌识别翻车HSV 阈值为什么这么“玄学”现象同一组 HSV 阈值白天下午识别率很高傍晚路灯下全部漏检偶尔还把深蓝色货车车身当车牌框出来。 原因HSV 里 H 对光照相对稳定但 S 和 V 会随亮度剧烈变化。傍晚光线弱蓝色像素饱和度掉到 50 以下V 也变暗用白天标定的阈值当然筛不到。 解决一组阈值不够我给 S 下限放到 30V 下限放到 30宁可让候选区域多两倍也不漏检同时把“面积 宽高比 内部字符纹理”三道过滤加重多的误检会被过滤掉。晴天场景下如果你发现蓝色区域过大可以再切一套“强光照阈值”做二次过滤。5.4 cv2.imread 读不到中文路径图片现象代码在英文路径下一切正常把测试图片放到“D:\车牌测试\”目录imread 返回 None。 原因OpenCV 的 imread 在 Windows 下不支持非 ASCII 路径直接返回空对象后续 cv2.cvtColor 立刻报错。 解决用 np.fromfile 读字节再 imdecode 解码或者先把文件复制成英文名再处理。下面这个函数可以直接替换 cv2.imread。def imread_unicode(path): data np.fromfile(path, dtypenp.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR)类似的问题还有 opencv 读取含空格或特殊字符的路径在 Linux 下也会偶发但大多数集中在 Windows 中文路径。做批量测试的时候我一般统一把图片重命名为 0001.jpg、0002.jpg 这种纯英文格式省去一堆中间问题。5.5 实时视频流识别率忽高忽低别指望每帧都用现象从摄像头读 RTSP 流跑识别初始化时还很正常跑几分钟后掉帧识别率跟着下降。 原因车牌识别全流程尤其带 ddddocr 这类推理库时CPU 占用很高树莓派或低配工控机上每帧都跑必然积压延迟另外车速快的时候抓到的是运动模糊帧字符边缘糊成一团识别器再强也没用。 解决抽帧策略每 5 帧或每 0.2 秒处理一帧控制台连续识别不需要每帧都跑。车牌像素高度小于 16 的帧直接丢人眼都难认的图模型也不会比你强太多。还有一个容易忽略的点摄像头曝光时间太长会导致动态模糊优先调快门速度而不是调图像增强算法。6. 把整个流水线封装成命令行工具批量验证与识别率统计当流程跑通、单张图能识别后最重要的一件事是把测试集批量跑一遍记录每个字符哪里错了。没有批量统计你改阈值、换模板全凭感觉很容易陷入“这张图好了那张图坏了”的循环。我通常会写一个极简的 pipeline 脚本把定位、分割、识别串成一行然后遍历 test 目录输出每张图的结果。def pipeline(img_path): img imread_unicode(img_path) for rect in locate_by_edge(img): plate crop_plate(img, rect) binary preprocess_plate(plate) chars split_by_projection(binary) if not validate_chars(chars, expected7): continue text .join(recognize_char(c) for c in chars) if post_process(text): return text return for f in sorted(os.listdir(test)): result pipeline(os.path.join(test, f)) print(f, result)批量跑完后把结果和 CSV 里的正确车牌逐个对比。统计时我分两个口径全对率是“7 位全对才算对”字符级准确率是“每个字符单独算对”。绝大多数项目全对率在 60% 到 80% 是正常的字符级准确率通常能到 90% 以上两者差得越大说明错误越分散后处理规则能救回来的空间越小。错误集中在哪些字符也要记录我最常见的是 0/O、1/I、5/S 三组混淆其次是省份汉字里字形相近的“湘”和“鄂”这种。最后一件事是把所有阈值参数挪出代码。以前我调 HSV 阈值全靠改代码重跑跑一次几十张图耗时又容易改乱后来把所有魔数写进一个配置文件批量回归测试时只改配置不碰代码才知道哪些改动真正提升了全对率。这条习惯帮我省了大量返工也让我再不敢把阈值硬编码在函数里。如果你也准备把车牌识别往真实场景落请一定先搭好这个测试台再谈算法优化。希望这篇笔记能帮你把第一版跑通少走几段我走过的弯路希望帮到你。本文还有配套的精品资源点击获取
返回列表