
简介一套基于Python实现的多模态垃圾分类系统课程设计资料包定位清晰面向需要完成课程设计或学习多模态识别的计算机、环境工程等专业学生针对人工垃圾分类效率低、易出错的问题提供一套可运行的完整方案。系统融合图像识别与文本描述两种输入方式覆盖可回收物、有害垃圾、厨余垃圾和其他垃圾四类垃圾分类并对分类准确率与响应时间做了工程性设计。压缩包共含1282个文件整体约77.96MB以621个py源码、544个pyc编译文件为主辅以proto、json、xml等配置描述文件以及txt、md等说明文档还包含Python运行环境与依赖组件便于直接运行和二次开发。资源已有148人学习下载可作为课设参考或项目脚手架。资料内含完整源码、课设报告和项目文档从需求分析、分层架构、数据预处理、特征提取到分类模型与用户界面均有涉及有助于快速理解多模态垃圾分类的实现思路与关键细节。1. 多模态垃圾分类课设包到手后先看这几件事如果你刚拿到一份“基于python实现多模态垃圾分类系统源码课设报告项目文档.zip”大概率是冲着“多模态”这三个字来的。单模态的图像分类课设已经太多答辩评委一眼就能看穿而把图像和文本两块信息一起送进模型再融合出分类结果听起来更像一个完整的AI系统也更容易在报告里写出东西来。这套系统实际做什么呢用户上传一张垃圾照片可能再补一句“矿泉水瓶”“塑料的”之类的描述系统综合判断它属于可回收物、厨余垃圾、有害垃圾还是其他垃圾输出类别和置信度同时还带一个简单的Web界面或桌面端。适合谁用需要完成毕设、课程设计的学生或者想在企业内部快速验证“多模态感知”方案的工程师。但先别急着解压跑源码——这类包最容易出的问题是把“多模态”当成两张模型的拼接而不是真正的特征融合。这篇笔记我会从方案选型、数据准备、模型训练到部署踩坑逐层拆开讲讲。2. 为什么“图像文本”多模态能提高垃圾分类准确率方案与选型依据2.1 单模态垃圾识别的瓶颈看图猜不中、看文字又没上下文做垃圾分类最常见的第一版方案是“端到端图像分类”一个ResNet或者MobileNet输入图片输出类别。这个方案在公开垃圾分类数据集上效果不错因为那个数据集里的瓶子是标准的瓶子、纸盒是标准的纸盒光线和背景非常干净。但课设答辩时往往要现场演示手机拍一张照片画面里存在反光、遮挡、手部阴影甚至目标是半埋在快递纸箱里的奶茶杯。这时候纯图像模型很容易翻车它可能把奶茶杯识别成“塑料杯”然后因为轮廓像纸杯兜兜转转给出一个矛盾结果。反过来如果只靠用户输入的文字呢比如一句话“没有喝完的珍珠奶茶”里面没有“塑料”也没有“纸”只有一个“奶茶”词语言模型很难直接对应到正确的垃圾类别。何况课设阶段的文本分类多半是短文本输入长度不超过几十个字信息量非常有限。所以图像和文本是天然互补的图像能看到材质、颜色、形状文本能点出用户观察到的关键属性比如“易拉罐是铝的”“废旧电池有害”。真正的多模态不是把两个结果平均一下而是让两类信号在模型中层发生交互互相修正才能把准确率从80%拉回90%以上。2.2 多模态融合的三种常见结构课设选哪种更稳多模态融合在结构上大致分三种。早期融合early fusion把图像特征向量和文本词向量直接拼成一个长向量再接分类器。优点是实现极简、训练快缺点是两种特征不在一个语义空间里图像特征是ResNet的高维视觉语义文本特征是Word2Vec或BERT的词义强行拼接常常让网络花很多轮才知道哪一维对应的是什么。晚期融合late fusion是两个模态分别出预测概率最后加权平均。这种方案最不容易翻车每个分支独立训练准确率有下限保障但严格说只能叫“决策融合”论文里通常不算真正的多模态融合答辩评委一句“你到底融合在哪一层”就能问住你。中间融合intermediate fusion是推荐做法图像网络提取到倒数第二层特征图文本网络提取到句子向量两者通过拼接或注意力机制融合再接全连接层。这种既能体现“中间层特征交互”训练也相对稳定。如果课设包的源码里只有晚期融合我会优先把它改成中间融合改动量不大但报告的深度一下就上去了。2.3 技术栈选型PyTorch 预训练权重 FastAPI 的落地搭配数据量方面课设通常只有几千到一万张图片直接用ResNet50从零开始训练一定会过拟合。常规做法是加载torchvision里ImageNet预训练权重冻结前若干层只微调高层。文本分支如果也上BERTCPU机器训练会非常痛苦所以更稳的选择是DistilBERT或多语言小模型如果文本只是“矿泉水瓶 可回收”这类短描述用TF-IDF加一个全连接层也能打而且速度肉眼可见地快。骨架选完接口和界面也需要照顾到课设答辩的演示环节。我习惯用FastAPI暴露一个 POST /predict 接口前端用一个最简单的HTML页面拖拽上传图片、填写描述框、点击识别。FastAPI自带/docs 文档页评委如果感兴趣可以直接看接口定义这比Tkinter做的桌面程序要耐看也比Flask异步性能好。最后模型导出为ONNX格式既可以在CPU上跑到30到50毫秒的单张推理也能在答辩时强调“工程化”。模块推荐选型理由图像分支ResNet18 / EfficientNet-B0ImageNet预训练参数量小不容易过拟合文本分支DistilBERT 或 TF-IDFMLP短文本场景下性价比高融合方式中间融合concat 注意力答辩站得住脚效果也好后端FastAPI Uvicorn异步性能好自带接口文档前端一个HTML页面 fetch演示直接拖图片不依赖外部服务选PyTorch还有一个理由是“生态里好抄作业”图像分支和文本分支的预训练权重都能从HuggingFace或官方渠道下载不需要自己训大模型。后面章节里的代码我都基于PyTorch 2.x编写注意CUDA不是必须的纯CPU也能跑通只是训练时间会慢三到五倍。3. 数据准备构建“图像-文本”多模态训练对含可复现代码3.1 垃圾类别体系与多模态数据集来源课堂演示用的垃圾类别不宜太细。常见四种是可回收物、厨余垃圾、有害垃圾、其他垃圾如果你有精力细分可以扩成六类塑料、玻璃、金属、纸类、织物、有害垃圾。但从零标注一个细粒度数据集的时间成本很高而且细分类的边界会有争议比如一个锡纸盒是金属还是纸评委自己都说不准到时候反而分散注意力。数据来源有三个方向一是直接用公开数据集比如华为垃圾分类数据集、各地市政发布的生活垃圾图集二是自己从网络用爬虫按类别抓图但要注意版权和图像去重否则训练集里两张几乎一样的图会让验证集准确率虚高三是手工拍摄找一个下午拍200到300张正好作为验证集或测试集你的模型如果能在自己拍的照片上表现好答辩基本稳了。多模态数据的“文本”部分建议不要用复杂长句避免模型把太多注意力放在语气词和标点上。常见做法是生成一组结构化描述模板例如“一个矿泉水瓶塑料材质可回收”“坏掉的灯泡玻璃外壳有害”“剩菜剩饭厨余垃圾”有这些模板后再配合文件名里的关键词就能自动生成图像对应的描述作为训练文本。这里有个关键点训练时描述要与真实场景一致。如果用户上传图片后系统要求他输入一句描述那么训练时文本也应当是完整句子而不是单独一个名词。3.2 离线数据增强、文本清洗与加载器编写数据增强的重点是让图像分支更鲁棒但又不能破坏类别本身的关键特征。像垃圾这种物体旋转和翻转是安全的颜色抖动要慎用——垃圾分类对颜色比较敏感饮料瓶的绿色和透明色在部分模型看来很重要颜色增强太猛反而会把“绿茶瓶”搞成“玻璃瓶”。推荐的增强组合是随机水平翻转、随机旋转±15度、随机缩放裁剪到224x224像素加很小的色彩抖动亮度±0.2对比度±0.2。文本清洗相对简单统一小写、把中文标点替换为英文空格、去掉特殊符号然后做截断一般限制在64个词以内。不要轻易做同义替换比如“矿泉水瓶”替换成“纯净水桶”语义并不完全一致对短文本模型的伤害比收益大。下面给出一个数据加载器的写法它会读CSV文件每一行包含图片路径、文本描述、类别ID。import pandas as pd import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class GarbageMultiModalDataset(Dataset): def __init__(self, csv_path, img_root, max_text_len64): self.df pd.read_csv(csv_path) self.img_root img_root self.max_text_len max_text_len # 图像增强训练阶段用复杂组合推理阶段只用缩放 self.train_transform T.Compose([ T.RandomHorizontalFlip(p0.5), T.RandomRotation(degrees15), T.RandomResizedCrop(size224, scale(0.7, 1.0)), T.ColorJitter(brightness0.2, contrast0.2, saturation0.1), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) self.val_transform T.Compose([ T.Resize(256), T.CenterCrop(224), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img_path f{self.img_root}/{row[image_name]} image Image.open(img_path).convert(RGB) image self.train_transform(image) # 训练时用增强验证时替换为 val_transform text str(row[description]).lower()[:self.max_text_len] label int(row[label_id]) return { image: image, text: text, label: torch.tensor(label, dtypetorch.long) }这段代码的说明transform里的RandomResizedCrop做的是随机裁剪再缩放模拟不同距离下垃圾占画面比例不同的情况ColorJitter的saturation只给了0.1避免颜色失真。文本只做了简单截断因为后面文本分支是DistilBERT它会自己添加tokenizer如果使用TF-IDF这串文本还会再走一步词表映射。注意CSV里“description”列是纯文本不能为空如果字段为空我会补一个类别默认描述比如“垃圾物品”否则后面tokenizer会报错。3.3 文本编码方式用分词器把描述变成向量加载器输出的是原始字符串模型并不能直接处理所以Dataset里通常还要内置一个tokenizer。这里建议在主程序初始化时创建一个DistilBERT分词器然后在__getitem__里调用但注意这样每次取数据都会重复调用tokenizer速度较慢。更稳妥的做法是在训练前预处理一次把所有文本tokenize后的input_ids保存到内存或磁盘再供训练循环读取。下面的例子是在初始化时就完成全部tokenizefrom transformers import AutoTokenizer import numpy as np class GarbageMultiModalDataset(Dataset): def __init__(self, csv_path, img_root, tokenizer_namedistilbert-base-uncased): self.df pd.read_csv(csv_path) self.tokenizer AutoTokenizer.from_pretrained(tokenizer_name) # 预先Tokenize文本避免训练时反复分词 texts self.df[description].fillna().astype(str).tolist() encoded self.tokenizer( texts, paddingmax_length, truncationTrue, max_length64, return_tensorsnp ) self.input_ids encoded[input_ids] self.attention_mask encoded[attention_mask] # ... 图像transform同上一段代码这里的关键参数是max_length64垃圾桶描述一般不会超过20个字64足够paddingmax_length会让所有序列长度相同方便组成batch。我把input_ids和attention_mask都存成numpy数组训练时直接转torch张量而不是每次调用tokenizer。如果课设包源码把这个预处理放在batch里跑慢一点记得先从这步优化。4. 双流模型实现与训练参数怎么调、损失怎么设4.1 图像分支与文本分支的骨干网络选择图像分支我默认用ResNet18它比ResNet50轻很多在课设的几千张图上足够编码视觉特征。取model.fc之前的输出得到一个512维的特征向量。用torchvision.models里的预训练权重初始化后只微调最后两层的参数冻结前面所有层。这个选择有两点考量第一垃圾图像的视觉模式瓶、罐、纸张在ImageNet里能找到很接近的低级特征第二冻结前面的层可以大幅减少显存占用如果你只有显卡也只有2GB显存这是活命的关键。文本分支如果上DistilBERT句子向量取pooler_output或最后一层的[CLS]位置维度是768。但DistilBERT每张图要跟着过一次前向训练时间会增加不少。如果对速度有要求可以把4960维的TF-IDF特征直接过一个两层MLP输出256维向量。两者在融合层面前都能正常工作只是后者的语义表达弱一些。这里给一个建议课设报告里可以明确写“图像分支提取材质、形状特征文本分支提取属性词特征两者在中间层拼接”。但代码里要保证这两个分支是可替换的——图像分支和文本分支分别定义为独立模块方便单独做消融实验。答辩时被问到“哪个模态贡献更大”你就可以现场把融合层的两个输入分别置零来验证。4.2 融合层设计与分类头拼接、注意力还是门控最简单的融合是torch.cat([img_feat, text_feat], dim1)再接一个MLP做分类。这个方案做消融实验非常方便但有一个隐患图像特征512维文本特征768维特征尺度不一致网络训练初期融合层容易偏向维度大的一侧。我一般会在拼接后先过一层LayerNorm再送入分类头。进阶一点可以用门控注意力。给两个模态分别算一个权重系数公式是g sigmoid(W * concat(img_feat, text_feat))然后用g * img_feat和(1 - g) * text_feat加权后再拼接。这个门控机制在课设里不算复杂但报告能多写两页而且确实能提高融合稳定性。下面是模型定义的示例代码import torch import torch.nn as nn import torchvision.models as models from transformers import DistilBertModel class MultimodalGarbageClassifier(nn.Module): def __init__(self, num_classes4, text_dim768): super().__init__() # 图像分支ResNet18去掉最后的全连接层 resnet models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) self.image_backbone nn.Sequential(*list(resnet.children())[:-1]) self.image_fc nn.Linear(512, 512) # 文本分支DistilBERT只使用其编码器输出 self.text_backbone DistilBertModel.from_pretrained(distilbert-base-uncased) self.text_fc nn.Linear(text_dim, 512) # 融合层门控权重 分类头 self.gate_fc nn.Linear(512 512, 2) self.classifier nn.Sequential( nn.LayerNorm(1024), nn.Linear(1024, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(256, num_classes) ) def forward(self, image, input_ids, attention_mask): # 图像特征输出是 (B, 512, 1, 1)先展平 img_feat self.image_backbone(image).flatten(1) img_feat torch.relu(self.image_fc(img_feat)) # 文本特征取 [CLS] 向量 text_out self.text_backbone(input_ids, attention_maskattention_mask) text_feat self.text_fc(text_out.last_hidden_state[:, 0]) # 门控融合计算两个模态的加权系数 gate_input torch.cat([img_feat, text_feat], dim1) gate torch.softmax(self.gate_fc(gate_input), dim1) img_gated img_feat * gate[:, 0].unsqueeze(1) text_gated text_feat * gate[:, 1].unsqueeze(1) fused torch.cat([img_gated, text_gated], dim1) return self.classifier(fused)参数说明image_backbone里面的BatchNorm层在预训练模式下表现最好训练时要把整个模型设为train模式但如果有冻结层要特别注意BatchNorm的running_mean在冻结层不会被更新这通常没问题text_backbone输出维度是768text_fc把维度降到512和图像特征对齐这样融合层不会偏向某一侧门控的权重只有2维学习起来很快我通常会把它作为整个模型里最稳定的部件看待。一个实用技巧训练初期门控可能不稳定可以在门控的gate_fc输入前加一个可学习的温度参数初始值调到1如果训练时门控几乎不更新再把温度调高到3或5。温度参数说白了就是放大gate_fc输出的logit差距让softmax结果更接近0或1。4.3 训练超参配置与过拟合控制含代码训练阶段有三个超参值得认真对待学习率、batch size、梯度裁剪。图像分支因为用了预训练权重学习率设置在1e-4附近文本分支用的是预训练的DistilBERT学习率要更低5e-6到2e-5比较安全融合层新鲜训练可以给3e-4。不同模块不同学习率这个细节在课设报告里写出来会让人觉得你真的调过参。损失函数直接用CrossEntropyLoss不用加额外的正则项Dropout已经承担了部分过拟合压力。优化器我推荐AdamW因为它在权重衰减和Adam之间处理得比标准Adam合理。下面给出一个训练循环的核心片段optimizer torch.optim.AdamW([ {params: image_model.parameters(), lr: 1e-4}, {params: text_model.parameters(), lr: 5e-6}, {params: fusion_head.parameters(), lr: 3e-4} ], weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max12, eta_min1e-6 ) for epoch in range(12): model.train() for batch in train_loader: image batch[image].cuda() input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() label batch[label].cuda() output model(image, input_ids, attention_mask) loss loss_fn(output, label) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm2.0) optimizer.step() # 验证阶段计算准确率 model.eval() total_correct 0 total 0 with torch.no_grad(): for batch in val_loader: image batch[image].cuda() input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() label batch[label].cuda() output model(image, input_ids, attention_mask) pred output.argmax(dim1) total_correct (pred label).sum().item() total label.size(0) print(fEpoch {epoch1}: val_acc {total_correct/total:.4f}) scheduler.step()训练说明clip_grad_norm_只截断整体梯度范数防止文本分支因为预训练权重过大的梯度导致参数跳飞CosineAnnealingLR配合12个epoch刚刚好如果课设时间不够也可以固定epoch为8。文本分支的学习率低得吓人这是有意为之因为DistilBERT本身已经在大量英文上预训练过垃圾描述只是微调学得太快容易灾难性遗忘。如果训练时loss震荡得像心电图第一反应不是调学习率而是检查batch里的label分布是否严重不平衡。垃圾分类的公开数据里“其他垃圾”往往占比过高我会用WeightedRandomSampler给少数类加大采样权重否则融合模型会把所有样本都预测成多数类。5. 常见问题与避坑多模态垃圾分类从训练到部署的 5 个典型翻车现场5.1 现象图像和文本loss降得很慢准确率卡在 60%原因通常是两个模态输入在数值尺度上差异太大。图像输入经过Normalize后是均值为0、方差1的张量而文本的input_ids是0到30522之间的整数索引。如果把原始input_ids直接丢给模型梯度会被文本分支的embedding大值带着走图像分支几乎学不到东西。另一个原因是学习率策略太粗糙用同一个学习率训练两个预训练分支。解决方法是先把文本通过Embedding层变成连续向量其分布仍然很大需要LayerNorm或者直接把文本分支的学习率降到图像分支的1/20。我在实际调参时会打印每个分支各自loss的贡献但更简单的是观察融合层的特征标准差——如果融合层某一维特征的标准差显著大于其他维就在融合前加LayerNorm或者用BatchNorm替换。5.2 现象各模态单独测试比融合后还高这是课设里最诡异的情况单独送图像准确率85%单独送文本准确率82%两个一起送进去反而跌到80%。原因多半是融合层的结构太复杂数据量不足以撑起参数量。门控机制参数虽然不多但文本分支如果只有一层线性映射整个融合层也需要几百个参数几千条数据很容易让融合层对训练集死记硬背。解决思路是先回到拼接LayerNorm的极简融合把分类头变成单层256维再输出看看能不能超过单模。如果不能问题就在数据矛盾上——同一个图像配了不同文本模型不知道该听谁的话。这时要把文本清洗得更严格删除语义含糊的描述比如“一个东西”“垃圾”这类无信息量文本。还有一个血泪经验验证集要和训练集完全同分布如果训练集是从网上抓的干净图验证集是自己手机拍的那么融合模型的鲁棒性再强也会被脏水淋头看起来“不如单模”其实是验证集难度差异造成的。5.3 现象课设报告里写的“多模态”其实是伪多模态有一部分源码包的作者会把“多模态”理解成“图像模型和文本模型各跑各的最后把概率取平均”。这种模块在报告里画结构图时是两个独立分支没有共享层也没有特征融合。答辩时评委只要问“你的多模态体现在哪”回答“最后平均了一下”就会被认定是伪融合。如果你拿到的是这样的源码包考虑到时间和风险我建议改成中间融合在两个分支的网络中间层比如图像是最后一层卷积的输出文本是最后一层Transformer编码器的输出做一次concat然后接分类头。这也是最稳妥的“真融合”改法因为不用大改数据加载器和训练流程只要把两个backbone的forward打印出来找到倒数第二层的输出位置就行。5.4 现象导出ONNX时动态轴配置出错课设最后的交付要一个可运行的部署包很多人喜欢导出ONNX。但直接用torch.onnx.export导出动态序列长度的文本模型时经常会见到 “Input axis 1 of input_ids is dynamic but corresponding dimension is 0” 这类报错。原因是在导出时没有显式声明哪些维度是动态的或者文本序列长度被固定成了64但论文里却写着支持任意长度。正确做法是导出时设置dynamic_axes把batch维和序列长度维都放开同时要保证模型内部的操作支持动态shape——比如某些卷积层的Flatten操作写死了维度就导出失败。我用的是最简单的固定序列长度方案导出时把max_length固定为64接口层做padding和截断告诉用户“系统支持最多64个字符的描述”。这样动态轴只用batch一个维度稳定得多。5.5 现象界面卡死、推理慢实时分类跟不上FastAPI接口本身异步性能不差卡顿通常出在图像解码和预处理。上传过来的图片可能是几MB的高清照片如果直接用PIL打开然后resize到224x224解码时间比模型推理时间还长。解决方法是接口层先把图片按比例缩放到短边256像素再居中裁剪降低解码压力。另外需要注意把模型加载到内存后永远不要每次请求都重新加载权重FastAPI里可以用lifespan事件加载一次然后在整个进程生命周期复用。推理慢的另一个坑是PIL在读取Exif方向信息时会自动旋转但这步耗时有时比模型本身还久。如果输入图片格式统一来自手机或网页上传可以在前端压缩一次后端收到后直接执行长边缩放再进入模型。训练和部署两张皮的问题也要留意训练时用RandomResizedCrop做数据增强但部署时如果用Resize(256)CenterCrop两者对图片的几何变换不一致会导致模型性能下降。我踩过一次后在部署预处理里固定为Resize(256)CenterCrop(224)在训练增强里保留RandomResizedCrop保证分布匹配。6. 交付升级用 Grad-CAM 把模型的“注意力”可视化让课设答辩更硬核如果模型瓶颈已经解决准确率也到了85%以上最后值得做的一件事是可视化多模态模型到底在看哪里、听什么。答辩时直接贴一张热力图比任何表格都有说服力。Grad-CAM的思路是对图像分支最后一个卷积层输出的特征图用分类梯度加权求和生成一张和原图分辨率近似的热力图。对于这个多模态系统我会把融合层对图像分支特征的梯度作为加权信号而不是最后分类层的梯度这样热力图能体现“文本信息引导图像关注”的效果。def grad_cam(model, image, input_ids, attention_mask, target_class): model.eval() # 注册钩子拿图像分支最后一个卷积层的输出 features [] def hook_fn(module, input, output): features.append(output) target_layer model.image_backbone[-1] # 以ResNet倒数第二层为例 hook target_layer.register_forward_hook(hook_fn) image.requires_grad_(True) output model(image, input_ids, attention_mask) model.zero_grad() score output[0, target_class] score.backward() hook.remove() # 对梯度求通道均值再和特征图加权 grad image.grad.abs().mean(dim(1, 2), keepdimTrue) weights grad.mean(dim(2, 3), keepdimTrue) cam (features[0] * weights).sum(dim1, keepdimTrue) cam torch.relu(cam) cam torch.nn.functional.interpolate( cam, sizeimage.shape[-2:], modebilinear, align_cornersFalse ) return cam.squeeze().detach().cpu().numpy()这段代码有几个值得注意的点。hook_fn里的features是列表因为钩子每次前向都会回调如果批量处理多张图要把features.clear()放在前向之前我这里是单图示例所以没问题。用grad.backward()计算梯度时要注意模型里的BatchNorm在eval模式下用的是历史均值梯度计算路径是通的但依赖的输入必须是原始Tensor不能是它的切片副本。多模态模型里图像分支路径和文本分支路径共享同一个分类损失所以score.backward()会把梯度传回文本分支也会影响图像分支的修改这里对图像输入做requires_grad_(True)是为了拿输入像素的梯度如果只想看特征图贡献可以把这行去掉只计算特征图对特征图的加权和。我做课设时习惯把Grad-CAM得到的热力图覆盖到原图上再用matplotlib保存成一个三宫格原图、热力图、热力图叠加原图。放到课设报告里配上图注“文本提示‘易拉罐’后模型将注意力集中在罐体上部”答辩老师基本会点头。最后还有一个实际交付技巧把模型导出的ONNX文件和Web接口一起打包启动脚本写死默认端口README里注明Python版本和依赖项。每学期找我改源码的同学里有一半是卡在环境配置上所以我会在启动脚本里加一段自动检测CUDA和CPU的基础代码确保没有显卡的电脑也能用CPU模式跑起来。回头复盘我对这类课设包最大的教训是拿到任何源码先花半小时跑通训练流程确认数据集和代码能对上再谈优化。不要一上来就改网络结构——多模态系统的坑往往藏在数据处理而不是模型代码里。希望这篇笔记里的方案能帮你在复现和课设报告两条路上都少走几个弯。本文还有配套的精品资源点击获取