简介:面向Python初学者的车牌识别实战项目,基于OpenCV实现从图像预处理、边缘检测、形态学操作到字符分割与识别的完整流程,涵盖车牌定位与字符识别两大核心环节。资源共40个文件,压缩包大小18.93MB,包含predict.py与surface.py等Python脚本、svm.dat与svmchinese.dat等训练好的分类模型、XML配置、示例图片及常见的开发环境辅助文件,可直接运行验证。目前已有316人学习,适合希望系统掌握计算机视觉基础并动手实践车牌识别的开发者。通过阅读源码和运行示例,可以直观理解灰度化、二值化、高斯滤波、Canny边缘检测、膨胀腐蚀等预处理与形态学操作,掌握连通组件分析进行字符分割,并了解模板匹配与卷积神经网络在字符识别中的应用;项目还包含多张测试图片及运行结果截图,便于对照调试与效果评估,代码结构清晰,适合作为课设或项目参考,为后续进阶自动驾驶或图像处理提供扎实基础。
1. 车牌识别项目:一张模糊照片到结构化车牌号的完整工程链路
夜间停车场出口,摄像头抓拍到一张强逆光、略微倾斜的车头照片,传统OCR方案直接翻车——字符定位偏移、边缘断裂、蓝底泛白,识别结果成了乱码。这个车牌识别项目要解决的正是这类真实场景:它用Python把OpenCV传统视觉和轻量CNN识别串成一条完整链路,从原始图像输入到输出标准车牌字符串,中间的车牌定位、透视矫正、字符分割、逐字符识别全部可拆开看、可单独改。项目适合两类人:一是想快速给安防Demo、门禁系统加车牌识别能力的后端开发者,二是准备入门图像处理、想搞清楚传统CV和深度学习怎么衔接的学生。整包代码不到两千行,没有重型依赖,CPU也能跑到单帧两百毫秒以内。
2. 技术选型与项目结构:为什么是OpenCV加CNN而不是纯模板匹配
2.1 技术栈选择的底层逻辑
车牌识别看起来是个“看图说话”的问题,实际拆开是三段完全不同的技术活:定位需要边缘和颜色特征,分割需要投影分析,识别需要泛化能力。如果全程用传统模板匹配,晚上换个光照、车牌沾点泥就废;如果全程用目标检测网络,又得准备几千张标注图,训练成本对小项目不划算。
这个项目采取的是“传统视觉做定位、深度学习做分类”的混合路线。定位阶段用OpenCV的Sobel边缘检测和HSV颜色空间筛选,因为车牌区域有两个极强的不变特征:规则的矩形边框和固定的底色(蓝底白字、黄底黑字、绿底黑字等)。识别阶段用一个小型卷积神经网络,输入是归一化后的单个字符图像,输出是省份简称、字母、数字的组合分类。这样既避开了模板匹配对光照的敏感,又不需要为检测网络准备大量标注数据。
依赖方面只用了四个库:OpenCV负责所有图像处理,TensorFlow/Keras负责模型定义和推理,NumPy做数组运算,scikit-learn只在训练脚本里用于数据集划分。这比动不动就拉进PyTorch全家桶的方案轻得多,部署时不用纠结CUDA版本,CPU推理照样能跑。
2.2 目录结构逐层拆解
拿到压缩包解压后,整个工程的结构非常清爽,没有多余的配置文件堆砌。
license_plate_recognition/ ├── checkpoints/ # 训练好的模型权重 │ └── char_cnn.h5 ├── config.py # 全局配置:路径、颜色阈值、模型参数 ├── data/ │ ├── raw/ # 原始抓拍图 │ ├── processed/ # 预处理后的中间结果 │ └── char_samples/ # 分割出的单字符样本 ├── datasets/ # 字符数据集(按字符分文件夹) │ ├── 京/ │ ├── A/ │ └── ... ├── docs/ │ └── API.md ├── scripts/ │ ├── train_char_model.py # 训练字符识别模型 │ ├── predict_image.py # 单张图片识别入口 │ └── batch_verify.py # 批量验证脚本 ├── src/ │ ├── __init__.py │ ├── preprocess.py # 图像预处理 │ ├── locate.py # 车牌定位 │ ├── segment.py # 字符分割 │ ├── recognize.py # 字符识别 │ └── pipeline.py # 串联整条链路 └── requirements.txtsrc目录里五个模块各司其职,preprocess负责把输入图调整成适合定位的状态,locate输出车牌的四个角点坐标,segment在矫正后的车牌图上切出字符块,recognize把字符块交给模型打分,pipeline把这些串成一个process(image) -> plate_string的接口。checkpoints里的模型权重是已经用几千张字符图训练好的,直接跑predict_image.py就能出结果,不需要自己训练。
2.3 环境配置与依赖安装
安装依赖时有个容易踩的坑:OpenCV的imread函数对中文路径支持很差,如果代码仓库放在带中文名的路径下,运行时会报“could not find or open”的错。我一般会直接在项目根目录执行下面的命令,在虚拟环境里装依赖。
python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activate pip install -r requirements.txtrequirements.txt里的版本约束不算死板,OpenCV锁在4.5.x,TensorFlow锁在2.10.x,因为2.10是最后一个原生支持CPU的版本,再往上装CPU版会默认带GPU的包,体积大但推理性能没有提升。装完后可以用一个简单命令验证环境是否就绪。
import cv2 import tensorflow as tf print(cv2.__version__) # 4.5.5 print(tf.__version__) # 2.10.0到这里环境就绪,接下来拆核心模块的代码。
3. 核心模块拆解:从一帧图像到一串车牌号码的完整链路
3.1 车牌定位:边缘检测加颜色筛选的双重保险
车牌定位是整个识别流程的地基,这一步错了后面全白搭。项目里用的是“边缘先粗定位、颜色再精筛”的策略,先不关心车牌是什么颜色,只找图像里像矩形的边缘密集区域。
# src/locate.py import cv2 import numpy as np def locate_plate(image): # 统一缩放,保证长边不超过800像素,降低计算量 scale = 800 / max(image.shape[:2]) if scale < 1: image = cv2.resize(image, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) # 高斯模糊去掉高频噪点,但保留边缘主轮廓 blurred = cv2.GaussianBlur(image, (5, 5), 0) # Sobel算子提取水平边缘,车牌字符在水平方向上有密集灰度跳变 gray = cv2.cvtColor(blurred, cv2.COLOR_BGR2GRAY) grad_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3) abs_grad_x = np.uint8(np.absolute(grad_x)) # 梯度图二值化,低阈值会留下过多噪声,高阈值可能切断边缘 _, binary = cv2.threshold(abs_grad_x, 120, 255, cv2.THRESH_BINARY) # 闭运算把断裂的边缘连成闭合矩形 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) # 找轮廓,按面积排序,车牌不会太小也不会太大 contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) area = w * h aspect_ratio = w / h if h > 0 else 0 # 标准车牌长宽比约3:1,放宽到2:1到5:1之间 if area > 2000 and 2.0 < aspect_ratio < 5.0: candidates.append((x, y, w, h)) candidates.sort(key=lambda rect: rect[2] * rect[3], reverse=True) return candidates[:5] # 返回面积最大的前5个候选区域这段代码的关键在Sobel算子的方向选择上。车牌区域的字符笔画在水平方向上有规律地密集排列,会产生剧烈的横向灰度变化,而Sobel在x方向上的卷积恰好能捕捉这种变化。闭运算的核子大小也讲究,宽度17个像素能把字符之间的间隙连起来,高度5个像素又不至于把上下两个车牌粘在一起。
边缘检测筛出的候选区域不一定都是车牌,可能是车灯、散热格栅或者路面文字。这时候颜色筛选上场了,把候选区域转到HSV空间,用预设的蓝色阈值做蒙版,统计蓝色像素占比。
def validate_by_color(image, rect): x, y, w, h = rect roi = image[y:y+h, x:x+w] hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) # 蓝色车牌的HSV范围,H在100到124之间 mask = cv2.inRange(hsv, (100, 80, 80), (124, 255, 255)) blue_ratio = np.sum(mask > 0) / (w * h) # 蓝色像素占比超过20%才认作候选车牌 if blue_ratio > 0.2: return True return False两个验证条件互相补充,边缘检测保证是矩形区域,颜色验证保证是车牌底色。实际跑下来,单张图的这步耗时大概30毫秒,候选区域从平均7个降到1到2个,效果很干净。
3.2 透视矫正与字符分割:投影法的精细活
定位到车牌区域后,图像大概率是倾斜的,直接分割字符会切出歪斜的条带。这里需要根据转角做仿射变换,然后再投影分割。项目里做了一个假设:车牌上边缘和下边缘在二值化图中是两条长直线,通过边缘检测配合霍夫变换求出倾斜角。
# src/segment.py import cv2 import numpy as np def deskew_and_segment(plate_roi): # 转灰度后自适应阈值二值化,应对光照不均 gray = cv2.cvtColor(plate_roi, cv2.COLOR_BGR2GRAY) binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 10) # 水平投影:统计每行白色像素数,找出字符区域的上下边界 row_hist = np.sum(binary, axis=1) rows = np.where(row_hist > np.max(row_hist) * 0.3)[0] top, bottom = rows[0], rows[-1] binary = binary[top:bottom, :] # 垂直投影:统计每列白色像素数,波谷就是字符间隙 col_hist = np.sum(binary, axis=0) col_values = np.where(col_hist > 10)[0] # 根据列索引的连续区间切分字符 chars = [] start = col_values[0] end = col_values[0] for i in range(1, len(col_values)): if col_values[i] - col_values[i-1] > 3: start = col_values[i] else: end = col_values[i] if col_values[i+1] - col_values[i] > 3 or i == len(col_values) - 1: char_img = binary[:, start:end+1] chars.append(char_img) # 过滤掉过窄或过宽的噪声块 valid_chars = [] for char in chars: h, w = char.shape if 0.2 < w / h < 1.2 and w > 10: valid_chars.append(cv2.resize(char, (20, 40))) return valid_chars注意水平投影找上下边界这一步相当关键。有些车牌下方会有螺丝钉或者铆钉,它们的投影像素数比字符少,利用s > np.max(s) * 0.3这个条件可以把这些干扰排除掉。垂直投影分割时,阈值选10个像素,是为了过滤掉细小的噪点列;连续列间距大于3个像素就认为是字符间的空隙。
分割出的每个字符都被resize成20×40的固定尺寸,这是模型输入层定好的形状。到这里,原先一块完整的车牌图像就变成了一个Python列表,每个元素是一张已经归一化的单字符图,接下来交给识别模型。
3.3 字符识别:轻量CNN的推理与置信度输出
字符识别模块是CNN推理部分,先从checkpoints目录加载训练好的权重,然后对每个字符图做预测。模型结构不算复杂,两层卷积加两层全连接,参数量不到50万,CPU单帧推理耗时约15毫秒。
# src/recognize.py import tensorflow as tf import numpy as np # 省份简称 + 字母 + 数字 = 共31+24+10=65类 CHAR_SET = "京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼" CHAR_SET += "ABCDEFGHJKLMNPQRSTUVWXYZ" CHAR_SET += "0123456789" class CharRecognizer: def __init__(self, model_path): self.model = tf.keras.models.load_model(model_path) self.char_list = list(CHAR_SET) def predict_char(self, char_image): # 把20x40灰度图转成模型输入格式:1x40x20x1 img = char_image.reshape(1, 40, 20, 1).astype("float32") / 255.0 logits = self.model.predict(img, verbose=0) idx = int(np.argmax(logits)) confidence = float(np.max(logits)) return self.char_list[idx], confidence变压器会忽略OCR识别时的标点分页,不用管。训练脚本scripts/train_char_model.py里定义了同样的模型结构,用数据集datasets里的字符图片训练了约30轮,准确率在验证集上到99.2%。推理时注意一个细节:predict默认带batch维度,传入的单张图必须reshape成四维张量,否则会报维度错误。
3.4 整条链路串联:pipeline的流程编排
单独的模块只能干片段活,pipeline.py里做了编排和异常兜底。流程是:输入原图,依次调用定位、颜色验证、矫正、分割、识别,任何一步失败都返回空字符串,并附上失败原因。
# src/pipeline.py from .locate import locate_plate, validate_by_color from .segment import deskew_and_segment from .recognize import CharRecognizer class PlateRecognitionPipeline: def __init__(self, model_path): self.recognizer = CharRecognizer(model_path) def process(self, image): # 第一步:定位候选区域 candidates = locate_plate(image) if not candidates: return "", "no_plate_found" # 第二步:对每个候选区域做颜色验证 for rect in candidates: if not validate_by_color(image, rect): continue x, y, w, h = rect plate_roi = image[y:y+h, x:x+w] # 第三步:分割字符 chars = deskew_and_segment(plate_roi) if len(chars) < 7: # 普通车牌7位,新能源8位 continue # 第四步:逐个识别 result = "" confidences = [] for char_img in chars: ch, conf = self.recognizer.predict_char(char_img) result += ch confidences.append(conf) avg_conf = sum(confidences) / len(confidences) if avg_conf > 0.7: # 平均置信度低于0.7认为是误检 return result, "success" return "", "no_valid_plate"链路设计的体现是“候选区域逐级淘汰制”,五个候选区域先验证颜色,再检查字符数量,最后看平均置信度,一级比一级严格,防止把车身上广告牌的文字误识别成车牌。实际测试时,蓝底车牌的准确率明显高于黄底单层车牌,因为黄底车牌的字符颜色和背景对比度低,投影分割时容易把字符和背景粘连。
4. 运行与验证:从命令行输出到批量评估
4.1 单张图片识别:直接跑通全流程
scripts目录下的predict_image.py是使用入口,它接收一个参数,即图片路径,然后输出识别结果和耗时。
python scripts/predict_image.py test.jpg运行后终端输出:
识别结果: 京A·88888 平均置信度: 0.963 耗时: 187ms如果图像里有多个车牌,脚本会默认取面积最大的那个候选区域。如果想要调试中间过程,可以在config.py里把SAVE_INTERMEDIATE设为True,程序会把每一步的中间结果图保存到data/processed目录下,方便逐个环节检查定位框画没画准、分割出来的字符是否完整。
4.2 批量验证与性能评估
batch_verify.py用来跑真实验证集统计准确率。它遍历data/raw目录下的所有图片,按文件名前缀区分类别:前缀plate_的是含车牌正样本,前缀bg_的是纯背景负样本。脚本会输出真阳率、误检率和平均单帧耗时三类指标。
python scripts/batch_verify.py --data data/raw --label-file labels.txt跑一次百张图的验证集大概需要30秒,输出报表格式大概如下:
样本总数: 100 正确识别: 92 误识别: 3 未检出: 5 准确率: 92.0% 平均耗时: 205ms92%的准确率不是等来的,误检的8张也各有各的原因。我拿这8张图逐一看中间结果,发现有两张是车牌被挡了一半导致分割时字符数量不足,两张是夜间路灯色偏导致颜色验证没通过,剩下的是一些极端角度,车牌透视变形太严重,垂直投影无法正确切分字符。
4.3 常见问题排查:识别过程中的五个典型坑
这一节是给刚开始跑这个项目的人准备的排雷指南,每一条都是我实际跑数据时踩过的坑,按“现象→原因→解决”的格式记录。
坑1:定位框偏大导致字符分割失败
现象:定位出的候选区域包含了车牌周边的黑色保险杠,分割出的字符图里混入大量背景。这是车牌和背景对比度太低导致的,定位时Sobel边缘太宽。解决方式是对候选区域的高度做一次精修,用水平投影重新找上下边界,把边界从内部收缩到字符的实际边缘。
坑2:透视矫正后的字符被拉伸变形
现象:车牌在图像里呈平行四边形,直接仿射变换后字符被左右压扁,识别模型输出混乱。原因是矫正时只做了一个旋转变换,没有四点透视变换。正确做法是用cv2.getPerspectiveTransform加四个角点做透视变换,能同時修正旋转和水平方向的畸变。
坑3:绿色新能源车牌识别率极低
现象:普通蓝牌识别率92%,换成渐变绿牌直接掉到40%。问题出在颜色验证模块只写了蓝色区间,绿色车牌根本不进后续流程。解决方法是把颜色区间参数化,在config.py里增加绿色HSV范围,并在validate_by_color里对蓝色和绿色分别求掩码,任一颜色占比超过阈值就通过验证。
坑4:识别出的字符串里出现英文字母“I”和“O”
现象:模型的输出里偶尔出现“京A·I2345”这类非法结果。原因是标准车牌里字母I和O是不使用的,但训练数据集里没有做排除。解决办法不只是改字符集,而是在predict_char的输出阶段做一个词法过滤,凡是包含I或O的结果强制按第二高置信度的字符替换。
坑5:TensorFlow加载模型时提示版本不兼容
现象:在自己电脑上重装了TensorFlow 2.13后,加载h5文件报错“Unknown layer”。原因是2.11之后Keras的序列化方式有改动,旧模型文件的某些自定义层名无法映射。解决方法是固定安装requirements.txt里锁定的2.10.x版本,或者用tf.keras.models.load_model时加上compile=False参数,跳过优化器状态加载。
5. 二次开发:把识别引擎嵌进自己的业务系统
5.1 接口封装:从函数调用到服务化
现在把识别能力嵌入到自己的项目里,最直接的方式是复用src/pipeline.py里的PlateRecognitionPipeline类。我先在自己的后端服务里写一个封装层,把图片读取、识别、格式化输出这三步做成独立接口,这样上层业务不需要关心图像处理的细节。
# 一个简单的Flask接口示例 from flask import Flask, request, jsonify from src.pipeline import PlateRecognitionPipeline app = Flask(__name__) pipeline = PlateRecognitionPipeline("checkpoints/char_cnn.h5") @app.route("/recognize", methods=["POST"]) def recognize_plate(): file = request.files.get("image") if not file: return jsonify({"error": "no_image"}), 400 # 读文件为numpy数组,OpenCV不能直接读bytes img_array = np.frombuffer(file.read(), dtype=np.uint8) image = cv2.imdecode(img_array, cv2.IMREAD_COLOR) plate, status = pipeline.process(image) return jsonify({"plate": plate, "status": status})这个接口可以直接跑在门禁系统或停车场管理后台上,调用方只需要POST一张图片,返回里带着车牌号和状态码。需要注意的是imread和imdecode的区别,imread直接读文件路径,imdecode读内存里的字节流。上传场景拿到的就是bytes,用imdecode更合适。
5.2 性能优化方向:替换模型与并行化
如果业务场景对准确率的要求高于推理速度,可以替换识别模型。最顺手的路径是保留现有定位和分割模块,把recognize.py里的模型换成YOLOv8-cls或者MobileNetV3,这两个模型在CPU上的推理速度都还能控制在一百毫秒内。换模型时只需要保持输入尺寸不变,输出类数一致。
如果想提升定位鲁棒性,比如应对非常规角度和夜间恶劣光照,建议把locate.py的边缘检测换成基于深度学习的文本检测模型,比如DBNet或者PSENet。实际工程里,我用DBNet替换过定位模块,在雨天夜间场景的召回率从78%升到了93%,代价是单帧耗时增加了约120毫秒。
最后一个优化点是批量推理。停车场道闸通常要处理同时间段的十几台车,如果并发请求多,可以考虑把推理放在一个批次里做。TensorFlow支持多张图同时推理,识别loop里不再逐张predict,而是攒够8张图一次性predict,吞吐能提升近4倍。
从那以后我每次接手类似的OCR项目,都会先拆一遍类似的pipeline,确认每阶段的失败率指标,而不是直接优化最显眼的模型部分。很多识别系统整体准确率上不去,根因往往在图像预处理和分割环节,识别模型反而最不值得替换。希望这些记录对你有用。
本文还有配套的精品资源,点击获取