ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

YOLOv5车牌识别毕业设计落地:B/S架构与识别链路排错

YOLOv5车牌识别毕业设计落地:B/S架构与识别链路排错

简介:面向深度学习、计算机视觉方向本科毕业设计,这份文档提供一篇以YOLOv5为核心的车牌识别系统论文参考,适合需要确定选题、梳理技术路线和搭建论文框架的学生。压缩包共1个docx文件,约1.14MB,内容为本科毕业(设计)论文,含题目、摘要、关键词、目录、绪论及研究背景与意义等部分。论文从传统人工识别在数据量和复杂环境下不足切入,讨论深度学习实时检测的优势,技术部分覆盖图像采集与预处理、数据集构建、YOLOv5模型训练、车牌定位、字符分割和字符识别,并提及Python、OpenCV等实现工具。读者可对照其章节组织、摘要关键词写法、系统模块划分与实验流程整理自己的毕业设计,也可用于查漏补缺。已有1006人学习,适合作为毕业设计前期的结构参考与技术路线归纳材料。

1. 从一份 YOLOv5 深度学习车牌识别毕业设计论文,到能跑起来的系统

答辩前一晚把一份基于 YOLOv5 的深度学习车牌识别毕业设计论文摊开,最容易出现的误判是:目录里写着登录、后台、车牌识别、统计分析、车牌地图、车牌管理六个页面,读起来头头是道,真去跑却发现一行可执行代码都没有。这类文档型资源给的是设计骨架和需求边界,不是双击即用的工程。它真正解决的问题是“我要做一个 YOLOv5 车牌识别系统,但不知道拆成哪些模块、数据库建几张表、模型怎么接后端、识别结果往哪儿落”。适合三类人:赶毕业设计需要一套完整闭环的学生、要交课程设计但没做过目标检测落地的开发者、以及第一次把检测模型塞进 B/S 系统的人。下面按论文目录一路拆到可运行代码。

2. 架构拆解:把论文目录翻成一套能跑的 B/S 五层骨架

论文里写的“系统总体设计”只有一段设计思想和一张功能模块图,真要落地,得先把这张图翻译成物理分层。我一般按“表现层—接口层—业务层—算法层—数据层”切,切完再回头看论文的功能需求,六项功能刚好一一对应,不多不少。

2.1 技术选型:为什么是 YOLOv5 + Python + MySQL 这套组合

检测模型选 YOLOv5,核心原因是单阶段检测的推理速度和工程友好度。车牌识别是典型的“视频流里抓一帧、必须几十毫秒内出结果”的场景,两阶段检测器精度或许更高,但候选框生成那一步的耗时在 CPU 环境下很难接受。YOLOv5 把检测当成回归问题一次出框,配合 640 分辨率输入,在中端笔记本上单图推理能做到百毫秒级,答辩现场用 CPU 演示也不会太难堪。另一个隐性优势是它的工程封装:torch.hub.load一行就能把自定义权重挂上来,前后处理都替你写好了,比从零写推理脚本省事得多。

后端框架论文没有写死,只说了 B/S 结构和 Python 开发。这种体量的系统,我一般直接用 Flask,理由是路由和模板足够、蓝图能把六个页面拆干净、和 OpenCV、PyTorch 同进程调用不用跨服务传图。如果后面要接异步任务队列再换 FastAPI 也不迟,但毕业设计阶段引入 Celery 只会给自己加戏。数据库按论文的选择用 MySQL,开源免费这条理由在纸面上成立,实际开发里更实在的是它和 Python 的驱动成熟、建表语句简单、Navicat 这类工具能把表结构导出来直接贴进论文附录。

图像处理走 OpenCV,负责读图、颜色空间转换、二值化、透视变换这些传统算子。可视化的统计分析和车牌地图用 ECharts,饼图柱状图加中国地图散点,前端不写复杂逻辑。整套依赖的关键版本要提前钉死:PyTorch 和 torchvision 版本必须匹配,opencv-python装 4.x 系列,MySQL 驱动用pymysql或mysqlclient都行但连接串字符集要写对。环境这一关是血泪经验里翻车率最高的地方,后面排查章节会专门讲。

2.2 目录结构:六个页面和一条识别链路放进同一棵树

论文的功能模块图只有六个方框,落到磁盘上要能一眼看出“哪个文件改哪个功能”。我习惯按职责分而不是按页面分,因为识别链路是横跨多个页面的公共能力,按页面分会导致同一段推理代码复制三份。下面这棵树是这套系统跑通后的样子,可以直接照着建:

