简介:面向K210边缘计算入门与实战的20类分类模型工程,基于Maix Dock开发板与MaixPy环境,借助NNCase将YOLO的tflite模型转换为板端可运行的kmodel,适合希望打通模型转换、部署与推理验证链路的开发者与学习者。压缩包共20个文件,约59.53MB,包含部署模型、测试脚本、说明文档,以及若干jpg、bmp样例图片和转换工具压缩包,便于直接验证分类效果、理解各文件用途并复现基本流程。已有6662人学习,说明该方案具有较高参考价值。读者可掌握模型转换参数、kmodel加载与推理测试方法,结合样例图片排查输入尺寸、类别映射与运行环境等问题,也能为其他边缘端视觉分类任务提供可复用的工程模板与排错思路,对课程设计、竞赛原型与毕业项目具有一定借鉴意义,可减少重复摸索时间。
1. K210 上跑 20 类模型:真正难的是 kmodel 落地
K210 上跑 20 类视觉模型,真正卡人的往往不是训练,而是把 YOLO 风格的输出压成能加载的 kmodel,再在边缘计算场景里稳住帧率和内存。这份资源给的是 20 类分类模型及实现代码,适合已经能把数据跑起来、却在上板阶段反复翻车的人:它把标签顺序、输入分辨率、量化校准和推理后处理放在同一条链路里,让 K210 不只是“能跑”,而是能复现、能排查、能改类。下面按数据、转换、上板、排错、验证五段拆开,代码和参数都能直接对着改。
2. 20类数据与YOLO式训练:先把输入输出钉死
2.1 数据目录与标签顺序:20类不是20个文件夹那么简单
20 类任务最先翻车的地方通常不是模型太小,而是标签顺序在训练、校准、板端三处各说各话。常见做法是固定一个labels.txt或labels.json,训练脚本从它生成class_to_idx,板端也只读同一份顺序;不要一边用文件夹名排序,一边在板端手写列表。数据目录按train/class00...class19、val/class00...class19组织,先跑一遍检查脚本,把类别数、样本数、空文件夹和损坏图片找出来。
# check_dataset.py from pathlib import Path from collections import Counter from PIL import Image root = Path("dataset/train") classes = sorted([p.name for p in root.iterdir() if p.is_dir()]) assert len(classes) == 20, f"期望20类,实际{len(classes)}类:{classes}" counter = Counter() bad = [] for p in root.rglob("*"): if p.suffix.lower() not in {".jpg", ".jpeg", ".png", ".bmp"}: continue try: with Image.open(p) as im: im.verify() counter[p.parent.name] += 1 except Exception as e: bad.append((str(p), str(e))) print("label_order =", classes) print("counts =", dict(counter)) print("bad_files =", bad[:10]) assert all(counter[c] > 0 for c in classes), "存在空类"这段脚本先按目录名排序得到稳定的 20 类顺序,再统计每类图片数量,并用Image.verify()排除半截文件。参数上最关键是root指向训练集根目录,classes的顺序要么直接作为标签表,要么和你已有的labels.txt逐项比对;如果代码包里已经给了标签文件,就以包里的顺序为准,不要临场改成sorted。验证集同理跑一遍,保证 20 类都有样本,否则混淆矩阵里会出现整列空白,后面调阈值会误判。
2.2 训练脚本:轻量骨干、固定输入、导出ONNX
K210 的 KPU 对算子类型和内存都敏感,训练端不要一上来堆大骨干。我一般先用一个只含 3×3 卷积、BN、ReLU6、全局池化和全连接的小网络把 20 类链路跑通,再考虑加宽或加深。输入分辨率固定为 224×224,训练时做随机裁剪、水平翻转和轻量颜色扰动,验证集只做 resize 和归一化。归一化建议直接写进导出模型,这样板端摄像头给 uint8 图像时少做一层浮点运算。
# train20.py import torch from torch import nn from torch.utils.data import DataLoader from torchvision import datasets, transforms DATA = "dataset/train" VAL = "dataset/val" IMG = 224 BATCH = 16 EPOCHS = 30 LR = 3e-4 NUM_CLASSES = 20 class SmallNet(nn.Module): def __init__(self, num_classes=20): super().__init__() def block(cin, cout, stride=1): return nn.Sequential( nn.Conv2d(cin, cout, 3, stride, 1, bias=False), nn.BatchNorm2d(cout), nn.ReLU6(inplace=True), ) self.features = nn.Sequential( block(3, 16, 2), # 112 block(16, 32, 2), # 56 block(32, 64, 2), # 28 block(64, 96, 2), # 14 block(96, 128, 2), # 7 nn.AdaptiveAvgPool2d(1), ) self.classifier = nn.Linear(128, num_classes) def forward(self, x): x = self.features(x) x = x.flatten(1) return self.classifier(x) train_tf = transforms.Compose([ transforms.RandomResizedCrop(IMG, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.2, 0.2, 0.2), transforms.ToTensor(), transforms.Normalize([0.5] * 3, [0.5] * 3), ]) val_tf = transforms.Compose([ transforms.Resize((IMG, IMG)), transforms.ToTensor(), transforms.Normalize([0.5] * 3, [0.5] * 3), ]) train_ds = datasets.ImageFolder(DATA, train_tf) val_ds = datasets.ImageFolder(VAL, val_tf) assert len(train_ds.classes) == NUM_CLASSES, train_ds.classes train_ld = DataLoader(train_ds, batch_size=BATCH, shuffle=True, num_workers=2) val_ld = DataLoader(val_ds, batch_size=BATCH, shuffle=False, num_workers=2) model = SmallNet(NUM_CLASSES) opt = torch.optim.AdamW(model.parameters(), lr=LR) crit = nn.CrossEntropyLoss() best_acc = 0.0 for epoch in range(EPOCHS): model.train() for x, y in train_ld: opt.zero_grad() loss = crit(model(x), y) loss.backward() opt.step() model.eval() correct = total = 0 with torch.no_grad(): for x, y in val_ld: pred = model(x).argmax(1) correct += (pred == y).sum().item() total += y.numel() acc = correct / max(total, 1) if acc > best_acc: best_acc = acc torch.save(model.state_dict(), "artifacts/cls20.pt") print(f"epoch={epoch} val_acc={acc:.4f} best={best_acc:.4f}")这段代码的重点不是追 SOTA,而是把输入尺寸、类别数、归一化方式固定住。IMG=224直接决定后面 kmodel 的输入 shape;BATCH=16只是训练参数,不影响板上;SmallNet里的通道数可以按 K210 内存往下砍,比如把 128 改成 96 或 64,但改完必须重新导出 ONNX。AdaptiveAvgPool2d(1)如果转换器不认,常见做法是换成nn.AvgPool2d(kernel_size=7),因为 224 输入经过五次 stride=2 后正好是 7×7。训练完只保存验证集最好的权重,避免拿过拟合模型去量化。
2.3 输出头对齐:分类头与YOLO检测头的两种接法
20 类模型的输出有两种常见形态:一种是分类头,输出(1,20);另一种是 YOLO 风格检测头,每个 anchor 输出5+20=25个通道,其中 4 个框参数、1 个目标置信度、20 个类别分数。上板前先看 ONNX 的输出 shape,不要凭项目名猜。分类头直接 argmax 或 softmax;检测头要做网格解码、置信度筛选和轻量 NMS。K210 上不要把 NMS 塞进 kmodel,后处理放在 CPU 侧,并且限制候选框数量,否则帧率会掉得很难看。
| 输出形态 | 典型 shape | 板上后处理 | 主要注意 |
|---|---|---|---|
| 20 类分类头 | (1,20)或(1,20,1,1) | 展平后 softmax/argmax | 标签顺序、是否已含 softmax |
| YOLO 检测头 | (1,H,W,A*25)或多尺度列表 | 解码框、置信度筛选、NMS | anchor、sigmoid 是否已在模型内 |
| 多任务头 | 分类与检测并列 | 先跑分类,再决定是否解码 | 输出内存和帧率预算 |
导出时把归一化包进模型,板端输入保持 uint8,减少一层浮点乘法。下面这段导出脚本用BoardWrapper把/255和(x-0.5)/0.5写进计算图,导出后检查输入输出,确保没有动态 batch 和多余的后处理节点。
# export_onnx.py import torch from torch import nn from train20 import SmallNet NUM_CLASSES = 20 IMG = 224 backbone = SmallNet(NUM_CLASSES) backbone.load_state_dict(torch.load("artifacts/cls20.pt", map_location="cpu")) backbone.eval() class BoardWrapper(nn.Module): def __init__(self, m): super().__init__() self.m = m def forward(self, x_uint8): x = x_uint8.float() / 255.0 x = (x - 0.5) / 0.5 return self.m(x) wrapped = BoardWrapper(backbone).eval() dummy = torch.zeros(1, 3, IMG, IMG, dtype=torch.uint8) torch.onnx.export( wrapped, dummy, "artifacts/cls20.onnx", input_names=["input"], output_names=["logits"], opset_version=11, do_constant_folding=True, ) import onnx m = onnx.load("artifacts/cls20.onnx") onnx.checker.check_model(m) print("input:", m.graph.input[0]) print("output:", m.graph.output[0])BoardWrapper的作用是把板端最常做的归一化提前进图,dummy用 uint8 是为了让 ONNX 输入类型和摄像头输出对齐。opset_version=11是常见兼容档位,不是越高越好;如果转换器报某个算子不支持,优先换等价写法,而不是盲目升 opset。导出后必须看input的 shape 是否为[1,3,224,224],output是否为[1,20]。如果是检测头,输出可能是多尺度列表,这时不要强行改成单输出,按转换器支持的多输出方式接。
提示:训练端如果已经做了 softmax,导出时不要再加一层 softmax;板端代码里也要先确认 kmodel 输出是 logits 还是概率。重复 softmax 会让置信度分布变得很平,看起来像“模型没学会”。
3. kmodel转换与量化:nncase参数不是玄学
3.1 转换前把ONNX剪干净:动态维、后处理、算子兼容
kmodel 转换失败,八成不是 nncase 本身的问题,而是 ONNX 里留了动态维度、NMS、复杂 reshape 或者训练态节点。转换前先跑onnx.checker,再把输入输出打印出来,确认 batch 固定为 1、空间维度固定、输出没有None。如果模型里带检测后处理,把后处理摘掉,只保留原始 head 输出;如果带 Dropout、Training 标记,导出时切到eval()。常见做法是保留一个“纯推理 ONNX”,另存一个“带后处理 ONNX”给 PC 端对比,不要混用。
# inspect_onnx.py import onnx m = onnx.load("artifacts/cls20.onnx") onnx.checker.check_model(m) for i in m.graph.input: shape = [d.dim_value if d.dim_value else d.dim_param for d in i.type.tensor_type.shape.dim] print("input", i.name, shape) for o in m.graph.output: shape = [d.dim_value if d.dim_value else d.dim_param for d in o.type.tensor_type.shape.dim] print("output", o.name, shape) ops = sorted({n.op_type for n in m.graph.node}) print("ops =", ops)这段脚本把输入输出维度和算子集合打印出来。看到dim_param就说明还有动态维,K210 编译时容易报 shape 不匹配;看到NonMaxSuppression、TopK这类节点,优先从模型里摘掉。算子集合里如果出现转换器文档没覆盖的类型,不要硬编译,先换等价结构,比如把大核卷积拆成两层 3×3,或者把不支持的激活换成 ReLU6。参数上最关键的是input必须是[1,3,H,W],output的通道数要和 20 类标签表对上。
3.2 校准集怎么选:20类每类都要有,别拿训练集原样糊弄
量化校准集不需要很大,但必须覆盖 20 类、不同光照、不同背景和板端真实摄像头风格。常见做法是每类抽 8 到 12 张,总共 160 到 240 张,统一 resize 到模型输入尺寸,保存为普通图片。不要只拿训练集里增强过的图,也不要把已经归一化成.npy的数据直接喂给只认图片目录的转换器。校准图和板上输入要同源:RGB 顺序一致、裁剪方式一致、分辨率一致。否则量化后的模型在 PC 上看着正常,上板就全是一类。
# make_calib.py from pathlib import Path from PIL import Image import random src = Path("dataset/train") dst = Path("calib/images") dst.mkdir(parents=True, exist_ok=True) random.seed(0) for cls_dir in sorted(src.iterdir()): if not cls_dir.is_dir(): continue imgs = [p for p in cls_dir.iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".bmp"}] random.shuffle(imgs) for i, p in enumerate(imgs[:10]): im = Image.open(p).convert("RGB").resize((224, 224)) im.save(dst / f"{cls_dir.name}_{i:03d}.png") print("calib_count =", len(list(dst.glob("*.png"))))这里每类取 10 张,20 类就是 200 张;如果某些类样本少,优先保证每类都有,不要从某一类抽 100 张把校准集带偏。convert("RGB")是必须的,K210 摄像头常见输出 RGB565,训练和校准都按 RGB 走,避免 BGR 顺序导致颜色通道错位。resize((224,224))要和模型输入一致;如果板上用中心裁剪而不是拉伸,校准图也应改成中心裁剪,否则量化统计会偏。
3.3 编译后验证:shape、dtype、内存三个黑匣子
转换命令不同发行包的参数名会有差异,先看nncase --help对齐,再跑编译。下面是一个 K210 编译骨架:输入 uint8、输出 float32、目标 k210、校准目录指向刚生成的calib/images。输出 float32 的好处是板端后处理简单,20 类输出很小,内存压力可控;如果检测头输出很大,可以改成 uint8 输出,但板端要按 scale/zero point 反量化。
# 先对齐参数名 nncase --help # K210 编译骨架:参数名以本机 nncase --help 为准 nncase compile artifacts/cls20.onnx artifacts/cls20.kmodel \ --target k210 \ --input-type uint8 \ --output-type float32 \ --dataset calib/images \ --input-shape 1,3,224,224--target k210决定生成 K210 能加载的 kmodel;--input-type uint8对应摄像头原始像素;--output-type float32让板端直接读概率或 logits;--dataset是量化校准目录;--input-shape必须和 ONNX 完全一致。编译日志里重点看两类信息:一是有没有算子回退到 CPU,二是内存报告里的峰值。如果编译能过但板上nn.load失败,优先怀疑 kmodel 太大、双缓冲占用过高、输入分辨率太高,而不是先改代码。
# board_smoke.py import sensor, image from maix import nn sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) sensor.set_windowing((224, 224)) sensor.skip_frames(time=500) task = nn.load("/sd/cls20.kmodel", dual_buff=False) img = sensor.snapshot() out = task.forward(img, quantize=True) print("out_type =", type(out)) print("out_len =", len(out) if isinstance(out, (list, tuple)) else "single")冒烟测试只做三件事:模型能不能加载、前向能不能跑、输出长度是不是 20。dual_buff=False是为了先排除双缓冲内存问题;如果加载失败,先关双缓冲、降分辨率、减通道,再逐步加回来。sensor.set_windowing((224,224))是中心裁剪,不一定是缩放;如果训练时用的是整图 resize,板上也要改成img.resize(224,224),否则视角和比例会变。输出长度不是 20 时,不要继续写后处理,先回去看 ONNX 输出 shape 和 kmodel 输出布局。
4. 上板代码:摄像头、推理、20类结果串起来
4.1 模型加载与标签表:先保证索引不错位
板端代码最容易犯的错是标签
本文还有配套的精品资源,点击获取