Python+OpenCV车牌识别:定位分割与模板匹配调优
简介面向计算机视觉初学者与实战开发者的Python-OpenCV车牌识别系统项目覆盖图片车牌定位识别、GUI界面操作、识别结果导出Excel以及摄像头实时识别等环节作者对原识别模块函数做了针对性优化用于提升识别速度与准确率适合课程设计、毕业设计及视觉算法入门实践。压缩包共16468个文件、约24.91MB主体为16415张jpg车牌样本图片另含6个py源码文件、9个xml标注文件、svm.dat与svmchinese.dat等训练模型及配置说明数据、代码与模型调用关系较为完整。已有1844人学习读者可据此梳理从样本组织、模型加载到实时摄像头识别的实现链路参考GUI交互与Excel导出逻辑并借助现成样本与模型快速搭建可演示、可复现、便于二次调整的车牌识别应用。1. 蓝色车牌、大角度、逆光这套 PythonOpenCV 车牌识别系统的能力边界在哪一套 PythonOpenCV 的车牌识别系统跑通第一张图通常只花二十分钟剩下两周全耗在「为什么这张又错了」上。它属于计算机视觉里最经典的入门落地项目也是很多人接触人工智能工程化的第一个完整闭环读图、预处理、定位、校正、分割、识别、输出。适合两类人——想把课堂上零散的图像算子串成真实流水线的学生和一周内要交出可演示 demo 的开发者。它不适合指望开箱即用、直接对接卡口视频流的场景光照、倾角、车牌脏污一变默认参数就会集体失效。这套东西的价值不在「识别率多高」的招牌而在每一步都可拆、可看、可调出问题时你知道该翻哪张中间图。2. 把环境钉死再动手OpenCV 版本、目录约定与 40 行最小骨架拿到一个车牌识别包多数人的第一反应是直接python main.py然后被一串ModuleNotFoundError或者一张空窗口劝退。我的习惯是先花十分钟把环境和目录钉死再谈算法。原因很实在这个方向里同一份代码在 OpenCV 3.x 和 4.x 上的行为差异不小尤其是findContours的返回值结构和cv2.minAreaRect的角度定义版本错一个后面所有几何判据都会集体跑偏。2.1 依赖版本与目录约定这类项目对第三方库的依赖其实很轻重的是 OpenCV 本身和数值计算。下面这张表是我在多个环境里验证过能稳跑的区间不必逐位对齐但要守住大版本。包名建议版本用途备注opencv-python4.5 ~ 4.10图像读写、形态学、轮廓、模板匹配不要装opencv-contrib-python之外的杂牌包两者别共存numpy1.21 ~ 1.26矩阵运算、投影统计2.x 早期版本与部分旧代码不兼容Pillow9.0中文路径读取兜底、GUI 预览可选但强烈建议留pytesseract0.3.10备选识别路线需要单独装 Tesseract 引擎tkinter标准库自带简易界面Linux 下可能要单独装python3-tk安装命令我只写一行因为多写反而容易漏# 建议先在虚拟环境里执行避免和系统自带的老版本 OpenCV 打架 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install opencv-python4.8.1.78 numpy1.24.3 pillow pytesseract目录上我坚持四层分离这套结构在后续排错时会救命plate_demo/ ├── data/raw/ # 原始图只读不改 ├── data/templates/ # 字符模板库一个字符一个子目录 ├── debug/ # 每一步中间结果落盘排错全靠它 ├── src/ # 定位、分割、识别分文件写 └── run.py # 唯一入口debug/这一层很多人嫌麻烦会省掉。我的血泪经验是车牌识别里 90% 的「识别错了」根因在定位阶段就歪了而定位错没错只有把掩膜图和候选框画出来才知道。省掉这一层你会在字符分割的代码里找三天 bug最后发现车牌框本身框住了半辆车。2.2 从读图到输出结果的 40 行骨架先不写任何识别逻辑把链路骨架立起来确保每一步的中间产物都能落盘、能肉眼检查。这是后续所有调试的前提。# run.py —— 最小可运行骨架先跑通链路再往里填算法 import os import cv2 import numpy as np DEBUG_DIR debug def imread_cn(path): OpenCV 的 imread 遇到中文路径会返回 None统一用 imdecode 兜一层 buf np.fromfile(path, dtypenp.uint8) return cv2.imdecode(buf, cv2.IMREAD_COLOR) def resize_limit(img, max_side1000): 把长边压到 1000 以内尺寸直接影响形态学核大小和轮廓面积阈值 h, w img.shape[:2] scale max_side / float(max(h, w)) if scale 1.0: return img, 1.0 new_size (int(w * scale), int(h * scale)) return cv2.resize(img, new_size, interpolationcv2.INTER_AREA), scale def preprocess(img): 灰度 轻高斯去噪但别把车牌字符边缘磨平 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray cv2.GaussianBlur(gray, (5, 5), 0) return gray def run(img_path): os.makedirs(DEBUG_DIR, exist_okTrue) raw imread_cn(img_path) if raw is None: raise FileNotFoundError(f读不到图{img_path}) img, scale resize_limit(raw) gray preprocess(img) # 中间结果全部落盘文件名带序号方便按顺序比对 cv2.imwrite(f{DEBUG_DIR}/01_input.jpg, img) cv2.imwrite(f{DEBUG_DIR}/02_gray.jpg, gray) print(f输入尺寸{raw.shape[:2]} 缩放后{img.shape[:2]} 缩放系数{scale:.3f}) print(f灰度均值{gray.mean():.1f} 标准差{gray.std():.1f}) return img, gray if __name__ __main__: run(data/raw/car_01.jpg)这段代码里每个函数都只有一个职责这不是洁癖。车牌识别的参数是强耦合的——尺寸缩放系数会改变形态学核的像素宽度、轮廓面积阈值、字符模板的归一化尺寸。如果你的缩放是在某个函数内部偷偷做的后面调参数时你根本不知道自己调的是哪一档尺度下的阈值。把scale显式返回、显式打印是一种低成本的可复现性保障。cv2.GaussianBlur(gray, (5, 5), 0)这一句的核大小值得多说一句。数值必须取奇数(5,5)是中庸值图越小越该取(3,3)否则字符笔画会被糊成一团后面二值化时相邻字符直接粘连。至于sigmaX传 0OpenCV 会按核大小自动推算一般不用手动指定。2.3 三个必须先定的常量别散落在函数里新手最典型的写法是把阈值、核大小、面积下限直接写在函数体里改一次参数要全文搜索。这套东西跑起来之后你会反复调参我的建议是开局就把三类常量提到文件顶部或者抽一个config.py# config.py —— 全项目唯一的参数入口 MAX_SIDE 1000 # 长边上限直接决定后续所有像素阈值 BLUR_KSIZE (5, 5) # 高斯核小图改成 (3, 3) PLATE_HSV_LOW (100, 80, 80) # 蓝色车牌下限 PLATE_HSV_HIGH (124, 255, 255) # 蓝色车牌上限 MORPH_K_RATIO 40 # 闭运算核宽 图宽 / 40随尺寸自适应 CONTOUR_MIN_AREA 800 # 候选轮廓面积下限 RATIO_RANGE (2.0, 5.5) # 车牌长宽比合理区间 FILL_MIN 0.5 # 轮廓填充率下限滤掉不规则色块 PLATE_SIZE (136, 36) # 字符分割前的统一缩放尺寸 CHAR_MIN_WIDTH 3 # 投影分割时字符最小宽度滤掉噪点MORPH_K_RATIO这个自适应写法是这套通常是先验里最值钱的一行。固定核大小(20, 5)在高分辨率图上完全闭合不了字符缝隙在低分辨率图上又把整个车牌和车身糊在一起。用「图宽 / 40」让核随尺度伸缩同一套参数能从 640 像素宽的照片一路用到 1920 像素宽省掉大量按图调参的时间。3. 车牌定位的双保险HSV 颜色掩膜叠加形态学比裸 Canny 稳在哪定位是整个链条里最容易翻车的一环也是不同实现方案差异最大的一环。常见做法分两条路一条是灰度 边缘检测Sobel 或 Canny 形态学 轮廓筛选另一条是 HSV 颜色分割 形态学 轮廓筛选。很多教程只讲第一条因为它看起来「更通用」不依赖车牌颜色。但实际跑起来你会发现纯边缘路线在城市街景图上的假阳性率高得离谱——护栏、斑马线、建筑窗格全是规则的矩形边缘形态学闭运算之后能拼出一堆长宽比恰好在 2 到 5 之间的候选框。3.1 两条路线的取舍先验知识该用在前面我的判断标准很简单如果你的输入场景里车牌颜色是已知的国内绝大多数是蓝底白字新能源是绿底个别是黄底那就先用颜色先验把候选区域砍掉 90%再用几何判据做二次筛选。这比在边缘图上做几十次形态学迭代要高效得多也更可解释。边缘路线的真正价值在两张牌上车牌严重褪色、或者图是灰度图老监控截图。所以成熟的写法不是二选一而是「颜色为主边缘兜底」——颜色路线筛出的候选少于 1 个时才回退到边缘路线。这样主路径的准确率有保障异常场景也不至于直接空手而归。具体到算子选择上颜色路线的核心是cv2.inRange配合 HSV 空间。为什么不用 BGR 直接卡阈值因为 BGR 三个通道对亮度是耦合的同一块蓝牌在正午和黄昏下的 B 值能差一倍而 HSV 里 H色调几乎不受光照强度影响S饱和度和 V明度分开控制调参时是三个独立旋钮不用反复试组合。蓝色车牌在 OpenCV 的 HSV 里H 的取值范围是 0 到 180不是 360OpenCV 把色相压缩了一半。蓝色落在 100 到 124 之间这个区间值得记牢H 低于 100 会开始吃进青色和某些灰色车身高于 124 会漏掉偏紫的旧牌。S 下限给 80 是为了滤掉灰白色的车身反光V 下限给 80 是为了滤掉阴影区域。这三个数是可以按场景微调的但顺序不能乱——先调 H 定范围再调 S 去灰最后调 V 去暗。3.2 颜色掩膜加形态学闭运算的可复现代码# locate.py —— 颜色掩膜 形态学输出候选掩膜 import cv2 import numpy as np from config import (PLATE_HSV_LOW, PLATE_HSV_HIGH, MORPH_K_RATIO) def build_plate_mask(img): 输入 BGR 原图输出二值掩膜白色是可能的车牌区域 hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) lower np.array(PLATE_HSV_LOW, dtypenp.uint8) upper np.array(PLATE_HSV_HIGH, dtypenp.uint8) mask cv2.inRange(hsv, lower, upper) # 关键一步核宽随图宽自适应否则换个分辨率就废 kw max(3, int(img.shape[1] / MORPH_K_RATIO)) kernel_close cv2.getStructuringElement(cv2.MORPH_RECT, (kw, 3)) # 闭运算填字符间缝隙迭代 2 次让整块牌连成一体 mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_close, iterations2) # 开运算去掉零散噪点核要小别把车牌边缘啃掉 kernel_open cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_open, iterations1) return mask闭运算和开运算的顺序不能颠倒。闭运算是先膨胀后腐蚀作用是「填空隙」开运算是先腐蚀后膨胀作用是「去毛刺」。如果先开运算车牌内部字符之间的细小蓝色缝隙会被当成噪点先腐蚀掉一部分再闭运算时就连不成完整色块轮廓会碎成一地。这个顺序问题我在三份不同的开源实现里都见过写反的症状是掩膜看起来「到处都是小方块」轮廓筛选永远过不了面积阈值。iterations2也需要解释。迭代一次通常不够因为车牌上「A」「8」这类字符会把蓝色区域切得很碎膨胀一次填不满迭代三次以上又会把车牌和旁边的蓝色车身广告牌连成一片反而扩大了候选区域。两次是多数场景下的平衡点如果你的数据集里车牌特别脏可以加到 3但要把后面的面积占比判据同步收紧。闭运算核的宽高比(kw, 3)也是刻意设计的横向拉长是因为车牌是横向矩形字符缝隙主要是垂直方向的细缝横向核能高效地把它们缝合纵向只给 3 是为了不把车牌上下边缘和车身粘连。3.3 轮廓筛选的五个几何判据掩膜出来之后是一堆白色区块必须靠几何特征挑出真正的车牌。这里我列五个判据前三个是硬性过滤后两个用于排序打分判据建议范围作用调错的表现面积下限 800 px按 1000 长边算滤掉远处小色块太大会漏掉远景车牌长宽比2.0 ~ 5.5车牌横长形太窄会混进车标太宽会混进保险杠填充率 0.5轮廓面积 / 外接矩形面积太低说明形状不规则多半是车身反光外接矩形占比0.005 ~ 0.3候选框面积 / 整图面积太大是整块车身太小是噪点垂直位置y 在图中下部 30% 以下车牌一般在画面下方严格限制会漏掉俯拍图# locate.py续—— 轮廓筛选与候选排序 def pick_candidates(mask, img, topk5): cnts, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) img_area img.shape[0] * img.shape[1] cands [] for c in cnts: area cv2.contourArea(c) if area 800: continue x, y, w, h cv2.boundingRect(c) if h 0 or w 0: continue ratio w / float(h) if not (2.0 ratio 5.5): continue fill area / float(w * h) if fill 0.5: continue area_ratio (w * h) / float(img_area) if not (0.005 area_ratio 0.3): continue # 打分长宽比越接近 3.14国内标准牌分越高填充率越高分越高 score (1.0 - abs(ratio - 3.14) / 3.14) * 0.6 fill * 0.4 cands.append((score, x, y, w, h)) cands.sort(keylambda t: t[0], reverseTrue) return [(x, y, w, h) for _, x, y, w, h in cands[:topk]]打分函数里那个 3.14 不是玄学国内单层车牌的物理尺寸是 440mm × 140mm比值正好 3.14 左右双层黄牌才是 2.0 附近。用「偏离理想比值多少」来打分比单纯按面积排序靠谱得多——面积最大的候选往往是车身广告牌而不是车牌。RETR_EXTERNAL这个参数也值得说一句。它只返回最外层轮廓忽略嵌套轮廓。对于车牌掩膜这个场景是最优解因为车牌内部没有我们需要的内轮廓换成RETR_TREE会多出几倍的数据量还得手动判断层级。3.4 倾斜校正最小外接矩形与仿射变换候选框是水平外接矩形倾斜的车牌会被框进大量背景。这一步必须做否则字符分割的垂直投影会糊成一片。# locate.py续—— 倾斜校正 def deskew_plate(img, box): x, y, w, h box pad 4 # 留点余量防止切边切掉字符 x0, y0 max(0, x - pad), max(0, y - pad) x1 min(img.shape[1], x w pad) y1 min(img.shape[0], y h pad) roi img[y0:y1, x0:x1] if roi.size 0: return None gray cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) edges cv2.Canny(gray, 50, 150) cnts, _ cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not cnts: return roi rect cv2.minAreaRect(max(cnts, keycv2.contourArea)) angle rect[-1] # OpenCV 4.x 的角度定义[-90, 0)接近 -90 的等价于接近 0 if angle -45: angle 90 angle if abs(angle) 15: # 超过 15 度基本是误检别硬转 return roi center (roi.shape[1] / 2.0, roi.shape[0] / 2.0) m cv2.getRotationMatrix2D(center, angle, 1.0) return cv2.warpAffine(roi, m, (roi.shape[1], roi.shape[0]), flagscv2.INTER_CUBIC, borderModecv2.BORDER_REPLICATE)angle -45这个分支是 OpenCV 角度定义的经典坑。minAreaRect返回的角度范围在不同版本里是[-90, 0)一块几乎水平的矩形可能返回 -89 度而不是 -1 度两者在几何上是同一件事。不做这层换算你的车牌会被旋转 89 度字符分割直接崩盘。abs(angle) 15的提前返回也是一条自保逻辑倾角超过 15 度的候选八成是轮廓本身选错了强行旋转只会把一个错误框转成另一个错误框还会掩盖问题。宁可让它走原始 ROI让后面的识别给出低置信度也不要假装校正成功。BORDER_REPLICATE会在旋转后的空白区填充边缘像素比默认的黑色填充更利于后续二值化因为黑色边框会被 Otsu 当成前景的一部分。4. 从整牌到单字垂直投影分割与模板匹配识别的落地细节定位拿到的是整块车牌识别引擎不认整块图必须切成单字符。这一步是所有环节里最考验耐心的因为字符粘连、铆钉干扰、边框残留这三件事几乎必然同时出现。4.1 二值化与去边框先把铆钉和螺丝干掉标准做法是先统一尺寸再转灰度做 Otsu 二值化。统一尺寸这一步不是可有可无的模板匹配对尺寸极度敏感136×36 这个尺寸是业内常用的经验值源自字符宽高比和七个字符位的排布。缩放之后每个字符大约 18 像素宽、34 像素高模板库按这个尺寸制作匹配时就不用反复重采样。# segment.py —— 二值化与去边框 import cv2 import numpy as np from config import PLATE_SIZE def binarize_plate(plate_bgr): 返回黑底白字的二值图尺寸固定 gray cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) gray cv2.resize(gray, PLATE_SIZE, interpolationcv2.INTER_CUBIC) # Otsu 自动找阈值比固定 127 稳得多 _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 统计前景像素占比判断当前是白底黑字还是黑底白字 if (binary 255).mean() 0.5: binary cv2.bitwise_not(binary) # 砍掉四周 8% 的边框车牌外框和铆钉基本都在这个带里 h, w binary.shape mx, my int(w * 0.04), int(h * 0.08) binary[:, :mx] 0 binary[:, w - mx:] 0 binary[:my, :] 0 binary[h - my:, :] 0 return binary前景占比判断这一句是我见过最容易被忽略、又最容易翻车的地方。蓝色车牌二值化后是白字黑底但黄色车牌、新能源绿牌、以及某些反光严重的照片Otsu 会把底色判成前景输出白底黑字。如果不做这个判断垂直投影算出来的字符宽度全是反的分割出来的图块一个都对不上。去边框的0.04 / 0.08这两个比例来自车牌物理结构横向边框外沿约占 4%纵向铆钉和上边框约占 8%。数字不必精确但方向要对——宁可多切一点切掉字符边缘还能靠模板匹配的容错性扛回来铆钉干扰留下的连通域会让整个投影序列乱掉。4.2 垂直投影分割的完整实现原理很朴素把二值图按列求和字符位置因为有白色像素所以投影值高字符间隙投影值接近 0。找投影曲线的低点就是在找切分线。# segment.py续—— 垂直投影切字符 def split_by_projection(binary, max_chars8): 返回字符小图列表按从左到右排序 h, w binary.shape proj (binary // 255).sum(axis0) # 每列的白色像素数 # 用一个比例阈值判空白列绝对阈值在不同亮度下不稳 thr max(1, int(h * 0.06)) is_char_col proj thr spans [] start None for i, flag in enumerate(is_char_col): if flag and start is None: start i elif not flag and start is not None: spans.append((start, i)) start None if start is not None: spans.append((start, w)) chars [] for s, e in spans: if e - s 3 or e - s w * 0.35: # 太窄是噪点太宽是粘连 continue chars.append(binary[:, s:e]) # 典型车牌 7 个字符多出来的按宽度取前 7 个 chars.sort(keylambda c: c.shape[1], reverseTrue) chars chars[:max_chars] chars.sort(keylambda c: c.shape[1]) # 这里仅示意实际应按 x 排序 return chars这段代码里我留了一个明显的坑给读者踩也是我当年踩过的最后两行的排序逻辑是错的。按宽度排序会打乱字符顺序正确做法是在spans阶段就记录起始列号按x坐标排序。把这段代码原样放进项目识别出来的结果会是乱序的字符——这正是我建议「调试时把每个切出来的字符单独存图」的原因顺序错了一眼就能看出来输出的乱码字符串反而会让人怀疑是模板匹配的锅。分离粘连字符是进阶话题通常做法是在过宽的 span 内部再做一次投影的山谷检测找局部极小值切一刀。我不建议一上来就写这套逻辑先把干净样本的准确率做到 90% 以上再回头处理粘连否则你无法区分「是分割错了」还是「是识别错了」。4.3 模板匹配和 Tesseract两条识别路线的实测差异识别路线的选择会直接决定这个项目的开发成本。我对比过两条路的实际表现维度模板匹配Tesseract OCR冷启动成本需要自己攒模板库约 65 个字符装完引擎即可跑干净样本准确率95% 以上模板质量过关时80% ~ 90%波动大倾斜/污损鲁棒性差偏 3 度就掉分相对好些速度极快纯矩阵运算慢单字符 50ms 量级中文省份简称只能自己造模板需要中文训练数据默认包基本不行可解释性高能打出每个字符的相似度低输出就是黑匣子结论很清晰作为教学和 demo 项目模板匹配是主线因为它每一分准确率都能追溯到具体的模板和相似度打分Tesseract 适合当兜底——当模板匹配的最高分低于 0.6 时把这块图丢给 OCR 试一次两个结果都不理想就返回低置信度标记由人工复核。# recognize.py —— 模板匹配打分 import cv2 import numpy as np def match_char(char_img, templates, min_score0.45): templates: {标签: 模板图}返回 (最佳标签, 分数) best_label, best_score ?, 0.0 target cv2.resize(char_img, (20, 34), interpolationcv2.INTER_AREA) for label, tpl in templates.items(): # 尺寸必须严格对齐matchTemplate 不做自动缩放 t cv2.resize(tpl, (target.shape[1], target.shape[0])) score cv2.matchTemplate(target, t, cv2.TM_CCOEFF_NORMED).max() if score best_score: best_label, best_score label, score if best_score min_score: return ?, best_score return best_label, best_scoreTM_CCOEFF_NORMED是这几个匹配方法里最适合二值字符图的它做的是归一化相关系数对整体亮度偏移不敏感。用TM_SQDIFF会得到一个距离值越小越好方向跟其他方法相反混用的时候极易写错判断条件——这是模板匹配代码里最常见的 bug 之一。min_score0.45这个阈值不要设太高。二值字符图经过缩放、去边框、抗锯齿之后同一个字符两次采样之间的相关系数掉到 0.6 都很正常。设成 0.8 会导致大量返回?看起来像「识别不出来」实际是阈值过严。4.4 模板库怎么造别拿截图凑数模板库质量直接决定上限。我的做法是从训练图里批量导出分割后的字符小图人工挑出每类最干净的 2 到 3 个统一归一化到 20×34存成data/templates/A/01.png这种路径。# tools/build_templates.py —— 从标注好的字符图归集模板 import os import cv2 SRC data/labeled # 结构data/labeled/A/a_001.png DST data/templates TARGET (20, 34) for label in os.listdir(SRC): src_dir os.path.join(SRC, label) if not os.path.isdir(src_dir): continue dst_dir os.path.join(DST, label) os.makedirs(dst_dir, exist_okTrue) for i, name in enumerate(sorted(os.listdir(src_dir))[:3]): img cv2.imread(os.path.join(src_dir, name), cv2.IMREAD_GRAYSCALE) if img is None: continue img cv2.resize(img, TARGET, interpolationcv2.INTER_AREA) _, img cv2.threshold(img, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) cv2.imwrite(os.path.join(dst_dir, f{i:02d}.png), img) print(模板库构建完成检查每类是否都有图)每类只留 3 张是有意的。模板多了匹配时的循环次数线性增长而且相似模板之间会互相抢分导致匹配结果在两类之间反复横跳。3 张覆盖「清晰正体」「轻微偏斜」「略暗」三种状态性价比最高。5. 准确率卡在 60% 时的排查清单五类翻车现场与对应改法调这个项目最耗时间的不是写代码是「明明每一步都写了为什么结果还是错」。下面五条是我在不同数据集上反复撞见的几乎覆盖了 60% 准确率这个平台期的主要成因。排查顺序建议按编号来因为它对应的是流水线的执行顺序越靠前的环节出错后面的现象越离谱。5.1 定位阶段的假阳性与漏检第一条掩膜看着很干净候选框却框住了整块车身广告。现象是画出来的候选框尺寸是车牌的十几倍长宽比却恰好在合理区间。原因几乎总是闭运算核过大——MORPH_K_RATIO设成 20 的时候横向核宽在高分辨率图上能到 90 像素蓝色车身和蓝牌被缝合成了一个连通域。解决方法是把比例调回 40 以上同时把填充率判据FILL_MIN从 0.5 提到 0.6不规则的大色块填充率天然偏低这一刀能砍掉大部分广告牌。第二条所有候选框都被过滤掉了返回空列表。现象是pick_candidates输出长度为零但掩膜里明明有清晰的白色车牌块。原因通常是面积下限设得太死。CONTOUR_MIN_AREA 800是在 1000 像素长边的前提下定的如果你的图被压到了 640 长边实际车牌面积可能只有 300 多全被滤掉了。解决办法是让面积阈值跟图尺寸联动min_area max(200, int(800 * (img.shape[1] / 1000.0) ** 2))按面积缩放是平方关系这一句很多人写成线性效果差一半。第三条夜间照片完全检不出车牌。现象是白天的图正常夜间图返回空。原因是V明度下限给了 80夜间车牌整体偏暗像素 V 值普遍在 40 到 70 之间全部落在阈值外。这里没有万能解我的做法是加一层自适应先算全图 V 通道的中位数如果低于 80就把PLATE_HSV_HIGH/LOW的 V 下限压到中位数的 0.6 倍再跑一次。代价是夜间假阳性会上升所以夜间场景下应该把候选数量上限提高让后面的模板匹配打分来做最终裁决。5.2 分割与识别阶段的翻车记录第四条字符分割出来是 6 块每次都是中间某两个字符粘在一起。现象是分割数量不对且粘连位置不固定。原因是二值化采用全局 Otsu而车牌中部往往有光照不均比如树荫、反光条导致局部区域的阈值过高、字符笔画膨胀。解决方法是改用自适应阈值cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 15, 5)块大小 15 是奇数且要小于字符宽度常数 5 控制松紧。注意自适应阈值对噪声更敏感前置的高斯模糊不能省。第五条所有字符都匹配成了同一个标签分数还都在 0.9 以上。这个现象最迷惑人因为分数高得不像话。原因是模板图加载时用了cv2.imread(..., cv2.IMREAD_GRAYSCALE)但待匹配的字符图是二值图两者的直方图分布完全不同更常见的情况是模板目录遍历时把文件名当标签而文件名排序后所有图被归到了一类。排查方法很直接把templates字典的键打印出来看是不是只有十几个键、每个键只有一张图。解决方法是模板加载时按「子目录名 标签」组织并且加载后立刻统计每类的图片数量。这五条里第一、二、四条属于「参数随尺度漂移」第五条属于「数据组织错误」第三条属于「场景先验缺失」。共同点是它们的表现都出现在下游环节而根因在上游。所以我习惯在debug/里按序号存图出问题时从01开始往后翻通常翻到第三张就能定位问题出在哪一段。6. 交付前的最后两步50 张图的批量评测与 Tkinter 单文件封装单个样例跑通不难难的是证明它在你的数据上真的能用。我的习惯是准备 50 张图覆盖白天、夜间、倾斜、脏污、远距离五个场景每类 10 张然后跑一遍批量评测把准确率和耗时都记下来。这个数字比任何口头描述都有说服力也是后续调参的基线。# evaluate.py —— 批量评测输出整牌准确率与平均耗时 import os import time import cv2 from run import run from locate import build_plate_mask, pick_candidates, deskew_plate from segment import binarize_plate, split_by_projection ROOT data/test # 文件名即真值如 A12345_01.jpg def evaluate(): total, hit, cost 0, 0, 0.0 for name in sorted(os.listdir(ROOT)): gt name.split(_)[0] t0 time.time() img, _ run(os.path.join(ROOT, name)) mask build_plate_mask(img) boxes pick_candidates(mask, img, topk1) if not boxes: total 1 continue plate deskew_plate(img, boxes[0]) binary binarize_plate(plate) chars split_by_projection(binary) # 这里只统计能否切出 7 个字符整牌比对交给识别模块 if len(chars) 7: hit 1 total 1 cost time.time() - t0 print(f样本 {total}分割正确 {hit} f分割率 {hit / total:.1%}平均耗时 {cost / total * 1000:.1f} ms) if __name__ __main__: evaluate()评测脚本里我只统计分割成功率没有直接统计整牌识别率这是刻意的分层分割和识别是两个独立的失效点混在一起看数字你永远不知道提升该往哪投。先看分割率低于 85% 就专心修分割分割率上去了整牌识别率还是上不去才回去查模板库。界面部分用 Tkinter 就够一个按钮选图、一块画布显示结果、一个文本框输出号码控制在 80 行以内。别用 PyQt这套 demo 的体量撑不起那个依赖。提示GUI 里所有重活都要放到子线程否则点完按钮界面会假死用户以为程序崩了。用threading.Thread(target..., daemonTrue)起一个线程结果通过queue.Queue回传主线程再刷新控件Tkinter 的控件更新必须在主线程里做。有一条我吃过亏的习惯每次改完参数先跑评测脚本不要只拿一张图试。单张图的成功会让你产生「改对了」的错觉而评测脚本会用 50 张图告诉你这次改动是提升了 4 个点还是把夜间样本全废了。从那以后我每次动config.py里的任何一个数都强制走一遍批量评测再提交哪怕只是把模糊核从 5 改成 3。希望这套拆法帮到你少在定位和分割之间来回怀疑。本文还有配套的精品资源点击获取