plate-system/ ├── app.py # 应用入口,注册蓝图、初始化数据库连接 ├── config.py # 数据库、上传目录、模型路径等配置集中在这里 ├── requirements.txt ├── blueprints/ │ ├── auth.py # 登录、登出、会话校验 │ ├── admin.py # 后台首页、用户管理 │ ├── plate.py # 车牌识别上传接口、车牌管理列表 │ └── stats.py # 统计分析、车牌地图的数据接口 ├── core/ │ ├── detector.py # YOLOv5 权重加载与检测封装 │ ├── recognizer.py # 车牌矫正 + 字符识别 │ └── preprocess.py # 尺寸归一、去噪、增强 ├── models/ # 业务层,封装对 MySQL 的读写 │ ├── user.py │ └── plate_record.py ├── static/ │ ├── uploads/ # 上传原图,按日期分子目录 │ ├── js/china.js # 地图 GeoJSON,必须本地化 │ └── css/ ├── templates/ # 六个页面的 Jinja2 模板 │ ├── login.html │ ├── admin_index.html │ ├── plate_recognize.html │ ├── plate_list.html │ ├── stats.html │ └── plate_map.html ├── weights/ │ ├── plate_best.pt # 训练好的检测权重 │ └── charset.txt # 字符识别字典,顺序必须与训练时一致 └── sql/init.sql # 建库建表脚本

core目录是这套系统的算法层,只依赖图像和权重,不认识数据库也不认识 Flask,好处是可以单独拿一张图跑通再往系统里塞,调试成本低一个数量级。models是纯数据访问,SQL 都收在这里,页面和接口都不直接拼 SQL。blueprints只做参数校验和编排,把“接收文件→调 core→写 models→返回 JSON”串起来,单个文件不会超过两百行。weights目录要单独提一句:很多论文交付时权重文件丢了,只有训练脚本,这种情况要么按 4.4 节自己训,要么找同分布的开源预训练检测权重先跑通链路。

2.3 接口清单与请求链路:一次上传从浏览器走到数据库

六个页面背后真正干活的接口不超过十个,先把这张表列清楚,再写代码就不会一边写一边改。论文里没写接口,但“统计分析按不同维度统计”“车牌地图按数量分布”这些描述,本质上就是在暗示聚合接口的存在。

接口路径方法对应页面作用
/auth/loginPOST登录页校验账号密码,写 session
/admin/indexGET后台首页返回用户数、车牌总数、系统时间
/plate/recognizePOST车牌识别页接收图片,返回车牌号、颜色、置信度
/plate/listGET车牌管理页分页返回识别记录
/stats/colorGET统计分析页按蓝牌绿牌黄牌聚合数量
/stats/trendGET统计分析页按日期聚合近三十天识别量
/stats/mapGET车牌地图页按归属省份聚合数量

链路是这样的:浏览器<form enctype="multipart/form-data">提交图片,Flask 从request.files取到文件对象,存盘的同时读成 numpy 数组交给core.detector出检测框,裁出来的车牌小图进core.recognizer出字符,最后把车牌号、颜色、置信度、图片相对路径、归属地一起写进plate_record表。整条链路上任何一步失败都要有明确的 HTTP 状态码和日志,否则答辩演示时页面只会给你一个白屏。

# app.py —— 入口只做三件事:装配配置、注册蓝图、启动 from flask import Flask from blueprints.auth import auth_bp from blueprints.admin import admin_bp from blueprints.plate import plate_bp from blueprints.stats import stats_bp from core.detector import load_model def create_app(): app = Flask(__name__) app.config.from_pyfile('config.py') # 权重只加载一次,挂在 app 上全局复用,别在请求里反复初始化 app.yolo = load_model(app.config['PLATE_WEIGHT'], device=app.config['DEVICE']) for bp in (auth_bp, admin_bp, plate_bp, stats_bp): app.register_blueprint(bp) return app if __name__ == '__main__': create_app().run(host='0.0.0.0', port=5000, debug=False)

这段的关键在app.yolo这一行。模型加载要读几百兆权重,耗时以秒计,放在请求函数里每次上传都重新加载,页面响应会从几百毫秒变成好几秒,看起来像“识别很慢”,实际是初始化开销。挂在 app 对象上进程内复用,是这类单机演示系统最省事的做法。debug=False也是刻意设的,Flask 的调试模式开着会启动重载器,模型被加载两遍,显存和内存都会翻倍,我在本地调试时踩过这个坑。

