简介:本资源是一套完整的Python中文车牌检测与识别系统源码,面向计算机视觉初学者、智能交通项目开发者及深度学习实践者,解决多类型车牌(蓝牌、黄牌、双层黄牌、农用车、警车、校车、教练车、港澳车牌、使领馆车牌及新能源绿牌等)在复杂场景下的端到端识别问题。压缩包共159个文件,含42个核心Python脚本(含模型训练、推理、预处理模块)、36张实测车牌图像(CCPD2019数据集样本及自采图)、19个配置用YAML文件、8个可视化PNG结果图,以及Dockerfile、.pth模型权重、.ttf字体、.so编译扩展等关键组件,整体大小36.51MB。已有513人学习下载,资源结构清晰,涵盖从OpenCV图像预处理、Canny/Hough边缘定位、连通域车牌裁剪,到CNN字符识别与新能源车牌格式适配的全流程实现,附带CCPD数据集路径配置与box_overlaps加速模块,便于快速复现、调试与二次开发。
1. 这不是又一个“YOLO+CRNN”玩具项目:它真能跑通新能源绿牌、港澳双层黄牌、使领馆黑底白字——而且不用改三行代码就切到你自己的监控视频流
去年在某市交管支队做边缘识别POC时,我被现场打脸:三套标榜“支持全类型车牌”的开源系统,面对一辆刚驶入卡口的粤Z港车+一辆悬挂京AD开头的使领馆牌照新能源车,全部返回空结果或错识成“京AD88888”。不是模型精度问题,是预处理逻辑硬编码了蓝牌灰度阈值、字符宽度比、甚至Hough直线检测的角度范围。而今天要拆解的这个 Python 中文车牌检测和识别系统,是我近半年实测中唯一一套——开箱即用跑通农用车牌(黑底黄字+“农”字徽章)、双层黄牌(上黄下黄+分隔线)、警车(白底红字+“警”徽)、校车(黄底黑字+“校车”标识)且无需重训模型、不碰训练脚本就能直接部署进Docker的完整源码包。它不靠堆算力,而是用一套可解释、可调试、可逐层替换的模块化流水线:从box_overlaps.c里手写的IoU加速核,到CCPD2019数据集的原生适配器,再到对tmp93E9.jpg这类强反光车牌的自适应二值化策略——所有关键路径都暴露在Python层,连OpenCV的cv2.threshold调用都封装成了带fallback机制的函数。适合两类人:想拿真实业务场景练手的CV工程师,以及需要快速验证算法边界、拒绝“黑匣子API”的集成方。别信“支持20+车牌类型”的宣传话术,重点看它怎么处理IMG_2199.jpg里那张被雨痕半遮的新能源渐变绿牌。
2. 从 Dockerfile 到 CCPD2019 数据集加载:为什么它能绕过 OpenCV 的cv2.findContours玄学失败?
这套系统最反直觉的设计,是把车牌定位(detection)和字符识别(recognition)彻底解耦,且定位模块完全不依赖传统轮廓检测。它用的是基于滑动窗口+轻量级CNN回归的方案,但核心在于——所有坐标计算都锚定在 CCPD2019 数据集的原始标注协议上,而非OpenCV输出的浮点坐标。这意味着你喂进去一张新图,它不会先cv2.findContours再筛矩形,而是直接用训练好的回归头预测四边形顶点,再通过box_overlaps.c里的C函数做NMS去重。这种设计规避了OpenCV版本升级导致的轮廓排序变化、连通域合并策略差异等“玄学翻车点”。
2.1 Docker 环境的最小可信启动:避开 CUDA 11.8 与 PyTorch 1.13 的隐式冲突
项目根目录的Dockerfile并非简单FROM python:3.8-slim,而是显式锁定了 CUDA 工具链版本:
FROM nvidia/cuda:11.3.1-cudnn8-runtime-ubuntu20.04 # 注意:这里强制指定 cudnn8,而非最新 cudnn8.6 或 cudnn9 RUN apt-get update && apt-get install -y \ python3.8 \ python3-pip \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip3 install --no-cache-dir -r requirements.txt # 关键:PyTorch 必须与 CUDA 11.3.1 完全匹配 RUN pip3 install torch==1.10.2+cu113 torchvision==0.11.3+cu113 -f https://download.pytorch.org/whl/torch_stable.html提示:如果你本地是 CUDA 11.8,请不要强行修改
Dockerfile里的基础镜像。正确做法是进入容器后执行nvidia-smi确认驱动兼容性,再用torch.version.cuda验证 PyTorch 调用的 CUDA 版本。很多“ImportError: libcudnn.so.8 not found”错误,根源是宿主机驱动太老,而非镜像问题。
2.2 CCPD2019 数据集加载器的四个隐藏契约
系统没有用torchvision.datasets.ImageFolder,而是自己写了ccpd_dataset.py。它严格遵循 CCPD2019 的文件名编码规则,例如ccpd_base/00000100000000000000000000000000.jpg对应的标注是00000100000000000000000000000000.txt,内容为:
123,456,789,1011,1213,1415,1617,1819,蓝,粤B12345其中前8个数字是四边形顶点坐标(x1,y1,x2,y2,x3,y3,x4,y4),第9位是车牌颜色(蓝/黄/绿/白/黑/渐变),第10位是车牌字符串。ccpd_dataset.py的关键契约有四条:
- 坐标归一化不做除法:不除以图像宽高,而是直接存原始像素值,避免浮点误差累积;
- 颜色字段强制映射:将“渐变”映射为“绿”,“白”映射为“警”,“黑”映射为“使领馆”,确保下游分类头输入维度固定;
- 字符串清洗规则:自动过滤“·”、“—”等非标准分隔符,将“粤Z1234港”标准化为“粤Z1234港”(保留“港”字);
- 双层黄牌特殊处理:当检测到
y3-y1 > 1.8*(x2-x1)且颜色为“黄”时,触发双层模式,启用独立的字符分割逻辑。
2.3box_overlaps.c:为什么不用纯Python写IoU?一次NMS调用省下230ms
box_overlaps.c是整个检测流水线的性能心脏。它用C语言实现了向量化IoU计算,编译后生成box_overlaps.cpython-38-x86_64-linux-gnu.so。其核心逻辑是:
// box_overlaps.c 伪代码节选 void compute_iou(float* boxes, int num_boxes, float* iou_matrix) { for (int i = 0; i < num_boxes; i++) { for (int j = 0; j < num_boxes; j++) { // 计算box[i]与box[j]的交集面积(四边形交集,非矩形) float inter_area = polygon_intersection_area(boxes + i*8, boxes + j*8); float area_i = polygon_area(boxes + i*8); float area_j = polygon_area(boxes + j*8); iou_matrix[i * num_boxes + j] = inter_area / (area_i + area_j - inter_area); } } }注意:这个IoU计算的是任意四边形交集,不是YOLO系常用的矩形IoU。因为CCPD2019标注的是四点坐标,直接转矩形会丢失倾斜车牌的精度。实测在1080p图像上,Python版四边形IoU耗时412ms,C版仅182ms——这正是它能支撑实时视频流的关键。
2.4 测试图1811AS.jpg的定位流程:从原始图像到四边形坐标的七步推演
我们用1811AS.jpg(一张典型的蓝牌+轻微旋转+车牌反光)走一遍完整定位链路:
- 输入图像:读入为
(1080, 1920, 3)BGR 格式; - 自适应灰度:不直接
cv2.cvtColor(..., cv2.COLOR_BGR2GRAY),而是先用cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))增强局部对比度; - 动态二值化:调用
adaptive_threshold(img_gray, block_size=31, C=12),其中C值根据图像全局方差动态调整(方差<30时C=8,>50时C=15); - 形态学闭运算:用
cv2.MORPH_RECT结构元(3×3)填充字符间缝隙; - 滑动窗口候选区生成:在二值图上以
stride=16步长滑动128×32窗口,每个窗口送入轻量CNN(MobileNetV2 backbone + 4-head regression); - 四边形回归:CNN输出8维向量
[dx1,dy1,dx2,dy2,dx3,dy3,dx4,dy4],叠加到窗口中心坐标,得到原始四边形; - C版NMS后处理:调用
box_overlaps.so计算IoU矩阵,阈值设为0.3,保留得分最高的3个框。
最终输出坐标是[x1,y1,x2,y2,x3,y3,x4,y4],可直接用于cv2.polylines绘制,或传给识别模块裁剪。
3. 新能源绿牌与港澳车牌的识别特化:为什么 CRNN 模型要拆成两个分支?
系统识别模块没用单一大模型吞下所有字符,而是按车牌类型分叉:蓝/黄/农用车牌走 CRNN 主干,新能源/港澳/使领馆走独立的 ResNet18+CTC 分支。这不是为了炫技,而是解决三个硬约束:字符集差异、字体渲染失真、光照鲁棒性。
3.1 字符集分裂:新能源绿牌的“D/E/F”与港澳车牌的“澳门”汉字
标准中文车牌字符集是0-9A-Z京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领馆警校教练农,共73类。但新能源绿牌强制包含字母D/E/F(代表纯电动/插混/燃料电池),且位置固定(第2位或第3位);港澳车牌则含“澳门”“香港”汉字及葡文/英文混合(如“AM”“HK”)。若强行塞进同一分类头,D和O在小样本下极易混淆。因此系统定义了两套标签映射:
| 类型 | 字符集长度 | 示例标签序列 | 特殊处理 |
|---|---|---|---|
| 通用车牌 | 73 | [粤,B,1,2,3,4,5] | “粤”字单独建模,不参与OCR |
| 新能源/港澳 | 89 | [粤,Z,1,2,3,4,D]或[澳,门,1,2,3,4,5] | “澳门”“香港”作为原子token,不拆字 |
识别模型recognition.py里,forward()方法会根据检测模块传入的plate_type字段自动路由:
def forward(self, x, plate_type): if plate_type in ['green', 'hk', 'macau', 'embassy']: # 走ResNet18+CTC分支 features = self.resnet18(x) # [B, 512, H, W] logits = self.ctc_head(features) # [B, T, 89] return logits else: # 走CRNN分支 features = self.crnn_backbone(x) # [B, 512, H, W] logits = self.crnn_head(features) # [B, T, 73] return logits3.2 渐变绿牌的色彩空间转换:为什么 HSV 比 RGB 更可靠?
新能源绿牌的“渐变绿”在不同光照下色偏极大:正午阳光下偏黄绿,阴天偏蓝绿,夜间补光灯下甚至发灰。系统放弃RGB阈值分割,改用HSV空间的双阈值策略:
def hsv_green_mask(img_bgr): img_hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) # 第一阈值:主绿区间(覆盖大部分渐变绿) lower1 = np.array([35, 43, 46]) upper1 = np.array([77, 255, 255]) mask1 = cv2.inRange(img_hsv, lower1, upper1) # 第二阈值:补漏蓝绿区间(针对阴天偏色) lower2 = np.array([78, 30, 30]) upper2 = np.array([99, 255, 255]) mask2 = cv2.inRange(img_hsv, lower2, upper2) return cv2.bitwise_or(mask1, mask2)注意:
cv2.inRange返回的是单通道mask,后续所有操作(如字符分割)都基于此mask,而非原始BGR图。这是保证绿牌字符提取稳定的核心。
3.3 港澳车牌的“澳门”汉字分割:连通域分析为何失效?垂直投影才是正解
港澳车牌(如澳門12345)的“澳門”二字是黑体加粗,字间距极小,cv2.findContours常把两个字合并为一个连通域。系统改用改进型垂直投影(Vertical Projection Profile, VPP):
- 对二值mask进行水平方向求和,得到一维数组
vpp = np.sum(mask, axis=0); - 找出
vpp > threshold的连续区间,每个区间视为一个字符列; - 关键改进:对每个区间,再做一次内部水平投影,若区间内存在
vpp_local < 0.3 * max(vpp_local)的谷底,则在此处分割。
def split_chinese_chars(mask): vpp = np.sum(mask, axis=0) peaks, _ = find_peaks(vpp, height=10, distance=5) # 找字符峰 chars = [] for i in range(len(peaks)-1): left = peaks[i] - 2 right = peaks[i+1] + 2 # 在[left,right]内做二次投影 sub_vpp = np.sum(mask[:, left:right], axis=0) valleys, _ = find_peaks(-sub_vpp, distance=2) # 找谷底 if len(valleys) > 0 and sub_vpp[valleys[0]] < 0.3 * np.max(sub_vpp): # 在第一个谷底处分割 chars.append(mask[:, left:valleys[0]+left]) chars.append(mask[:, valleys[0]+left:right]) else: chars.append(mask[:, left:right]) return chars3.4 使领馆黑底白字的反色预处理:为什么cv2.bitwise_not不能直接用?
使领馆车牌(如使领馆12345)是黑底白字,但监控摄像头常因过曝导致白字边缘发灰,cv2.bitwise_not后变成灰底浅灰字,OCR直接失效。系统采用自适应反色:
def adaptive_invert(mask): # 计算mask的全局均值 mean_val = np.mean(mask) if mean_val > 128: # 原图偏亮(白底黑字) return cv2.bitwise_not(mask) else: # 原图偏暗(黑底白字) # 先做CLAHE增强,再反色 clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(4,4)) enhanced = clahe.apply(mask) return cv2.bitwise_not(enhanced)实测在tmp10B1.jpg(一张强逆光下的使领馆牌)上,传统反色识别率为62%,自适应反色提升至91%。
4. 避坑指南:五个让开发者凌晨三点还在重启Docker的血泪问题
4.1 现象:docker run启动后立即退出,日志只显示Segmentation fault (core dumped)
原因:box_overlaps.c编译时未指定-fPIC,导致共享库在容器内地址空间冲突。
解决:进入容器,重新编译C模块:
cd /app/src/utils gcc -shared -fPIC -o box_overlaps.cpython-38-x86_64-linux-gnu.so box_overlaps.c -lm4.2 现象:识别结果中“粤”字永远识别成“粤B”,即多出一个“B”
原因:plate_type字段传递错误。检测模块输出plate_type='blue',但识别模块误读为'blueB',导致字符集索引越界。
解决:检查detector.py第217行return {'boxes': boxes, 'labels': labels, 'plate_type': plate_type.strip()},必须加.strip()去除换行符。
4.3 现象:处理251.jpg(一张农用车牌)时,cv2.polylines绘制的框严重偏移
原因:农用车牌标注中四边形顶点顺序是[x1,y1,x3,y3,x2,y2,x4,y4](交叉顺序),而非标准顺时针。polygon_intersection_area()函数假设顺时针,导致IoU计算错误。
解决:在ccpd_dataset.py的__getitem__中,对plate_type=='agricultural'的样本,手动重排顶点:
if plate_type == 'agricultural': coords = [coords[0], coords[1], coords[4], coords[5], coords[2], coords[3], coords[6], coords[7]]4.4 现象:tmp9890.jpg(新能源绿牌)识别出“粤Z1234D”,但实际是“粤Z1234F”
原因:CRNN分支误用了通用字符集,F的embedding与D太接近。
解决:强制路由到新能源分支——在inference.py中,当检测到color=='green'时,跳过plate_type判断,直接调用recognition_model.forward(x, 'green')。
4.5 现象:Docker内cv2.imshow报错Gtk-WARNING **: cannot open display
原因:容器未挂载X11 socket,且未设置DISPLAY环境变量。
解决:开发调试时,用ssh -X连接宿主机,运行容器时加参数:
docker run -it --rm \ -e DISPLAY=host.docker.internal:0 \ -v /tmp/.X11-unix:/tmp/.X11-unix \ your-image-name生产环境请关闭所有cv2.imshow,改用cv2.imwrite保存结果图。
5. 将tmp93E9.jpg(雨痕+反光新能源牌)接入你的RTSP流:三步完成端到端部署
这套系统真正的价值,不是跑通几张测试图,而是无缝接入你现有的视频流管道。下面以tmp93E9.jpg为例(一张被雨水斜纹覆盖、右半边强烈反光的粤B新能源绿牌),演示如何把它从静态图升级为RTSP实时识别服务。核心思路:不改模型,只改数据加载器,用OpenCV的cv2.VideoCapture替代cv2.imread,并加入帧率控制与结果缓存。
5.1 修改inference.py:从单图推理到流式推理
原代码inference.py是面向单图的:
img = cv2.imread('tmp93E9.jpg') result = detector.detect(img) recog_result = recognizer.recognize(result['cropped_plate']) print(recog_result)改为流式需增加三处:
- 帧率控制:防止GPU过载,限制每秒处理帧数(FPS);
- 结果缓存:同一车牌连续出现3帧才上报,避免抖动;
- ROI裁剪优化:对RTSP流,只处理画面中央1/3区域,跳过无用背景。
import time from collections import defaultdict, deque class StreamInference: def __init__(self, rtsp_url, target_fps=5): self.cap = cv2.VideoCapture(rtsp_url) self.detector = Detector() self.recognizer = Recognizer() self.target_fps = target_fps self.last_process_time = 0 self.plate_cache = defaultdict(lambda: deque(maxlen=3)) # {plate_str: deque of timestamps} def run(self): while True: ret, frame = self.cap.read() if not ret: break # 控制FPS current_time = time.time() if current_time - self.last_process_time < 1.0 / self.target_fps: continue self.last_process_time = current_time # 只处理中央ROI(减少计算量) h, w = frame.shape[:2] roi = frame[h//3:2*h//3, w//3:2*w//3] # 检测 result = self.detector.detect(roi) if result['boxes'] is not None: for box in result['boxes']: # 将ROI坐标映射回原图坐标 box[:4] += np.array([w//3, h//3, w//3, h//3]) cropped = self.crop_polygon(frame, box) recog = self.recognizer.recognize(cropped) # 缓存去抖 self.plate_cache[recog].append(time.time()) if len(self.plate_cache[recog]) == 3: t0, t1, t2 = self.plate_cache[recog] if t2 - t0 < 1.0: # 3帧在1秒内 print(f"[ALERT] Plate {recog} detected at {time.strftime('%H:%M:%S')}") # 这里可对接MQTT/Kafka/数据库5.2 针对tmp93E9.jpg的反光抑制:在detector.py中注入动态Gamma校正
tmp93E9.jpg的反光集中在车牌右侧,传统直方图均衡化会拉爆左侧暗部。我们加入区域自适应Gamma校正:
def adaptive_gamma_correction(img, roi_box): # roi_box 是四边形,先外接矩形 x_coords = [roi_box[0], roi_box[2], roi_box[4], roi_box[6]] y_coords = [roi_box[1], roi_box[3], roi_box[5], roi_box[7]] x1, y1, x2, y2 = int(min(x_coords)), int(min(y_coords)), int(max(x_coords)), int(max(y_coords)) crop = img[y1:y2, x1:x2].copy() # 计算右侧1/3区域的平均亮度 right_part = crop[:, 2*crop.shape[1]//3:] mean_right = np.mean(right_part) # 若右侧过亮(反光),降低gamma if mean_right > 180: gamma = 0.6 else: gamma = 1.0 inv_gamma = 1.0 / gamma table = np.array([((i / 255.0) ** inv_gamma) * 255 for i in np.arange(0, 256)]).astype("uint8") return cv2.LUT(img, table) # 在 detect() 函数中调用 def detect(self, img): # ... 前处理 img = adaptive_gamma_correction(img, box) # box 是检测到的四边形 # ... 后续流程5.3 雨痕干扰的形态学对抗:tmp93E9.jpg的专用滤波器
雨痕在图像上表现为细长、低对比度的斜线。系统内置了一个rain_filter模块,使用方向性形态学开运算:
def rain_filter(img): # 定义45度方向结构元 kernel_45 = np.array([[0, 0, 1], [0, 1, 0], [1, 0, 0]], dtype=np.uint8) # 定义135度方向结构元 kernel_135 = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=np.uint8) # 分别开运算 opened_45 = cv2.morphologyEx(img, cv2.MORPH_OPEN, kernel_45) opened_135 = cv2.morphologyEx(img, cv2.MORPH_OPEN, kernel_135) # 取并集 return cv2.bitwise_or(opened_45, opened_135) # 在 detector.py 的 preprocess 阶段插入 def preprocess(self, img): # ... 其他步骤 if self.is_rainy_scene(img): # 简单判断:计算梯度幅值方差 img = rain_filter(img) return img5.4 生产环境部署 checklist:从开发机到边缘盒子的六项确认
| 项目 | 检查方式 | 不通过后果 | 我的血泪经验 |
|---|---|---|---|
| CUDA驱动兼容性 | nvidia-smi与cat /proc/driver/nvidia/version对比 | 容器内CUDA不可用,PyTorch报错 | 曾在Jetson Xavier上因驱动版本低一级,折腾12小时 |
| Docker存储驱动 | docker info | grep "Storage Driver" | overlay2 未启用时,box_overlaps.so加载失败 | 默认aufs,必须sudo dockerd --storage-driver=overlay2 |
| 内存限制 | docker run --memory=4g | GPU显存不足时,torch.cuda.OutOfMemoryError | RTSP流开10路,不设限直接OOM |
| 时区同步 | date与timedatectl status | 日志时间戳错乱,排查困难 | 某次告警延迟3小时,发现是容器时区为UTC |
| RTSP超时设置 | cv2.VideoCapture.set(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 5000) | 网络抖动时卡死,无法recover | 加了超时后,断网30秒自动重连 |
| 结果持久化路径 | os.makedirs('/app/output', exist_ok=True) | 识别结果写入失败,无日志 | 边缘盒子SD卡满,/tmp被清空,结果全丢 |
从那以后我每次部署新设备,都强制走一遍这个checklist,哪怕只是临时测试。它不花时间,但能让你少熬三个通宵。希望帮到你。
本文还有配套的精品资源,点击获取