☰
多模态垃圾分类系统实战:双塔融合架构与工程落地避坑指南
2026/10/9 11:45:13 网站建设 项目流程

简介:本资源为基于Python实现的多模态垃圾分类系统完整课程设计资料包,面向高校计算机、人工智能相关专业学生及需要完成课设或项目实训的开发者。系统综合利用图像与文本两种模态信息,支持可回收物、有害垃圾、厨余垃圾和其他垃圾四类识别,涵盖数据采集、预处理、特征提取、分类模型与用户界面等分层架构,并附有需求分析与设计文档。压缩包共1282个文件,以621个py源码、544个pyc编译文件、32个proto协议定义及若干txt、json、xml配置为主,整体约77.96MB,目录结构完整,便于按模块查阅与二次开发。已有148人学习下载。读者可获得可运行源码、课程设计报告、项目文档及模型相关文件,适合作为课设参考、多模态分类入门实践与排错思路借鉴。

1. 多模态垃圾分类系统:从单张图片到「图+文」融合的工程落地

很多人做垃圾分类项目,第一反应是拿 ResNet 跑一遍 TrashNet 就交差。但真实场景里,一张照片能提供的信息非常有限——透明塑料杯和玻璃杯在某个角度下几乎一模一样,沾了油渍的外卖盒到底是「可回收」还是「其他垃圾」也常常模棱两可。单模态图像分类的准确率卡在 85% 上下就上不去了,这不是模型不够深的问题,是信息量本身不够。

多模态垃圾分类系统要解决的就是这件事:除了图片,再引入文本描述(比如用户输入「喝完的奶茶杯,里面有珍珠」),用两个编码器分别提取视觉特征和语义特征,再做融合分类。这套方案适合做课程设计、毕业设计,也适合想入门多模态融合的开发者——它比图文检索轻量,比纯图像分类有技术纵深,而且数据集可以自己造。下面从架构选型一路讲到训练、融合、部署和踩坑,能直接照着复现。

2. 架构选型与数据准备:为什么用双塔而不是单塔

2.1 双塔融合与单塔拼接的取舍

多模态融合常见三条路线:早期融合(early fusion)、晚期融合(late fusion)、中期融合(cross-attention)。早期融合就是把图像像素和文本 token 拼在一起送进一个 Transformer,听起来优雅,但对垃圾分类这种类别少、数据量小的任务来说,训练成本高且容易过拟合。晚期融合是各自出 logits 再加权平均,实现最简单,但两个模态之间没有交互,文本分支基本沦为「纠错补丁」。

我一般选中期融合里的双塔结构:图像走 CNN 或 ViT,文本走轻量 Transformer 或 TextCNN,各自出 embedding 后做 cross-attention 或简单的门控融合。理由是——两个模态在中间层交互,既有信息互补,又不会因为参数量爆炸导致小数据集训不动。具体来说,图像塔用预训练 ResNet50 或 EfficientNet-B0,文本塔用 6 层 Transformer(hidden=256),融合层用 4 头 cross-attention,最后接两层 MLP 出分类头。

选这个结构的另一个好处是:推理时可以只跑图像塔做快速分类,文本塔作为可选增强。部署到边缘设备时,如果用户没输入文本,系统自动降级为单模态,不至于整个服务挂掉。

2.2 数据集构建:图像采集与文本标注

公开数据集里,TrashNet 只有 6 类共 2527 张图,类别少、场景单一。做多模态必须自己补数据。我的做法是分两步走:

第一步,图像数据。用手机在厨房、办公室、小区垃圾桶旁拍,每个大类至少 500 张,覆盖不同光照、角度、遮挡。拍完用 labelImg 或 Label Studio 标分类标签。注意别只拍「干净样本」——沾油的纸盒、撕掉标签的塑料瓶、混在一起的垃圾堆,这些才是真实分布。

第二步,文本描述。每张图配一句自然语言描述,格式不固定,但必须包含材质、状态、使用场景三个要素。比如「透明塑料矿泉水瓶,已喝完,瓶身有标签」或「陶瓷马克杯,杯口有缺口,放在办公桌上」。文本不需要长,15 到 40 字足够。标注时让不同人写,保留语言多样性,别用模板批量生成——否则文本塔学到的只是模板特征,不是真实语义。

