交通标志识别这个课题,我在定选题之前其实纠结了好一阵子。目标检测、语义分割看上去都比纯图像分类热闹,但毕业设计的周期摆在那里,算法选得太重,后期跑不动就是灾难。最后我选了“图像分类 + 残差神经网络”这条路:用 ResNet 对交通标志图片做分类,把数据、训练、评估、部署这一整条链路完整跑通,还顺手做了一个能对摄像头画面做实时识别的 Demo。这篇文章会把我从数据处理到模型调参、再到应用落地的完整过程写清楚,每步都给可复制的代码和参数,适合正在做类似毕业设计、课程项目的同学参考,也适合刚入门深度学习、想搞懂 ResNet 到底怎么用的读者。
1. 项目全貌与需求拆解
1.1 识别并不等于检测
很多同学把“交通标志识别”等同于目标检测,一上来就打算用 YOLO 或 Faster R-CNN 把标志框出来。但实际上,毕业设计里最稳妥的做法是先把问题拆成两个子任务:检测出标志在哪里,识别出标志是什么。我这个题目严格来说是“识别”,也就是拿到一个已经抠好的、只包含单个交通标志的图片,判断它是哪一类。至于标志的定位,可以通过滑动窗口、候选区域提取,或者干脆在推理阶段用目标检测器先帮我把标志裁出来,再接上我的分类模型。
这么拆分有一个很现实的好处:分类模型的训练数据非常容易准备,公开数据集里已经帮你做好了类别标注和裁剪,你不需要自己拉几百张图片去画标注框。训练时间也相对短,用残差网络这种中等规模的模型,普通显卡甚至 CPU 也能在可接受的时间内跑完。如果直接上端到端的检测算法,数据处理和训练成本都高一个数量级,论文答辩时解释起来也更费劲。
1.2 为什么选 ResNet 而不是 VGG 或者 ViT
我最终选 ResNet,核心原因有三个。第一,网络深度和精度的关系被它解决得很好,残差结构让深层网络在 ImageNet 这种大数据集上能稳定收敛,不会像 VGG 一样简单堆到二三十层就开始退化;第二,它在小数据集上也能表现稳定,配合预训练权重做迁移学习,哪怕只有几千张交通标志图,也能在几分钟内达到可用精度;第三,它结构规整,代码实现和讲解都很清晰,论文里写原理、画网络结构图都很顺手。
对比之下,VGG 虽然结构简单,但参数量大、计算冗余严重,训练速度慢还容易过拟合;Transformer 架构现在很火,ViT 在大规模数据上确实能打,可它在两三万张图片的小数据集上往往不如 CNN 稳,而且调参难度高,对显卡显存要求也大。对于本科毕业设计而言,ResNet 是性价比最高的选择。
1.3 整体技术路线怎么排
我给自己定的技术路线是五步走:数据准备与增强、模型设计、训练调优、评估分析、应用实现。第一步把公开数据集整理成统一的尺寸和格式,同时做数据增强扩大样本量;第二步把 ResNet 网络改成适配小尺寸交通标志输入的版本;第三步设计损失函数、优化器和学习率策略,开始训练并记录日志;第四步用准确率、混淆矩阵和典型错误案例去分析模型哪里还有问题;第五步把训练好的模型封装成一个能对图片和摄像头画面做实时识别的应用。这五步每一环都直接决定后一步能不能顺利进行。
2. 数据集准备与增强
2.1 数据集的选型和类别结构
我用的是一套公开的交通标志分类数据集,总共包含 43 个类别,覆盖限速、禁令、警告、指示等常见标志。类别数之所以重要,是因为它直接决定了模型最后的全连接层节点数。比如限速标志有 20km/h 到 120km/h 共 7 种,还有禁止超车、禁止驶入、停车让行、行人横道、施工警示等不同类别。每个类别的样本量并不均匀,多的有一两千张,少的可能只有一两百张,这种天然的不平衡分布给训练带来了一定挑战,但也更贴近真实场景。
数据集的划分我严格按照训练集、验证集、测试集来拆,比例大概在 7:1.5:1.5。网上很多项目把全部数据都拿来训练,最后报告的准确率虚高,因为模型很可能在训练的时候已经见过验证图片了。我这里用随机种子固定划分,保证每次实验的可复现性,也方便在论文里写清楚实验设置。
2.2 预处理流程
交通标志图片的来源很杂,尺寸从几十像素到几百像素都有,颜色空间也不统一。我统一做了这么几步:
- 全部缩放到 32×32 或 64×64 像素。小尺寸是交通标志数据集的一个特点,因为真实场景下标志在画面里本来就很小,模型需要学会在低分辨率下也能辨别类别。
- 转成 RGB 三通道。虽然很多交通标志在灰度图下也能认出来,但红色禁令标志和蓝色指示标志的色彩信息还是很有区分力的,所以保留颜色更划算。
- 像素值归一化到 [0,1] 区间。我这里不用 (x-127.5)/255 的标准做法,而是直接用归一化到 [0,1],配合预训练模型的 mean 和 std 再做一次标准化。
这里有个容易踩的坑:如果用 ImageNet 预训练权重,那么输入数据也必须按照 ImageNet 的 mean=[0.485, 0.456, 0.406]、std=[0.229, 0.224, 0.225] 来做标准化。我第一次就是直接归一化到 [0,1] 然后拿去跑预训练模型,结果收敛速度明显变慢,损失函数一直下不来,后来才发现是预处理和预训练权重不匹配。
2.3 数据增强:我用的是这四招
数据增强的核心目的是让模型对光照变化、角度变化、遮挡等情况更鲁棒。交通标志是在户外拍摄的,同一种标志在晴天、阴天、逆光条件下可能看起来差别很大,你要是只在原始图片上训练,模型很容易对背景和亮度产生过拟合。我用了四个增强操作:
- 随机旋转:在 [-15°, 15°] 范围内随机旋转,模拟摄像头安装角度不一样的情况。
- 随机亮度扰动:在 [0.8, 1.2] 倍范围内改变亮度,模拟阳光直射和傍晚光线变暗。
- 随机缩放裁剪:随机裁掉边缘 10% 的部分再缩放回原尺寸,模拟标志被部分遮挡或者没拍全的情况。
- 随机水平翻转:这里要特别注意,水平翻转只适合那些翻转后语义仍然一致的标志。很多交通标志,比如“禁止右转”,翻转后就变成“禁止左转”了,这是不允许的。所以我只对特定的几个类别做水平翻转,或者干脆全局不做翻转。
数据增强的比例不是越大越好。我之前试过把旋转角度开到 ±30°,结果是训练集上 loss 降得特别慢,因为图片转得太夸张,很多标志的关键特征都变形了。调到 15° 以内之后,明显就正常了。原则是:增强的幅度要符合真实场景,不能为了扩大数据量而制造一堆现实中根本不可能出现的图片。
2.4 类别不平衡要不要处理
前面说了,43 个类别里样本数差距很大。处理不当的话,模型会偏向样本多的类别,少数类别的准确率会变得很低。我做了一个简单的样本权重统计:每个类别的损失权重 = 总样本数 / (类别数 × 该类样本数)。这样样本少的类别在损失函数里会获得更大的权重,模型就会更重视它们。效果比较直观,少数类别的召回率提升了不少,代价是多数类别准确率稍微降了一点,但整体 F1 分数是上升的。
3. 残差网络原理与模型设计
3.1 深层网络为什么反而更难训练
在 ResNet 出现之前,大家就发现一个反直觉的现象:网络层数增加到一定深度之后,在训练集上的误差反而会变大,而且不是过拟合导致的,因为训练误差本身就下不去。有人认为这是梯度消失造成的,但其实更深层的原因是网络优化难度变大了。一个 56 层的网络理论上有足够表达能力去拟合一个 20 层网络能拟合的函数,但实际训练时它很难搜索到这个解,因为随着层数加深,非线性的变化会让梯度和特征分布都变得非常不稳定。
残差学习就是针对这个问题提出来的。作者发现,与其让网络直接学习映射 H(x),不如让网络学习残差 F(x) = H(x) - x,最后输出 H(x) = F(x) + x。这样就算 F(x) 什么都学不到,网络也至少能退化成恒等映射,不会比浅层网络更差;只要 F(x) 学到一点有效特征,网络精度就会往上涨。
3.2 残差模块的代码实现
残差模块的代码其实特别简单,用我熟悉的深度学习框架写出来大概是这样:
import torch import torch.nn as nn class ResidualBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1, downsample=None): super(ResidualBlock, self).__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.downsample = downsample def forward(self, x): identity = x out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) if self.downsample is not None: identity = self.downsample(x) out += identity out = self.relu(out) return out关键点有两个。第一是out += identity这个跳跃连接,它把输入原封不动地加到卷积输出上,梯度可以从后面的层直接回传到前面的层,缓解梯度消失。第二是 downsample 分支,当输入通道数或者特征图尺寸变化时,identity 也需要做一次 1×1 卷积或者池化来保证形状一致,否则两个矩阵没法相加。
你会发现这个结构里没有 Dropout。ResNet 主要依靠 BatchNorm 的归一化效果来抑制过拟合,同时残差连接本身有一种隐性的正则化作用。加 Dropout 反而容易破坏 BatchNorm 的统计特性,训练时表现更差。
3.3 ResNet18、ResNet34 还是 ResNet50
这三个变体我全都试过,最后稳定用的是 ResNet34。你可能觉得 ResNet50 效果肯定更好,但交通标志是 32×32 的小图片,ResNet50 的瓶颈结构是为了处理 224×224 输入设计的,直接搬到小图片上反而很浪费。ResNet18 只有 4 个残差层,训练速度快,但最后一层特征抽象能力稍弱,对相似标志的区分不够好。ResNet34 在同等的计算开销下比 ResNet18 多提取了一层特征,精度明显更高,所以它是这个任务里的甜点位。
我自己改过的网络结构大概是这样的:最前面接一个 3×3 卷积,输入通道 3、输出 64;然后依次接 3 个、4 个、6 个、3 个残差模块,每个模块内部的卷积核数量从 64 逐渐增加到 128、256、512。最后经过全局平均池化,再接到一个 43 类的全连接层上。如果不用预训练权重,直接从随机初始化训练,这个网络在公开数据集上要跑一两百个 epoch 才能收敛。用预训练权重的话就快很多,我在后面会详细讲。
3.4 迁移学习的策略
直接从头训练一个 ResNet34 不是不行,但对现代深度学习来说效率太低了。CNN 的前几层学到的基本都是边缘、纹理、颜色块这类通用特征,这些特征在不同图像任务之间是通用的。因此我加载了在 ImageNet 上预训练好的权重,然后把最后一层全连接替换成本项目的 43 类输出。
训练初期我会冻结前面大部分层,只训练最后的全连接层和最后几个残差模块,让模型先适应交通标志的数据分布。等验证集准确率涨势放缓之后,再解冻更多层,用很小的学习率对整个网络做微调。这个方法在样本量不足的时候特别好用,既能防止过拟合,又能加快收敛速度。
不过这里要提醒一句:加载预训练权重时,如果输入图片只有 32×32,而预训练模型默认卷积核感受野是针对 224×224 设计的,直接缩到 32×32 会让模型觉得自己看到了一个模糊不清的大图,效果不太好。我的解决方式是适当增加输入尺寸,把 32×32 放大到 64×64 再送入网络。这样既保留了预训练权重的优势,又不会让输入信息丢失太多。
4. 训练过程与关键参数调优
4.1 损失函数和优化器怎么选
多分类任务常规选择是交叉熵损失。PyTorch 里的CrossEntropyLoss实际上把 softmax 和负对数似然合并了,所以你在模型输出层不需要再单独加 softmax,直接输出 43 个 logits 就行,内部会自动处理。
优化器我用的是 SGD 而不是 Adam。很多初学者上来就选 Adam,因为它自适应学习率很好用,但 Adam 的一个常见问题是容易在泛化性能上吃亏,它找到的解往往不如 SGD 配合动量那么好。对于 ResNet 这种结构规整的网络,SGD + momentum 0.9 是一个非常经典且稳定的组合,收敛过程虽然看着慢一些,但最终测试准确率通常更高。当然,如果你发现 SGD 怎么调都调不动,也可以先用 Adam 快速确认代码和数据没问题,再切换回 SGD 做最终训练。
4.2 完整训练流程
我训练时的核心代码框架大概长这样:
import torch import torch.optim as optim from torch.utils.data import DataLoader model = create_resnet34(num_classes=43) model.load_state_dict(torch.load("pretrained_resnet34.pth"), strict=False) model.train() criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=60, eta_min=1e-5) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=4) valid_loader = DataLoader(valid_dataset, batch_size=64, shuffle=False, num_workers=4) best_acc = 0.0 for epoch in range(60): total_loss = 0.0 for img, label in train_loader: optimizer.zero_grad() output = model(img) loss = criterion(output, label) loss.backward() optimizer.step() total_loss += loss.item() scheduler.step() acc = evaluate(model, valid_loader) if acc > best_acc: best_acc = acc torch.save(model.state_dict(), "best_model.pth") print(f"Epoch {epoch+1}, Loss: {total_loss/len(train_loader):.4f}, Acc: {acc:.4f}")这里我每个 epoch 结束后都会在验证集上做一次评估,并且只保存验证集上表现最好的权重,而不是保存最后一个 epoch 的权重。如果你只保存最后一轮的模型,等你发现 loss 已经过拟合的时候,拿出来的可能是一个验证准确率已经回落的模型。
另外,DataLoader里的num_workers尽量设成大于 0,图片加载和预处理就可以并行执行。不过要注意,Windows 系统下num_workers设得过高有时会报内存错误,Linux 下则一般没问题,我训练时用的就是 4 到 8 个 worker。
4.3 学习率、Batch Size 和权重衰减
这几个超参数是调参的难点。先说学习率。我采用 warmup 策略:前 5 个 epoch 学习率从 0.001 线性上升到 0.01,防止模型一开始大步更新导致震荡,然后再配合余弦退火,在 60 个 epoch 内把学习率降到 1e-5。预训练权重微调阶段,学习率会降一个数量级,大概是 0.001,因为预训练模型已经处于一个比较好的局部区域,学习率大了容易把学到的通用特征破坏掉。
Batch Size 我试过 32、64、128,最终选了 64。Batch Size 太小,梯度噪声大,训练不稳定;太大,模型容易收敛到过于平坦的最小值,泛化能力反而下降。在 64 这个尺度下,一个 epoch 大概有几百个 batch,更新频率和计算效率比较平衡。权重衰减我设为 1e-4,这是 ResNet 在 ImageNet 上的默认配置,在交通标志这种中小数据集上也能有效抑制过拟合。
4.4 训练曲线怎么解读
训练过程中记录下来的 loss 和 acc 曲线是判断模型状态最重要的依据。我这边出现过两种情况。第一种是训练 loss 下降得很快,但验证 loss 压不下去,这说明过拟合了,这时候应该增大数据增强强度、提高权重衰减,或者提前终止训练。第二种是训练和验证 loss 都一直很高,说明模型还没收敛,可能是学习率太大导致震荡,也可能是预处理和预训练权重不匹配。解决办法是先用 Adam 试跑 10 个 epoch,如果 loss 能稳定下降,说明数据链路没问题;要是还降不了,就逐层打印中间特征检查是不是哪里形状不一致。
我个人的经验是,训练日志最好每 5 个 epoch 输出一次,并且定期保存 checkpoint。有一次我跑了一个通宵的实验,结果中途断电,所有进度全部丢失,从那以后我就每 10 个 epoch 自动保存一次权重,再也没丢过数据。
5. 评估分析与误差诊断
5.1 准确率之外还要看什么
毕业设计答辩时,很多同学只会报一个“模型准确率 98%”,但老师一问到细节就露馅了。完整评估应该包含:
- 总体准确率。
- 混淆矩阵。
- 每类别的精确率、召回率、F1 值。
- 典型错误图片的可视化分析。
在我的实验里,测试集总体准确率能做到 96% 到 98% 之间。但只看这个数字还不够,我画了混淆矩阵,发现好几十个类别之间的错误是可以被归类的。比如“限速 30”和“限速 50”会互相混淆,“禁止停车”和“禁止长时间停车”也会互相混淆。这些错误在颜色和形状上都非常相似,别说卷积网络,人眼在低分辨率下也经常分不清。
5.2 类别不平衡带来的影响
因为每个类别的样本数量相差很多,少数类别的召回率往往偏低。有一个类别专业叫做“其他危险”,整个训练集里只有几百张样本,模型对它的召回率只有 70%,而像“限速 50”这种样本量大的类别,召回率能达到 99.5%。对比这个数据就会意识到,光看总体准确率真的会掩盖少数类别的巨大问题。答辩时把这张表拿出来,老师会明显觉得你的分析是有深度的。
针对少数类别,我试过几种办法:重新平衡采样、类间损失权重、迁移学习。最有效的是类间损失权重加上数据增强,少数类别的召回率从 70% 提到了 85% 左右。不过,样本数实在太少的类别,再怎么调也很难和多数类别持平,这属于数据本身的极限,不完全是模型的问题。
5.3 如何利用错误案例做改进
我随机抽样了 50 张测试集上的错误样本,逐个看,发现错误可以分成三类。第一类是图片本身就非常模糊,比如雨雾天气拍的,信息量不足,这种属于理论上无法避免的错。第二类是输入的预裁剪框没有对准,标志没有完全处于画面中心,模型被背景干扰。第三类则是类间相似度高,例如空心圆和实心圆在低分辨率下界限很不明显。
针对第二类问题,我改进的方式是在推理端做细微调整:检测到标志后把裁剪框往外扩一点,让标志占画面的 90% 左右而不是刚好框死,这样模型反而能获得更多上下文信息,识别准确率更高。这个操作成本极低,但收益明显,属于那种没人告诉你你就很难想到的细节。针对第三类,改进方式是提高输入分辨率,从 64×64 提升到 96×96,准确率又涨了大约一个百分点,但训练速度也慢了不少,所以要自己衡量。
6. 识别应用实现
6.1 从模型到可运行 Demo
模型训练出来不能只存一堆权重文件,得变成一个用户能用的东西。我做了两版应用:一版是离线图片识别,命令行传入图片路径,输出识别结果和置信度;另一版是实时摄像头识别,调起摄像头画面,对每一帧画面中的交通标志进行识别并画框。为了更贴合使用场景,我考虑在视频流中做毫秒级的抽帧识别,每隔几帧检测一次,既保证流畅性,又不会让 CPU 占用率拉满。
第一版的核心代码很简单:
import torch from PIL import Image from torchvision import transforms model = create_resnet34(num_classes=43) model.load_state_dict(torch.load("best_model.pth")) model.eval() def predict(image_path): img = Image.open(image_path).convert("RGB") img = img.resize((96, 96)) tensor = transforms.ToTensor()(img).unsqueeze(0) with torch.no_grad(): logits = model(tensor) prob = torch.softmax(logits, dim=1)[0] cls_id = torch.argmax(prob).item() return class_names[cls_id], prob[cls_id].item() print(predict("test_images/speed_limit.jpg"))这里有个必须注意的点:model.eval()一定要记得调用。如果忘了切到评估模式,BatchNorm 层用的还是训练时的统计量,推理结果会因为模型仍然以意想不到的方式“更新”统计量而变得不稳定,每次运行结果都可能不一样。我初次做推理时就在这上面栽过跟头,还以为是模型没有收对,后来才发现是模式切换的问题。
6.2 实时识别的工程细节
实时摄像头识别光有模型不够,还需要先找出画面里哪里有交通标志。我在 Demo 里用了一个相对轻量的方式:先从画面中提取候选区域,再把这些候选区域交给 ResNet 分类。为了减少误检,我要求分类结果的置信度必须超过 0.85 才显示,低于这个阈值宁可漏检也不要乱检。漏检顶多是不显示,乱检会让用户觉得这个系统不可靠。
处理每一帧时,我会先做一次缩放,把长边限制到 640 像素,减少不必要的计算量。接着对候选区域做非极大值抑制,避免同一个标志被重复画好几个框。输出层我画了一个简单遮罩,只保留置信度最高的前 3 个结果,避免杂乱。
这个实时 Demo 在普通笔记本上能跑到每秒 15 到 25 帧左右,虽然没有商用系统那么流畅,但用来做课程演示和毕业答辩已经绰绰有余了。如果你觉得帧率不够,一个简单有效的方法是隔帧检测,比如每隔 2 帧才做一次完整推理,中间的帧直接沿用上一次检测结果,体验上几乎没差别。
6.3 部署时最容易忽略的事
部署到别人的电脑上,最麻烦的不是模型,而是环境。我的建议是提前导出一个 ONNX 格式的模型文件,这样对方只需要装 ONNX Runtime,不需要装完整的深度学习训练框架。ONNX 导出之后,推理速度通常比直接用训练框架还会快一点,因为少了自动求图的额外开销。量化方面,我试过把权重从 FP32 转成 INT8,体积变小三分之二,推理速度明显提升,但准确率下降了两到三个百分点。毕业设计场景下,我觉得不做量化也可以,除非你确实需要在树莓派之类的设备上跑。
7. 常见问题与排查技巧实录
7.1 环境配置折磨了我两天
我相信很多人的第一个坑一定是在环境。深度学习框架的版本之间兼容性问题特别多,CUDA、cuDNN、PyTorch 三者版本必须匹配,否则就是各种报错。我的建议是:训练服务器上用官方给的 pip 命令创建全新的虚拟环境,不要用系统自带的 Python。如果你手头有 GPU,先在终端跑一个小例子确认 CUDA 可用,再去跑训练代码,否则容易出现耗时半天结果发现模型全程在 CPU 上跑的尴尬处境。
7.2 训练时显存溢出怎么办
我刚开始跑 ResNet50 的时候,batch size 设成 128,结果直接把显存干爆了。解决办法不是马上换一个小模型,而是先算一下显存需求概数:模型参数数量乘上每个参数的位宽,再乘上 batch 中同时存在的激活值数量。最直接的做法是 batch size 降到 32 或者 64,如果还不够,就把输入图片分辨率从 96 降到 64。再不行,就启用梯度累积,把几个小 batch 的梯度攒起来一起更新,虽然训练时间变长,但效果上和用大 batch 并没有特别大的差别。
7.3 准确率一直卡在 90% 上不去
如果你的验证准确率卡在 90% 左右上不去,我的经验是先用常规手段逐项排查。
| 现象 | 可能原因 | 解决建议 |
|---|---|---|
| 训练 loss 不降 | 数据预处理错误 | 打印图片确认 tensor 值范围与通道顺序 |
| 训练 loss 降低,验证准确率停滞 | 学习率过大或过拟合 | 降低学习率,增大权重衰减 |
| 验证准确率忽高忽低 | BatchNorm 模式问题 | 确认 val 阶段调用了model.eval() |
| 个别类别准确率特别低 | 样本不均衡 | 增加类间损失权重或扩充该类数据 |
一次偶然的机会我发现,我的问题其实出在数据加载时没有对图片做标准化,导致预训练权重和输入特征分布不匹配,模型的初始损失就异常高。填掉这个坑之后,准确率直接从 90% 拉到了 96% 以上。
7.4 关于答辩和验收,我最后想多说两句
这条不是技术,但很重要。答辩时老师问问题不会只问“你的代码跑通没有”,而是会关注几个核心点:为什么选这个模型、为什么这样调参、有没有和其他方法做对比、模型失败在哪里、还有没有改进空间。我在最后阶段专门整理了三张表:一张是 ResNet18、ResNet34、ResNet50 的对比实验表,一张是不同数据增强组合的效果对比表,还有一张是典型错误案例的可视化分析图。这些内容不是花架子,是真的能让答辩过程顺畅很多。
踩过这些坑之后,我再回过头看这个课题,体会最深的一点是:毕业设计真正锻炼人的不是把网络跑通,而是弄清楚每一个选择背后的原因。你把 ResNet 的跳跃连接为什么有效讲明白了,把交叉熵为什么比 MSE 更适合分类讲明白了,把为什么预训练要冻结前几层讲明白了,这套知识的迁移能力就会远远超过这一个课题本身。项目做完之后,把 checkpoints、ONNX 模型和推理脚本都归档好,以后面试或申请项目时拿出来稍微改造一下,也能成为很不错的作品集素材。