3. 数据层落地:user、news 两张表和车牌业务表的字段怎么定

论文里给了 E-R 图和两张表结构,但user表偏通用后台管理、news表是内容表,和车牌业务本身关系不大。真写代码时得在它们之外补一张核心业务表,否则识别结果无处可落,“统计分析”和“车牌地图”两个页面就成了无源之水。

3.1 从 E-R 图到能执行的建表 SQL

论文的user表字段是 id、用户名、密码、姓名、性别、出生年月、QQ、邮箱、电话、内容、添加人,这套结构能覆盖用户管理页面。news表是 id、title、note、creater、node,属于通用的信息发布表,功能上可以留着做系统公告。两张表都要补create_time,因为后台首页要显示“当前系统收录的用户数”和“近七天新增”,没有时间字段这个统计做不出来。

-- sql/init.sql —— 建库时字符集必须一次定对,后面改很麻烦 CREATE DATABASE IF NOT EXISTS plate_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE plate_db; CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `yonghuming` varchar(50) NOT NULL COMMENT '登录名', `mima` varchar(64) NOT NULL COMMENT 'MD5 加盐后的密文', `salt` varchar(16) NOT NULL COMMENT '每个用户独立盐值', `xingming` varchar(50) DEFAULT NULL, `xingbie` varchar(10) DEFAULT NULL, `chushengnianyue` varchar(20) DEFAULT NULL, `QQ` varchar(20) DEFAULT NULL, `youxiang` varchar(50) DEFAULT NULL, `dianhua` varchar(20) DEFAULT NULL, `neirong` longtext, `tianjiaren` varchar(50) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`yonghuming`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

相比论文原表,这里多了salt字段和create_time,mima长度从 50 提到 64 是因为要存十六进制摘要。uk_username唯一索引是必须的,否则两个同名用户可以同时注册,登录时fetchone拿到哪个全看数据库心情。utf8mb4而不是utf8,是因为后者在 MySQL 里只有三字节,存车牌归属地这类中文没问题,但一旦用户输入带 emoji 的备注就会截断报错,属于典型的“上线之后才发现”的问题。

业务表要同时支撑识别页、管理页、统计页和地图页,字段拆成识别结果和来源信息两组:

CREATE TABLE `plate_record` ( `id` int(11) NOT NULL AUTO_INCREMENT, `plate_no` varchar(16) NOT NULL COMMENT '识别出的车牌号,如 苏A12345', `plate_color` varchar(8) NOT NULL DEFAULT 'blue' COMMENT 'blue/green/yellow', `confidence` decimal(5,4) DEFAULT NULL COMMENT '检测框置信度', `image_path` varchar(255) DEFAULT NULL COMMENT '原图相对路径', `province` varchar(16) DEFAULT NULL COMMENT '归属省份,用于地图聚合', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `user_id` int(11) DEFAULT NULL COMMENT '操作人', PRIMARY KEY (`id`), KEY `idx_create_time` (`create_time`), KEY `idx_province` (`province`), KEY `idx_plate_no` (`plate_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

三个索引对应三类查询:管理页按时间倒序分页要idx_create_time,地图页GROUP BY province要idx_province,管理页的“按车牌号搜索”要idx_plate_no。confidence用decimal(5,4)不用 float,是为了统计页做“置信度区间分布”时比较结果稳定,浮点数的相等判断在 SQL 里容易出玄学。province是冗余字段,从plate_no第一位就能推出来,但入库时算好存下来,比查询时用SUBSTRING现算再分组要快得多,这个取舍在数据量上万之后差距非常明显。

3.2 密码为什么用 MD5 加盐,以及登录态怎么维护

论文明确写了“用户名密码采用 MD5 加密”,这条要保留,但裸 MD5 有个致命问题:同一个密码在库里永远是同一个摘要,彩虹表一撞一个准。加盐之后同样的明文在不同用户身上得到不同摘要,撞表成本陡增。这不是学术级别的安全设计,但对毕业设计够用,也能在论文的“安全需求分析”一节里说清楚。

# core/security.py —— 注册时生成盐,登录时用同一个盐复算 import hashlib import uuid def make_password(raw: str): """返回 (密文, 盐),注册时把两个都存进 user 表""" salt = uuid.uuid4().hex[:8] md5 = hashlib.md5() md5.update((raw + salt).encode('utf-8')) return md5.hexdigest(), salt def check_password(raw: str, hashed: str, salt: str) -> bool: """登录校验,比较用 hmac.compare_digest 避免短路比较被计时攻击""" import hmac md5 = hashlib.md5() md5.update((raw + salt).encode('utf-8')) return hmac.compare_digest(md5.hexdigest(), hashed)

uuid.uuid4().hex[:8]取八位随机盐,长度够用又不至于让user表膨胀。盐必须和密文分开存,拼在密文里再解析属于自找麻烦。hmac.compare_digest这一句是习惯,普通==在字符串首个不匹配字符处就返回,理论上能通过响应时间推断前缀,虽然毕设场景没人这么攻击,但写上不亏。

登录态直接用 Flask 的 session,登录成功后写session['user_id']和session['username'],再写一个装饰器挂在所有需要登录的视图上。这里有个容易忽略的点:session 的密钥必须写死在配置里,不能每次启动随机生成,否则 Flask 重启后所有已登录用户全部掉线,演示时页面疯狂跳回登录页,很容易被当成 bug 排查半天。

3.3 识别结果入库的字段映射与事务边界

上传接口拿到的原始结果是一组数字,落库前要做一次映射。检测框给的是坐标和置信度,识别器给的是字符串,颜色判断要么靠识别模型的分类头,要么靠传统颜色空间统计,我一般用 HSV 空间算背景主色调,蓝牌绿牌黄牌各自有明确的色相区间,比再训一个分类模型省事。

入库时要控制事务边界:图片文件先落盘,拿到相对路径再开事务写plate_record,写入失败就删掉刚存的图。反过来先写库再存图,磁盘满的时候库里会留一堆指向空文件的记录,统计页显示有数据、管理页点开图片 404,这种不一致比直接报错难查得多。image_path存相对路径不存绝对路径,换台机器部署目录一变,绝对路径全废,相对路径加一个UPLOAD_FOLDER前缀就能拼回来。

4. 识别核心:从 YOLOv5 检测框到字符识别的完整链路

论文摘要里把流程列得很完整:图像采集、预处理、模型训练、车牌定位、字符分割、字符识别。落到代码基本就是三个函数:检测、矫正、识别。这章按调用顺序拆,每段都能单独跑。

4.1 预处理与 YOLOv5 推理:把一张图变成若干个车牌小图

预处理最容易写重。YOLOv5 官方推理封装内部已经做了 letterbox 缩放和归一化,我们在外面再做一次 resize 到 640×640 反而会把长宽比拉变形,导致训练和推理的输入分布不一致,框会整体偏移。正确做法是只做必要的色彩转换和尺寸上限保护:超大图先等比缩到长边 1920,太小的图不放大。

# core/detector.py —— 权重加载与单图检测 import cv2 import numpy as np import torch def load_model(weight_path: str, device: str = 'cpu'): """force_reload=False 避免每次启动都去拉官方仓库代码""" model = torch.hub.load('ultralytics/yolov5', 'custom', path=weight_path, force_reload=False) model.conf = 0.45 # 置信度阈值,低于它的框直接丢,宁可漏检不要误检 model.iou = 0.45 # NMS 的 IOU 阈值,重叠框合并比例 model.classes = [0] # 单类别检测,0 就是 plate model.to(device).eval() return model def detect_plates(model, img_bgr, max_side=1920, min_size=20): """返回 [(车牌小图, [x1,y1,x2,y2], 置信度), ...]""" h, w = img_bgr.shape[:2] scale = min(1.0, max_side / max(h, w)) # 只缩不放,保长宽比 if scale < 1.0: img_bgr = cv2.resize(img_bgr, (int(w * scale), int(h * scale))) img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) results = model(img_rgb, size=640) # 640 是精度与速度的折中 boxes = results.xyxy[0].cpu().numpy() out, hh, ww = [], img_bgr.shape[:2], img_bgr.shape[1] for x1, y1, x2, y2, conf, _ in boxes: x1, y1 = max(0, int(x1)), max(0, int(y1)) x2, y2 = min(ww, int(x2)), min(hh, int(y2)) if (x2 - x1) < min_size or (y2 - y1) < min_size // 2: continue # 过小的框多为误检 out.append((img_bgr[y1:y2, x1:x2], [x1, y1, x2, y2], float(conf))) return out

model.conf和model.iou是最常调的两个旋钮。置信度从 0.45 提到 0.6,误检会明显减少但斜拍、逆光的车牌容易漏;调低到 0.3,检出率上去了,但车身反光、广告牌上的文字也会被当成车牌。毕设演示场景建议 0.45 起步,演示前用十张典型图片跑一遍确认。classes=[0]只有在训练时就设成单类别才成立,如果数据集里还标了车窗、车标,这里要按实际类别下标改,否则拿到的是别的目标。

min_size那一行看着不起眼,实际能砍掉一大半误检。车牌在整图里占比很小,小于 20 像素宽的检测框送到字符识别环节,识别器输出的必然是乱码,还白白占用一次前向计算。宁可在这里丢掉,也不要把垃圾数据写进库,因为管理页一旦有乱码记录,你分不清是识别不准还是根本不该识别。

4.2 车牌矫正与字符识别:从倾斜小图到一串文本

检测框裁出来的车牌常常是斜的,直接送字符识别准确率会掉得很厉害。传统做法是先灰度、反二值化把字符提成白色前景,再用形态学闭运算接上断裂笔画,接着找最小外接矩形做透视变换摆正。这套流程对蓝牌绿牌这类深色字符浅色底的场景很有效,黄牌反色,要先判断主色调再选二值化策略。

# core/recognizer.py —— 二值化 + 透视矫正 + 字符识别 import cv2 import numpy as np def binarize(plate_bgr): """深色字符浅色底 -> OTSU 反二值化;黄牌先反转通道""" gray = cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) hsv = cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2HSV) hue = np.median(hsv[:, :, 0]) if 15 <= hue <= 35: # 黄牌色相区间,前景背景要整体翻转 gray = 255 - gray _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) return cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) def align(plate_bgr): """用最小外接矩形把倾斜车牌转正,返回固定高 64 的灰度图""" binary = binarize(plate_bgr) contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return cv2.resize(plate_bgr, (128, 64)) rect = cv2.minAreaRect(max(contours, key=cv2.contourArea)) box = cv2.boxPoints(rect).astype(np.float32) # 目标框宽高按 2:1 摆正,和中文字符车牌的常见比例一致 dst = np.float32([[0, 0], [128, 0], [128, 64], [0, 64]]) m = cv2.getPerspectiveTransform(box, dst) return cv2.warpPerspective(binarize(plate_bgr), m, (128, 64))

hue的判断阈值 15 到 35 对应 OpenCV 的 H 通道范围(0 到 180),黄牌大致落在这个区间,蓝牌在 100 到 124,绿牌在 35 到 85。这几个数字不是固定的,不同摄像头白平衡差异会导致整体偏移,我一般会在真实图片上采样一批取中位数,再上下放宽十来个单位,卡死在某个精确值上很容易翻车。

字符识别这一步,论文里写的是“字符分割 + 字符识别”,实际工程里更常见的是端到端的序列识别,用 CRNN 加 CTC 直接输出整串文本,省掉分割。分割那套在车牌有污损、字符粘连时会大量失败,而 CTC 允许输入长度和输出长度不对齐,鲁棒性好得多。字典文件charset.txt的顺序必须与训练时完全一致,第一行是占位符(CTC 的 blank),后面依次是省份简称、字母、数字。

# core/recognizer.py 续 —— 序列识别推理 import torch CHARSET = open('weights/charset.txt', encoding='utf-8').read().strip().split('\n') def decode_ctc(logits, blank=0): """贪心解码:取每帧最大概率,合并重复并去掉 blank""" idx = logits.argmax(dim=2)[0].cpu().numpy() out, prev = [], -1 for i in idx: if i != prev and i != blank and i < len(CHARSET): out.append(CHARSET[i]) prev = i return ''.join(out) def recognize(rec_model, aligned_gray): """aligned_gray 是上一步 align 的输出,128x64""" x = aligned_gray.astype(np.float32) / 255.0 x = torch.from_numpy(x).unsqueeze(0).unsqueeze(0) # [1, 1, 64, 128] with torch.no_grad(): logits = rec_model(x) # [1, T, num_classes] return decode_ctc(logits)

unsqueeze两次是把 H×W 的二维图补成 N×C×H×W,缺一维模型直接报维度错误,这是新手最常见的翻车点。decode_ctc里i != prev的合并逻辑是 CTC 的硬性要求,同一个字符连续出现两帧要合并成一个,否则“苏A12345”会变成“苏AA1122334455”。i < len(CHARSET)的边界判断是为了防模型输出越界下标,一旦出现会直接抛异常,加个判断至少能把这条记录丢掉而不是让整个请求 500。

4.3 识别接口封装:把三段拼起来并对失败负责

单张图从上传到入库,一个接口要处理的事情比看上去多:文件类型校验、大小限制、临时落盘、调用链路、结果映射、异常兜底。任何一步没有兜底,前端拿到的就是 500 加一段看不懂的堆栈。

# blueprints/plate.py —— 上传识别核心视图 import os, uuid, datetime from flask import Blueprint, request, jsonify, session from core.detector import detect_plates from core.recognizer import align, recognize from models.plate_record import insert_record plate_bp = Blueprint('plate', __name__) ALLOWED = {'jpg', 'jpeg', 'png', 'bmp'} @plate_bp.route('/plate/recognize', methods=['POST']) def recognize_view(): if 'user_id' not in session: return jsonify(code=401, msg='未登录'), 401 f = request.files.get('file') if not f or '.' not in f.filename: return jsonify(code=400, msg='请上传图片文件'), 400 ext = f.filename.rsplit('.', 1)[1].lower() if ext not in ALLOWED: return jsonify(code=400, msg='仅支持 jpg/png/bmp'), 400 day = datetime.date.today().strftime('%Y%m%d') name = f'{uuid.uuid4().hex}.{ext}' rel = os.path.join('uploads', day, name) abs_path = os.path.join(current_app.config['UPLOAD_FOLDER'], rel) os.makedirs(os.path.dirname(abs_path), exist_ok=True) f.save(abs_path) img = cv2.imdecode(np.fromfile(abs_path, dtype=np.uint8), cv2.IMREAD_COLOR) if img is None: os.remove(abs_path) return jsonify(code=400, msg='图片无法解析'), 400 results = [] for patch, box, conf in detect_plates(current_app.yolo, img): text = recognize(current_app.rec_model, align(patch)) if len(text) < 6: # 车牌最短也在 6 位以上,短串当识别失败 continue insert_record(text, conf, rel, province_of(text), session['user_id']) results.append({'plate_no': text, 'confidence': round(conf, 4)}) return jsonify(code=0, data=results), 200

cv2.imdecode(np.fromfile(...))这种读法是为了兼容中文路径。cv2.imread在 Windows 下遇到中文目录会静默返回 None,表现是“图片明明存在却报无法解析”,这个坑几乎每个中文环境的人都踩过一次。文件先存盘再解码,解码失败要把刚存的垃圾文件删掉,否则uploads目录会越堆越大,跑一段时间磁盘告警。

len(text) < 6这条过滤看着武断,实际非常必要。检测器偶尔会把车身贴纸、广告牌误检成车牌,裁下来送进识别器,模型无论如何都会输出一串字符,只是置信度低、长度乱。用长度做个粗筛,能把大部分垃圾挡在入库前,管理页的列表会干净很多。

4.4 训练自己的检测权重:数据集、标注和几个不能改错的参数

论文里“模型训练是核心”这句话没错,但它没说清楚训练和推理的参数必须对齐。自制车牌数据集规模不用很大,每个省份的蓝牌绿牌各来几百张,加上夜间、逆光、倾斜、部分遮挡的场景,总量三五千张就能出可用权重。标注用 labelImg 打 YOLO 格式,一张图一个同名 txt,每行类别 中心x 中心y 宽 高,全部归一化到 0 到 1。类别只留车牌一类。

# 训练命令,参数按 CPU 演示场景调整 python train.py \ --img 640 \ # 必须与推理时的 size 一致 --batch 8 \ # 显存不够就往下调,8 是 6G 显存的安全值 --epochs 100 \ # 三千张图基本 80 到 120 轮收敛 --data data/plate.yaml \ # 里面写 train/val 路径和 nc、names --weights yolov5s.pt \ # 从官方小模型迁移,比从头训快得多 --device 0 # 没显卡就写 cpu,训练时间翻十几倍

--img 640和推理时的size=640是强绑定关系。训练用 640、推理用 416,框会整体缩小一圈;训练用 416、推理用 640,框会放大且边缘溢出。这类问题在验证集上看不出来,因为验证脚本默认沿用训练分辨率,只有真接进系统才会暴露,属于典型的“离线指标很好、上线全歪”。--weights yolov5s.pt用迁移学习是常识,从零训三千张图根本收敛不到可用精度,官方预训练权重已经在通用目标上学过边缘和纹理特征,微调几十轮就能把车牌这类强纹理目标学出来。

评估看mAP@0.5,及格线大概 0.9,低于这个值优先查标注质量而不是加轮数。我见过标歪的框导致模型学到“车牌在图片右上角”这种伪特征,训练 loss 降得很漂亮,换一批图立刻崩。训练完的best.pt拷到weights/plate_best.pt,重启服务前记得确认classes=[0],类别数对不上会直接抛 shape 错误。

5. 页面联调与排查:统计图表、车牌地图和识别结果的五个坑

六个页面里,登录和车牌管理是纯 CRUD,照着模板改就行;真正容易卡住的是统计分析和车牌地图,因为它们依赖聚合 SQL 和前端可视化资源,两端任何一边对不上都是空白页。这章先把两个可视化页的关键代码给了,再把踩过的坑按现象、原因、解决逐条列出来。

5.1 统计分析页:从聚合 SQL 到 ECharts 环形图

统计页按论文描述要有“不同车型、不同颜色”的维度。车型在检测阶段没做分类,实际能落地的维度是车牌颜色和识别趋势。颜色统计就是一条GROUP BY,趋势统计按天分组。接口返回统一结构{code, data: [{name, value}]},前端拿到直接喂给 ECharts,中间不做任何转换,这样换图表类型时只改前端。

# blueprints/stats.py —— 颜色分布聚合 @stats_bp.route('/stats/color') def stats_color(): rows = db.session.execute(text( "SELECT plate_color AS name, COUNT(*) AS value " "FROM plate_record GROUP BY plate_color" )).fetchall() mapping = {'blue': '蓝牌', 'green': '绿牌', 'yellow': '黄牌'} data = [{'name': mapping.get(r.name, '其他'), 'value': r.value} for r in rows] return jsonify(code=0, data=data)
// static/js/stats.js —— 环形图配置,数据来自上面的接口 const chart = echarts.init(document.getElementById('colorChart')); fetch('/stats/color').then(r => r.json()).then(res => { chart.setOption({ tooltip: { trigger: 'item', formatter: '{b}: {c} 辆 ({d}%)' }, legend: { bottom: 0 }, series: [{ type: 'pie', radius: ['45%', '70%'], // 环形图,中间留给总数文字 avoidLabelOverlap: true, label: { show: true, formatter: '{b}\n{c}' }, data: res.data // [{name:'蓝牌', value:128}, ...] }] }); }); window.addEventListener('resize', () => chart.resize()); // 窗口缩放必须手动重算

radius的['45%', '70%']是内外半径,写单个值就是实心饼图。window.resize那一行经常被漏掉,后果是浏览器窗口一调整,图表还停在旧尺寸上,右边留一大块空白或者被裁掉,看起来像布局崩了。formatter: '{b}: {c} 辆 ({d}%)'用了 ECharts 的模板变量,{d}是百分比,比自己算省事。

5.2 车牌地图页:省份聚合与必须本地化的地图资源

地图页是这套系统里最“看着唬人”的页面,实际实现比统计图还简单,前提是地图数据到位。ECharts 5 之后不再内置中国地图,必须自己注册 GeoJSON。常见做法是下载china.js放到static/js/下,在页面里先引echarts.min.js再引china.js,顺序反了会报Map china not exists。地图数据走本地文件,不要图省事引 CDN,答辩现场网络一波动,整个页面直接空白,这种场面我见过不止一次。

// static/js/map.js —— 省份散点图 const mapChart = echarts.init(document.getElementById('plateMap')); fetch('/stats/map').then(r => r.json()).then(res => { mapChart.setOption({ tooltip: { trigger: 'item', formatter: p => `${p.name}: ${p.value || 0} 辆` }, visualMap: { min: 0, max: 200, left: 20, bottom: 20, inRange: { color: ['#e0f3f8', '#4575b4'] }, // 数量越多颜色越深 text: ['高', '低'] }, series: [{ type: 'map', map: 'china', // 与 registerMap 时用的名字一致 roam: true, // 允许缩放拖动,演示时观感更好 label: { show: false }, // 省份名全显示会糊成一片 data: res.data // [{name:'江苏', value:37}, ...] }] }); });

visualMap的max要按实际数据量调。写死 200 而实际最高只有 30,整张图全是浅色,看不出差异;反过来实际有 500,超过 200 的部分全被压成最深色,也没法区分。稳妥做法是接口把最大值一起返回,前端动态设。label.show关掉是经验,三十多个省份名同时显示会严重重叠,鼠标 hover 时 tooltip 已经够了。

5.3 五条排查记录:从白屏到乱码的现场还原

第一条:上传后接口 500,日志里是cannot identify image file。现象是前端点了识别按钮转圈几秒后弹错误。原因九成是表单没写enctype="multipart/form-data",文件根本没传到后端,request.files是空的,代码退化成读一个空流。另一种可能是后端用了request.form.get('file')而不是request.files.get('file'),拿到的永远是 None。解决办法是先打印request.files的 keys 确认,再检查前端表单标签,两分钟能定位。

第二条:模型能出框,但框整体偏移,有的还超出图片边界。现象是检测框明显偏右下或偏左上,偏多少还不固定。原因是训练时的 letterbox 填充和推理时的直接 resize 不一致,YOLOv5 官方封装内部做的是保持长宽比加灰边填充,如果自己在预处理里又 resize 到正方形,等于把图拉伸了一遍。解决办法是去掉外层的手动 resize,只保留等比缩放长边的保护逻辑,把尺寸归一交给模型封装。这个坑的迷惑性在于验证集指标完全正常,只有换真实图片才暴露。

第三条:车牌号识别成乱码,或者字符重复出现。现象是“苏A12345”识别成“苏AA1122334455”,或者一长串不存在的字母。重复是因为 CTC 解码漏了i != prev的合并判断,每帧都往结果里塞。乱码通常是charset.txt的顺序和训练时不一致,比如训练时字典是“数字在前、字母在后”,推理时换成了“字母在前”,下标全错位,输出的每个字符都是错的但长度看着正常。解决办法是把训练用的字典文件原样拷过来,一行都别动,解码函数里加下标越界保护。

第四条:地图页空白,控制台报Map china not exists或Cannot read property 'geoJson'。现象是页面其他元素都在,只有地图区域一片白。原因有两个:一是只引了echarts.min.js,没引地图数据文件;二是引入顺序反了,地图数据先于 ECharts 加载,echarts.registerMap执行时 echarts 还没定义。解决办法是固定引脚本顺序,地图数据用本地文件,并在页面加载完成后检查echarts.getMap('china')是否为 undefined,是就提示资源缺失而不是静默白屏。

第五条:管理页能看到记录,但中文车牌显示成问号。现象是列表里plate_no全是???或者乱码方块。原因是建库时用了默认字符集(MySQL 5.7 默认 latin1),或者连接串没指定 charset。只改建库不够,已经建好的表要用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4转一遍,连接串写成mysql+pymysql://user:pwd@host/db?charset=utf8mb4。这个坑的排查成本在于数据已经写进去了,你得先确认是存储层坏了还是展示层坏了,直接查库看原始值最快。

6. 二次开发:换模型、加字段、提准确率,三个改造口的取舍

拿到这套骨架之后,最常见的三类改造是换检测模型、扩业务字段、拉识别准确率,它们改动的位置完全不同,混在一起改最容易把能跑的版本改崩。我的习惯是每次只动一个口,动完立刻用同一组十张图做回归,指标不退步才继续。

改造目标主要改动文件是否动数据库验证方法
换 YOLOv8 / 其他检测器core/detector.py的load_model和detect_plates否同一批图对比框位置与置信度
增加车型、拍摄地点字段sql建表、models/plate_record.py、管理页模板是新记录能写入、列表能显示
提升字符识别准确率core/recognizer.py、weights/charset.txt否统计一百张图的整串正确率

换检测模型时,唯一要守住的是函数签名不变:输入 BGR 图,输出[(小图, 框, 置信度)]。上层接口和入库逻辑一行都不用动,这是当初把算法层单独拆出来的价值。如果新模型的输出格式是归一化的 cxcywh 而不是 xyxy,转换在detect_plates内部做完,别让它泄漏到业务层。

加字段最容易忽略的是历史数据。plate_record加一列vehicle_type,老记录的该列是 NULL,管理页显示时要么给默认值“未知”,要么在 SQL 里COALESCE(vehicle_type, '未知')。我见过直接在模板里{{ row.vehicle_type }}的写法,老数据全是空白单元格,看着像功能没做完。

提准确率最划算的一步不是换更大的模型,而是卡检测阈值和字符长度。把conf从 0.45 提到 0.55、过滤长度小于 6 的结果,整串正确率往往能涨几个百分点,代价是少量漏检。对演示场景来说,宁可少识别几辆也不要显示一串乱码,观感和可信度完全是两回事。真要再进一步,就补数据:夜间、雨雾、斜拍的样本各来几百张,重训一轮的收益比调任何参数都大。

从那以后我每次改这套链路,都会强制走一遍“十张固定图回归 → 查库看原始值 → 翻页看管理页显示”这三步,不管改的是一行阈值还是一个模型文件。这套流程挡住过好几次“本地看着没问题、换台机器全崩”的事故。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表