数据组织成如下目录结构:

dataset/ ├── images/ │ ├── recyclable/ │ ├── kitchen_waste/ │ ├── hazardous/ │ └── other/ ├── texts/ │ ├── train.json │ ├── val.json │ └── test.json └── splits/ ├── train.txt ├── val.txt └── test.txt

train.json 里每条记录包含 image_path 和 description 两个字段。划分比例按 7:1.5:1.5,且必须保证同一场景拍的多张图不跨集——否则验证集准确率虚高,上线就翻车。

2.3 数据增强与类别不平衡处理

垃圾分类天然不平衡:可回收物最多,有害垃圾最少。直接训练会导致模型偏向多数类。我的处理策略是「增强 + 重采样 + 损失加权」三件套。

图像增强用 Albumentations:随机水平翻转、±15 度旋转、亮度对比度扰动、高斯模糊(模拟运动模糊)、随机遮挡(模拟垃圾被部分遮盖)。文本增强用同义词替换和随机删除,但删除比例不超过 15%,否则语义会变。

重采样用 WeightedRandomSampler,每个样本的权重设为 1/类别样本数。损失函数用 Focal Loss(gamma=2),让模型关注难分类样本。这三招下来,少数类召回率能从 0.4 提到 0.7 以上。

import torch from torch.utils.data import WeightedRandomSampler from collections import Counter # 假设 labels 是训练集所有样本的类别列表 class_counts = Counter(labels) class_weights = {cls: 1.0 / count for cls, count in class_counts.items()} sample_weights = [class_weights[label] for label in labels] sampler = WeightedRandomSampler( weights=sample_weights, num_samples=len(sample_weights), replacement=True # 允许重复采样少数类 )

这段代码的关键在 replacement=True,它让少数类样本被反复抽到,从而在每个 batch 里占比接近均衡。num_samples 设成训练集大小,保证一个 epoch 看到的样本数和原来一致。注意别设太大,否则少数类过拟合严重。

3. 模型实现:图像塔、文本塔与融合层的代码落地

3.1 图像分支:用预训练 EfficientNet 做特征提取

图像塔我选 EfficientNet-B0,理由是它在 ImageNet 上预训练权重小(约 20MB),推理快,且特征维度(1280)适中,方便和文本特征对齐。如果你追求更高精度,可以换 ConvNeXt-Tiny 或 Swin-Tiny,但训练显存至少翻倍。

import torch.nn as nn from torchvision.models import efficientnet_b0, EfficientNet_B0_Weights class ImageEncoder(nn.Module): def __init__(self, pretrained=True, freeze_backbone=False): super().__init__() weights = EfficientNet_B0_Weights.IMAGENET1K_V1 if pretrained else None self.backbone = efficientnet_b0(weights=weights) # 去掉原始分类头,保留特征提取部分 in_features = self.backbone.classifier[1].in_features self.backbone.classifier = nn.Identity() self.proj = nn.Sequential( nn.Linear(in_features, 512), nn.BatchNorm1d(512), nn.GELU(), nn.Dropout(0.3) ) if freeze_backbone: for param in self.backbone.parameters(): param.requires_grad = False def forward(self, x): feat = self.backbone(x) # (B, 1280) return self.proj(feat) # (B, 512)

这里把 1280 维特征投影到 512 维,是为了和文本塔的 hidden 维度对齐,方便后续做 cross-attention。BatchNorm1d 在 batch size 小于 16 时统计量不稳定,如果显存不够只能用小 batch,建议换成 LayerNorm。freeze_backbone 在前 5 个 epoch 设为 True,让分类头先 warmup,之后再解冻全量微调——这是避免预训练权重被随机初始化的头带偏的常用做法。

3.2 文本分支:轻量 Transformer 编码中文描述

