ARTICLE DETAIL

资讯详情

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

OpenCV车牌识别实战:定位、分割、模板匹配与避坑指南

OpenCV车牌识别实战:定位、分割、模板匹配与避坑指南 简介一套基于OpenCV实现车牌识别全流程的C源代码包面向计算机视觉入门与中级开发者覆盖车牌定位、校正、字符识别等核心环节可帮助理解边缘检测、霍夫变换、仿射变换、透视变换及SVM字符分类等关键技术。压缩包共30个文件主要由C源码、头文件和Shell脚本组成同时提供12张JPG测试图片、已训练好的XML模型和说明文档整体约5.54MB目录结构按图像预处理、车牌检测、车牌校正、字符识别等模块划分。目前已有566人学习/下载。代码附有SVM与OCR训练脚本、批量处理与评估脚本既可运行现有模型验证效果也可训练自定义数据集。整体适合作为课程设计、毕业设计或实际项目落地的参考框架。1. 拿到 lpr.rar 之前先搞懂 OpenCV 车牌识别到底在识别什么很多人是从网盘下载了 lpr.rar 这类压缩包开始接触 OpenCV 车牌识别的。解压后往往是一堆 .py/.cpp 文件但真正能跑起来的没几个因为里面经常夹杂着作者本机的绝对路径、旧版 API 和一堆没清洗的测试图片。车牌识别这个需求本质是把一张整车照片里的蓝色车牌找出来再把它上面的 7 个字符逐个读出来。OpenCV 传统路线分三步车牌定位、字符分割、字符识别。它不依赖 GPU也不用标注几百张图适合摄像头角度固定、车牌清晰、蓝底白字的场景。搞清楚这三步各自吃什么输入、输出什么比急着跑通一段代码更重要。2. 车牌定位先用 Sobel 边缘和 HSV 把车牌从整车图里筛出来2.1 为什么先做边缘检测而不是直接找蓝色车牌在图像里最明显的特征是字符密集、边缘横向连续同时蓝底有稳定的色调。直接按颜色找蓝色会撞上大量蓝色车身、蓝色路牌和阴影只做边缘检测车标和散热格栅又会成为强干扰。常见做法是先把图像灰化用 Sobel 水平边缘突出字符的竖边再用闭运算把邻近边缘连成一个块最后根据轮廓宽高比筛出候选区域。但边缘检测对树影、车灯、车身线条也很敏感所以还要用 HSV 蓝色比例做二次确认。这也是传统 OpenCV 车牌定位最稳的组合边缘找位置颜色验证身份。如果只做 HSV 蓝色阈值反光、阴影会让蓝色区间飘移如果只做边缘车标和格栅经常是强候选。把两者叠加后候选框数量会从十几条降到一两条。对固定机位的卡口场景这一步基本能把车牌抠出来。2.2 定位代码轮廓筛选 HSV 二次确认下面这段是车牌定位的最小实现兼容 OpenCV 4.x。代码不短但每一步都在干一件明确的事。import cv2 import numpy as np def plate_candidates(img): # 缩小处理可以提速也降低路面纹理噪声 scale 800 / max(img.shape[:2]) if scale 1: img cv2.resize(img, (int(img.shape[1] * scale), int(img.shape[0] * scale))) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, (5, 5), 0) # 水平方向 Sobel 突出字符边缘 sobel_x cv2.Sobel(blurred, cv2.CV_64F, 1, 0, ksize3) sobel_x cv2.convertScaleAbs(sobel_x) # Otsu 自动阈值把边缘图变成黑白 _, binary cv2.threshold(sobel_x, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 闭运算把字符区域横向连通 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) if w 60 or h 20: continue ratio w / h # 蓝牌宽高比通常在 2.5 ~ 5.5 if 2.5 ratio 5.5: roi img[y:y h, x:x w] if blue_ratio(roi) 0.30: candidates.append((x, y, w, h)) return candidates def blue_ratio(roi): # 转 HSV 后统计蓝色像素占比 hsv cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) mask cv2.inRange(hsv, (100, 80, 60), (130, 255, 255)) return cv2.countNonZero(mask) / (roi.shape[0] * roi.shape[1])执行顺序是缩小图片 → 灰度 → 高斯模糊 → Sobel 水平方向 → Otsu 二值化 → 闭运算 → 找轮廓 → 按宽高比筛 → 再按蓝色比例确认。高斯模糊核选 (5,5)用来抹掉路面颗粒Sobel 只用 x 方向是因为车牌字符的竖边比横向边更密集横向边缘容易被车身边缘干扰。闭运算核 (17,5) 的含义是水平 17 像素、垂直 5 像素能把靠近的字符边缘连成一个大块。核设太大会把整块车头连进去设太小字符区域又是碎块所以这个参数是定位环节第一个要调的。宽高比过滤时标准蓝牌约 3.14但摄像头畸变和视角会把它压缩到 2.55.5。太窄可能是立牌太宽可能是车身反光条。最后 blue_ratio 用 HSV 区间 (100,80,60) 到 (130,255,255) 统计蓝色占比阈值 0.30 能容忍一定边框和不均匀光照。你可以把blue_ratio单独拿出来调试用cv2.imshow看 mask 是否覆盖车牌底。提示如果总是框住蓝色车身把 blue_ratio 阈值从 0.30 提高到 0.45如果蓝牌被漏掉多半是 HSV 的 V 通道下限太高阴天时亮度不足。3. 车牌字符分割垂直投影和连通域把 7 个字符干净切开3.1 分割前必须先做倾斜校正和透视变换定位返回的框是矩形但真实车牌在照片里往往带有透视角度。如果不校正直接二值化会得到歪斜的字符影响后续识别。常见做法是先用cv2.minAreaRect拿到最小外接矩形再用旋转角度或透视变换把车牌拉正。最小外接矩形在停车角度不大时可以直接旋转但车头朝侧前方时四个角点会出现梯形畸变用四点透视变换更稳。对于大多数固定摄像头场景定位框裁出的 ROI 可以先做一次倾斜校正。校正的输入是车牌区域需要提取出车牌本身的轮廓。这可以在二值化后的图上找最大轮廓取它的最小外接矩形然后对四个角点做排序生成透视矩阵。排序的细节很容易错通常按坐标和差值判断左上、右上、右下、左下。3.2 倾斜校正与分割代码def rectify_plate(plate): # plate 是定位阶段裁出的彩色车牌图 gray cv2.cvtColor(plate, cv2.COLOR_BGR2GRAY) gray cv2.medianBlur(gray, 3) _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return plate cnt max(contours, keycv2.contourArea) rect cv2.minAreaRect(cnt) box cv2.boxPoints(rect) box np.array(box, dtypefloat32) # 把四个角点排序为左上、右上、右下、左下 s box.sum(axis1) diff np.diff(box, axis1) tl box[np.argmin(s)] br box[np.argmax(s)] tr box[np.argmin(diff)] bl box[np.argmax(diff)] width int(np.linalg.norm(tr - tl)) height int(np.linalg.norm(bl - tl)) dst np.array([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], dtypefloat32) M cv2.getPerspectiveTransform(np.array([tl, tr, br, bl], dtypefloat32), dst) rectified cv2.warpPerspective(plate, M, (width, height)) return rectified def split_chars(rectified): gray cv2.cvtColor(rectified, cv2.COLOR_BGR2GRAY) gray cv2.medianBlur(gray, 3) # 蓝底白字用反向二值化让字符变白 _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 去掉上下边框统计每行白色像素 row_white cv2.reduce(binary, 1, cv2.REDUCE_SUM).reshape(-1) rows np.where(row_white 255)[0] if len(rows) 0: return [] y1, y2 rows[0], rows[-1] binary binary[y1:y2 1, :] # 垂直投影找到字符列的起止 col_white cv2.reduce(binary, 0, cv2.REDUCE_SUM).reshape(-1) cols np.where(col_white 0)[0] groups [] start prev cols[0] for c in cols[1:]: if c - prev 2: # 间隔超过2列说明字符断了 groups.append((start, prev)) start c prev c groups.append((start, prev)) # 过滤太窄的区域并去掉贴边干扰 chars [] h binary.shape[0] for (x1, x2) in groups: w x2 - x1 1 if w h * 0.05 or w h * 0.6: continue chars.append(binary[:, x1:x2 1]) return chars这里先做中值滤波再做 Otsu 反向二值化目的是把白色字符和车牌背景分开。row_white统计每行白色像素量可以去掉上下边框和铆钉干扰。真正的字符行集中在中间所以从第一个和最后一个超过阈值的行裁切。垂直投影col_white统计每一列白色像素数量白色像素连续出现的地方就是一个字符间隔大于 2 列就切一刀。参数上间隔阈值取 2 个像素能容忍字符内部的小断裂如果字符有粘连可以调大间隔但会把左右结构的汉字如“京”误分成两段。w h * 0.05过滤噪声点w h * 0.6过滤可能残留的边框。分割完的chars不是最终尺寸后续要统一 resize 到模板尺寸再识别。提示如果分割出来总是少字符检查col_white 0有没有被边框污染。可以在二值化后做一次腐蚀把边框和字符断开。4. 车牌识别模板匹配的路子最省事但要注意模板和预处理一致4.1 生成一套字符模板用真实样本还是用 cv2.putText 渲染模板匹配是传统 OpenCV 里最直观的识别方式。它要求你准备 31 个省份简称、24 个字母I 和 O 通常不用和 10 个数字的模板图片。很多开源代码包自带的模板是白底黑字或黑底白字使用前必须统一处理灰度化、二值化、resize 到相同尺寸并保证字符宽度占满。如果模板是直接从真实车牌切出来的识别率会高不少但样本量少时不如用cv2.putText渲染一套标准字体来得稳定。我一般建议先做一套渲染模板跑通流程再慢慢用真实切图替换误识别多的字符。渲染模板的好处是可控坏处是字体和真实牌照字体有差异但车牌识别场景字符种类少模板匹配对细微变化没有那么敏感。4.2 模板匹配识别代码与置信度输出import os def preprocess_char(img, size(40, 80)): # 统一灰度、反色、resize if len(img.shape) 3: img cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) _, binary cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) binary cv2.resize(binary, size, interpolationcv2.INTER_AREA) return binary def load_templates(template_dir): templates {} for name in os.listdir(template_dir): if not name.lower().endswith((.png, .jpg)): continue label os.path.splitext(name)[0] path os.path.join(template_dir, name) tpl cv2.imread(path, cv2.IMREAD_GRAYSCALE) tpl preprocess_char(tpl) templates[label] tpl return templates def match_char(char_img, templates): char_img preprocess_char(char_img) best_label None best_score -1 for label, tpl in templates.items(): # TM_CCOEFF_NORMED 对亮度变化不敏感 res cv2.matchTemplate(char_img, tpl, cv2.TM_CCOEFF_NORMED) _, score, _, _ cv2.minMaxLoc(res) if score best_score: best_score score best_label label return best_label, best_scorepreprocess_char把所有输入转换成 40x80 灰度图并保证字符二值化方向一致。如果直接用原始模板和原始字符图做匹配matchTemplate的分数容易被背景噪声抬高。load_templates读目录下所有图片文件名就是标签例如京.png、A.png、7.png。match_char用TM_CCOEFF_NORMED计算相关性这个标准化方法对光照不均匀比较稳。识别时还要处理一个细节完整车牌是 7 个字符第一个是汉字第二个是字母后面五个是字母数字混合。实际执行时可以对分割出的每个字符块逐字匹配然后拼接。需要注意模板尺寸和字符图尺寸必须完全一致否则matchTemplate会报尺寸断言错误这是新手最常见的坑之一。提示在字符送入模板匹配前可以做一次最小外接矩形裁切只保留字符主体避免把边框或铆钉边缘算进去。4.3 识别结果后处理过滤 I/1、O/0 常见混淆模板匹配结果通常会出现“I”与“1”、“O”与“0”、“Z”与“2”混淆。车牌规则可以帮你砍掉一批错误第二位必须是字母后五位中不能出现 I/O。简单做法是第二位强制从字母集合取后五位的后四位强制从数字集合取置信度低于 0.6 的字符直接输出?。def check_plate(result, scores, threshold0.6): if len(result) ! 7: return None for i, (ch, score) in enumerate(zip(result, scores)): if score threshold: return None if i 0 and not ch.isalpha(): # 省份简称是汉字 return None if i 1 and not ch.isalpha(): return None if i in (2, 3, 4, 5, 6) and ch in IO: return None return .join(result)这段后处理把“第二位是字母”这类先验硬编码进去。实际工程里很多识别错误不是模板不够多而是没有做规则校正。把threshold调到 0.7 会更严格蓝牌识别率可能小幅下降但错牌数会明显减少。5. 车牌识别避坑新手翻车最多的 5 个点附排查命令5.1 现象cv2.error: OpenCV(4.4.0) ... error: (-215:Assertion failed) !img.empty() in function cv::resize原因读图路径不对或输入图像是空。很多 lpr.rar 代码包里的相对路径是从作者机器上写的你换个目录跑就崩。尤其是中文路径OpenCV 的imread在某些 Windows 版本下读不了带中文的文件。解决不要用cv2.imread(车牌.jpg)硬编码改用命令行传参路径含中文时用np.fromfile加cv2.imdecode读取。读完后立刻判断img is None这是账号识别代码的第一道防线。def read_image(path): if not os.path.exists(path): raise FileNotFoundError(path) # Windows 下中文路径用 imdecode 稳一些 data np.fromfile(path, dtypenp.uint8) img cv2.imdecode(data, cv2.IMREAD_COLOR) if img is None: raise ValueError(f无法解码图片: {path}) return img5.2 现象ModuleNotFoundError: No module named cv2原因代码跑在虚拟环境里但pip install opencv-python装到了别的环境或者是 conda 环境没激活。OpenCV 安装成功后import cv2却找不到最常见是当前 shell 用的 python 和 pip 不是同一个。解决先pip list | grep opencv看装没装再python -c import sys; print(sys.executable)看当前解释器路径。如果装到了 base 环境而项目用 venv重新激活环境再安装。安装时建议指定版本pip install opencv-python4.8.1.78不要装最新版后遇到 API 变化再回来找旧代码。python -m pip install --upgrade opencv-python python -c import cv2; print(cv2.__version__)5.3 现象候选框总是框住车标、散热格栅或车身反光条原因边缘闭运算把车头的弧线边缘连成了大块宽高比又恰好落在 2.55.5 区间颜色二次确认没拦住。尤其白色车身、银色车标在 HSV 里 S 通道很低但贴了蓝色车膜的车身会把蓝色比例拉高。解决缩小图像后适当增大形态学闭运算的核例如从 (17,5) 改成 (25,7)让真正的车牌区域连成更完整的一块同时把blue_ratio阈值从 0.3 提高到 0.5。还可以加一个面积上限超过图像面积 20% 的候选框直接丢弃。最有效的办法是统计候选框内部的字符边缘密度车牌区域有 7 个字符边缘点密度远高于车身边缘。5.4 现象字符分割后出现汉字被切两半、字母左右粘连原因垂直投影对左右结构汉字“京”“鲁”等特别敏感而且二值化后笔画内部有断裂。汉字被切成两段时分组后会是 2 个窄框宽度小于阈值被过滤最终丢字。解决在垂直投影之前先对二值图做一次cv2.dilate核选 (3,3)把断裂笔画连起来。这样做的副作用是字母间距变小所以还要在分组时增加间隔阈值比如c - prev 3。如果仍然丢字符就把w h * 0.05改成w h * 0.1宁可多留噪声也不要丢中文字符。5.5 现象模板匹配分数很高但识别结果是错的原因模板匹配算法天然对“形状相似”的字符区分度低而且当模板本身有噪声时置信度会虚高。另一种常见情况是输入字符没做边缘裁切字符周围空白多归一化相关被整体抬升。解决不要只看最高分要看前两名的分数差。如果第一名和第二名差距小于 0.1说明这个字符属于易混淆类别结合车牌规则强制取舍。比如后五位不允许 I/O 时遇到 O 和 0 分数接近直接取 0。如果分数差仍不理想给该字符做一次轮廓分析数字 0 内部没有小孔字母 O 内部有完整孔洞数字 1 宽度窄字母 I 宽度更窄。这属于老工程师的土办法但很有效。6. 进阶一技透视变换校正倾斜车牌顺手做批量验证6.1 用最小外接矩形做旋转校正如果你不想引入四点透视可以用cv2.minAreaRect的角度直接旋转。但鲁棒的做法是拿矩形四角点做透视变换这样能同时处理旋转和侧视造成的梯形畸变。前面rectify_plate已经给了角点排序方法这里补充一个稳定角度判断rect[2]的绝对值大于 45 度时要转成90 - abs(angle)否则旋转方向会反。def rotate_or_warp(plate): gray cv2.cvtColor(plate, cv2.COLOR_BGR2GRAY) _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return plate cnt max(contours, keycv2.contourArea) rect cv2.minAreaRect(cnt) angle rect[2] if angle -45: angle 90 angle elif angle 45: angle angle - 90 M cv2.getRotationMatrix2D(rect[0], angle, 1.0) rotated cv2.warpAffine(plate, M, (plate.shape[1], plate.shape[0])) return rotated我建议优先用透视变换旋转校正只作为备用。因为停车场的侧拍视角很常见梯形畸变会让字符上下宽度不一致旋转后仍然歪。而透视变换能把车牌拉成标准矩形后续分割才稳定。6.2 批量验证与置信度统计单张图片跑通不代表能上线。我会把测试图片放到一个目录写个脚本循环识别把每张图的识别结果、每个字符置信度和平均置信度写进 CSV。这样能快速看出哪个字符在什么光照下掉分。import csv def run_batch(image_dir, template_dir, out_csv): templates load_templates(template_dir) rows [] for f in sorted(os.listdir(image_dir)): if not f.lower().endswith((.jpg, .png)): continue img read_image(os.path.join(image_dir, f)) candidates plate_candidates(img) if not candidates: rows.append([f, 未定位, , ]) continue x, y, w, h candidates[0] plate img[y:y h, x:x w] rectified rectify_plate(plate) chars split_chars(rectified) result, scores [], [] for c in chars: label, score match_char(c, templates) result.append(label) scores.append(f{score:.2f}) avg_score sum(float(s) for s in scores) / len(scores) if scores else rows.append([f, .join(result), .join(scores), f{avg_score:.2f}]) with open(out_csv, w, newline, encodingutf-8-sig) as fp: writer csv.writer(fp) writer.writerow([image, result, char_scores, avg_score]) writer.writerows(rows)这个脚本会输出平均置信度我一般用它做回归测试。改参数后跑一遍如果平均置信度下降超过 0.05说明这次改动伤害了整体识别。现在回头看我最早折腾这类 OpenCV 车牌识别代码的时候最深的教训是车牌识别不是把代码堆出来就完而是先定场景、再定方案。蓝底白字的常规车牌用传统 OpenCV 足够新能源绿牌、大角度俯拍、夜间强反光就得考虑更重的模型或者至少把预处理链做得更细。传统方案的价值在于每一行都能调出问题看得见不像深度学习模型那样是个黑匣子。希望帮到你。本文还有配套的精品资源点击获取
返回列表