去年我拿到一个挺典型的项目需求:某个园区出入口想做一个本地化的车牌识别小工具,不用云端、不用专用道闸设备,摄像头就用现成的网络摄像头,电脑是普通办公机。一开始我也觉得这不就是OCR套个壳嘛,真做起来才发现,从图像里定位到一块车牌、把字符一个个切出来、再准确认出汉字和字母数字,每一步都有不少门道。
这篇就把我基于 Python + OpenCV 搭车牌识别系统,再加 PyQt5 做可视化界面的完整过程拆开讲。内容包括整个工作流程如何设计、车牌定位与字符分割识别的具体实现、界面集成时那些文档里不会写的问题,以及实测中遇到的意外情况和调优思路。项目麻雀虽小五脏俱全,非常适合想练手计算机视觉工程化的朋友参考。
1. 先拆解整个系统:车牌识别为什么不是"一个识别模型"的事
1.1 从一张图到一行车牌号,中间隔了四步
很多人以为车牌识别就是拿模型"看"一下图片,然后直接吐出结果。实际上完整的流程是:图像采集 → 车牌定位 → 车牌区域预处理 → 字符分割 → 字符识别 → 结果输出。这里面只有最后一步是"识别",前面每一步的输出质量都直接决定最后结果准不准。我打个比方,这就像一个流水线车间,前道工序如果切出的零件都是歪的,后道工序再厉害的机器也装不出合格产品。
具体到我这个项目,流程是这样的:
- 输入:单张图片或者摄像头的一帧画面;
- 定位:在整幅图中找到车牌所在的矩形区域;
- 预处理:把车牌区域转正、去噪、增强对比度,统一尺寸;
- 分割:把"某A12345"这样一串字符逐个切出来;
- 识别:对每个字符做分类,输出汉字、字母、数字;
- 后处理:按车牌编码规则校验结果,比如第一位必须是汉字、第二位必须是字母,最后把七个字符拼成完整车牌号。
很多人会问,为什么不用现成的商用SDK,或者干脆上一个大模型?这个问题我下面单独说。
1.2 技术选型:Python + OpenCV 在这个场景下的优势
市面上的方案大概有三类。第一类是商业 API,识别率高但按调用次数计费,且图片要传出去,很多内部系统不允许;第二类是纯深度学习方案,用目标检测框架定位车牌、再用识别网络认字符,精度高但训练成本和部署成本都不低,而且需要不少标注数据;第三类就是本文这种 OpenCV 传统图像处理 + 轻量分类器的路线。
我最终选了第三类,原因很直接:项目环境是普通办公电脑,CPU 为主,没有 GPU;需求是"本地方案、离线运行、结果可用即可",不追求极端精度;开发周期短,希望能在两周内跑通一个能演示的版本。OpenCV 做定位和分割非常成熟,字符识别阶段可以用模板匹配,也可以接一个很小的 CNN,两三百行代码就能搭起来,而且每步中间结果都能可视化,调试直观。
我的整体模块划分如下表:
| 模块 | 输入 | 输出 | 主要技术 |
|---|---|---|---|
| 采集模块 | 图片文件 / 摄像头帧 | BGR 图像 | OpenCV VideoCapture |
| 定位模块 | 整幅图像 | 车牌 ROI 区域 | HSV 颜色筛选 + 形态学 + 轮廓分析 |
| 预处理模块 | 车牌 ROI | 归一化二值图像 | 透视校正、灰度化、OTSU 二值化 |
| 分割模块 | 二值车牌图 | 单个字符图像列表 | 垂直投影法 + 连通域校验 |
| 识别模块 | 字符图像 | 单个字符类别 | 模板匹配 / 轻量 CNN |
| 界面模块 | 用户操作 | 可视化结果与车牌号 | PyQt5 |
1.3 输入输出的边界条件要先想清楚
这个项目最容易犯的错是"什么图都往里面扔"。你真的去做就会发现,不同场景下车牌尺寸、角度、光照差异极大。我自己在项目一开始就明确了边界:针对园区出入口场景,车辆正面或轻微侧面 45 度以内、白天自然光、车距摄像头 3 到 8 米。这个边界定下来之后,后面所有参数调整都有了基准,而不是盲目追求"所有情况都能识别"。
明确边界还有一个好处:后续给用户讲解时,对方能理解哪些情况识别不好是正常的。比如大逆光、车牌严重遮挡、夜间无补光,这些条件下任何方案都会打折。
2. 车牌定位:整套系统里最考验"图像直觉"的一步
2.1 为什么选 HSV 颜色空间而不是直接用 RGB
车牌定位的第一步,是告诉程序"在哪找车牌"。中国最常见的蓝底白字车牌,最显著的特征就是底色是蓝色的。很多人第一反应是"判断像素是不是蓝色,RGB 里 B 大于 R 就行",实际一跑就翻车,因为 RGB 对光照非常敏感,阳光下的白色车身反光都可能是"蓝色",阴影里的蓝色车牌反而偏灰。
我换成 HSV 颜色空间来做这件事。HSV 把色相(H)、饱和度(S)、明度(V)分开,H 通道表示颜色本质,S 和 V 受光照影响但没有 H 那么大。OpenCV 中默认的 HSV 范围是 H 0~180,S 0~255,V 0~255,蓝色车牌的 H 值大概在 100~130 这个区间。我给蓝色车牌定的筛选范围是这样的:
import cv2 import numpy as np def find_plate_by_color(image): hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) # 深蓝和浅蓝都覆盖一些,S/V下限给低一点以兼容阴影 lower_blue = np.array([95, 50, 40]) upper_blue = np.array([135, 255, 255]) mask = cv2.inRange(hsv, lower_blue, upper_blue) return maskS 下限只设到 50、V 下限只设到 40,是因为车牌在侧面受光时蓝色会变淡,阈值太严格会把整个车牌区域筛掉。这里要特别提醒,HSV 的 H 范围不要抓得太宽,否则蓝色车身、蓝色车衣都会进来。
2.2 形态学操作:把零零散散的蓝色像素"焊"成一个整体
颜色筛选出来的 mask 往往是斑斑点点的,字符是白色的,蓝色区域被一个个字符和铆钉孔隔开。不做处理直接找轮廓,可能找到几十个小碎块,而不是一块完整车牌。我用的是闭运算,也就是先膨胀再腐蚀,作用是把邻近的蓝色区域连接起来,填平字符造成的空洞。
def refine_mask(mask): kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9)) closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 再做一些小区域的清理 closed = cv2.morphologyEx(closed, cv2.MORPH_OPEN, kernel) return closed这里 kernel 为什么用 9x9 而不是 3x3?因为字符本身的宽度就有几十到上百像素,如果用太小的核,闭运算连接不起来;如果太大,容易把车身其他蓝色区域也连进来。我一开始用 5x5,结果车牌区域经常被掏空;改到 9x9 之后效果明显稳定。这是第一次调参时最直观的感受:形态学核的大小要根据目标物体在图像中的实际尺寸来定,不能凭感觉乱拍。
2.3 边缘检测和颜色筛选取交集,误检率能降很多
只靠颜色,还是会有假阳性。比如蓝色的门牌、蓝色指示牌、车身贴纸。我的做法是颜色检测之外,再加一个边缘检测作为"双重保险":车牌区域有大量字符和边框,纹理非常丰富,边缘密度一定显著高于大面积纯色的蓝色物体。具体实现是先对原图做 Canny 边缘检测,然后对边缘图做闭运算,再和颜色 mask 做按位与,最后再找轮廓。
def locate_plate(image): mask_color = find_plate_by_color(image) gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) edges = cv2.Canny(gray, 100, 200) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7)) edges_dense = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel) combined = cv2.bitwise_and(mask_color, edges_dense) combined = cv2.morphologyEx(combined, cv2.MORPH_CLOSE, kernel) contours, _ = cv2.findContours(combined, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) return combined, contours这个交集策略在实测中把误检率从大约 30% 压到了 5% 以内。原理其实不复杂:单通道信号容易受干扰,两个互相独立的信号(颜色 + 纹理密度)同时成立时,置信度会高很多。
2.4 候选区域筛选:用宽高比和面积排除干扰
找到轮廓之后,还要用几何特征筛一遍。标准蓝牌的尺寸比例是 440:140,约等于 3.14:1。实际拍摄时车牌可能有轻微倾斜,我会把宽高比范围放宽到 1.5~4.0。面积也设一个下限,太小的轮廓说明要么车牌太远,要么是噪声。
def filter_contours(contours, image_shape): candidates = [] h_img, w_img = image_shape[:2] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) if w < 50 or h < 10: # 太小直接忽略 continue ratio = w / h area_ratio = (w * h) / (w_img * h_img) if 1.5 < ratio < 4.0 and 0.01 < area_ratio < 0.3: candidates.append((x, y, w, h)) return candidates在这一步我有过一次印象深刻的事故:某个候选区域宽高比、面积全合格,结果是一辆蓝色货车车厢侧面的反光贴条。这类误检单纯靠几何特征躲不掉,后来是靠"车牌区域内部应该有明显的字符纹理"这个条件补刀才处理掉的。具体做法是检查该区域二值化后的白色像素比例是否在一定范围上。这个思路其实已经有点接近"验证车牌区域内是否存在离散字符块"了。
3. 字符分割:把一串字符干净地切开是门细活
3.1 定位到车牌后,先做透视校正
摄像头很少是正对着车牌拍的,多少会有点透视变形。如果不校正就直接分割,字符会是斜的,后续识别率大打折扣。校正方法不复杂:拿步骤 2 得到的候选区域外接矩形,用其中边缘直线拟合出一个更精确的四边形,再透视变换到标准尺寸。实操中更"省事"的做法是直接用cv2.minAreaRect找到轮廓的最小外接矩形,拿到中心点、尺寸和旋转角度,再用cv2.getRotationMatrix2D转正。
def deskew(plate_img): gray = cv2.cvtColor(plate_img, 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_img cnt = max(contours, key=cv2.contourArea) rect = cv2.minAreaRect(cnt) angle = rect[2] # OpenCV 中角度范围是 [-90, 0),需要换算 if angle < -45: angle = angle + 90 center = tuple(map(int, rect[0])) matrix = cv2.getRotationMatrix2D(center, angle, 1.0) result = cv2.warpAffine(plate_img, matrix, (plate_img.shape[1], plate_img.shape[0]), flags=cv2.INTER_CUBIC) return result这一步做过之后,倾斜太大导致的分割错位问题基本就消失了。不过我要提醒一下,如果车牌本身横向旋转超过 15 度,单靠这个校正还不够,需要更进一步用仿射变换映射到固定尺寸。
3.2 二值化:大津法在车牌场景下的表现
字符分割前,必须把灰度图转成二值图——背景是黑色,字符是白色,这样后面统计像素投影才干净。我用的是 OTSU 大津法,它会自动计算一个全局阈值,把前景和背景分得尽可能开。对车牌这种"蓝底白字"的高对比度图像,OTSU 多数情况表现很好,比人工指定 127 这类固定阈值靠谱得多。
但有一种情况会翻车:车牌区域整体偏暗或者有遮挡阴影时,OTSU 会把阴影区域一起判成背景,字符断开。我的补救方案是对灰度图先做直方图均衡化cv2.equalizeHist,让局部的明暗差异拉平之后再做 OTSU。注意这个操作只对车牌区域做,不要对整张图做,否则会放大背景噪声。
3.3 垂直投影法原理:数每一列的白色像素
二值化之后,分割字符的大杀器就是垂直投影法。原理极其朴素:统计二值图每一列上有多少个白色像素,得到一个数组。字符所在列因为笔画密集,像素数会明显多;字符之间的间隙列几乎没有白色像素。数组就呈现出一个"高峰—低谷—高峰"的波形,波峰是字符,波谷是间隙,沿着波谷切开就是字符边界。
def vertical_projection_split(binary): h, w = binary.shape col_sum = np.sum(binary // 255, axis=0) regions = [] in_char = False start = 0 for i in range(w): if col_sum[i] > 0 and not in_char: in_char = True start = i elif col_sum[i] == 0 and in_char: in_char = False regions.append((start, i)) if in_char: regions.append((start, w - 1)) return regions在标准 440 宽的归一化车牌图上跑这个函数,通常能切出 7 到 8 个区域——7 个字符之外,可能还会把边框或铆钉噪声也切进来。所以切完只是第一步,后面一定要做校验。
3.4 用宽度、比例和字符数量规则剔除噪声
切割结果里会混入噪声块。我的过滤规则是:
- 字符宽度应在平均宽度的一定范围以内,比如平均宽度的 0.3~1.6 倍;
- 高度应大于区域高度的 60%,太矮的小碎片直接扔掉;
- 字符间应有合理的间距,间距过大说明中间可能漏切了。
def validate_regions(regions, binary): h, w = binary.shape heights = [] for (s, e) in regions: region_width = e - s if region_width > w / 20: # 太窄的噪声过滤 heights.append(region_width) if not heights: return [] avg_w = np.mean(heights) valid = [] for (s, e) in regions: region_width = e - s if avg_w * 0.3 < region_width < avg_w * 1.6: valid.append((s, e)) return valid在项目后期,我还加了"字符数量优先"的启发式:中国蓝牌标准是 7 位(新能源是 8 位,且第二位字母 D 或 F),如果分割出的合法字符数量明显多于 8 或少于 7,就回溯调整阈值重新分割一次。这种"分割—校验—重分割"的回环是工业系统里很常见的做法,比一次性分割成功靠谱得多。
4. 字符识别:模板匹配打底,再谈要不要上 CNN
4.1 模板匹配的适用条件与实现
分割出的每个字符图像,尺寸不一,需要先统一 resize 到固定大小,比如 32x32,然后和模板库里的每一张样本做cv2.matchTemplate,取相似度最高的那个模板作为识别结果。
def match_char(char_img, templates): char_img = cv2.resize(char_img, (32, 32)) best_label = None best_score = -1 for label, template in templates.items(): # TM_CCOEFF_NORMED 对光照和对比度相对鲁棒 res = cv2.matchTemplate(char_img, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, _ = cv2.minMaxLoc(res) if max_val > best_score: best_score = max_val best_label = label return best_label, best_score问题在于模板匹配非常吃模板库的质量。汉字模板尤其麻烦,同一个"某"字,不同厂家的车牌字体差异很大,一个模板根本不够。我的做法是每个字符类别准备 3 到 5 个代表性模板,分别来自不同字体风格的车牌样本。这样匹配率能明显提升,但当遇到陌生字体时依然可能翻车。
4.2 模板匹配的致命伤:相似字和字体变体
模板匹配的相似度度量是像素级对比,对形近字的区分能力很差。数字"0"和字母"O"在模板匹配里几乎无法区分,因为它们的二值图像几乎一样,还有"8"和"B"、"1"和"I"、"2"和"Z"。这些字符在普通图像处理视角下就是同一种形状。所以模板匹配方案必须配合后处理规则兜底,不能让它裸奔。
4.3 换用轻量 CNN:26 万参数解决"形近字"难题
为了解决模板匹配的形近字问题,我在第二版里把识别模块换成了一个小型卷积网络。网络结构不复杂:输入 32x32 灰度图,两层卷积 + 池化,再接全连接层输出到类别数。类别是省份简称汉字(31 类)加上 24 个大写字母(去掉 I 和 O)+ 10 个数字,一共 65 类。
from tensorflow.keras import layers, models def build_cnn(num_classes=65): model = models.Sequential([ layers.Conv2D(32, (3, 3), activation='relu', input_shape=(32, 32, 1)), layers.MaxPooling2D((2, 2)), layers.Conv2D(64, (3, 3), activation='relu'), layers.MaxPooling2D((2, 2)), layers.Flatten(), layers.Dense(128, activation='relu'), layers.Dropout(0.5), layers.Dense(num_classes, activation='softmax') ]) model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy']) return model这个网络只有大约 26 万参数,在 CPU 上识别一张字符图大约 20 到 40 毫秒,整个车牌 7 个字符加起来不到 0.3 秒,完全能接受。训练数据用的是公开的车牌字符数据集,每个类别几百张,加上随机旋转、平移、加噪做数据增强。换成 CNN 之后,最直观的变化是"8"和"B"、"0"和"O"的错误率降下来了,因为 CNN 能学到局部的笔画结构差异,而模板匹配只能比对整体像素。
4.4 后处理校验:让规则兜底
不管用模板匹配还是 CNN,识别完之后一定要过一遍车牌编码规则。国内民用车牌的标准格式是:第一位是省级行政区简称汉字,第二位是发牌机关代码字母,第三到七位是字母和数字的混合。但注意字母 I 一般不使用,数字 0 和字母 O 容易冲突,实际车牌里 O 也很少见。我的后处理逻辑是:
- 第一位如果不是汉字,则强制检查最高概率的几个汉字候选,重选;
- 第二位如果不是大写字母,同理回退;
- 如果识别结果里出现字母 I,并且置信度低于阈值,优先替换为数字 1;
- 如果出现字母 O 且置信度较低,优先替换为数字 0;
- 整体置信度低于 0.7 的结果标记为"待人工确认"。
这个后处理看起来简单,实际能把最终整牌准确率往上拉三到五个百分点。个人体会是:识别系统最后拼的不是单个字符有多准,而是能不能把上下文约束用起来。
5. PyQt5 界面封装:把命令行算法变成别人能用的工具
5.1 界面布局应该如何规划
算法写完了,如果不做成界面,每次都得命令行里改文件路径跑脚本,给用户演示也很不方便。PyQt5 的好处是开发迅速、控件齐全、与 OpenCV 图像处理的集成顺手。我的界面设计就三块区域:左边是图像预览区,显示原始图像、定位结果和分割结果;右边是控制区,选择图片或者启动摄像头,识别结果实时显示在一个大号文本标签里;中间底部是一个日志区,输出每一步耗时。
这个布局是照着"用户最关心什么"设计的。预览让用户确认系统"看到"了什么,结果区给出最终答案,日志区方便调试。不需要做太花哨的皮肤,工具类软件核心是信息传递效率。
5.2 摄像头实时识别必须用多线程,否则界面秒卡死
把摄像头识别做成实时功能时,最容易犯的错是直接在 UI 线程里while True读帧。这样做的后果是界面完全卡住,用户拖一下窗口都会无响应。根本原因是 OpenCV 的VideoCapture.read()是阻塞的,每一帧还要跑完整识别流程,占用很大。
正确的做法是把摄像头循环放到QThread子线程里,通过信号把图像传回主线程刷新界面。这里要特别留意 Qt 的一个约束:子线程不能直接操作 UI 控件,必须用pyqtSignal发射信号,主线程的槽函数里再更新 QLabel。
from PyQt5.QtCore import QThread, pyqtSignal from PyQt5.QtGui import QImage class VideoThread(QThread): frame_signal = pyqtSignal(QImage) def run(self): cap = cv2.VideoCapture(0) while not self.isInterruptionRequested(): ret, frame = cap.read() if not ret: continue result_img, plate_text = self.process_frame(frame) rgb = cv2.cvtColor(result_img, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888) self.frame_signal.emit(qimg) self.text_signal.emit(plate_text) cap.release()关于QImage这里有一个我从第一版就踩坑的细节:OpenCV 的 BGR 顺序和 QImage 默认期望的 RGB 顺序不同,不转换的话显示出来红蓝互换。要么先cv2.cvtColor转成 RGB,要么在构造 QImage 时用Format_RGB888但保证数据已经是 RGB。用Format_BGR888也可以,但需要确认你安装的 PyQt 版本支持。我建议统一先转成 RGB,兼容性最好。
5.3 信号槽传参:图片和识别结果如何安全送达主线程
pyqtSignal传参要注意类型限制。传 QImage、str、int 这些内置或 Qt 类型都没问题;但如果想传自定义的复杂结构体,最好转成字典或元组再发射。我在项目里是定义了两个信号,一个传图像,一个传识别结果文本:
class VideoThread(QThread): frame_signal = pyqtSignal(QImage) text_signal = pyqtSignal(str)主线程里对应的槽函数再分别刷新 QLabel 和结果标签。这里有一个很值得说的习惯:即使只有两个信号,也建议把信号定义在独立的类里,不要东一个西一个,后面扩展"抓拍保存""车牌号入库"等功能时,信号管理会清晰很多。
5.4 打包发布时那几个坑
界面做完了需要给别人用,就得打包。PyInstaller 打包 PyQt5 + OpenCV 程序常见的问题:
- OpenCV 的 DLL 有时不被自动收集,运行时报找不到
videoio相关模块,需要在 spec 文件里把cv2的二进制目录加进去; - 模板文件或模型权重文件不会被打包进 exe,需要用
--add-data把资源目录带上,代码里用sys._MEIPASS判断运行时资源路径; - PyQt5 默认打包出来很大,我建议用
--exclude-module排除用不到的 Qt 模块,比如 QtWebEngine、QtMultimedia,体积能从 200MB 降到 80MB 左右。
我最终打出来的工具体积 85MB 左右,在普通办公电脑上运行流畅,说明这个规模的项目打包难度完全可控,不要被网上"PyInstaller 打包就一定报错"的说法吓住。
6. 实测数据与踩坑记录:光照、绿色车牌和各种意外
6.1 车载测试环境与实测准确率
我在园区门口实测了两周,统计了大约 300 张图片,覆盖白天、傍晚、阴天三种条件。图片模式整体车牌识别准确率在 92% 左右,其中定位成功率达到 97%,定位成功的前提下字符识别准确率约 95%。单张图片处理耗时为 0.3 到 0.5 秒,摄像头实时模式下大概 12 到 15 帧每秒,在办公级 CPU 上跑是能接受的。
识别失败的情况分布很有意思:40% 是光照导致定位偏移,30% 是倾斜角度过大导致分割错位,20% 是形近字误判,10% 是车牌污损。这说明绝大多数问题出在定位和分割,不在识别环节本身。
6.2 光照问题:最隐蔽也最让人抓狂的敌人
车牌识别最大的敌人是光照,不是算法不够高级。大逆光下车牌区域整体发暗,颜色检测很难框准;正午强光下车身反光,蓝色车牌的饱和度被压低到接近白色;雨后的地面反光会在地面形成车牌的倒影,被误检为候选区域。
我的应对措施分三层:
- 颜色检测的 HSV 阈值不要定得太死,S 和 V 的下限放低一些,让阴影中的车牌也能被召回;
- 定位阶段用边缘密度与颜色取交集,减少地面倒影这类假阳性;
- 预处理阶段加入直方图均衡化,让暗部细节露出来再二值化。
这三层下来,光照导致的问题大约减少了一半。但夜间没有补光的场景我直接没有强求,那个场景需要硬件配合,不是纯算法能解决的。
6.3 新能源绿牌与黄牌:阈值如何扩展
项目做到一半,用户提出新能源车牌也要识别。新能源车牌是渐变绿底 + 黑字,而且是 8 位字符,第二位是字母 D 或 F。这个改动牵扯到定位和分割两层。
定位层需要在 HSV 颜色筛选里增加绿色的范围。新能源绿牌的 H 值大约在 35~85,S 值 50~255,V 值 40~255,和蓝色车牌的范围分开处理,两套 mask 各自生成候选区。分割层需要调整字符数量校验:7 位数是普通蓝牌,8 位数是新能源,且第二位 D/F 是固定规则。
黄牌(大型车)则是黄底黑字,H 值大概在 20~35,同样加一套即可。最终代码里我维护了一个"车牌类型字典",每种类型有独立的颜色范围、字符位数、字符颜色,这样扩展起来非常清晰。
6.4 性能优化:让实时画面从"能看到"变成"跟得上"
性能优化的关键是减少无效计算。我最先做的一件事是"降采样 + 缩小搜索范围":摄像头帧率保持 640x480,不放大更大分辨率;在画面中间区域优先检测——出入口场景中车牌大概率出现在画面水平中线附近,这个先验直接让漏检率没变但速度提升了约 30%。
第二个优化是"两步式定位":先降低分辨率快速找到候选区域,确认有车牌后再在原图对应区域做精确分割。这个方法对检测类任务都适用,本质是金字塔思路的简化版。
6.5 常见问题对照表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 蓝色区域连成一片,找不到车牌 | 形态学核太大,车身蓝色与车牌合并 | 缩小 kernel 到 7x7 或 5x5 |
| 蓝牌框住了但里面字符不全 | 颜色阈值范围偏窄 | 放宽 S/V 下限,尝试 H 范围 100~135 |
| 分割出 9 个或 10 个区域 | 边框或铆钉被误判为字符 | 加强宽度/高度比例校验,结合连通域过滤 |
| 蓝牌识别成"B"或"8" | 字符图像分辨率太低 | 字符 resize 前先放大到统一高度,用最近邻插值 |
| 界面实时画面卡顿 | 识别放在主线程 | 改用 QThread + 信号槽 |
| 打包后运行提示缺少 DLL | PyInstaller 未收集 opencv 依赖 | 在 spec 中手动添加 opencv 的 DLL 目录 |
7. 一点后续想法:这套流程还能往哪些方向延伸
这个项目做到这里,已经是一个非常完整的"传统图像处理 + 深度学习小模型"的混合体。回头看我自己的体会是,OpenCV 方案不是过时的玩具,在场景边界清晰、算力有限、想快速落地的情况下,它依然是非常划算的路线。如果以后想进一步扩展,我会优先考虑两个方向:一是把定位模块也升级成轻量目标检测网络,解决极端角度下的定位问题;二是接入车牌颜色识别,同时对车辆品牌、车身颜色做记录,这在停车场管理场景里很实用。
如果让我给正在做类似项目的朋友一句建议:先别急着上网络模型,老老实实把定位和分割这两个基础环节调明白,把中间过程可视化,你会对图像处理有非常深的理解。这套思路不仅限于车牌识别,门禁卡号、仪表盘读数、货物标签识别,背后的逻辑都是同一套。