1. 选题逻辑与整体架构:交通标志识别为什么是性价比最高的毕业设计方向
带过几届学生的毕设之后,我对选题这件事有个很朴素的判断标准:数据能不能拿到、算法有没有公开基线、系统能不能跑起来给别人看。交通标志识别这个题目恰好三条全占。GTSRB 这个数据集直接挂在网上就能下,四万多张标注好的图片,43 个类别;CNN 分类的基线代码在主流框架的官方示例里就有;至于系统演示,一张图片拖进去、一个框弹出来,答辩老师三秒钟就能看懂你在做什么。这种"看得见摸得着"的题目,比那些讲一堆理论但跑不出结果的选题,通过率要高得多。
1.1 标题背后的真实需求拆解
把"基于深度学习的交通标志识别系统设计与实现"这句话拆成词来看,每一个词背后都对应着一块必须交付的内容。"基于深度学习"意味着你要有网络结构、有训练过程、有指标对比,不能只调个库函数了事;"交通标志识别"限定了任务边界,是分类还是检测,是单帧图片还是视频流,这个必须提前定死;"系统设计与实现"说明最终产物不是一个 notebook,而是一个能交互的软件,有界面、有输入输出;
"计算机毕业设计源码+LW文档"则直白地点出了两份交付物——能跑通的代码工程,和能通过查重的论文文档。
我见过太多人栽在第一步:题目理解偏了,把所有精力砸在调参上,最后发现论文里"系统设计"那一章只有半页纸,一辩就被问住。所以动手之前先列一张交付清单,把"代码模块、实验结果表格、论文章节、演示流程"四件事对齐,后面会省掉大量返工。
1.2 三条技术路线怎么选
交通标志识别按实现深度可以分成三个层次,难度和工作量的差别非常大,选错了要么做不出来,要么工作量不够被质疑。
| 路线 | 核心方法 | 工作量 | 适用场景 | 风险点 |
|---|---|---|---|---|
| 路线一 | HOG/LBP 特征 + SVM 分类 | 小 | 只需分类、追求可解释性 | 准确率天花板低,容易被质疑"没用深度学习" |
| 路线二 | CNN 分类 + 滑动窗口/传统检测定位 | 中 | 图片分类为主,附带简单定位 | 检测速度慢,实时性差 |
| 路线三 | YOLO 系列端到端检测 + 分类 | 大 | 需要视频实时识别演示 | 训练时间长,标注要求高 |
我的建议是走**"分类为主、检测为辅"的折中方案**:主干用轻量 CNN 做 43 类(或合并后的十几类)分类,在这一章把深度学习的部分做扎实——网络结构设计、损失函数、消融实验都有;检测环节用 YOLO 预训练权重做迁移,只负责把路牌框出来,框出来的 ROI 再送给分类网络。这样既保证了"深度学习"的技术含量,又把训练成本控制在单卡能承受的范围内。
1.3 系统整体架构
整个系统我习惯按四层来切,这个分层在论文里也特别好写,每一层对应一个章节,逻辑非常顺。
数据层负责原始图片的采集、清洗、标注、增强和划分,输出统一尺寸的张量;算法层包含检测模型和分类模型两部分,各自独立训练、独立评估;服务层是一个轻量 Web 后端,对外暴露一个 HTTP 接口,接收图片字节流返回结构化 JSON;展示层可以是 Web 页面,也可以是 PyQt 桌面程序,负责上传、展示、记录历史。
提示:分层的关键在于层与层之间只通过明确定义的接口通信。算法层不要直接读数据库,展示层不要直接调模型,中间必须夹一层服务。这个约束看起来是给自己找麻烦,但答辩时老师问"你的系统耦合度怎么样",你能直接把这套结构画出来,分数就稳了。
1.4 源码目录结构规划
工程结构混乱是毕设代码最常见的扣分点。我在实际项目里用的这套结构,几乎可以直接套用:
traffic-sign-recognition/ ├── configs/ # 超参数、路径配置 │ ├── train_cls.yaml │ └── detect.yaml ├── data/ │ ├── raw/ # 原始数据集 │ ├── processed/ # 划分后的 train/val/test │ └── dataset.py # Dataset 与 DataLoader 定义 ├── models/ │ ├── classifier.py # 分类网络定义 │ ├── detector.py # 检测模型封装 │ └── backbone.py # 骨干网 ├── engine/ │ ├── train.py # 训练主循环 │ ├── evaluate.py # 评估与指标计算 │ └── infer.py # 单图/批量推理 ├── server/ │ ├── app.py # Flask 入口 │ ├── routes.py # 接口路由 │ └── db.py # 数据库操作 ├── web/ # 前端页面 ├── weights/ # 训练权重 ├── requirements.txt └── README.md这份结构最大的好处是论文和代码能一一对应。写第四章"模型设计与实现"时就是讲models/,写第五章"系统实现"时就是讲server/和web/,老师翻源码的时候一眼能找到对应位置,这种细节非常加分。
2. 数据集准备:从 GTSRB 到自建样本集
数据这一章很多人写得特别虚,两句话就过去了,其实这是最容易被追问的地方。老师常问的是"你的数据集有多少张图""类别怎么来的""训练集测试集怎么分的""有没有类别不平衡"。这几个问题如果你答不上来,基本就说明你没真正跑过实验。
2.1 GTSRB 与 CCTSDB 的取舍
GTSRB(German Traffic Sign Recognition Benchmark)是交通标志分类最经典的公开数据集,43 个类别,训练集 39209 张、测试集 12630 张,图片尺寸从 15×15 到 250×250 不等,并且带有一部分遮挡、光照变化、运动模糊的样本,非常适合做鲁棒性验证。它的问题是类别分布极度不均,有些类上千张,有些类只有两三百张,直接训练会让模型严重偏向多数类。
CCTSDB 是国内场景下的交通标志检测数据集,标注形式是检测框,类别粗分为指示、警告、禁止三大类,图片来自真实道路场景,光照和天气变化更丰富,但类别粒度比 GTSRB 粗很多。如果你的系统要做"视频里实时框出标志",CCTSDB 更合适;如果重点在分类精度对比,GTSRB 更合适。
我通常的做法是用 GTSRB 训练分类器,用 CCTSDB 做检测模型的微调,两者各取所长,论文里也能自然形成一个"跨数据集验证"的实验小节,显得工作更完整。
2.2 类别体系怎么设计
43 类直接分类不是不行,但国内应用场景下很多类别根本用不上。我在项目里做了一次类别合并,逻辑是这样的:
- 限速类:把 20/30/50/60/70/80/100/120 合并成一个"限速"大类,但同时保留数字子类作为二级标签;
- 禁令类:禁止通行、禁止左转、禁止右转、禁止超车、禁止鸣笛等归为"禁止"类;
- 警告类:注意儿童、注意行人、施工、湿滑路面等归为"警告"类;
- 指示类:直行、左右转、环岛、人行横道等归为"指示"类。
这样做的好处有三个:一是缓解了类别不平衡,每个大类样本量都上千;二是降低了任务难度,分类准确率能轻松上到 98% 以上;三是更贴近实际业务,实际的车载系统往往先判断大类再细化,不存在 43 类平铺直叙的需求。
注意:合并类别一定要在论文里写清楚映射表,说明每一类包含哪些原始 ID,并且用一张表格列出来。这既是工作量证明,也是复现的前提。老师如果问"你的 43 类怎么变成 12 类的",你能直接指出映射表的位置,这个问题就变成了送分题。
2.3 数据增强与类别不平衡处理
增强手段上,我一般只用几何变换和轻度色彩扰动,不做大幅度的随机裁剪和旋转,原因是交通标志本身是固定比例的正方形牌面,过度变换会产生大量不真实的样本,反而拉低测试精度。
具体配置如下:
| 增强方式 | 参数范围 | 作用 |
|---|---|---|
| 随机缩放 | 0.9 ~ 1.1 | 提升尺度鲁棒性 |
| 随机平移 | ±10% | 缓解定位偏差 |
| 随机亮度 | ±0.2 | 模拟光照变化 |
| 随机对比度 | ±0.2 | 模拟逆光/阴影 |
| 随机高斯噪声 | sigma 0 ~ 0.03 | 模拟低质量摄像头 |
| 水平翻转 | 仅限对称标志 | 翻倍样本量,需排除文字类别 |
关于类别不平衡,我用过效果最好的组合是加权交叉熵 + 平衡采样。加权交叉熵给少数类更高的损失权重,权重按类别频率的倒数开方计算,这样既不会让权重过于极端,又能显著缓解偏置。平衡采样则是让每个 batch 中各类别的期望数量大致相等,实现起来也简单,用一个WeightedRandomSampler就能搞定。
2.4 DataLoader 实现要点
数据集加载这块有两个坑必须提前避开:一是图片路径的编码问题,GTSRB 里有些文件名带特殊字符,Windows 下用默认编码读会报错;二是验证集的一致性,训练用增强、验证不能用增强,否则指标会虚高。
import os import cv2 import torch import numpy as np from torch.utils.data import Dataset, WeightedRandomSampler from torchvision import transforms from PIL import Image class TrafficSignDataset(Dataset): def __init__(self, root, split="train", img_size=224): self.root = root self.split = split self.samples = self._scan() if split == "train": self.tf = transforms.Compose([ transforms.Resize((img_size, img_size)), transforms.RandomAffine(degrees=0, translate=(0.1, 0.1), scale=(0.9, 1.1)), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) else: self.tf = transforms.Compose([ transforms.Resize((img_size, img_size)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) def _scan(self): samples = [] for cls_id in sorted(os.listdir(self.root)): cls_dir = os.path.join(self.root, cls_id) if not os.path.isdir(cls_dir): continue for name in os.listdir(cls_dir): if name.lower().endswith((".png", ".jpg", ".jpeg", ".ppm")): samples.append((os.path.join(cls_dir, name), int(cls_id))) return samples def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] img = Image.open(path).convert("RGB") return self.tf(img), label def sample_weights(self): labels = [s[1] for s in self.samples] counts = np.bincount(labels) w = 1.0 / np.sqrt(counts[labels] + 1e-6) return torch.as_tensor(w, dtype=torch.double)调用的时候只需要WeightedRandomSampler(dataset.sample_weights(), num_samples=len(dataset), replacement=True),训练时电话就被自动拉平了。这套代码我反复用过,稳定可靠,data/dataset.py里放这一份就够了。
3. 模型选型与网络结构设计
网络结构是论文的核心章节,也是决定你能不能做出高指标的关键。很多人一上来就堆 ResNet50,结果单卡训练一个 epoch 要二十分钟,调参空间被压得极小,最后只能拿个半成品交差。我的经验是:分类任务在交通标志这种规整图像上,轻量网络完全够用,甚至比大网络表现更好。
3.1 为什么 LeNet-5 直接调库会被扣分
有些参考资料里给的基线是 LeNet-5,改一改就能跑出 95% 以上的准确率,看起来很香。但这个东西放在答辩现场就是个雷。老师会问:"这个网络是哪一年提出的""为什么用 5×5 卷积而不是 3×3""参数量是多少""和 ResNet 比差在哪"。如果你只是抄了结构没有自己的改动,这几个问题一个都答不上来。
更现实的问题是,纯 LeNet-5 在带遮挡、带模糊的样本上掉点非常严重,我在 GTSRB 的困难子集上测过,准确率会掉到 88% 左右,这个数字在论文里不好看。所以哪怕最终结构还是以简单卷积为主,也必须加入至少一到两个你自己的设计,比如注意力模块、多尺度特征融合、或者改进的激活函数。
3.2 轻量骨干网横向对比
我在实际项目里对三个骨干网做过比较,如果你要写"模型选型"小节,可以直接参考这类对比思路:
| 骨干网 | 参数量 | 输入 224 时单图推理耗时(CPU) | GTSRB 测试集准确率 | 特点 |
|---|---|---|---|---|
| MobileNetV3-Small | 约 2.5M | 约 18 ms | 98.1% | 最轻,适合端侧部署 |
| ShuffleNetV2 1.0x | 约 2.3M | 约 21 ms | 97.8% | 通道打散,访存友好 |
| ResNet18 | 约 11.7M | 约 55 ms | 98.6% | 精度略高,参数量大 |
选型逻辑很清楚:如果系统最终要跑在嵌入式设备或者树莓派这类资源受限的平台上,MobileNetV3 是首选;如果只是 PC 端跑,ResNet18 的精度优势值得那点算力开销。我在毕设里通常用 MobileNetV3-Small 做主干,因为"轻量化"本身就是一个可以大书特书的创新点,论文里能单独写一节讲模型压缩。
3.3 自定义分类网络结构
下面这个结构是我在 MobileNetV3 主干之后接的一个轻量分类头,加了 SE 注意力模块和特征金字塔式的多尺度拼接,参数量只增加了不到 0.3M,但准确率能提 0.5 到 0.8 个点。
import torch import torch.nn as nn import torchvision class SEBlock(nn.Module): def __init__(self, channels, ratio=16): super().__init__() self.pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Linear(channels, channels // ratio, bias=False), nn.ReLU(inplace=True), nn.Linear(channels // ratio, channels, bias=False), nn.Sigmoid(), ) def forward(self, x): b, c, _, _ = x.size() w = self.pool(x).view(b, c) w = self.fc(w).view(b, c, 1, 1) return x * w class TrafficSignNet(nn.Module): def __init__(self, num_classes=43, pretrained=True): super().__init__() backbone = torchvision.models.mobilenet_v3_small( weights="IMAGENET1K_V1" if pretrained else None) self.stem = backbone.features[:-2] self.se = SEBlock(576) self.head = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.3), nn.Linear(576, 256), nn.Hardswish(inplace=True), nn.Dropout(0.2), nn.Linear(256, num_classes), ) def forward(self, x): x = self.stem(x) x = self.se(x) return self.head(x)这里有两个细节值得说明。第一,Dropout(0.3)放在全局池化之后而不是卷积层里,是因为交通标志的判别特征集中在局部区域,卷积层加 Dropout 会破坏空间结构;第二,用Hardswish而不是ReLU,是因为 MobileNetV3 本身就是用 Hardswish 训出来的,激活函数保持一致可以避免预训练权重失效。
3.4 损失函数与优化器参数计算
分类任务用交叉熵是标准做法,但配合类别加权才是完整方案:
weights = torch.tensor(class_weights, dtype=torch.float32).to(device) criterion = nn.CrossEntropyLoss(weight=weights, label_smoothing=0.1) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)label_smoothing=0.1是个很实用的技巧,能抑制模型对单一类别的过度自信,在 GTSRB 上大约能带来 0.3 个点的泛化提升。优化器我倾向AdamW而不是SGD,原因是数据集规模不大,AdamW 收敛更快,省下来的时间可以多跑几组消融实验。
Batch size 和显存的关系必须算清楚。以 MobileNetV3-Small、输入 224×224 为例,FP32 训练时单张图片的激活值占用大约 25 MB 左右(含前向激活和反向梯度缓存),batch size 取 64 的话,大约需要 1.6 GB 显存;如果加上数据加载的缓冲和 CUDA 上下文开销,实际峰值在 2.2 GB 左右。所以 4 GB 显存的卡跑 batch 64 是安全的,6 GB 可以上到 batch 128。这个计算逻辑写进论文的"实验环境"小节,会显得很专业。
4. 训练流程与调参实录
模型结构定下来之后,训练脚本才是真正花时间的地方。我把训练拆成三个阶段:热身、主训练、微调,每个阶段的学习率和数据策略都不一样。这套流程在 GTSRB 上跑 30 个 epoch,最终测试集准确率稳定在 98% 以上。
4.1 训练主循环实现
import time import torch from tqdm import tqdm def train_one_epoch(model, loader, criterion, optimizer, device, epoch): model.train() total_loss, correct, total = 0.0, 0, 0 pbar = tqdm(loader, desc=f"Epoch {epoch}") for imgs, labels in pbar: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() logits = model(imgs) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() * imgs.size(0) correct += (logits.argmax(1) == labels).sum().item() total += imgs.size(0) pbar.set_postfix(loss=f"{total_loss/total:.4f}", acc=f"{correct/total:.4f}") return total_loss / total, correct / totalclip_grad_norm_这一行建议保留,交通标志数据里有少量标注噪声样本,梯度爆炸虽然不常见,但一旦出现会让整轮训练白跑。加上梯度裁剪,最坏情况也只是这一批样本学不进去,不会污染整个模型。
4.2 学习率策略与早停
学习率我用的是 warmup + cosine 的组合。前 3 个 epoch 从 1e-5 线性升到 1e-3,之后按余弦曲线衰减到 1e-5。warmup 的必要性在于:预训练权重是 ImageNet 上训出来的,直接上大学习率会瞬间把预训练特征打散,模型需要重新学,反而更慢。
早停的判据我一般设成"验证集准确率连续 8 个 epoch 没有提升就停"。但要注意一点:早停只保存最优权重,不要把最后一次的权重当作最终结果。我踩过的坑是训练完忘了加载 best checkpoint,用最后一轮的权重去测,准确率比最优值低了将近 1.5 个点,白忙活一晚上。
提示:检查点保存的时候把
epoch、optimizer.state_dict()、best_acc一起存下来,这样中断之后还能恢复训练,不用从头再来。这个小习惯在跑长实验时能救命。
4.3 训练曲线怎么读
训练过程中的曲线是最能反映问题的。常见的三种形态:
形态一,训练准确率持续上升但验证准确率 5 个 epoch 后就平了,这是典型过拟合。处理方式是加强正则,把 Dropout 从 0.2 提到 0.4,weight decay 从 1e-4 提到 5e-4,同时检查数据增强是否太弱。
形态二,两条曲线都在震荡且 loss 下降很慢,这通常是学习率过大或者 batch size 太小导致梯度噪声太大。把 lr 降一个数量级,或者开梯度累积来等效放大 batch。
形态三,训练 loss 降不下去,一直在 3.0 以上,这种情况八成是标签有问题。要么类别 ID 从 1 开始编号导致越界,要么路径扫描时把非图片文件也读进来了。我遇到过一次是因为数据集里混进了一个叫desktop.ini的文件,被当成图片读,直接报通道数不对。
4.4 模型导出与量化
训练完之后,系统要落地就得考虑部署效率。我一般导两份:一份 PyTorch 原生权重供服务端使用,一份 ONNX 供跨平台调用。
import torch model.eval() dummy = torch.randn(1, 3, 224, 224).to(device) torch.onnx.export( model, dummy, "weights/tsr_mobilenetv3.onnx", input_names=["input"], output_names=["logits"], dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}}, opset_version=12, )导出之后建议做一次动态量化,把卷积和全连接层的权重从 FP32 压到 INT8,模型体积能缩到原来的四分之一,CPU 推理速度大约提升两倍,精度损失通常在 0.5 个点以内。这一节写进论文,就是"模型轻量化部署"的完整闭环,比只写训练过程要完整得多。
5. 检测环节:从分类到定位
如果整个系统只做分类,输入必须是裁剪好的标志图片,这在演示的时候很尴尬——老师随手拍一张马路照片进去,模型会强行把它分到某一类。所以必须补上检测环节,让系统能先在整幅图里找到标志,再送去分类。
5.1 检测与分类的解耦
我的做法是检测模型和分类模型完全独立,检测用 YOLO 系列的 nano 版本,分类用前面训练的 MobileNetV3。这样做的好处是:检测模型只要区分"是不是交通标志"或者粗分三大类即可,标注成本低;分类模型专注细粒度识别,精度高。两个模型通过接口串联,各自可以独立替换升级。
具体流程是:输入图像先送到检测网络,得到若干个候选框和置信度,置信度低于 0.25 的框直接丢弃;剩下的框按坐标从原图裁剪出来,做一次尺寸归一化,再送给分类网络;分类网络输出的类别和置信度,与原框坐标合并,形成最终的识别结果。
5.2 推理加速的几个手段
串联两个模型会让单帧耗时翻倍,实测下来如果不做优化,CPU 上跑 640×640 的图大约要 300 ms 左右,勉强能到 3 FPS,做视频演示会卡。我在项目里用了三个优化手段:
输入尺寸降采样,检测模型从 640 降到 416,耗时减少约 45%,小目标召回率只掉 1 个点,交通标志通常在画面中占比不小,这个代价可以接受。
ROI 批量推理,把一帧里检测到的所有候选框拼成一个 batch 一次性送进分类网络,避免循环调用带来的调度开销。实测在一帧有 5 个标志的场景下,能省下约 30% 的时间。
半精度推理,如果服务端有 GPU,把模型转成 FP16,速度提升接近一倍,精度几乎无损。如果是纯 CPU 环境,就用前面提到的 INT8 动态量化。
5.3 后处理细节
NMS 的 IoU 阈值我一般设 0.45,交通标志之间通常不会重叠,阈值设大一点没关系,设小了反而会把同一个标志的两个框都保留下来。置信度阈值设 0.25 起步,如果误检多就提到 0.35。
还有一个容易被忽略的问题:检测框的边界处理。裁剪 ROI 的时候,如果框的坐标超出了图像边界,直接切片会得到空数组,程序就崩了。必须在裁剪前做一次clip操作:
def crop_roi(img, box, pad=4): h, w = img.shape[:2] x1, y1, x2, y2 = map(int, box) x1 = max(0, x1 - pad) y1 = max(0, y1 - pad) x2 = min(w, x2 + pad) y2 = min(h, y2 + pad) if x2 <= x1 or y2 <= y1: return None return img[y1:y2, x1:x2]这个小函数看着不起眼,但没有它,演示的时候只要有一张图里标志贴边,程序就直接挂掉,现场非常尴尬。
6. 系统集成与界面实现
到了这一步,前面所有的算法成果都要包装成一个能用的软件。毕设评审对"系统"的要求其实不高,但底线是能启动、能上传、能出结果、有记录。缺一个环节都会被认为"系统不完整"。
6.1 Flask 后端接口设计
后端我建议用 Flask,代码量小、依赖少,一个本科生完全能hold住。核心接口只有三个:图片识别、历史查询、模型信息。
from flask import Flask, request, jsonify import numpy as np import cv2 import uuid app = Flask(__name__) model_bundle = None # 全局加载,避免每次请求重新加载模型 @app.route("/api/predict", methods=["POST"]) def predict(): file = request.files.get("image") if file is None: return jsonify({"code": 400, "msg": "未接收到图片"}), 400 buf = np.frombuffer(file.read(), np.uint8) img = cv2.imdecode(buf, cv2.IMREAD_COLOR) if img is None: return jsonify({"code": 400, "msg": "图片解析失败"}), 400 results = pipeline(img, model_bundle) record_id = save_record(results) return jsonify({ "code": 0, "record_id": record_id, "count": len(results), "items": results, }) @app.route("/api/records", methods=["GET"]) def records(): page = int(request.args.get("page", 1)) size = int(request.args.get("size", 20)) return jsonify({"code": 0, "data": query_records(page, size)}) if __name__ == "__main__": model_bundle = load_models("weights") app.run(host="0.0.0.0", port=5000, threaded=True)两个关键点:模型必须在启动时加载一次并常驻内存,不能每次请求都torch.load,那样单次响应要好几秒;threaded=True必须开,Flask 默认单线程,前端同时发两个请求就会排队,演示时看起来像卡死了。
6.2 前端展示与交互
前端可以用最简单的原生 HTML + fetch,不引框架,代码更好维护。页面结构分三块:上传区、结果区、历史记录区。上传区用一个拖拽区域加一个文件选择按钮,结果区用 canvas 把检测框画在图片上,历史记录区用一个表格列出每次识别的缩略图、时间、识别到的标志类别。
画检测框的代码不复杂,但要注意坐标缩放。如果展示的图片被 CSS 缩放过了,画框的时候必须按实际显示尺寸做比例换算,否则框会偏。我一般的做法是让 canvas 尺寸与原图尺寸一致,再用 CSS 控制显示大小,这样画框的时候直接用原图坐标就行,不用换算。
6.3 数据库表设计
识别记录必须落库,否则"系统"这两个字就站不住。我用 SQLite,零配置,拷贝即用。两张表足够:
CREATE TABLE tb_user ( id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password_hash VARCHAR(128) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE tb_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, image_path VARCHAR(255) NOT NULL, result_json TEXT NOT NULL, sign_count INTEGER DEFAULT 0, cost_ms INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_record_user_time ON tb_record(user_id, created_at DESC);result_json用 TEXT 存 JSON 字符串,查询的时候在应用层反序列化,比拆成多张关联表简单得多,对毕设来说完全够用。索引建在user_id + created_at上,历史记录按时间倒序分页查询就能走索引,十万条数据下响应也在毫秒级。
6.4 性能优化与工程细节
有几个细节我在实际部署时反复验证过,值得单独说。模型加载加锁,多线程环境下如果两个请求同时触发懒加载,可能出现重复加载甚至崩溃,用threading.Lock包一层最稳妥。图片先压缩再存,原图可能有几兆,直接存硬盘很快就把空间吃满,识别完之后用 JPEG 质量 75 重新编码再存,体积能降到十分之一。耗时统计要拆开,把检测耗时、分类耗时、总耗时分别记录,排查性能瓶颈的时候一眼就能看出是哪一段慢。
7. LW 文档与源码的对应关系
论文这块,很多人的问题不是写不出来,而是写出来的东西和代码对不上。查重的时候能过,但答辩老师随便点开一个文件问"你这章讲的这个模块在哪",答不上来就麻烦了。我的建议是从一开始就按章节组织代码,让两者天然对齐。
7.1 论文章节安排建议
| 论文章节 | 对应源码 | 需要产出的实验数据 |
|---|---|---|
| 第二章 相关技术 | 无 | 技术对比表格 |
| 第三章 需求分析与总体设计 | configs/、架构图 | 功能模块图、用例描述 |
| 第四章 数据与模型设计 | data/、models/ | 数据集统计表、网络结构图 |
| 第五章 系统实现 | engine/、server/、web/ | 接口文档、界面截图 |
| 第六章 测试与分析 | engine/evaluate.py | 准确率、混淆矩阵、速度对比 |
按这个对应关系写,老师问任何一章的内容,你都能直接打开对应的文件夹,说服力非常强。
7.2 实验图表怎么生成
论文里最花时间的其实是图表。混淆矩阵、PR 曲线、训练曲线,这些都要用真实实验结果画,不能用示意图糊弄。我用的是 matplotlib 直接生成,比截图更清晰,格式也更统一。
import matplotlib.pyplot as plt import numpy as np from sklearn.metrics import confusion_matrix cm = confusion_matrix(y_true, y_pred) fig, ax = plt.subplots(figsize=(12, 10)) im = ax.imshow(cm, cmap="Blues") ax.set_xlabel("Predicted") ax.set_ylabel("True") ax.set_title("Confusion Matrix on GTSRB Test Set") fig.colorbar(im) plt.tight_layout() plt.savefig("figures/confusion_matrix.png", dpi=300)dpi=300这个参数一定要加,论文排版时图片缩放后如果分辨率不够,打印出来就是一团糊。另外建议所有图都统一用同一套配色和字体大小,整篇论文看起来会专业很多。
7.3 消融实验怎么做
消融实验是体现工作量的关键。我的建议是至少做三组对照:基线(不加任何改进)、加数据增强与类别加权、加 SE 注意力模块,每一组都记录准确率、参数量和推理耗时。三组数据放在一张表里,提升链条一目了然。
如果时间充裕,还可以补一组"不同骨干网对比",把 MobileNetV3、ShuffleNetV2、ResNet18 的结果列出来。这类表格在论文里非常受认可,因为它直接回答了"你为什么选这个模型"这个必然会被问到的问题。
注意:所有实验必须在同一套数据划分、同一套评估脚本下跑出来,不要东拼西凑。我在审核时见过有人把不同轮次的实验结果拼在一张表里,数值相互矛盾,被老师一眼看穿。
8. 常见问题与排查技巧实录
这一节是我觉得最该写、但大部分参考资料都不会写的部分。真正的坑都在运行过程中,文档里那些"顺利运行"的教程,基本没法帮你解决问题。
8.1 环境与依赖类问题
报错CUDA out of memory:先降 batch size,降一半还报就把输入尺寸从 224 改到 192。如果降到 batch 8 还报,说明显存被别的进程占着,用nvidia-smi看一下,把僵尸进程清掉。
报错ModuleNotFoundError: No module named 'torchvision'但明明装了:大概率是装了 CPU 版本又装了 GPU 版本,或者 conda 环境和 pip 环境混用。统一用 pip 在一个虚拟环境里安装,别混着来。安装 torch 的时候记得指定 CUDA 版本,装错版本会静默降级到 CPU,训练速度差十倍。
中文路径读图失败:OpenCV 的imread对中文路径支持不好,用np.fromfile加cv2.imdecode的组合替代,前面代码里已经给过了。
8.2 训练类问题速查
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| loss 一直是 nan | 学习率过大或数据含 NaN | 降 lr 到 1e-4,检查数据归一化 |
| 准确率长期卡在 20% 左右 | 标签错位或类别数配置错误 | 打印一个 batch 的标签核对 |
| 训练准确率高但实际测试差 | 数据泄露,验证集混入训练集 | 重新划分,检查路径重叠 |
| 某些类别识别全军覆没 | 类别样本极少或未被采样 | 检查采样权重,补充样本 |
| 训练速度异常慢 | DataLoader 的 num_workers 设为 0 | 改到 4 或 8,并开启 pin_memory |
我在实际项目里最常遇到的是最后一条。num_workers=0意味着数据加载在主进程里做,GPU 大部分时间在等数据,利用率可能只有 30%。改成 4 之后,同样的 epoch 时间能缩短一半。但要注意num_workers太大也会有问题,Windows 下超过 8 容易报共享内存错误,4 到 8 之间比较稳。
8.3 答辩常见追问与应对思路
老师的问题基本集中在四个方向,提前准备好答案,现场就不会慌。
"你这个创新点在哪里":不要泛泛说"用了深度学习",要具体到某个改动,比如"在 MobileNetV3 主干后加入了 SE 注意力模块,并把类别加权交叉熵和平衡采样结合使用,在少数类上召回率提升了 6 个百分点"。有数字支撑的回答才有分量。
"为什么不用 YOLO 直接做端到端":可以从任务特点回答,交通标志分类需要细粒度区分,43 类或者十几类的精度要求高,两阶段方案在分类精度上有优势,而且检测和分类解耦便于后续单独优化。
"系统能跑多快":给出实测数据,并说明测试环境。CPU 上单帧多少毫秒,GPU 上多少毫秒,分别对应什么输入尺寸。有具体数字就赢了。
"如果实际场景光照很差怎么办":可以从数据增强角度回答,说明训练时用了亮度、对比度扰动和高斯噪声,并且提到了困难样本子集上的测试结果作为佐证。
8.4 我踩过的几个坑
最后分享几条用真金白银换来的经验。第一,数据集一定要在早期就完整跑通一遍加载流程,不要等模型写完才发现图片路径扫描有问题,那时候改起来牵一发动全身。第二,实验记录千万别靠脑子记,每个超参配置存一个 yaml,结果写进 csv,等到写论文要对比数据的时候,你会发现这是最省时间的习惯。第三,权重文件要按"日期_配置_指标"命名,比如20240512_mbv3_se_acc9832.pth,训练几十次之后,只有这种命名能让你一眼认出哪个是最优的。第四,演示前一定要用一个完全没参与过训练的新场景图片测一遍,我见过太多人在自己的测试集上跑得飞起,换一张真实马路照片就完全失灵的情况。
这个题目其实还有不少可以继续深挖的方向,比如把视频流的时序信息利用起来做多帧投票,或者引入轻量的字符识别来读取限速牌上的数字,都属于在现有框架上做加法的思路。你如果时间够,随便挑一个做出来,论文的创新性就能再上一个台阶。