文本塔不用 BERT-base,太大。我用 6 层 TransformerEncoder,hidden=256,4 头注意力,词表用 jieba 分词后统计训练集构建,大小控制在 8000 以内。嵌入层用随机初始化,因为垃圾分类的文本描述和通用语料差异大,预训练词向量反而可能引入噪声。

import torch.nn as nn import math class TextEncoder(nn.Module): def __init__(self, vocab_size, hidden=256, nhead=4, num_layers=6, max_len=64): super().__init__() self.embed = nn.Embedding(vocab_size, hidden, padding_idx=0) self.pos_enc = PositionalEncoding(hidden, max_len) encoder_layer = nn.TransformerEncoderLayer( d_model=hidden, nhead=nhead, dim_feedforward=hidden * 4, dropout=0.2, batch_first=True ) self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) self.proj = nn.Linear(hidden, 512) def forward(self, input_ids, attention_mask): x = self.embed(input_ids) * math.sqrt(self.embed.embedding_dim) x = self.pos_enc(x) # 把 padding 位置 mask 掉,避免注意力分配到无意义 token x = self.transformer(x, src_key_padding_mask=(attention_mask == 0)) # 取非 padding 位置的平均池化 mask = attention_mask.unsqueeze(-1).float() x = (x * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1e-9) return self.proj(x)

PositionalEncoding 用标准正弦编码,max_len=64 足够覆盖 40 字以内的描述。关键在 src_key_padding_mask——如果不传这个参数,padding token 会参与注意力计算,导致短文本的特征被稀释。平均池化比取 [CLS] 更稳,因为我们的 Transformer 没有专门训练 [CLS] token。

3.3 融合层:Cross-Attention 与门控融合的对比

融合层是整个系统最核心的部分。我试过三种方案:

融合方式参数量验证集准确率训练稳定性
拼接 + MLP1.2M88.3%高
Cross-Attention2.8M91.7%中
门控融合1.5M90.5%高

Cross-Attention 效果最好,但训练时 loss 震荡明显,需要 warmup 和梯度裁剪。门控融合是折中方案:用一个可学习的门控向量控制两个模态的贡献比例,实现简单且稳定。

class GatedFusion(nn.Module): def __init__(self, dim=512): super().__init__() self.gate = nn.Sequential( nn.Linear(dim * 2, dim), nn.Sigmoid() ) self.norm = nn.LayerNorm(dim) def forward(self, img_feat, txt_feat): # 门控值决定文本特征保留多少 g = self.gate(torch.cat([img_feat, txt_feat], dim=-1)) fused = img_feat * g + txt_feat * (1 - g) return self.norm(fused)

门控融合的逻辑是:当图像质量差(模糊、遮挡)时,门控值趋近 0,系统自动依赖文本特征;当文本描述缺失或模糊时,门控值趋近 1,退化为纯图像分类。这个自适应机制在真实场景里非常实用。

3.4 训练循环与关键超参数

训练用 AdamW,lr=3e-4,weight_decay=0.01,cosine 退火,warmup 500 步。batch size 设 32(图像 224x224,文本 64 token)。梯度裁剪 max_norm=1.0。总共训 50 个 epoch,前 5 个 epoch 冻结图像 backbone。

optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01) scheduler = torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr=3e-4, total_steps=num_epochs * len(train_loader), pct_start=0.1 # 前 10% 步数做 warmup ) criterion = FocalLoss(gamma=2.0, alpha=class_weights) for epoch in range(num_epochs): model.train() for batch in train_loader: images = batch['image'].cuda() input_ids = batch['input_ids'].cuda() mask = batch['attention_mask'].cuda() labels = batch['label'].cuda() img_feat = image_encoder(images) txt_feat = text_encoder(input_ids, mask) logits = classifier(fusion(img_feat, txt_feat)) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad()

OneCycleLR 比 StepLR 收敛快,但 pct_start 别设太大,0.1 到 0.15 之间比较稳。FocalLoss 的 alpha 传类别权重,gamma=2 是经验值,如果难样本不多可以降到 1。梯度裁剪一定要加,Cross-Attention 层在初期容易梯度爆炸。

4. 避坑与排查:多模态垃圾分类的 5 个血泪教训

