简介:这套实战资源面向计算机视觉入门者与智能交通应用开发者,以 Python 结合 OpenCV 把图像预处理、车牌定位、字符分割与模板匹配识别四个环节串联起来,可用于课程设计、毕业项目或自学时对照复现完整流程,也能为车辆追踪、停车场管理等场景提供技术参考。压缩包共 2000 个文件、约 12.37MB,其中 1999 个 jpg 覆盖车辆原图、车牌样本和切分出的单字符素材,1 个 py 脚本承载识别主流程,字符匹配模板随包提供,省去自行采集与标注的成本。目前已有 3109 人学习下载。读者可拿到可直接运行的源码骨架、多车牌的实测图片,以及灰度化、二值化、滤波去噪、边缘检测、轮廓查找、形态学处理、垂直投影分割、模板比对等关键步骤的实现参考,便于读者调整参数、替换模板或扩展到视频流识别。
1. 车牌识别真正难点在预处理:一套 Python + OpenCV 的可交付方案
有位同学用手机拍了二十张停车场入口照片,跑完识别只对了六张,问题不在模型,而是二值化那一步把蓝底白字糊成了一团。车牌识别看着像“检测 + 识别”两件事,实际上一张抓拍图从进来到输出字符串,中间要经过定位、矫正、切分、匹配四道关卡,任何一道出问题,后面的步骤都会白费。这套 Python + OpenCV 车牌自动识别方案正是按这四道关卡搭建的,附源码、样本数据和字符匹配模板,适合做课程设计、毕业设计,也适合要给小型停车场或门禁 Demo 补上识别能力的人。
2. 需求拆解与技术选型:四个处理阶段与工具的职责边界
2.1 从一张抓拍图到一条字符串:处理链的四个阶段
把整个系统看成一条流水线,比看成“一个识别函数”更容易做对。输入是一张 BGR 的抓拍图,输出是一个字符串加一个置信度,中间经过四段:
第一段是预处理,负责把原图变成“车牌区域明显、噪声被压住”的图。第二段是定位,输出若干候选矩形框,并且要按可信度排序,因为很多图里不止一块车牌,还可能有广告牌上的蓝色矩形来干扰。第三段是矫正,把倾斜、透视变形的车牌拉成固定尺寸的正面图,通常归一化到 136×36 或 144×40 这类宽高比接近 3.14 的尺寸。第四段是切分加识别,把整块车牌切成七个字符图,再逐个与模板比对打分,拼成最终结果。
每段的输入输出必须是显式的。比如定位模块只接受 BGR 图和一组阈值参数,返回(x, y, w, h)列表;矫正模块只接受 BGR 图加一个框,返回固定大小的 BGR 图。这种约束看起来啰嗦,但它是后面能单独调试、单独替换某一环的前提。我见过太多课程设计把四段写成一个八百行的函数,定位错了根本不知道是二值化阈值的问题还是轮廓筛选的问题,只能靠猜,这就是翻车的开始。
2.2 为什么用 OpenCV + 模板匹配,而不是直接上深度学习
这个选型不是“退而求其次”,而是场景决定的。车牌识别的目标场景通常是固定的:一个入口、一个角度范围、一种或两种车牌底色。在这种约束下,传统视觉方案的性价比远高于训练模型。
具体对比可以看这张表,判断标准是交付周期和可解释性,而不是绝对精度:
| 方案 | 依赖 | 需要的数据量 | CPU 单图耗时 | 可解释性 | 适合场景 |
|---|---|---|---|---|---|
| 颜色/边缘 + 形态学定位 + 模板匹配 | 仅 OpenCV、NumPy | 几十张样本 + 一套字符模板 | 30~120 ms | 高,每步可可视化 | 固定机位、单一底色、课设/毕设 |
| 轻量检测模型 + CNN 字符分类 | 需深度学习框架 | 数千张标注 | 100~400 ms | 中,需看热力图 | 多角度、多底色、样本可采集 |
| 端到端 OCR | 需预训练模型与大算力 | 迁移学习即可 | 数百 ms 起 | 低 | 通用场景、非固定机位 |
模板匹配的优势在于每一步都能画出来:掩膜图、轮廓图、切分后的七个字符图、每个字符的匹配分数。答辩时把这些中间图往 PPT 上一放,逻辑链条一目了然,这比“我训了个模型,准确率 92%”有说服力得多。
它的边界也要说清楚:模板匹配对字符形变和笔画断裂很敏感,对中文省份简称的识别率明显低于数字和字母,光照剧烈变化时会掉点。所以方案里通常会加两道保险:一是匹配分数低于阈值就输出“待人工确认”,二是对易混字符(0/O、1/I、8/B、5/S)做位置约束,比如第二位只可能是字母,最后一位只可能是数字或字母,用先验知识纠错。
提示:如果你的数据里同时存在蓝牌和黄牌,别指望一套 HSV 阈值能覆盖,老老实实写两组范围,各跑一遍定位。
2.3 源码、样本数据与字符模板的组织方式
工程能不能被别人接手,八成取决于目录结构。字符模板的命名方式尤其关键,文件名就是标签,不要另建一份映射表,否则改一个字符要动两个文件。
plate_recognition/ ├── config.py # 所有阈值、路径、尺寸参数集中在这里 ├── locate.py # 预处理 + 车牌定位 ├── warp.py # 透视矫正 ├── segment.py # 垂直投影字符切分 ├── recognize.py # 模板匹配识别 ├── run_batch.py # 批量跑图入口 ├── data/ │ ├── samples/ # 原始抓拍图,文件名即图片编号 │ └── result.csv # 批量结果,含失败原因 └── templates/ ├── province/ # 省份简称,每字 1 张,尺寸 40x20 │ ├── 京.png │ └── 沪.png └── alnum/ # 数字与字母,每字 1 张,尺寸 20x40 ├── 0.png ├── A.png └── ...模板尺寸必须固定:数字字母统一 20×40,省份简称因为字形宽扁,用 40×20 更合适,硬拉到同一尺寸反而会让匹配分数失真。样本数据按“机位 + 光照”分组存放比按时间堆放有用得多,比如samples/gate1_day/、samples/gate1_night/,验收时按组统计准确率,你就知道这套参数到底在哪一组成效差。
字符模板不需要很多,常用字符集大约 65 类(31 个省份简称 + 10 个数字 + 24 个字母,去掉 I 和 O 避免与 1、0 混淆)。每类一张干净模板就够起步,关键是模板本身要来自同一批数据、经过同样的二值化和尺寸归一化处理,否则模板和待测图的分布不一致,匹配分数会系统性地偏低。
3. 总体架构设计:定位、矫正、切分、识别四层怎么划分
3.1 图像预处理:颜色通道与灰度通道各管一段
预处理不是一个函数,而是两条并行的支路。颜色支路负责回答“哪里可能是车牌”,灰度支路负责回答“车牌的边缘在哪、字符在哪”。两条支路的结果在定位阶段汇合。
颜色支路把 BGR 转 HSV。选 HSV 而不是 RGB,是因为 HSV 把色调和亮度解耦,白天和傍晚的蓝牌在 H 通道上位置接近,只是 V 通道整体下降,调 V 的下限就能补偿,不用重调 H 的范围。蓝色车牌的 H 大致落在 100~124 区间,饱和度 S 和明度 V 给到 90 以上可以滤掉大部分灰白色干扰。
灰度支路做三件事:转灰度、高斯滤波、Sobel 求水平梯度。车牌区域的字符是竖向排列的,水平方向的梯度变化非常密集,用cv2.Sobel(gray, cv2.CV_16S, 1, 0, ksize=3)取绝对值后再二值化,字符区域会连成一片亮块。这一步在逆光和阴影下比纯颜色法更稳,但它对车身文字也很敏感,所以只作为颜色掩膜的补充,不单独使用。
形态学是这两条支路的收尾动作。车牌是一整块连通区域,字符之间的小空隙需要被填上,用一个扁长的矩形核做闭运算,比如(17, 5),横向核大于纵向核,是因为车牌横向长、字符间隙主要在横向上。核再大一点,相邻的两辆车牌会被粘成一个轮廓;再小一点,字符之间填不满,轮廓会碎成七块。
3.2 车牌定位:候选框的粗筛、精排与打分
定位阶段的目标不是“找到唯一正确的框”,而是“给出一个排好序的候选列表”。这个设计上的转变很关键,因为单一阈值必然会在某些图上失手,留一个候选列表,后面可以用识别置信度反选。
粗筛靠cv2.findContours拿到所有外轮廓,然后用三个几何条件过滤:宽高比在 2.2~4.2 之间(标准车牌 440×140,比值 3.14,留出透视余量)、面积大于某个下限(太小的一定是噪声)、矩形度(轮廓面积与外接矩形面积之比)大于 0.5。这三条能砍掉九成以上的干扰。
精排用打分。给每个候选框算一个分数,分数由三部分加权:面积占比、宽高比与 3.14 的偏离程度、以及该框内颜色掩膜的白色像素占比。分数高的排前面。实际项目里我一般把权重设成 0.3、0.4、0.3,然后在样本集上手动调两轮,比拍脑袋定阈值靠谱。
import cv2 import numpy as np def score_box(box, mask, img_area): """给候选框打分:面积、宽高比、掩膜占比三项加权""" x, y, w, h = box ratio = w / float(h) area_score = min(w * h / (img_area * 0.25), 1.0) # 面积越大越好,封顶 1 ratio_score = max(0.0, 1 - abs(ratio - 3.14) / 3.14) # 越接近 3.14 越高 roi_mask = mask[y:y + h, x:x + w] fill_score = roi_mask.mean() / 255.0 # 掩膜白色占比 return 0.3 * area_score + 0.4 * ratio_score + 0.3 * fill_score这段代码里img_area * 0.25是个经验分母,意思是面积达到整图四分之一就认为足够大,不再加分,避免大广告牌把分数拉爆。ratio_score用线性衰减而不是硬阈值,是为了让接近标准的框之间还能分出高下。fill_score依赖前面形态学闭运算的质量,如果掩膜坑坑洼洼,这一项会普遍偏低,此时应该回头调核大小,而不是调权重。
3.3 几何矫正:把斜拍的车牌拉回水平
不矫正直接切分,是最常见的错误来源。斜拍的车牌在垂直投影上,字符列的投影峰会互相重叠,切出来的字符要么粘连要么缺笔画。矫正这一步花的代码不多,收益极大。
标准做法是取候选框的四个角点做透视变换。如果轮廓本身比较规整,可以直接用cv2.minAreaRect求出最小外接旋转矩形,再cv2.boxPoints拿到四个顶点;如果轮廓有明显的缺角,就用外接矩形的四个角点近似,虽然会引入一点误差,但对后续切分足够。
变换的目标尺寸要按车牌真实宽高比定,136×36 是我常用的,原因是字符模板按这个尺度设计,切出来的单字符正好落在 20×40 附近,不用反复缩放。插值方式选cv2.INTER_CUBIC,虽然慢一点,但字符边缘更清晰,比INTER_LINEAR的匹配分数平均高 2~3 个百分点。
def warp_plate(bgr, box, target=(136, 36)): """把候选框内的车牌区域矫正到固定尺寸""" x, y, w, h = box roi = bgr[y:y + h, x:x + w] src = np.float32([[0, 0], [w, 0], [w, h], [0, h]]) dst = np.float32([[0, 0], [target[0], 0], [target[0], target[1]], [0, target[1]]]) M = cv2.getPerspectiveTransform(src, dst) return cv2.warpPerspective(roi, M, target, flags=cv2.INTER_CUBIC)参数target不要随意改。改了之后字符模板的尺寸必须同步改,否则cv2.matchTemplate会因为尺寸不匹配直接报错,或者更糟:尺寸凑巧兼容但分布错位,匹配分数普遍虚高却识别全错,这种问题极难排查。
3.4 模块接口约定:用数据结构固定上下游契约
四层之间传什么,最好用数据结构写死,而不是传一堆散装变量。一个简单的结果类就能让调试效率翻倍:
from dataclasses import dataclass, field from typing import List, Tuple import numpy as np @dataclass class PlateResult: box: Tuple[int, int, int, int] # 原图坐标 plate_bgr: np.ndarray # 矫正后的车牌图 chars: List[np.ndarray] = field(default_factory=list) # 切分出的字符图 text: str = "" # 最终字符串 scores: List[float] = field(default_factory=list) # 每个字符的匹配分 @property def avg_score(self) -> float: return sum(self.scores) / len(self.scores) if self.scores else 0.0avg_score这个属性是给上层用的:批量跑图时按平均分排序,最低的那批就是最该人工看的图。box保留原图坐标,是为了能把识别结果画回原图做可视化,答辩演示时这张叠加图比任何数字都直观。
接口定好之后,单独替换识别模块就变得很轻:想从模板匹配换成 CNN 分类器,只要新模块能填满chars、text、scores三个字段,其他代码一行不用动。这是分层设计在实际项目里最实在的好处。
4. 代码实现:把四段流水线拼成可运行工程
4.1 工程目录与配置项:把可调参数集中到一处
前面定过目录,这里说配置怎么放。所有阈值、尺寸、路径都收进config.py,用普通字典或类属性都行,关键是不要散落在各模块里。散落之后,你在locate.py里把 S 的下限从 90 调到 70,忘了warp.py里还有一份,结果两边行为不一致,排查半天。
# config.py CONFIG = { "hsv_blue": {"lower": (100, 90, 90), "upper": (124, 255, 255)}, "hsv_yellow": {"lower": (15, 90, 90), "upper": (35, 255, 255)}, "morph_kernel": (17, 5), "min_area": 1200, "ratio_range": (2.2, 4.2), "rect_fill_min": 0.5, "plate_size": (136, 36), "alnum_size": (20, 40), "province_size": (40, 20), "match_threshold": 0.45, "template_dir": "templates", "sample_dir": "data/samples", "result_csv": "data/result.csv", }match_threshold定在 0.45 是经验值,用TM_CCOEFF_NORMED时,正确匹配一般落在 0.55~0.85,错误匹配多在 0.2~0.4,0.45 是个保守的分界。阈值定太高,正常图会被判成失败;定太低,错字会被当成对的输出。这个值一定要在自己的样本集上验证,别照抄。
4.2 定位与矫正:颜色掩膜到透视变换的实现
把 3.1 到 3.3 的思路落到一个函数里,注意两件事:蓝色和黄色车牌分别处理,候选框按分数排序返回全部。
def locate_plates(bgr, cfg=CONFIG): """返回按分数降序排列的候选框列表""" img_area = bgr.shape[0] * bgr.shape[1] hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) boxes, scores = [], [] for key in ("hsv_blue", "hsv_yellow"): lower = np.array(cfg[key]["lower"]) upper = np.array(cfg[key]["upper"]) mask = cv2.inRange(hsv, lower, upper) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_RECT, cfg["morph_kernel"])) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for c in contours: x, y, w, h = cv2.boundingRect(c) if w * h < cfg["min_area"]: continue ratio = w / float(h) lo, hi = cfg["ratio_range"] if not (lo <= ratio <= hi): continue if cv2.contourArea(c) / float(w * h) < cfg["rect_fill_min"]: continue boxes.append((x, y, w, h)) scores.append(score_box((x, y, w, h), mask, img_area)) order = np.argsort(scores)[::-1] # 分数从高到低 return [boxes[i] for i in order]cv2.RETR_EXTERNAL只取外轮廓,是因为车牌区域内部还有字符孔洞,用RETR_TREE会把每个字符也当轮廓送进来,白白增加过滤负担。morph_kernel用扁长核是前面讲过的,横向闭运算把字符间隙填上,让整块车牌成为一个连通域。
拿到候选框后,对前三个框各跑一次矫正和识别,取平均分最高的那个作为最终结果。这个“多候选 + 置信度反选”的策略比只取面积最大的框稳得多,尤其是在有蓝色广告牌的画面里。
4.3 切分与匹配:垂直投影加模板打分
切分用垂直投影,逻辑简单但参数敏感。核心思路是统计每一列的白像素数量,连续有白像素的列构成一个字符块,列间空白的宽度超过阈值就断开。
def segment_chars(gray_plate, min_w=8, max_w=45, gap_th=2): """垂直投影切分,返回字符图列表""" _, bin_img = cv2.threshold(gray_plate, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) bin_img = cv2.morphologyEx(bin_img, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8)) # 去掉细小噪点 col_sum = (bin_img > 0).sum(axis=0) chars, start = [], None for i, v in enumerate(col_sum): if v > gap_th and start is None: start = i elif v <= gap_th and start is not None: if min_w <= i - start <= max_w: chars.append(bin_img[:, start:i]) start = None if start is not None and min_w <= len(col_sum) - start <= max_w: chars.append(bin_img[:, start:]) return charsgap_th=2的意思是某列白像素数不超过 2 就当空白,这个值太小会把笔画稀疏的字符(比如 1)切断,太大则粘连。min_w和max_w卡的是字符宽度范围,超出范围的块多半是车牌边框或铆钉,直接丢弃。正常切出来应该是 7 个字符块,如果不是 7,说明这一张大概率要失败,可以直接标记为“切分异常”而不是硬着头皮识别。
识别部分用cv2.matchTemplate加TM_CCOEFF_NORMED,逐个模板比对取最高分。第一位是省份简称,走province模板目录;后六位走alnum目录。
class TemplateMatcher: def __init__(self, cfg=CONFIG): self.size_alnum = cfg["alnum_size"] self.size_prov = cfg["province_size"] self.alnum = self._load(f"{cfg['template_dir']}/alnum", self.size_alnum) self.province = self._load(f"{cfg['template_dir']}/province", self.size_prov) def _load(self, folder, size): table = {} for name in os.listdir(folder): char = os.path.splitext(name)[0] img = cv2.imread(os.path.join(folder, name), cv2.IMREAD_GRAYSCALE) if img is None: # 路径含中文时 imread 会返回 None continue img = cv2.resize(img, size) _, img = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) table[char] = img return table def match(self, char_img, is_province=False): table = self.province if is_province else self.alnum size = self.size_prov if is_province else self.size_alnum query = cv2.resize(char_img, size) best, best_score = "", -1.0 for char, tpl in table.items(): s = cv2.matchTemplate(query, tpl, cv2.TM_CCOEFF_NORMED)[0][0] if s > best_score: best, best_score = char, s return best, best_scoreTM_CCOEFF_NORMED输出在 -1 到 1 之间,对整体亮度变化不敏感,比TM_SQDIFF更适合模板和待测图来自不同光照的情况。注意待测字符图也要先 resize 到同一尺寸再比对,缺了这一步matchTemplate会直接抛尺寸不匹配异常,或者更隐蔽地返回一个无意义的高分。
省份简称的识别率天然偏低,因为中文字形复杂、笔画细,二值化后容易断。实际做法是给省份单独放宽阈值,或者在结果里标一个低置信度标记,让人工复核时优先看这一位。
4.4 批量跑图与结果落盘:让失败样本自己浮出来
单张跑通只完成了一半工作量,另一半是批量跑图、统计失败原因。结果表里除了识别文本,还要记录失败阶段,这样你才能知道问题集中在定位还是切分。
def run_batch(sample_dir, cfg=CONFIG): matcher = TemplateMatcher(cfg) rows = [] for name in sorted(os.listdir(sample_dir)): path = os.path.join(sample_dir, name) img = cv2.imread(path) # 路径含中文请改用 imdecode if img is None: rows.append((name, "", "read_error", 0.0)) continue boxes = locate_plates(img, cfg) if not boxes: rows.append((name, "", "locate_fail", 0.0)) continue best_text, best_score, stage = "", -1.0, "segment_fail" for box in boxes[:3]: plate = warp_plate(img, box, cfg["plate_size"]) gray = cv2.cvtColor(plate, cv2.COLOR_BGR2GRAY) chars = segment_chars(gray) if len(chars) != 7: continue text, scores = "", [] for i, ch in enumerate(chars): c, s = matcher.match(ch, is_province=(i == 0)) text += c scores.append(s) if sum(scores) / len(scores) > best_score: best_text, best_score, stage = text, sum(scores) / len(scores), "ok" rows.append((name, best_text, stage, round(best_score, 3))) pd.DataFrame(rows, columns=["file", "text", "stage", "score"]) \ .to_csv(cfg["result_csv"], index=False, encoding="utf-8-sig")locate_plates返回多个候选时只试前三个,是性能和收益的折中;再多试几个,收益递减但耗时线性增长。stage字段是最有价值的一列:跑完一批图,先看stage的分布,如果locate_fail占四成,说明定位参数有问题,根本不用去看识别准确率。score排序后取最低的十张,就是你的重点复查清单。
注意:
cv2.imread在 Windows 下遇到中文路径会静默返回 None,不会报错。批量跑图时如果发现大量read_error,先检查文件名。
5. 常见问题排查:这套链路最容易出问题的五个点
5.1 换一组图片就大面积定位失败
现象:自己拍的样本上定位很准,换一批别人给的图,locate_fail比例突然涨到一半以上。
原因:HSV 阈值是在单一光照下调出来的,换了机位、时段、天气之后,蓝色车牌的 S 和 V 分布整体偏移,固定阈值直接失效。另外白牌、绿牌根本没写对应的范围,自然定位不到。
解决:把阈值改成可搜索的参数,用一批混合样本做小范围网格搜索,S 和 V 各取三档,选命中率最高的一组。同时给每种底色单独配置一条颜色分支,分支数量由你的实际数据决定,不要假设只有蓝牌。做完这一步再补一条兜底规则:颜色分支全部失败时,退回到 Sobel 边缘 + 形态学的纯灰度定位。
5.2 定位框落在车身上而不是车牌上
现象:可视化图里,红框圈住的是车身侧面或者蓝色车标,车牌在框外。
原因:候选框按面积排序,而车身、广告牌的面积远大于车牌。粗筛条件里的面积下限只设了下界没设上界,大块区域全部通过。
解决:在打分里压低面积的权重,提高宽高比和掩膜填充率的权重;同时给面积加一个上限,比如超过整图 15% 的框直接排除。另外取前三个候选分别识别、用置信度反选,也能在不完美的候选里挑出正确结果。
5.3 切出来的字符数量不是七个
现象:segment_chars返回 5 个、6 个或 9 个字符块,后面识别直接跳过或者输出乱码。
原因有三类:二值化后字符笔画断成两截,被当成两个字符;相邻字符粘连,被当成一个;车牌边框、铆钉、螺丝被当成字符。垂直投影对断裂和粘连都没有免疫力。
解决:先做开运算去小噪点(核 2×2 就够,再大笔画就没了),再对切分结果做后处理:按宽度排序,宽度明显偏小的块尝试与相邻块合并,宽度明显偏大的块用投影谷值二次切分。最后加一条长度校验,切不出 7 个就直接标记为异常样本,不要硬识别。
5.4 匹配分数很高但结果全是错字
现象:每个字符的匹配分都在 0.8 以上,拼出来的字符串却明显不对。
原因:模板和待测图的尺度和极性不一致。常见情况是模板是白字黑底、待测图二值化后是黑字白底,TM_CCOEFF_NORMED对反相并不总是敏感,会给出虚高的分数。另一种情况是 resize 时用了默认的双线性插值,模板线条被抹平,反而和任何输入都“有点像”。
解决:在_load和match里统一做一次极性检查,统计白像素占比,超过 0.5 就bitwise_not反相,保证两边都是白字黑底。resize 统一用INTER_NEAREST或INTER_CUBIC,不要混用。改完之后重新验证一批已知答案的样本,分数分布应该明显拉开。
5.5 省份简称几乎没对过
现象:后六位数字字母基本正确,第一位汉字错得离谱。
原因:中文字形笔画多、结构复杂,二值化后细节丢失严重;模板只有一张,无法覆盖不同字体、不同模糊程度;加上省份简称的候选集有三十多类,干扰项太多。
解决:给省份单独放大匹配尺寸(比如 40×20 而不是硬压到 20×40),容忍更多细节;每个省份准备 2~3 张不同来源的模板,取最高分;把识别结果和车牌第二位字母做联合校验,某些组合在实际中不存在,可以直接排除一部分候选。如果条件允许,第一位单独换成一个小的分类器,比模板匹配稳得多。
6. 验收与答辩:用四组指标证明这条链路可靠
验收不能只看“我跑通了”。评审最常问的是“准确率多少、怎么算的、在什么数据上算的”。准备好下面四组指标,问题基本都能接住。
| 指标 | 计算方式 | 建议判定线 | 说明 |
|---|---|---|---|
| 定位命中率 | IoU>0.5 的图数 / 总图数 | ≥ 90% | 定位不准,后面全是白费,先看这个 |
| 整牌准确率 | 七位全对 / 总图数 | ≥ 75% | 最核心指标,别用单字准确率糊弄 |
| 单字准确率 | 正确字符数 / 总字符数 | ≥ 90% | 用来定位问题在切分还是匹配 |
| 单图耗时 | 平均 ms(CPU) | ≤ 150 ms | 超过这个数实时场景就不用谈了 |
统计一定要分组。按光照分成白天、傍晚、强逆光三组,按机位分成正拍和斜拍两组,每组单独算整牌准确率。整体 80% 但逆光组只有 40%,这个结论比一个笼统的数字有价值得多,也是答辩时最能体现你做过实验的地方。样本量别太少,少于 50 张的统计没有说服力,凑不出就说明数据采集还没做完。
演示环节建议脚本化,把流程固定成五步:先展示一张原图,再展示颜色掩膜,然后展示候选框排序(把前三个框用不同颜色画出来),接着展示矫正后的车牌和切分出的七个字符图,最后展示匹配分数表和最终字符串。中间任何一步出问题,你都能立刻指出卡在哪一层,而不是只能说“它有时候不太准”。
现场容易翻车的两个细节提前处理掉。一是换一台电脑演示,路径变了、OpenCV 版本变了,cv2.imread可能读不到图,演示前在目标机器上完整跑一遍批量脚本。二是准备一组“已知会失败”的样本,主动讲清楚失败原因和后续改进方向,这比被评委问出来强得多。评委不怕方案有边界,怕的是你说不清边界在哪。
我从做完第一个车牌项目之后养成一个习惯:每次交付前,一定把result.csv里score最低的十张图逐张打开看一遍,不管整体准确率多好看。那十张图里藏着的往往是同一类问题,比如某个机位的反光、某种字体的粘连,改一处参数就能把整体拉上去三五个点。这个习惯比任何调参技巧都管用。希望帮到你。
本文还有配套的精品资源,点击获取