资讯详情

基于Python和OpenCV的实时视频流车牌识别系统实现

📅 2026/9/29 9:11:44 | 华诺云谱 👁 阅读
基于Python和OpenCV的实时视频流车牌识别系统实现
车牌识别这几年确实火停车场道闸、出入口监控、高速收费站、小区门禁到处都在用这套技术。我抽空做了一个基于 Python 和 OpenCV 的实时视频流车牌识别项目从摄像头读入视频帧定位车牌区域再做字符分割和识别最后把结果叠加回画面里实时显示。整个过程没有依赖重型深度学习框架纯 OpenCV 图像处理就能跑通对机器配置要求不高适合想入门计算机视觉、或者需要快速做出一个可用原型的同学参考。先说清楚这个项目能干什么它能从实时视频流中识别出蓝色底、黄色底以及新能源渐变绿底的车牌输出车牌号码并在画面上用方框把车牌框出来。如果你手头有摄像头可以直接对着停车场方向跑起来没有摄像头也可以用视频文件或网络 RTSP 流来测试。整个源码是模块化设计的定位、分割、识别三个环节独立成文件想单独调参数或者替换识别算法都很方便。下面我会把从环境搭建到代码实现、再到各种坑的排查过程完整走一遍不绕弯子直接上干货。1. 项目整体思路与技术选型1.1 项目要解决的核心问题车牌识别在计算机视觉里属于“限定场景的字符识别”问题流程上可以拆成三个阶段车牌定位、字符分割、字符识别。这三步环环相扣前面出错了后面全白搭。定位要是框偏了分割必然乱分割要是把字符切碎了识别结果必然错。第一阶段要解决的是“车牌在哪”。画面里可能有车头、车灯、保险杠、地面、行人、树木等大量干扰信息我们需要通过颜色、边缘、形态学等方式把车牌那块区域单独抠出来。第二阶段解决的是“车牌上的字符怎么拆开”。拿到一个规整的车牌图像之后要把汉字、字母、数字一个个切分开为后面的识别做准备。第三阶段解决的是“每个字符念什么”这一步可以用模板匹配也可以接 OCR 模型。这个项目原本的目标场景是停车场出入口的监控视频流车辆经过道闸时速度不快角度也相对固定整体环境光照可控。这种场景下纯图像处理方案完全够用识别速度和稳定性都比较理想。如果你要处理的是高速公路上高速行驶的车辆或者夜间光照很差的画面那需要引入更重的模型这超出了这篇文章的讨论范围。1.2 为什么选用 OpenCV 而不是深度学习方案现在一说车牌识别很多人第一反应是上 YOLO 检测目标、再上 OCR 识别字符准确率确实高但代价是环境依赖重、部署体积大、推理速度受硬件限制。我这个项目选择纯 OpenCV 方案首先是为了轻量OpenCV 加 numpy 两个库就能跑起来一台普通笔记本也能保持 20 帧以上的处理速度。其次是可解释性强每个环节做了什么、为什么这么做一行行代码都能讲清楚对学习图像处理的原理帮助很大。当然这不代表深度学习方法不好。实际生产环境里如果车牌角度变化大、光照条件复杂深度学习方案的鲁棒性确实更强。但作为个人项目或者教学项目OpenCV 方案的成本和门槛低得多而且它的定位思路——颜色筛选、边缘检测、形态学操作——即便将来切到 YOLO 方案这些预处理技巧也依然用得上。另外我特意把源码做成了模块化结构定位、分割、识别三块互相解耦。将来你想把识别部分从模板匹配换成 PaddleOCR只需要改一个文件其他模块不用动。这种设计对后续扩展非常友好。2. 环境准备与源码结构设计2.1 开发环境选型与安装开发环境这块Python 我用的是 3.9OpenCV 用的 4.x 版本numpy 1.24 左右。这几个版本在兼容性上比较稳不建议一上来就追最新版 Python有些第三方库对高版本 Python 的支持会有滞后。安装命令很简单核心依赖就两个pip install opencv-python numpy如果你机器上装的是 Anaconda也可以用 conda 装不过 conda 默认源有时候速度很慢可以换成清华镜像。这里提醒一句opencv-python 这个包是和 opencv-contrib-python 互斥的如果之前装过 contrib 版本建议先卸载干净再装否则 import cv2 的时候可能出一些莫名其妙的符号冲突。有个高频报错是ModuleNotFoundError: No module named cv2出现这种问题通常就是 pip 安装路径和当前 Python 解释器路径不一致。比如你在 Anaconda 环境里用 conda 装了一半又在系统 Python 里敲 pip 命令装来装去两个环境互相不认。最直接的排查办法是打开 Python 交互环境执行import cv2; print(cv2.__version__)看到版本号就说明没问题。我把整个项目按功能拆成了四个文件加一个模板目录plate_recognition/ ├── main.py # 主程序入口负责读取视频流、调用处理流程 ├── plate_locator.py # 车牌定位模块输出候选车牌区域 ├── char_segment.py # 字符分割模块把车牌切成单个字符 ├── char_recognizer.py # 字符识别模块模板匹配实现 ├── config.py # 公共参数配置 └── templates/ # 字符模板图片目录这样拆的好处我在前面也提过调试的时候可以单测某个模块。比如定位不准你只需要针对 plate_locator.py 喂一张图调参数不用每次从视频第一帧开始跑。模块间用简单的函数调用连接main.py里先调用locate_plate把返回的车牌区域交给segment_chars再丢给recognize_chars流程一目了然。2.2 源码模块化设计的思考模块化不仅仅是为了代码好看更是为了“可调参”。车牌识别这种图像处理项目最耗时间的就是调参数——HSV 颜色范围、形态学核大小、轮廓筛选面积阈值、二值化窗口大小每个参数都会直接影响最终准确率。把这些参数集中放到 config.py 里改起来非常方便。# config.py HSV_LOWER_BLUE (100, 50, 50) HSV_UPPER_BLUE (124, 255, 255) PLATE_MIN_AREA 2000 PLATE_RATIO_RANGE (2.0, 5.0) CHAR_MIN_WIDTH 10 BINARY_BLOCK_SIZE 11在我自己的实践里这种集中管理参数的方式至少帮我省了三分之一的调试时间。而且程序跑在真实摄像头场景下不同时间段的光照差异很大早上十点和傍晚六点的 HSV 范围可能就要微调集中改参数比到处搜代码里的魔法数字要可靠得多。3. 车牌定位从整帧画面中锁定目标区域3.1 为什么先转到 HSV 颜色空间车牌定位的第一步是找到“哪里可能有车牌”。中国最常见的蓝底白字车牌颜色特征非常鲜明所以颜色筛选是最直观的突破口。很多人一开始直接对 BGR 图像做颜色判断比如判断像素的 B 值是不是远大于 R 和 G这种办法在理想光照下能用但稍微遇到阴影、反光、黄昏就歇菜了。原因在于 BGR 三个通道对光照变化非常敏感。同样一块蓝色车牌在阳光直射和阴影下BGR 数值可能差出一大截。HSV 颜色空间把“色相 H”从光照亮度中分离出来了只要物体的颜色没变H 值就基本稳定。这就是为什么车牌定位要先转换到 HSV 空间。OpenCV 里要注意 HSV 的取值范围和常规理解不完全一样H 范围是 0 到 179S 和 V 范围都是 0 到 255。蓝色车牌的 HSV 范围我实测下来用下面这一组参数在大多数摄像头下表现不错import cv2 import numpy as np hsv cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) lower_blue np.array([100, 50, 50]) upper_blue np.array([124, 255, 255]) mask cv2.inRange(hsv, lower_blue, upper_blue)这里 H 取 100 到 124覆盖了标准蓝色区域。S 下限设成 50 是为了滤掉偏灰白的颜色V 下限设成 50 是为了避开太暗的区域。这组参数在室内灯光和白天室外环境下都挺稳但如果你用的是夜间带红外补光的摄像头S 和 V 的阈值可能要往下调具体可以在调试时把 mask 实时显示出来观察。3.2 形态学操作与轮廓筛选inRange出来的 mask 是一个二值图包含车牌区域的同时也会有一些零散的蓝色噪点比如车标、车身贴纸、远处广告牌等。这些噪点区域往往是不连通的碎块我们需要用形态学闭运算把邻近的像素连成片把车牌区域“补全”成一个完整的连通域。闭运算的操作是先膨胀后腐蚀效果是把小块断裂的区域桥接起来。我用的是椭圆结构元素尺寸 5×5对车牌这种长条形目标比较友好kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) closed cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)闭运算做完车牌区域基本就凝成一块了接下来用轮廓查找来定位contours, _ cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] for contour in contours: x, y, w, h cv2.boundingRect(contour) area w * h ratio w / max(h, 1) if area 2000 and 2.0 ratio 5.0: candidates.append((x, y, w, h))轮廓筛选的条件主要看两个面积和宽高比。车牌在画面里通常不会太小area 2000能过滤掉一大批小噪点。标准车牌宽高比大约是 3.14:1考虑到拍摄角度可能有些变形我放宽到 2.0 到 5.0这样既不会漏掉倾斜太厉害的车牌也能过滤掉明显的非车牌区域。如果画面里同时出现多个候选区域我会按面积从大到小排序优先处理面积最大的那个因为主车道的车辆车牌在画面里通常占的面积最大。这个方法不复杂但在实际测试中效果不错。3.3 透视矫正与角度修正车牌在画面里不一定是正对着摄像头的车辆可能略微偏转车牌会有小角度的倾斜。如果直接把倾斜的车牌送去分割字符垂直投影会被扭曲字符边界不整齐识别准确率会下降。矫正的思路是找到车牌四个角点做透视变换。不过基于颜色 mask 找到的外接矩形是 axis-aligned 的不包含旋转信息。一个更好用的办法是先找到面积最大的轮廓然后获取轮廓的近似多边形如果这个多边形有四个顶点就可以认为是车牌的四个角。if len(candidates) 0: x, y, w, h max(candidates, keylambda r: r[2] * r[3]) plate frame[y:yh, x:xw]我实测下来停车场场景车辆很少有大角度转弯通过的情况倾斜一般都在 10 度以内。对于这种小角度倾斜直接按外接矩形裁剪也能凑合字符分割时自适应阈值和投影法都能承受一定程度的倾斜。所以这个项目里我没有做强透视矫正而是把精力放在了更影响识别率的环节上。如果你的场景是大角度俯拍或者车辆严重偏转就再补一步 getPerspectiveTransform。4. 字符分割与识别让系统“看懂”车牌4.1 自适应二值化处理拿到车牌区域之后接下来要做的就是把字符从背景里分离出来。蓝色底、白色字的车牌字符和背景的灰度差异比较明显所以可以通过二值化把字符变成白色像素、背景变成黑色像素。直接使用全局阈值比如固定 127的问题是不同车牌图片的亮度差异很大有时候牌照表面有一层反光全局阈值会把整片高光区域都二值化成白色。更稳的办法是用自适应阈值它会根据每个像素周围小区域的灰度分布动态计算出阈值对光照变化更鲁棒gray cv2.cvtColor(plate, cv2.COLOR_BGR2GRAY) binary cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 4 )这里THRESH_BINARY_INV是把字符置为白色、背景置为黑色因为车牌字符比背景亮。窗口大小 11 是奇数配合高斯加权效果好于均值加权。偏移量 4 是个经验值太小会把噪点也带进来太大会削弱字符边缘。处理完这步车牌图像就变成了一张黑白图白色像素基本就是字符区域可以直接进入分割环节。4.2 投影法切分字符字符分割最经典的方法就是投影法。原理不复杂统计二值化图像每一列上白色像素的数量得到垂直投影直方图。字符所在列因为有大量白色像素投影值会突高字符间的空隙列几乎全黑投影值接近零。根据投影值的起伏就能把每个字符的左右边界找出来。col_projection np.sum(binary, axis0) char_regions [] in_char False start 0 for i, value in enumerate(col_projection): if value 0 and not in_char: in_char True start i elif value 0 and in_char: in_char False end i if end - start 10: # 过滤掉过窄的噪声列 char_regions.append((start, end))上面对列做了一次遍历把连续有像素的列段提取出来。end - start 10这个条件很关键它能把噪点、铆钉或边框残留造成的细长列段滤掉。实际车牌的第一个字符和第二个字符之间可能存在分隔符“·”投影图上会有个小峰但因为宽度不够会被过滤掉不影响整体。拿到左右边界之后在每个字符区域内对行再做一次水平投影可以去掉字符上下的边框残留和干扰然后把每个字符裁剪成统一大小比如 20×40存成列表。分割完的效果可以直接用 imshow 逐个显示出来检查这一步我强烈建议调试时看一眼能直观发现二值化或者投影的问题。4.3 模板匹配识别详解模板匹配的思路很朴素准备一组字符模板图像把待识别的字符和所有模板逐一比较找出相似度最高的那个作为识别结果。OpenCV 提供的matchTemplate函数支持多种匹配方法我用的是归一化相关系数TM_CCOEFF_NORMED输出的数值范围在 -1 到 1 之间1 表示完全匹配。def recognize_char(char_img, templates): best_score -1 best_label None for label, template in templates.items(): res cv2.matchTemplate(char_img, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, _ cv2.minMaxLoc(res) if max_val best_score: best_score max_val best_label label return best_label, best_score这里有一个细节模板和待识别字符的尺寸必须一致否则matchTemplate会报错或者匹配结果不可靠。我的做法是在准备模板阶段就把所有字符统一 resize 到 20×40待识别字符裁出来后也 resize 到同样大小。模板本身的来源我推荐一个实操中很有效的方法从真实的二值化车牌图像里截取字符再人工标注。这种方法得到的模板和实际待识别字符的风格最接近识别率远高于用字体直接渲染出来的模板。0 到 9 的数字、A 到 Z 的字母、各个省份简称的汉字这些都要准备好。省份简称优先准备常见的那些比如京、沪、粤、苏、浙、鲁、川等。识别之后最好设置一个置信度阈值比如相关系数低于 0.5 就视为未识别成功结果显示为“?”避免把明显的噪声强行识别成某个字符。这个阈值也是个调参项不同摄像头下需要微调。4.4 三种识别方案横向对比模板匹配虽好但局限性在于它对字体变化、字符粗细、干扰噪声都比较敏感。如果你觉得模板匹配的准确率不够可以考虑下面几种替换方案。方案优点缺点适用场景模板匹配速度快、无额外依赖、实现简单对字体变化和噪声敏感需要准备大量模板车牌字体规范、角度固定的停车场场景Tesseract OCR开源免费、支持中文对车牌字体适配一般需要额外安装引擎和语言包通用文档 OCR不太适合直接做车牌PaddleOCR识别准确率高、中文效果好依赖 paddlepaddle模型体积大推理速度稍慢对准确率要求高、硬件条件较好的场景实际项目的演进路径通常是这样先用模板匹配跑通整个流程验证定位和分割环节的正确性然后再把识别模块替换成更强大的方案。我在自己的项目里也保留了这种扩展性识别模块只需要保持recognize_chars(char_imgs)这个接口内部换成 PaddleOCR 完全不影响上游代码。如果你要用 PaddleOCR安装命令是pip install paddlepaddle paddleocr识别时注意把输入的图像统一转成 RGB因为 PaddleOCR 内部默认按 RGB 处理。实测下来它对清晰的正视车牌的识别准确率能到 95% 以上对模糊和倾斜车牌的容忍度也比模板匹配高出不少。5. 实时视频流接入与性能优化5.1 从摄像头、视频文件和 RTSP 流读取画面这个项目的实时视频流处理核心就是cv2.VideoCapture。它既能打开本地摄像头也能读取视频文件还能连接网络摄像头或流媒体服务器。根据输入源不同构造参数也不一样# 本地摄像头0 代表默认摄像头 cap cv2.VideoCapture(0) # 本地视频文件 cap cv2.VideoCapture(test_video.mp4) # 网络 RTSP 流 cap cv2.VideoCapture(rtsp://192.168.1.100:554/stream1)cap.read()每次返回一帧图像和一个布尔值布尔值为 False 说明视频流结束或读取失败。对视频文件来说读到最后要记得释放资源对 RTSP 流来说偶尔会出现断流这时候可能需要重启 VideoCapture 或者加一个重连机制。一个值得注意的细节直接从摄像头读取的帧分辨率可能高达 1920×1080对全帧做车牌定位很耗 CPU。我习惯在进入主流程之前先把帧缩小到 640×480大幅减少计算量识别速度几乎可以翻倍。车牌虽然在缩小后的画面里会变小但对常见的停车场监控距离来说依然足够清晰。5.2 跳帧处理与多线程加速实时视频流处理最大的矛盾是分辨率越高、算法越复杂单帧处理时间越长而视频流每秒钟会过来 25 到 30 帧。如果每帧都做完整的定位分割识别处理速度跟不上输入速度画面就会卡顿积压的待处理帧越来越多。最直接的策略是跳帧。对道闸、门禁这类场景车辆通过时在画面里的停留时间通常超过 1 秒不需要每帧都识别。我实际的做法是每 3 帧处理 1 帧也就是只挑帧号能被 3 整除的那一帧做完整识别其余帧直接跳过识别结果保存在一个队列里后面帧直接复用最近一次的结果。这样处理速度可以稳定保持在 20 帧以上。另一种更彻底的办法是多线程。读线程专门负责cap.read()把原始帧扔进队列处理线程从队列里取帧做识别把结果放到显示缓冲区。Python 的多线程因为 GIL 限制对纯计算任务提升有限但读帧和识别属于 I/O 和计算的混合任务用多线程还是能明显降低延迟的。架构上就是典型的生产者-消费者模型。5.3 结果叠加显示识别结果需要直观地展示在画面上通常是画一个矩形框住车牌旁边写上识别出的车牌号。画矩形用cv2.rectangle写文字用cv2.putText这两个 API 都不复杂。但这里有一个新手必踩的坑cv2.putText直接用中文字符串会显示成乱码或者问号因为 OpenCV 的 putText 底层用的 Hershey 字体不支持中文。解决方案是借助 PIL 把中文画上去再转回 OpenCV 的图像格式。from PIL import Image, ImageDraw, ImageFont def draw_text_cn(img, text, pos, font_size24, color(0, 255, 255)): img_pil Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) draw ImageDraw.Draw(img_pil) font ImageFont.truetype(simhei.ttf, font_size) draw.text(pos, text, fontfont, fillcolor) return cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)字体文件路径要根据你的系统改Windows 一般有simhei.ttfLinux 上可能需要自己安装中文字体包。这个问题卡了我不少时间当时看着乱码一度以为识别结果错了后来才发现是显示层的问题。为了防止识别结果在画面上闪烁我还会做一个简单的平滑处理只有连续两帧识别出相同的车牌号才认为结果是稳定的才把它显示出来。这个技巧在减少误报方面非常有效。6. 常见问题与排查技巧6.1 环境与依赖问题速查我在开发和调试过程中包括和不少做类似项目的朋友交流整理了一些最容易踩的环境问题。下面这个表格基本能覆盖大部分场景报错现象可能原因解决方案ModuleNotFoundError: No module named cv2opencv-python 未安装或安装到了别的 Python 环境在正确的环境里执行pip install opencv-python用import cv2; print(cv2.__version__)验证error: (-215) size.width0 size.height0 in function imshow读到的图像是空的可能是视频路径写错或摄像头编号不对检查cap.read()的返回值打印帧的 shapecv2.error: Unknown C exceptionOpenCV 版本冲突比如同时装了两个版本的 opencv 相关包先卸载 opencv-python 和 opencv-contrib-python再重新安装其中一个matchTemplate报尺寸错误模板和待识别字符的尺寸不一致统一 resize 到相同尺寸比如 20×40中文显示为乱码putText 不支持中文用 PIL 绘制中文文字对于ModuleNotFoundError这一项我想再多说两句。很多朋友在服务器上跑项目习惯用 root 用户执行 pip结果装到了系统全局环境里而代码是用某个虚拟环境跑的自然就找不到包。排查思路很简单在你运行代码的那个终端里先执行which python然后在 Python 交互界面里 import cv2 试试。确保 pip 和 python 是同一个环境这个问题就解决了一大半。6.2 识别准确率相关的问题如果你发现系统时灵时不灵同一辆车这帧识别出来了下一帧又识别不出来多半不是代码逻辑的锅而是图像质量的问题。我总结下来准确率波动的头号原因是光照变化。白天阳光强、车牌反光严重或者傍晚光线变暗HSV 颜色范围就抓不到车牌了定位环节直接失败。针对光照问题我的经验是把 HSV 的范围适度放宽一点特别是 S 和 V 的下限要尽量低。S 下限设到 30V 下限设到 40在大多数非极端光照下都能覆盖。代价是会增加一些误检区域但这些区域在后续的轮廓筛选和宽高比筛选中会被过滤掉实际影响不大。还有一个细节是模板匹配的误识别。数字“0”和字母“O”很容易混数字“1”和字母“I”也很容易混。解决办法是结合车牌字符的位置信息做约束——中国车牌的第二个字符必然是字母其余五位通常是字母和数字混合但在特定位置上比如第五位如果候选结果在“0”和“O”之间摇摆可以结合上下文规则决定。这个规则虽然简单但能把误识别率压低不少。6.3 实时性优化加速如果处理速度上不去画面一直卡顿可以按优先级从这几个方向排查。首先看输入分辨率是不是太高如果用的是 1080p 的摄像头建议先缩放到 640×480 再处理这个优化收益最大。其次是看是不是每帧都在做全流程识别如果识别结果变化不频繁完全可以用跳帧策略。还有一个容易忽略的点cv2.imshow和cv2.waitKey(1)的组合。waitKey 的参数如果设成 0程序会卡在那里等按键看起来就像卡死了一样。正确的做法是传 1让显示线程主动让出控制权。如果你用了多线程还要注意队列的消费速度。读线程太快、处理线程太慢队列会不断堆积内存占用持续增长。一个实用的办法是给队列设置最大长度满了就丢弃最老的帧保证实时性优先于完整性。写在最后的几点实战经验项目做到后面我发现最花时间的不是写代码而是调参数。不同摄像头、不同时间段、不同停车场同一套参数的表现可能天差地别。所以我建议你把上面这些参数都提取到配置文件里每到一个新场景就用几张实际抓拍图先做离线测试调好参数再上线跑实时流。不要指望一套参数通吃所有场景。字符模板的质量对识别准确率的影响比大多数人想象的要大。用真实车牌截图的字符做模板和用字体生成的模板相比识别率差距能到 10 个百分点以上。准备模板的时候尽量覆盖多种字重和清晰度甚至可以考虑为每个字符准备多张模板匹配时取最相似的那张。最后再分享一个小技巧实时系统里不要一帧识别失败就立刻判定“识别不了”可以连续多帧投票。比如最近 5 帧里有 3 帧识别出同一个结果就认为结果是可信的。这个方法对车辆运动过程中偶发的模糊帧非常有效实际体验下来稳定性提升非常明显。这个项目的完整源码和模板准备过程我后续还会再整理一版更细的说明包括如何用脚本批量生成和标注模板感兴趣的话可以持续关注。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