4.1 文本塔过拟合:验证集 loss 先降后升

现象:训练到第 8 个 epoch,文本塔的验证 loss 开始上升,但图像塔还在降,整体准确率停滞。

原因:文本描述标注时用了太多重复句式,模型记住了模板而不是语义。比如「这是一个XX,已经XX」出现频率过高。

解决:标注时强制要求不同人用不同句式,且对高频模板做下采样。另外在文本塔加 Dropout=0.3 和权重衰减 0.05,比图像塔的 0.01 更激进。

4.2 模态失衡:图像塔压倒文本塔

现象:门控值在训练后全部趋近 1,文本特征几乎不起作用,融合退化成单模态。

原因:图像塔用了预训练权重,初始特征质量远高于随机初始化的文本塔,梯度更新时图像塔主导了融合层。

解决:前 5 个 epoch 冻结图像 backbone,只训文本塔和融合层;或者给文本塔设更大的学习率(图像塔 1e-4,文本塔 5e-4)。我一般两个都做。

4.3 推理时文本缺失导致服务崩溃

现象:部署后用户不输入文本,系统直接报错或输出随机结果。

原因:训练时每个样本都有文本,模型没学过「文本为空」的情况。

解决:训练时随机将 10% 样本的文本替换为空字符串,让模型学会在文本缺失时依赖图像。推理时如果文本为空,门控值自动趋近 1,走纯图像分支。

4.4 图像预处理不一致导致精度骤降

现象:本地验证准确率 91%,部署到服务端后掉到 76%。

原因:训练用 Albumentations 做归一化(mean=[0.485,0.456,0.406]),部署时用了 OpenCV 默认的 BGR 和 0-255 范围,没做一致的归一化。

解决:把预处理逻辑封装成一个类,训练和推理共用同一份代码。别在部署时重写预处理。

4.5 类别标签映射错位

现象:模型预测「有害垃圾」的样本,实际是「可回收物」,但混淆矩阵显示两类互相错分严重。

原因:数据标注时类别文件夹按字母排序,但标签映射表按中文拼音排序,导致索引错位。

解决:用 JSON 文件显式定义类别到索引的映射,训练和推理都读同一个文件。别依赖文件夹遍历顺序。

5. 进阶技巧:用置信度校准和 TTA 把准确率再提 3 个点

模型训完之后,别急着交报告。还有两个几乎零成本的技巧能再榨出几个点。

置信度校准。多模态融合后,模型在某些样本上会过度自信——比如图像模糊但文本描述清晰时,softmax 输出 0.99,实际错了。用 Temperature Scaling 在验证集上拟合一个温度参数 T,把 logits 除以 T 后再 softmax。T 通常取 1.2 到 2.0 之间,能让置信度更接近真实准确率。代码就几行:

class TemperatureScaler(nn.Module): def __init__(self): super().__init__() self.temperature = nn.Parameter(torch.ones(1) * 1.5) def forward(self, logits): return logits / self.temperature # 在验证集上优化 temperature 参数 scaler = TemperatureScaler().cuda() optimizer = torch.optim.LBFGS([scaler.temperature], lr=0.01, max_iter=50) # ... 用 NLL loss 拟合

校准后,模型输出的置信度可以直接用来做拒识——低于 0.6 的样本转人工审核,避免自动分错。

测试时增强(TTA)。推理时对同一张图做 5 种变换(原图、水平翻转、±10 度旋转、中心裁剪),分别预测后取平均 logits。文本侧也可以做同义词替换后的多次预测。TTA 能让准确率再提 1.5 到 2.5 个点,代价是推理时间翻 5 倍。如果服务对延迟不敏感,这个技巧非常划算。

我自己的习惯是:先把 Temperature Scaling 加上,确认置信度可靠后,再决定要不要上 TTA。如果业务允许 200ms 以上的延迟,TTA 必加;如果要求 50ms 以内,就只做校准。

最后说一个教训:别在测试集上调任何参数。我见过有人用测试集选温度 T,结果上线后校准完全失效。验证集就是验证集,测试集只在最后跑一次。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询