简介:基于深度学习的垃圾分类系统项目包,聚焦图像识别自动分类场景,面向正在学习计算机视觉和Python工程化的开发者,也适用于课程设计、毕业设计或小型智能应用开发参考。项目通过ONNX标准格式导入预训练模型,打通了从图像预处理、特征提取到结果输出的完整流程,并内置了简单的Web交互模块,方便直观验证识别效果。压缩包体量很小,仅12KB,共含6个文件,包括3个CSV数据表(用户信息、标签映射、历史记录)、2个Python脚本(主应用与核心识别逻辑)以及1个pyc缓存文件,目录结构一目了然,适合快速通读与二次改造。目前已有116人学习下载,读者能从中学习到ONNX跨平台模型部署、Flask轻量级应用搭建以及垃圾分类系统的模块化组织方式,对于入门深度学习项目落地很有帮助。
1. 垃圾分类系统为什么最终让模型走 ONNX 导入这条落地方案
当你打开一个名为“基于深度学习的垃圾分类系统(模型使用ONNX导入).zip”的项目包,眼睛别只盯着“垃圾分类”这几个大字,真正决定这套系统能不能从训练环境搬到生产环境的关键,是“ONNX导入”这个动作。训练阶段用 PyTorch 怎么折腾都行,动态图、打印中间张量、随时改结构;但到了部署阶段,没人愿意在一个只有几十兆内存的容器里再装一整套 PyTorch 和 CUDA。ONNX 把训练好的网络固化成一张静态计算图,交给 onnxruntime 这种轻量推理引擎去执行,依赖少、启动快,也方便以后换硬件。下面我会把从数据准备、PyTorch 训练、Pytorch 转 ONNX 导出,再到 onnxruntime 推理和问题排查的完整过程讲透,让新手能按步骤跑通,让熟手能找到参数设置的边界。
2. 把项目拆成数据、训练与推理三段:ONNX 这个交接点怎么选
2.1 数据集选型与类别映射:垃圾图片分类的最小可用方案
垃圾图片分类本质上是细粒度图像分类,同类垃圾在不同光线、不同拍摄角度下差别很大。一个常见做法是从公开渠道收集垃圾分类图片,再按业务需要整理成 5~15 个类别。比如可回收垃圾下的塑料瓶、易拉罐、纸箱可以拆成三个类,也可以合并成一个“可回收物”。别小看这个决策:类别太细,样本不够,模型容易过拟合;类别太粗,类内差异太大,模型内部会学成几个子类再硬合并,边界一团乱。
为了不把时间浪费在手工整理路径上,我一般先用一个脚本按类别目录划分训练集和验证集:
import os, random, shutil from collections import defaultdict root = "garbage_all" # 原始数据:garbage_all/类别名/*.jpg train_dir = "dataset/train" val_dir = "dataset/val" val_ratio = 0.2 class_files = defaultdict(list) for cls in os.listdir(root): cls_path = os.path.join(root, cls) if not os.path.isdir(cls_path): continue files = [os.path.join(cls_path, f) for f in os.listdir(cls_path) if f.endswith(('.jpg', '.jpeg', '.png'))] class_files[cls] = files random.seed(0) for cls, files in class_files.items(): random.shuffle(files) split_idx = int(len(files) * (1 - val_ratio)) for phase, subset in (("train", files[:split_idx]), ("val", files[split_idx:])): target_dir = os.path.join(train_dir if phase == "train" else val_dir, cls) os.makedirs(target_dir, exist_ok=True) for f in subset: shutil.copy(f, os.path.join(target_dir, os.path.basename(f)))这个脚本的关键是按类别分别划分,而不是对整个数据集做一次随机打乱。垃圾图片数据往往类别数量极不平衡,比如“有害垃圾”可能只有两三百张,而“其他垃圾”有几千张;如果整体随机切分,验证集里有可能一个有害样本都分不到,最后模型对有害垃圾的召回率显示 100%,实际部署时却频频漏检。random.seed(0)固定随机种子,保证你每次跑出的划分一致,后面做模型对比时才有公平基础。
类别映射表建议用数字 id 固化,而不是让代码去猜字符串。下面是我常用的一套最小类别方案:
| 类别ID | 类别名 | 典型物品 | 训练样本建议 |
|---|---|---|---|
| 0 | 可回收物 | 塑料瓶、纸箱、易拉罐 | ≥800 |
| 1 | 厨余垃圾 | 果皮、菜叶、剩饭 | ≥800 |
| 2 | 有害垃圾 | 电池、过期药品、灯泡 | ≥300 |
| 3 | 其他垃圾 | 卫生纸、碎陶瓷、烟蒂 | ≥800 |
如果某个类别实在凑不够样本,不要用复制粘贴硬补数据,那样会让模型记住重复图像而不是真正的类别特征。常见替代方案是调整损失函数的类别权重,或者用数据增强中的随机裁剪、旋转、色彩抖动去扩充有效样本,我一般会在训练脚本里用WeightedRandomSampler平衡采样。
2.2 为什么用 PyTorch 训练却用 ONNX 导入推理
训练和部署是两套逻辑。训练时你需要动态图带来的调试自由度,能随时 print 张量形状,能中途改网络结构,PyTorch 的 eager 模式非常适合这种实验节奏。但当一个垃圾分类系统要作为服务交付时,看中的是启动速度、内存占用和长时间运行的稳定性。继续让 PyTorch 参与推理,意味着部署机器上要装一堆训练期的依赖,哪怕你只用到其中 10% 的算子。
ONNX 的价值就是在这个交接点上提供一种中立的模型表示。你可以在 PyTorch 里导出,也可以在 TensorFlow 里导出,最终交给 onnxruntime 统一执行。onnxruntime 针对 CPU 和 GPU 都做了算子融合与内存优化,很多情况下推理速度比原生 PyTorch 还快一截。更重要的是,部署端不一定用 Python,可以用 C++、Java、C# 写服务,只要加载同一个.onnx文件就行。
这也是项目标题里特意标出“模型使用ONNX导入”的原因:训练框架生成权重,但真正的运行环境只认识 ONNX 计算图。两者各管一段,靠.onnx文件解耦。很多同学踩过的坑是只存.pth,到部署环境才想起来没有原训练类定义,加载直接变成黑匣子;ONNX 相当于提前给模型补了一份可在任意机器上解释的存档,至少不会因为缺文件就当场翻车。
2.3 项目目录里你至少需要哪几个文件
拿到这类 zip 包,第一件事不是立刻跑 demo,而是检查文件结构。一个能顺利落地的最小项目,解压后通常长这样:
| 文件 | 作用 | 注意事项 |
|---|---|---|
| requirements.txt | 记录 onnxruntime、torch、opencv-python 等依赖 | 版本锁大版本即可 |
| data/train 与 data/val | 训练/验证图片,按类别分子目录 | 类别目录名要与 classes.txt 顺序一致 |
| model.py | 定义网络结构 | 训练、导出、部署复用同一份定义 |
| train.py | 训练入口,保存 best_model.pth | 保存 state_dict,不要保存整个 model |
| export_onnx.py | 把 .pth 转成 .onnx | 是部署链路的枢纽 |
| infer.py | 加载 .onnx 并用 onnxruntime 推理 | 单图/摄像头逻辑都放在这里 |
| classes.txt | 类别名,按行排列 | 第一行必须是类别 0 |
其中最容易出问题的是classes.txt和model.py的对齐。训练时你用ImageFolder,它按目录名字母排序生成标签,如果classes.txt是手写的,顺序不一致就会导致“模型把有害垃圾识别成可回收物”这种看似玄学、实则在映射表里就能发现的问题。我建议在训练脚本里把dataset.classes直接导出成classes.txt,这样一劳永逸。
3. 从 PyTorch 训练到 Pytorch 转 ONNX 导出:opset、动态轴与校验
3.1 训练一个轻量分类模型并保存权重文件
导出 ONNX 的前提是你手上有一个真正训练过的模型。垃圾分类不是千类级识别任务,我习惯用 ResNet18 或 MobileNetV3 做骨干网络,把最后一层全连接改成类别数。下面是训练入口最需要注意的几段:
import torch import torch.nn as nn from torch.utils.data import DataLoader, WeightedRandomSampler from torchvision import datasets, models, transforms num_classes = 4 epochs = 10 device = torch.device("cuda" if torch.cuda.is_available() else "cpu") transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) train_ds = datasets.ImageFolder("dataset/train", transform=transform) val_ds = datasets.ImageFolder("dataset/val", transform=transform) # 对类别不平衡做平衡采样 sample_weights = [1.0 / train_ds.targets.count(t) for t in train_ds.targets] sampler = WeightedRandomSampler(sample_weights, num_samples=len(train_ds), replacement=True) train_loader = DataLoader(train_ds, batch_size=32, shuffle=False, sampler=sampler, num_workers=4) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False) model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, num_classes) model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)这段代码里有一个对后续导出影响深远的细节:预处理用了Resize((256,256))再CenterCrop(224),而不是直接Resize((224,224))。这么做一方面让训练时图片保留更多上下文,另一方面也让部署端必须复刻“先放大再裁剪”的逻辑。如果你偷懒改成直接缩小,模型推理时看到的物体比例和训练时不一致,准确率会肉眼可见地掉。
训练循环本身不复杂,关键是每个 epoch 在验证集上评估一次,只保留最优 checkpoint:
best_acc = 0.0 for epoch in range(epochs): model.train() for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() model.eval() correct = total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) preds = model(images).argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) acc = correct / total if acc > best_acc: best_acc = acc torch.save(model.state_dict(), "checkpoints/best_model.pth") print(f"epoch {epoch+1} acc {acc:.3f}")保存state_dict而不是torch.save(model, ...)是我吃过亏后养成的习惯。整个模型保存会把一堆类引用、优化器状态一并序列化,换一台机器或改了目录结构,加载时经常报“cannot pickle”或类不存在。只保存参数,导出脚本里用统一结构实例化再load_state_dict,干净且跨环境友好。
3.2 torch.onnx.export 的导出脚本:五个必须锁定的参数
权重固定后,写一个export_onnx.py。这里没有特别多花活,但每个参数都可能成为部署端的坑。直接看代码:
import torch import onnx from model import create_model num_classes = 4 model = create_model(num_classes) model.load_state_dict(torch.load("checkpoints/best_model.pth", map_location="cpu")) model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, "rubbish_classifier.onnx", export_params=True, opset_version=14, do_constant_folding=True, input_names=["input"], output_names=["logits"], dynamic_axes={ "input": {0: "batch_size"}, "logits": {0: "batch_size"} } )五个关键参数逐个说。
export_params=True表示把训练好的参数打包进 ONNX 文件,这是必须的;如果设成 False,导出的只是空骨架,推理结果全是随机数。opset_version是算子集合的版本号。太老(比如 9)可能导致新激活函数没有对应导出;太新(比如 18)则需要部署端的 onnxruntime 也够新。我一般锁 14,因为它在导出Resize、BatchNorm、Softmax这些常用算子时都比较稳,老版本 onnxruntime 也大多支持。如果你部署环境特别老,优先降到 12 而不是 9。
do_constant_folding=True会把计算图里与输入无关的常量计算提前折叠,减少运行时节点数,模型体积也会小一些。input_names和output_names是给计算图节点起名字,部署时通过这个名字喂数据,取输出也靠它,命名不统一是低级但高频的坑。
dynamic_axes是最需要动脑的参数。这里把 batch 维度标成了动态,意味着推理时一次可以传 1 张,也可以一次传 16 张,计算图会按实际传入的 batch 形状执行。代价是有轻微性能损耗。如果你确定只做单图请求,可以不设dynamic_axes,ONNX 文件会更小、推理略快;但一旦需求变成“一次批量识别 10 张”,就要重新导出,所以为了灵活性,我更推荐保留 batch 动态。
提示:导出前一定记得调用
model.eval()。漏掉这一步会让 BatchNorm 层使用训练时的统计量,导出出的模型在单图推理时行为异常,而你在导出阶段几乎看不出问题。
3.3 导出后先校验:onnx.checker 与形状对齐
模型导出后别直接拿去上线,先在本地做一次体检:
import onnx onnx_model = onnx.load("rubbish_classifier.onnx") onnx.checker.check_model(onnx_model) # 结构不合法会直接抛异常 graph = onnx_model.graph print("输入:", graph.input[0].name, graph.input[0].type.tensor_type.shape) print("输出:", graph.output[0].name, graph.output[0].type.tensor_type.shape) ops = {node.op_type for node in graph.node} print("算子集合:", sorted(ops))check_model能发现一半的导出问题,比如权重缺失、节点输入不匹配。打印出的输入形状会直接告诉你dynamic_axes有没有生效:如果维度是[1,3,224,224],说明 batch 维没变成动态;如果是[None,3,224,224],就对了。None在 ONNX 里就表示任意数值。
还有一道更重要的验证:拿几张训练集图片,先走 PyTorch 模型拿输出,再走 ONNX 模型拿输出,对比 logits 的差异。差异在1e-3量级算正常,如果大到0.1以上,优先检查导出前是否model.eval()、是否加载了正确权重、预处理是否一致。这一步相当于给部署买一份“后悔药”,总比在用户现场排查强。
4. 用 ONNXRuntime 把 .onnx 模型接进垃圾分类系统
4.1 加载模型与第一次推理:session 初始化与输入输出
部署端不再需要 PyTorch,核心依赖只有一个 onnxruntime。第一次加载模型时,先别急着写完整业务,把输入输出摸清楚:
import onnxruntime as ort import numpy as np session = ort.InferenceSession( "rubbish_classifier.onnx", providers=["CPUExecutionProvider"] ) input_info = session.get_inputs()[0] output_info = session.get_outputs()[0] print("输入名:", input_info.name) print("输入形状:", input_info.shape) print("输入类型:", input_info.type) print("输出名:", output_info.name) dummy = np.random.randn(1, 3, 224, 224).astype(np.float32) result = session.run([output_info.name], {input_info.name: dummy}) print("输出 shape:", result[0].shape)providers参数控制了推理后端。如果你装的是onnxruntime-gpu,可以把["CUDAExecutionProvider", "CPUExecutionProvider"]写在列表里,onnxruntime 会优先尝试 CUDA,失败时回退 CPU。但如果 CUDA 库版本不匹配,启动时可能直接报找不到动态库,因此我建议在项目初期先用 CPU 跑通全流程,再考虑 GPU 加速,避免把精力耗在环境问题上。
有一点必须强调:session.get_inputs()[0].type如果是tensor(float),喂进来的 numpy 数组必须转成float32。很多初版代码把 OpenCV 读出的uint8矩阵直接传入,session 不会自动帮你转型,结果要么报错,要么输出概率看起来像随机数。
4.2 图像预处理复用训练参数:归一化、尺寸与通道顺序
ONNX 图只认识数值,不认识你在 PyTorch 里写的transforms对象。所以推理端的预处理必须把训练端每一行代码翻译过来。下面是我常用的一个函数:
import cv2 import numpy as np def preprocess(image_bgr): # 1. OpenCV 读出来是 BGR,而训练时用的是 PIL 的 RGB,必须转 image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) # 2. 训练时 Resize((256, 256)) 后 CenterCrop(224) h, w = image_rgb.shape[:2] scale = 256 / max(h, w) new_w, new_h = int(round(w * scale)), int(round(h * scale)) resized = cv2.resize(image_rgb, (new_w, new_h)) top = (resized.shape[0] - 224) // 2 left = (resized.shape[1] - 224) // 2 cropped = resized[top:top+224, left:left+224] # 3. ToTensor 相当于除以 255,再按 ImageNet 统计值归一化 x = cropped.astype(np.float32) / 255.0 mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) std = np.array([0.229, 0.224, 0.225], dtype=np.float32) x = (x - mean) / std # 4. HWC -> NCHW,并扩展 batch 维 x = np.transpose(x, (2, 0, 1)) x = x[np.newaxis, ...].astype(np.float32) return x这段代码最容易错的是前两步。训练时先放大到短边 256,再做 224x224 的中心裁剪,推理时必须完整复刻。如果图省事直接cv2.resize(image, (224,224)),相当于把训练时的裁剪步骤删了,物体比例完全不同,准确率下降是必然的。颜色通道也一样:PIL 读进网络的是 RGB,OpenCV 默认读成 BGR,不转的话模型等于在看一幅红蓝互换的图。
下面这张表建议贴在项目文档里,以后排查“训练和部署结果不一致”问题时会非常有用:
| 阶段 | 训练端 (torchvision) | 部署端 (OpenCV) |
|---|---|---|
| 缩放 | Resize((256,256)) | 按比例缩到短边 256 |
| 裁剪 | CenterCrop(224) | 手动居中剪到 224x224 |
| 颜色 | PIL 读入 RGB | BGR -> RGB |
| 数值 | ToTensor 除以 255 | 先转 float32 再除以 255 |
| 归一化 | Normalize(mean,std) | 相同 mean/std 逐通道减除 |
| 维度 | 输出 NCHW | transpose 到 NCHW 后再加 batch 维 |
最后一行的transpose是隐藏 boss。DataLoader 给出的张量是(B,C,H,W),而你手动读图得到的是(H,W,C),不做轴交换的话,通道维和宽维搅在一起,模型不会报错,但输出概率跟随机一样。这种错误比显式异常难查得多,最好的办法是写一个单测,把同一张图分别喂给预处理函数和 torchvision 的 transform 流程,对比输出张量。
4.3 一条完整的单图推理函数与类别打码输出
把预处理和 session 调用拼成一个函数:
def predict(image_bgr, session, class_names): x = preprocess(image_bgr) input_name = session.get_inputs()[0].name logits = session.run(None, {input_name: x})[0] probs = np.exp(logits - logits.max(axis=1, keepdims=True)) probs = probs / probs.sum(axis=1, keepdims=True) idx = int(np.argmax(probs[0])) return class_names[idx], float(probs[0][idx])session.run(None, ...)里的None表示返回模型所有输出,对于单输出模型没问题,但如果以后改成输出logits和feature_map,就建议用输出名列表精确指定,防止拿错张量。softmax 手动实现时先减去最大值,这一步是为了防止 logits 很大时指数溢出,和 PyTorch 内部Softmax算子的处理逻辑一致。
读class_names时一定要记住,第一行对应的是类别 0。如果你习惯把类别列表写成从 1 开始数,模型输出的idx=0会对应到第二个类别,整个系统悄无声息地全错位。我建议把上面这个函数存成infer.py,单独跑几张测试图,打印出(类别名, 置信度),确认和训练时的分布一致再继续接入摄像头或服务接口。
实际做摄像头实时识别时,有一个性能大坑:不要在每帧图像处理函数里创建InferenceSession。模型加载一次可能几百毫秒,推理一帧只要几十毫秒,每一帧都重新加载会直接把实时性拖垮。正确做法是在程序启动时创建好 session,后续只调用session.run;如果需要多线程并发,最常见的方式是每个线程单独创建 session,而不是共用一个,因为InferenceSession不是线程安全的。
5. ONNX 导入与落地的常见问题排查:五次翻车记录和排查路径
5.1 导入失败:session 创建时报 opaque 或 cannot load
现象:ort.InferenceSession("rubbish_classifier.onnx")直接抛异常,报错信息里有 “Failed to load model” 或 “opaque” 关键字,有时还会提示某个算子 “Unsupported operator”。原因通常有两种:一是导出时opset_version设得太高,部署端 onnxruntime 版本老,不认新算子;二是模型图里包含某个 onnxruntime 没实现的算子。解决:先看报错里有没有给出具体算子名,再回到export_onnx.py把opset_version下调到 12 重新导出;如果仍然失败,把那个算子替换成等价组合。比如部分自定义激活函数在 ONNX 里没有专门算子,可以用Clip或Sigmoid组合替代。还有一个笨办法是升级 onnxruntime 到最新稳定版,但升级前要做好回归测试,因为新版也可能改变算子行为。
5.2 推理结果全是随机猜:归一化管道不一致
现象:训练时怎么测都有 90% 以上准确率,同一个 checkpoint 导出 ONNX 后在测试集上准确率掉到 10% 左右,基本跟随机一样。原因第一嫌疑就是部署端预处理和训练端不一致。常见错误包括:忘了减均值除以方差、用 OpenCV 读图没转 RGB、直接 Resize 到 224 而没有先放大到 256 再中心裁剪。解决:把训练脚本里的transforms每一行注释出来,逐项对着部署端代码检查。更可靠的验证方法是拿同一张图先走一遍训练框架的预处理,再走一遍部署端preprocess,打印中间张量的均值和形状,确认差异在1e-6量级内。我见过不少项目在这一点上踩坑,最后发现只是cv2.cvtColor漏了。
5.3 模型文件 200MB 以上,压缩包里的权重到底该不该带
现象:解压 zip 后发现.onnx文件有 200MB 甚至更大,部署到边缘设备时内存紧张,每次启动加载都要等好几秒。原因十有八九是网络太宽。ResNet50/101 这类大模型权重占大头,而部署推理只需要前向计算图的权重,不需要优化器状态。解决:第一优先换轻量骨干,比如 MobileNetV3 或 EfficientNet-Lite,ONNX 文件能缩到 10~30MB,精度损失往往在 1 个点以内;如果非要保留 ResNet,导出时确保do_constant_folding=True,并去掉导出脚本里不必要的中间张量输出。还要注意:压缩包理应只带部署必需文件,训练 checkpoint 和优化器状态别一股脑塞进部署目录,否则连同用户共享文件一起拖慢下载。
5.4 导出后 batch 固定成 1,摄像头实时推理被拖垮
现象:单张图片离线推理很快,一旦接实时视频流,每帧都调一次session.run,帧率只有个位数,设备发热严重。原因除了硬件算力不足,更多是导出时没把 batch 维设为动态,导致模型固定接受形状[1,3,224,224],你就算在客户端攒了 4 帧也只能一帧一帧喂。解决:导出时设置dynamic_axes={"input": {0: "batch_size"}, "logits": {0: "batch_size"}},推理时可以一次投喂[4,3,224,224]的 batch,节省重复调用的开销。不过动态 batch 也不是银弹,它会让计算图多出动态 shape 分支逻辑,单帧延迟可能略微上升;如果并发量很大,更值得投入的方向是服务端批量推理队列,而不是一味压榨单个 session。
5.5 精度下降 0.3——是 ONNX 玄学还是算子精度差异
现象:原始 PyTorch 模型 Top-1 准确率 92.0%,ONNX 模型测出来 91.7%,整体看起来没毛病,但心里总觉得是哪里出了问题。原因:ONNX 导出时会对 BatchNorm 层做折叠,把卷积和归一化合并成一个算子,浮点计算顺序变化后结果会有一点点不同。卷积算子在不同实现下也可能使用不同的累加策略,导致 logits 在高位一致、低位有1e-3级别的差异。解决:拿 10 张图片对比两边的 logits,差异量级在1e-3就是正常现象,不是代码 bug;如果准确率差距超过 1%,优先检查预处理和输出后处理,这两个环节造成的差异远比算子精度大。要追求可复现,可以把导出时opset_version、onnxruntime 版本都固定下来,写进 requirements.txt,避免环境升级后结果漂移。
6. 验证与进阶:用测试集算指标,再试 int8 量化加速
6.1 用测试集算准确率与类别召回
单张图片跑出置信度不算完事,ONNX 导入后的系统要上线,必须用部署同款的.onnx模型在验证集上重新算一遍指标。我是这样做的:
import onnxruntime as ort import numpy as np session = ort.InferenceSession("rubbish_classifier.onnx") all_preds, all_labels = [], [] for images, labels in val_loader: batch = np.array([preprocess_from_pil(img) for img in images], dtype=np.float32) logits = session.run(None, {"input": batch})[0] preds = np.argmax(logits, axis=1) all_preds.extend(preds) all_labels.extend(labels.numpy()) correct = sum(p == l for p, l in zip(all_preds, all_labels)) print(f"Overall Acc: {correct / len(all_preds):.3f}") # 按类别打印召回 from collections import Counter for cls_id in range(num_classes): mask = [l == cls_id for l in all_labels] if sum(mask) > 0: cls_correct = sum(p == cls_id for p, l in zip(all_preds, all_labels) if l == cls_id) print(f"class {cls_id} recall: {cls_correct / sum(mask):.3f}")关注点应该放在“其他垃圾”和“有害垃圾”这两类的召回率上。如果“其他垃圾”被大量分到“可回收物”,说明模型决策边界倾向把纹理不明显的物品归到样本多的类;这时候去调后处理比盲目增加训练轮数更有效,比如在输出 softmax 概率上加一个置信度阈值,低于阈值返回“不确定”,让系统在真实场景里宁可拒绝也不乱扔。
6.2 int8 量化:模型瘦身一半还有多少精度
如果你的部署环境是 CPU 且对吞吐有要求,ONNX 导入后还可以再做一步 int8 动态量化。这是最省交付成本的加速方式:
from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( "rubbish_classifier.onnx", "rubbish_classifier_int8.onnx", weight_type=QuantType.QUInt8, op_types_to_quantize=["Conv", "MatMul"] )量化后模型体积通常能减到原来的三分之一左右,推理延迟在 CPU 上也有明显下降。但我不建议拍脑袋直接上生产。量化对权重分布敏感,有些模型量化后准确率只掉 0.5%,有些会掉 5% 甚至更多。正确动作是量化完立刻跑一遍 6.1 的验证脚本,比较 int8 和 fp32 的准确率差距。如果掉点有点大,可以把op_types_to_quantize改成["Conv"],让 MatMul 保持浮点,精度损失更小,只是收益打折。
我自己在这个环节有过一次深刻教训:项目里把 ONNX 量化后单张推理确实快了,一上多线程并发反而变慢,原因是动态量化在运行时需要把 int8 权重反量化回浮点,线程多到一定程度后内存带宽反而成了瓶颈。所以量化前一定要压测,至少跑 100 张图的平均耗时,同时观察 CPU 占用率,别被单次加速的数据迷惑。
6.3 一个经验:ONNX 不是终态,是接口
做了一两个模型上线的项目后,我越来越觉得 ONNX 的意义不在于给你一个“最优部署格式”,而是让你把训练和部署彻底分开。训练团队可以继续换模型、调参,部署团队只需要维护一套读取.onnx的推理代码。就算下次换成 MobileNetV3 或者别的网络,只要导出的输入输出协议不变,业务代码一行都不用改。这种解耦带来的长期收益,比单独把某个模型的数据再压几个百分点更值钱。希望我的这些踩坑和验证习惯能帮你少走一点弯路,也希望你的垃圾分类系统一导入就跑通,别让预处理这种小问题卡住整个项目。希望帮到你。
本文还有配套的精品资源,点击获取