冰箱视觉识别:从分类到检测与 K210 部署的学习笔记
本页以学习为主线:先解释模型、数据、YOLO、量化和 K210 部署各自解决什么问题,再用真实代码和实验数字验证理解,最后说明哪些路线为什么不能继续。不要把它当成“模型已经修好”的宣传稿。
审计范围声明(2026-09-23):本页不是对
E:\embedded的逐文件代码审查。当前只直接核对了对话中出现的实验报告,以及分类训练/导出/校准、YOLO 训练/loss/解码、nncase 兼容和 K210 场景验收等关键入口文件。只读清单显示student_k210原始目录约 164,130 个文件;排除归档、第三方site-packages和缓存后,仍有约 2,243 个源码/报告文件(372 个 Python、1,721 个 JSON、138 个 Markdown、12 个 PowerShell)。未被本页列出的文件,不应被理解为已经逐个审阅。
扩大扫描记录(2026-09-23):随后又对
E:\embedded\pretrained_food_pc做了全量“文件清单 + 语法/JSON 解析 + 关键词静态审计”。排除archive、第三方site-packages和__pycache__后,纳入扫描的源码/配置/报告文件为 4,482 个:1,778 个 Python、1,844 个 JSON、561 个 JSONL、187 个 Markdown、98 个 YAML、14 个 PowerShell。1,778 个 Python 全部通过 AST 语法解析(0 个语法错误),共发现约 29,607 个函数定义、12,362 个 import 节点和 244 个命令行入口标记;1,844 个 JSON 全部可解析(0 个格式错误)。这证明代码库在“能否被解析”层面没有整体损坏,但不等于每条实验路线都正确,更不等于模型已经通过产品验收。
0. 学习路线:先把一条完整推理链讲通
把冰箱识别看成下面这条链,后面的每个实验都能放回对应位置:
图片
↓ RGB / 裁剪 / 缩放 / 归一化
模型输出 logits
↓ 解码
候选框 + objectness + class probability
↓ 置信度筛选 + NMS
食材类别
↓ 拒识规则 / 连续帧 / UI 状态机
入库,或 UNKNOWN
学习时最重要的不是背某个 mAP 数字,而是知道每一层负责什么、错了以后会影响哪一层。
0.1 分类、检测、拒识分别解决什么问题
分类回答“整张图最像哪一类”。softmax 会把所有类别变成概率并强制选出一个最大值:
prob = torch.softmax(logits, dim=1)
class_id = prob.argmax(dim=1)
所以把背景送进闭集分类器时,它也会选出一个食材。加阈值只能把低分结果改成 UNKNOWN,不能阻止背景恰好得到高分。
检测先回答“哪里有目标”,再回答“目标是什么”。YOLO 每个网格/anchor 同时预测:
tx, ty, tw, th 框的位置和尺寸
to objectness:这里是不是目标
tc[0..C-1] 类别 logits
拒识不是一个神奇阈值,而是检测器之外的产品层约束:目标分数、类别间隔、连续帧稳定性、空桌/盒子/手部负样本都要一起判断。把三者混成“softmax 最大值超过 0.7 就算识别”就是常见错误路线。
0.2 输入契约:同一张图必须真的进入同一个模型
输入契约就是模型看到的张量定义。本项目学习阶段固定为:
原图 → RGB
→ 中心裁剪成正方形
→ resize 到 224×224(或 YOLO 明确指定的尺寸)
→ HWC 转 NCHW
→ float32 / 0~1 或 0~255
→ 是否减 mean、除 std 必须写死
下面两种写法不是“差不多”,而是两个不同输入:
# NCHW、0~1
x = np.asarray(im, dtype=np.float32).transpose(2, 0, 1)[None] / 255.0
# HWC、0~255;如果模型按前一种训练,这就是错的
x = np.asarray(im, dtype=np.uint8)[None]
224/320、中心裁剪/stretch、RGB/BGR、01/0255 混用,会让我们误以为是“模型能力变化”,实际是输入不一致。
0.3 YOLO 目标分配:标签怎样进入输出张量
假设目标中心是归一化坐标 (cx, cy),网格边长为 S:
gx, gy = cx * S, cy * S
ix, iy = int(gx), int(gy)
tx, ty = gx - ix, gy - iy
(ix, iy) 决定目标落在哪个网格,(tx, ty) 是网格内偏移;目标宽高再和 anchor 比较 IoU,选出负责它的 anchor,并把 tw/th 编成相对 anchor 的对数比例。
旧代码如果发现同一个网格和 anchor 已经有目标,就直接跳过第二个目标,标签因此丢失。另一个问题是:目标周围 IoU 很高但没有被选为正样本的预测,不能简单当作普通负样本。正确学习顺序是先理解 one-to-one 分配,再理解 ignore mask,最后才研究多目标分配候选。
0.4 loss:每一项到底在教模型什么
L = λxy Lxy + λwh Lwh + λobj Lobj + λcls Lcls
Lxy:中心点偏移是否正确;Lwh:框的宽高是否正确;Lobj:有目标的位置应该高,没有目标的位置应该低;Lcls:在有目标的框上,类别是否正确。
如果所有未分配位置都用 objectness=0 强压,负样本数量会远大于正样本,模型容易学会“全部说没有目标”。ignore mask 保护高 IoU 的候选框,focal loss 降低大量容易负样本的权重,CIoU 同时约束重叠、中心距离和长宽比;它们针对的是不同问题,不能互相替代。
推理时不能直接把原始 tx/ty/tw/th 当成像素坐标。典型解码是:
bx = (sigmoid(tx) + ix) / S
by = (sigmoid(ty) + iy) / S
bw = anchor_w * exp(tw) / S
bh = anchor_h * exp(th) / S
score = sigmoid(to) * sigmoid(tc[class_id])
先解码,再按 score 筛选,最后用 NMS 抑制同一目标的重复框。只要 decoder、阈值或 NMS IoU 改了,mAP 就不再是同一套指标;这也是为什么旧 +0.5 decoder 的历史结果不能和修正版直接比较。
0.5 从 PyTorch 到 K210:数值等价不等于识别能力等价
PyTorch checkpoint → ONNX → nncase → KModel / INT8 → K210 实机
量化通常把浮点数映射到整数:
q = round(x / scale) + zero_point
x ≈ scale × (q - zero_point)
scale 和 zero_point 由校准数据决定。如果校准图太少、没有覆盖真实亮度/背景,某些激活会被截断,最后分类 logits 或 objectness 发生明显变化。max_abs_diff ≈ 4e-6 只说明 ONNX 和原模型在同一输入上数值接近;simulator finite 只说明 KModel 能运行且输出不是 NaN。它们都不能推出真实摄像头识别准确。学习上要分开三件事:算子是否等价、模型是否学到目标、产品场景是否可靠。
0.6 如何读实验数字
同口径 YOLO 结果中,E3 浮点完整 dev mAP50 为 0.643116,对应 INT8 为 0.560355;召回从 0.625205 降到 0.521516。这说明量化会改变 logits,进而改变阈值、NMS 和漏检率。
新类别也要区分训练回放和独立验证:土豆 67 张训练回放 AP/P/R 约为 0.7616/0.7308/0.7600,甜品 5 张训练回放为 1/1/1,但外部甜品 10 图 Recall 只有 0.0833。模型可能记住训练图的背景和构图,却没有学会类别本身。
因此数据切分也要作为技术细节记录,而不是只写一个“训练集/验证集”:同一原图的裁剪、同一视频的相邻帧、同一拍摄者和同一背景都可能泄漏到两边。至少要保存 manifest 的 hash,并按来源组或图片内容 hash 做隔离;否则验证集分数高,只能说明模型见过相似画面。
0.7 建议的学习顺序
第 1 步:logits、softmax、阈值和 UNKNOWN
第 2 步:RGB/裁剪/resize/归一化输入契约
第 3 步:手算一个 YOLO 目标怎样落到 grid/anchor
第 4 步:理解 objectness、ignore、focal、CIoU
第 5 步:用同一 manifest 比较 FP32、ONNX、INT8
第 6 步:最后做 K210 摄像头、连续帧和产品 UI 验收
后面的章节保留代码和实验记录,但阅读顺序应是:先问原理,再看实现,再看数字,最后判断是否能交付。
0A. 扫描后新增的实验对照
分类学生模型:能编译不等于能交付
这次直接复核了 student_k210/README.md、ood_rejection/README.md、runs/stable_taxonomy_v2_k210/stable_taxonomy_v2_k210_delivery_gate_audit_v1.md 以及多个量化候选报告。结论必须拆成三层:
- 主机/模拟器层面有通过项:
stable_food_100_v2的 ONNX 输入为[1,3,224,224],ONNX 与 PyTorch 最大绝对误差约4.29e-6;KModel 为uint8 NHWC,内置预处理,大小约1,958,736 B,主机模拟器固定 20 张回放得到 Top-1 85%、Top-5 100%。 - 真实板端仍是阻塞项:该交付门审计明确标记
board_verified=false。完整 dev 只有 1,130 张、覆盖 53 个类别,88.50% 的数字不能解释成 100 类产品准确率。 - OOD 阈值路线不能单独解决拒识:已知食物保留约 95% 时,26 张非食物样本仍有 19 张被接受,误接受率为 73.08%;四分数联合也有 11/26 被接受。非食物样本过少且场景单一,说明阈值只是一层保护,不能替代硬负样本、独立相机集和真机验收。
因此,compile PASS、simulator finite、host Top-1 只能分别说明“可运行、数值有限、这批输入上有一定能力”,不能合并成“可以上线”。
YOLO 当前主线:浮点在进步,量化和拒识仍未达标
直接核对 research/yolo_common_20260922/QUALITY_AUDIT_CURRENT.md 与 customer_20260922/CURRENT_EVIDENCE.md 后,保留同一输入、解码和 NMS 口径的数字:
| 路线/阶段 | 完整旧 dev mAP50 | Precision | Recall | 判定 |
|---|---|---|---|---|
| 标准 E1 v4(旧路线) | 0.228609 | 0.339059 | 0.218443 | 远未达标 |
| E3 浮点配对基准 | 0.643116 | 0.725910 | 0.625205 | 研究基线 |
| E3 INT8 配对 | 0.560355 | 0.686910 | 0.521516 | 量化掉点,不能交付 |
| E6 浮点 | 0.672454 | 0.727704 | 0.660451 | 有进步,仍未达标 |
| E7 浮点 | 0.682134 | — | — | 训练侧指标,非产品验收 |
| E9 浮点同口径 | 0.693618 | 0.734558 | 0.679918 | 仍为旧 44 类覆盖 |
| E10 浮点同口径 | 0.700227 | 0.745499 | 0.687295 | 小幅提高,未量化 |
这里最容易走错的路线是“只看 mAP 上升就宣布可用”。E3 INT8 在相同 dev 上比浮点少约 0.08276 mAP、少约 0.10369 recall;阈值从 0.5 提高到 0.7 时,INT8 的背景误接受从 16/312 降到 2/312,但正样本 recall 也降到 0.402254。拒识和识别必须联合验收,不能用调高阈值掩盖漏检。
更直接的实机场景回放是:同一 E1 affineconv 板载候选中,浮点香蕉最高分约 0.845,而真实 K210 留存帧回放输出香蕉 [];手部为空也不能算“拒识通过”,因为没有独立 30 帧验收。这个结果把问题定位到“量化/部署候选 + 场景验收”层,而不是简单归因于 NMS。
新类别和背景:训练回放不能冒充独立验证
CURRENT_EVIDENCE.md 还记录了一个重要反例:E9 的土豆 67 张训练回放 AP/P/R 约为 0.7616/0.7308/0.7600,甜品 5 张训练回放为 1/1/1;但外部甜品 10 图只有 AP 0.3375、Recall 0.0833。这说明“补数据后训练集命中”只能证明模型记住了这批样本,不能证明新类别已经泛化。
同理,E9→E10 的 ginger 外部 holdout 明显回退,milk、ginger、yogurt 没有统一最优 checkpoint。每次换数据、冻结 BN、恢复优化器或加入背景图,都应新建实验 ID、保存 checkpoint/hash,并重新跑完整 dev、独立新类、INT8 和实机回放;不能覆盖旧结论。
代码库级别的审查结论
这轮扫描还发现了大量 train、eval、onnx、kmodel、nncase、quant、camera、serial 和 reject 分支。它们可解析不代表逻辑同口径;当前最危险的不是 Python 语法,而是:
- 旧 decoder 的
+0.5曾让历史 mAP 全部失效; compare_beta4_probe.py曾用 stretch 输入,且原地减法污染 logits view,旧 MAE/相关性不能用;- 224 导出报告曾继承 320 checkpoint 的 dev 指标,不能当成 224 真实评估;
- 多个分支的模型、manifest、预处理、NMS 和校准集不匹配,不能按文件名或 epoch 直接横向比较;
stable_taxonomy_v2、stable_v2_aux_siglip_kd_gap_v3、alpha05_relu6、alpha05_relu_kd_v3是不同容量/损失/量化路线,不能把各自最好数字拼成一个“最佳模型”。
本次审查后的总判断是:解析层面健康,实验管理和产品证据仍未闭环;主线可以继续,但当前没有任何一条路线同时满足“完整独立 dev、独立拒识、最终 INT8、真实 K210、连续帧和产品 UI”六项条件。
1. 先固定问题定义
旧模型做的是闭集分类:无论输入是不是食物,都必须从类别表里选一个类别。冰箱产品需要的是开放世界识别:识别到目标才输出类别,包装、桌面、空场景和未知物体应该输出 UNKNOWN。
因此下面的代码虽然能实现“低置信度拒绝”,但不能从根本上修复闭集分类器的强制选类问题:
import numpy as np
def classify_with_threshold(logits: np.ndarray, names: list[str], threshold: float):
logits = logits - logits.max()
probs = np.exp(logits)
probs /= probs.sum() + 1e-9
class_id = int(probs.argmax())
score = float(probs[class_id])
if score < threshold:
return {"label": "UNKNOWN", "score": score}
return {"label": names[class_id], "score": score}
阈值只是在“模型已经选出的类别”上做拒绝。它不能保证背景不会被高置信度地识别成食物,所以“继续给旧分类器调阈值”这条路线已经否定。
2. 数据审计先于换模型
对话中已经确认的事实:
- 严格测试集 271 张图中,106 张来自两张复杂拼图图像;约 97 个错误集中在这类复杂场景。
- 类别严重失衡:鸡蛋约 4050 张,而馄饨约 3 张、馒头约 15 张、鸭肉约 17 张、金针菇约 27 张。
- 内部验证没有覆盖完整类别表,内部 Top-1 约 89% 不能当作完整 97/100 类能力。
- FP32 严格测试约 46.86%,K210 模拟器约 42.07%;量化损失约 4.79 个百分点。
- 模糊测试下 FP32 约 28.04%,说明图像质量与场景域差异本身就是主要问题。
所以不能把问题归结为“旧 V4 INT8 量化坏了”。量化链路确实有问题,但 FP32 已经失败,数据分布、标签覆盖和场景差异必须单独修复。
3. 输入契约必须写死
训练代码 E:\embedded\pretrained_food_pc\student_k210\research\yolo_common_20260922\train_yolov2_44c.py 中的实际输入处理是:
with Image.open(r["image"]) as im:
im = im.convert("RGB")
w, h = im.size
side = min(w, h)
x = (w - side) // 2
y = (h - side) // 2
im = im.crop((x, y, x + side, y + side))
im = im.resize((INPUT_SIZE, INPUT_SIZE), Image.Resampling.BILINEAR)
x = np.asarray(im, dtype=np.float32).transpose(2, 0, 1).copy()
这里隐含了四个必须记录的约束:
RGB,不是 BGR
float32 NCHW
中心裁剪后 resize
是否除以 255、是否折叠 ImageNet mean/std,必须和 KModel 一致
V3 期间曾出现 0~1、0~255、RGB/BGR 和归一化折叠不一致。只要其中一项不一致,PC、nncase 模拟器和 K210 实机就不是同一个模型输入。
4. 原始 YOLOv2 的目标分配
原训练代码使用一个最匹配 anchor 和一个网格单元负责每个目标:
def make_targets(boxes_batch, device):
shape = (len(boxes_batch), ANCHOR_COUNT, GRID, GRID)
xy = np.zeros((*shape, 2), dtype=np.float32)
wh = np.zeros((*shape, 2), dtype=np.float32)
obj = np.zeros(shape, dtype=np.float32)
cls = np.full(shape, -1, dtype=np.int64)
for bi, boxes in enumerate(boxes_batch):
for box in boxes:
cx = min(max(float(box["cx"]), 1e-5), 1 - 1e-5)
cy = min(max(float(box["cy"]), 1e-5), 1 - 1e-5)
gw = max(float(box["w"]) * GRID, 1e-4)
gh = max(float(box["h"]) * GRID, 1e-4)
inter = np.minimum(ANCH[:, 0], gw) * np.minimum(ANCH[:, 1], gh)
iou = inter / (ANCH[:, 0] * ANCH[:, 1] + gw * gh - inter + 1e-9)
ai = int(iou.argmax())
gx, gy = cx * GRID, cy * GRID
ix, iy = min(int(gx), GRID - 1), min(int(gy), GRID - 1)
if obj[bi, ai, iy, ix] > 0.5:
continue
obj[bi, ai, iy, ix] = 1
xy[bi, ai, iy, ix] = [gx - ix, gy - iy]
wh[bi, ai, iy, ix] = np.log(
np.asarray([gw, gh], dtype=np.float32) / (ANCH[ai] + 1e-9)
)
cls[bi, ai, iy, ix] = int(box["class_id"])
return tuple(torch.from_numpy(v).to(device) for v in (xy, wh, obj, cls))
原方案的问题不是“代码不能运行”,而是所有没有被分配的网格位置都被当成 objectness=0 的普通负样本,没有 ignore mask。一个目标周围本来可能存在合理候选框,却被损失函数强行压成背景,正负样本比例严重失衡。
5. 原始 loss 为什么失败
原始版本的 objectness loss 是:
objloss = nn.functional.binary_cross_entropy_with_logits(
p[..., 4],
target_obj,
reduction="none",
pos_weight=torch.tensor(8.0, device=raw.device),
).mean()
它没有区分:
- 正样本 objectness;
- 真正的负样本;
- 与真实框重叠、但不负责回归的候选框。
旧分支 E4~E6 的 mAP50 只有约 0.121~0.132,FP 约 4600~5100,不是简单增加 epoch 能解决的问题。
6. 修正后的 loss:ignore、focal 和 CIoU
实验中的 train_yolov2_lossv2_smoke.py 使用了以下核心思想:
import torch.nn.functional as F
boxes_pred = decoded_boxes(p)
ignore = torch.zeros_like(pos)
with torch.no_grad():
for bi, boxes in enumerate(boxes_batch):
if not boxes:
continue
gt = torch.tensor([
[z["cx"] - z["w"] / 2,
z["cy"] - z["h"] / 2,
z["cx"] + z["w"] / 2,
z["cy"] + z["h"] / 2]
for z in boxes
], device=raw.device, dtype=raw.dtype)
max_iou = pairwise_iou(
boxes_pred[bi].reshape(-1, 4).detach(), gt
).amax(1)
ignore[bi] = max_iou.reshape(ANCHOR_COUNT, GRID, GRID).gt(0.5)
obj_logits = p[..., 4]
pos_obj = F.binary_cross_entropy_with_logits(obj_logits[pos], torch.ones_like(obj_logits[pos]))
neg = ~pos & ~ignore
neg_prob = obj_logits[neg].sigmoid()
neg_bce = F.binary_cross_entropy_with_logits(
obj_logits[neg], torch.zeros_like(obj_logits[neg]), reduction="none"
)
neg_obj = (neg_bce * neg_prob.square()).sum() / npos
回归分支还加入 CIoU:
ciou_loss = (1.0 - ciou_aligned(
boxes_pred[pos], target_boxes(txy, twh, pos)
)).mean()
total = (
2.0 * pos_obj
+ 0.5 * neg_obj
+ xy_loss
+ 0.5 * wh_loss
+ 2.0 * ciou_loss
+ cls_loss
)
这条路线是合理的优化假设,但“小数据集同训同测 mAP50 达到 0.85”只能证明容量足够,不能证明产品泛化。
7. YOLO 解码必须固定,不能混指标
当前研究代码的中心点解码是:
sigmoid = lambda x: 1.0 / (1.0 + np.exp(-np.clip(x, -40, 40)))
cx = (sigmoid(tx) + grid_x) / GRID
cy = (sigmoid(ty) + grid_y) / GRID
w = np.exp(np.clip(tw, -6, 4)) * anchor_w / GRID
h = np.exp(np.clip(th, -6, 4)) * anchor_h / GRID
box = [cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2]
中途曾经使用过额外的 +0.5,后来被去掉。解码器改变后,框坐标和 IoU 都会改变。因此 decoder_v1 与 decoder_v2 的 mAP 不能混在同一条曲线上比较。旧 decoder 的指标必须标记为历史无效或单独分支。
8. mAP50 的计算边界
固定阈值的 P/R 只能作为工作点诊断,不能替代完整 mAP。研究代码的匹配条件是:
if best_iou >= 0.5 and not matched[image_id][best_index]:
tp.append(1)
else:
fp.append(1)
需要同时记录:
IoU 匹配阈值:0.50
置信度工作点:例如 0.25
NMS IoU:例如 0.45 或 0.30
类别集合和类别顺序
评估 decoder 版本
评估数据 manifest 的 SHA-256
同训同测、oracle 责任网格、固定阈值 P/R 都不能作为最终产品准确率。
9. K210 模拟器与实机代码
V3 K210 运行包的实际核心代码如下:
import sensor
import time
import KPU as kpu
MODEL = 0x400000
sensor.reset()
sensor.set_pixformat(sensor.RGB565)
sensor.set_framesize(sensor.QVGA)
sensor.set_windowing((224, 224))
sensor.run(1)
task = kpu.load(MODEL)
while True:
img = sensor.snapshot()
t0 = time.ticks_ms()
values = kpu.forward(task, img)[:]
elapsed_ms = time.ticks_ms() - t0
order = sorted(range(100), key=lambda i: values[i], reverse=True)[:5]
print("V3_FORWARD n=%d elapsed_ms=%d top5=%s" %
(len(values), elapsed_ms,
[(labels[i]["en"], values[i]) for i in order]))
这段代码只能验证:模型能加载、摄像头能取帧、输出维度正确、推理能返回。它不能单独证明产品准确率,也不能证明背景拒识能力。
更严格的实机场景验收应该使用 30 帧窗口:
banana_frames = 0
nonempty_frames = 0
for _ in range(30):
img = sensor.snapshot()
boxes = kpu.run_yolo2(task, img)
decoded = [] if not boxes else [
(b.classid(), b.value(), b.rect()) for b in boxes
]
if decoded:
nonempty_frames += 1
best = max(decoded, key=lambda item: item[1])
if int(best[0]) == BANANA_CLASS_ID:
banana_frames += 1
assert banana_frames >= 24 # 目标场景至少 80% 帧正确
assert nonempty_frames == 0 # 背景/空场景不能误接受
实机验收必须同时包含目标场景和拒识场景,不能只测试香蕉或单张图片。
10. 必须否定的路线
- 继续给旧闭集分类器调阈值,解决不了开放世界误识别。
- 把 PC 端 SigLIP2 简单裁剪后直接当作 K210 模型。
- 把教师模型预测结果直接当作真实标签。
- 只追加一小批数据、只训练一个 epoch,就断言“加数据没有用”。
- 把同训同测 mAP50=0.85 当作产品准确率。
- 把不同 decoder、输入契约、类别数和数据集的指标混合比较。
- 多个训练进程同时写同一实验目录,然后挑一个更好看的结果。
- 仅凭编译成功、KModel 加载成功或输出维度正确,就宣布实机识别成功。
- 为了凑够 100~200 类而强行合并有歧义的类别。
- 在数据版权、来源和标签域没有核验前,把外部数据直接纳入产品训练。
11. 当前应保留的结论
- 旧分类路线已经归档,不再作为产品主路线。
- 数据域、类别失衡、标签覆盖和复杂背景是主要根因。
- V3 ReLU6 + 浮点头可以作为分类候选基线,但严格 Top-1 约 74.96%,尚未达到 80% 验收目标。
- 旧 YOLO loss 和目标分配存在结构性错误。
- 修正后的 YOLO loss 具有研究价值,但当前只证明了拟合能力和阶段性训练改善。
- 目前没有任何路线同时完成独立测试集、量化模拟器、K210 实机和开放世界拒识验收。
12. 后续唯一可靠的实验闭环
冻结 taxonomy 和类别顺序
↓
冻结 train / dev / board_acceptance 三份 manifest
↓
固定 RGB、尺寸、归一化和模型 SHA-256
↓
PC FP32 评估
↓
量化模拟器评估
↓
K210 实机目标场景 + 拒识场景 30 帧验收
↓
只有三层结果一致,才允许进入产品 UI
本页引用的主要工程文件:
E:\embedded\pretrained_food_pc\student_k210\research\yolo_common_20260922\train_yolov2_44c.pyE:\embedded\pretrained_food_pc\student_k210\research\yolo_common_20260922\train_yolov2_lossv2_smoke.pyE:\embedded\pretrained_food_pc\student_k210\build_relu6_v3_input01_runtime.pyE:\embedded\pretrained_food_pc\student_k210\verify_kmodel_runtime.pyE:\embedded\pretrained_food_pc\student_k210\research\yolo_common_20260922\board_scene_acceptance.py
这篇笔记的“完成”标准不是某个数字变大,而是每个数字都能回到同一个数据 manifest、同一个输入契约、同一个模型文件和同一个后处理版本。
附录:已覆盖实验对照与代码证据
13. 对话中的实验时间线
13.1 旧分类器阶段
最早的方案是 97/100 类图像分类器。它在输入任何图片时都会给出一个类别,因此把香蕉、包装、桌面或复杂拼图都强行映射到食物类别。后续增加 UNKNOWN 阈值,只能改变“拒绝多少”,不能改变闭集分类器的输出逻辑。
这一阶段应该保留的证据是:
严格测试中出现明显的跨类误识别
简单香蕉图没有稳定得到香蕉 Top-1
复杂拼图图像贡献了大量错误
不同阈值无法同时满足召回、误接受率和已接受样本准确率
这一阶段不应该继续做的事情是:继续寻找一个“神奇阈值”,或者把 Top-5 里包含正确类别当作产品成功。
13.2 大模型教师阶段
SigLIP2 等大模型可以作为 PC 端教师或候选数据审查器,但不能直接当作 K210 学生模型。教师模型的作用是产生辅助软目标或帮助筛选候选图片,真实标签仍然由人工或可信数据源提供。
教师标签加载代码已经对无效来源做了拒绝:
def load_teacher(row: dict, classes: int):
if row.get("teacher_valid") is not True:
return None
value = row.get("teacher_logits_file") or row.get("teacher_logits_path")
if not value:
return None
logits = np.load(resolve_path(str(value))).astype(np.float32).reshape(-1)
if not np.isfinite(logits).all():
return None
if logits.shape != (classes,):
return None
mask = np.asarray(row.get("teacher_class_mask", np.ones(classes)), dtype=bool)
if mask.shape != (classes,) or int(mask.sum()) < 2:
return None
return logits, mask
这里的关键不是“教师预测越多越好”,而是:
teacher_valid 不成立 → 不使用
logits 形状不匹配 → 不使用
含 NaN/Inf → 不使用
教师只覆盖部分类别 → 只在 mask 范围内蒸馏
13.3 Fruits-360 增量路线
Fruits-360 的问题不是数据集本身不能用,而是它和冰箱真实场景之间存在明显域差异:单一水果、背景干净、姿态简单、类别定义和产品类表不完全一致。
对话中的独立迁移结果约为:
PC 教师:86.11% Top-1
K210 学生:13.89% Top-1
因此该路线可以作为迁移失败案例保留,但不能作为产品替代模型。
13.4 40 类研究子集
40 类路线的正确定位是“可控研究子集”,不是最终冰箱类目。它可以用来快速验证:
- 数据读取和标签映射是否正常;
- 模型头是否能收敛;
- 量化输入契约是否一致;
- K210 是否能加载和推理。
但它不能证明最终 100~200 类产品具备同样能力。类别数量必须由真实产品场景、标签质量和独立测试覆盖决定,不能为了好看而凑数。
14. 分类学生模型的真实实现
学生训练器位于:
E:\embedded\pretrained_food_pc\student_k210\train_student.py
它的关键设计是:只使用官方 ImageNet MobileNetV1 主干,新建分类头,不加载旧 Food97 分类头。
def build_student(model_id: str, classes: int,
depth_multiplier: float = 0.75,
hidden_act: str = "relu6"):
config = MobileNetV1Config.from_pretrained(
model_id, local_files_only=True
)
config.depth_multiplier = depth_multiplier
config.hidden_act = hidden_act
config.num_labels = classes
config.image_size = 224
config.tf_padding = False
model = MobileNetV1ForImageClassification(config)
official = MobileNetV1ForImageClassification.from_pretrained(
model_id, local_files_only=True
)
model.mobilenet_v1.load_state_dict(
official.mobilenet_v1.state_dict()
)
# 分类头重新随机初始化,避免把旧分类器权重带入新类表。
nn.init.normal_(model.classifier.weight, mean=0.0, std=0.01)
nn.init.zeros_(model.classifier.bias)
return model
训练增强和评估预处理必须分开:
self.transform = transforms.Compose([
transforms.RandomResizedCrop(size, scale=(0.65, 1.0))
if train else transforms.Resize(size + 32),
transforms.RandomHorizontalFlip() if train
else transforms.CenterCrop(size),
transforms.ToTensor(),
transforms.Normalize(
[0.485, 0.456, 0.406],
[0.229, 0.224, 0.225]
),
])
不能在评估集上使用 RandomResizedCrop,也不能在训练和 K210 运行时分别使用不同的裁剪逻辑。
15. 分类模型的数值证据应这样写
从实际报告中应区分三种结果,而不是只写一个“准确率”:
| 阶段 | 数据/模型 | Top-1 | Top-5 | 解释 |
|---|---|---|---|---|
| 旧 V4 | FP32/旧分类模型,strict1130 | 56.37% | 81.59% | 旧基线 |
| ReLU6 学生 | PC FP32,strict1130 | 86.28% | 97.43% | 证明 PC 学生路线有能力 |
| ReLU6 V3 | K210/nncase,strict1130 | 74.96% | 95.31% | 量化和端侧兼容后下降 |
其中 ReLU6 V3 的实机报告还记录了:
模型输出:100
模型大小:1,070,440 bytes
Flash 地址:0x400000
设备:Sipeed M1 / OV5642
实测 forward:25 ms
COM9 烧录和加载:通过
这组结果说明:
PC FP32 86.28% → K210 74.96%
端侧还有约 11.32 个百分点差距。因此“ReLU6 已经完全解决模型能力问题”是不成立的;正确说法是“ReLU6 学生模型明显改善,已经形成可进一步验证的候选基线”。
16. ONNX 导出和输入契约代码
实际导出脚本 export_student.py 使用 wrapper 把输入归一化明确写入模型:
class Wrapper(torch.nn.Module):
def __init__(self, model, raw_uint8=False):
super().__init__()
self.model = model
self.raw_uint8 = raw_uint8
self.register_buffer(
"mean", torch.tensor([0.485, 0.456, 0.406])
.view(1, 3, 1, 1)
)
self.register_buffer(
"std", torch.tensor([0.229, 0.224, 0.225])
.view(1, 3, 1, 1)
)
def forward(self, pixel_values):
if self.raw_uint8:
pixel_values = (pixel_values / 255.0 - self.mean) / self.std
return self.model(pixel_values=pixel_values).logits
导出后必须做 PyTorch 和 ONNX Runtime 数值对照:
with torch.no_grad():
torch_logits = wrapper(dummy).numpy()
onnx_logits = session.run(
["logits"],
{"pixel_values": dummy.numpy()}
)[0]
max_abs_diff = float(np.max(np.abs(torch_logits - onnx_logits)))
assert max_abs_diff < 1e-4
如果这一步不通过,就不能进入 nncase,也不能把后面的 KModel 结果归因给训练模型。
17. 校准集错误与修正
旧的 64 张校准图曾经按照类别编号顺序选取,实际可能只覆盖前 64 个类别。校准集数量看似足够,但类别覆盖并不均衡。
当前 prepare_calibration.py 采用轮询类别的方式:
by_class = defaultdict(list)
for row in rows:
by_class[int(row["target_id"])].append(row)
selected = []
index = 0
class_ids = sorted(by_class)
while len(selected) < samples:
added = False
for class_id in class_ids:
if index < len(by_class[class_id]):
selected.append(by_class[class_id][index])
added = True
if len(selected) >= samples:
break
if not added:
break
index += 1
但轮询只是最低限度修正。正式校准仍应报告:
校准图片数量
覆盖类别数
每类最少/最多样本
原始图像来源组数量
是否包含不同曝光、背景和目标尺度
校准集只能来自训练集,不能偷用独立验收集,否则会污染最终证据。
18. nncase 兼容性改写不能替代精度验证
make_nncase_v4_compatible_onnx.py 做过几类兼容改写:
x / 255 - mean / std → depthwise 1×1 Conv
ReLU6 Clip → ReLU + Min(6)
GlobalAveragePool → ReduceMean 或固定 7×7 Conv
Gemm → 1×1 Conv
这些改写只是在算子兼容性层面保持等价,必须先通过:
ONNX checker
shape inference
PyTorch/ONNX 数值对照
独立 FP32 评估
nncase 模拟器评估
不能因为“成功生成 KModel”就认为改写没有损失。
19. YOLO 路线的实验分支隔离
对话里至少存在这些不同分支:
100 类闭集分类
40 类研究分类
44 类 YOLOv2 检测
旧 decoder / +0.5
修正 decoder / 无 +0.5
旧 loss
loss v2:ignore + focal + CIoU
小数据集同训同测
全量 train / dev
这些分支不能只按 epoch 编号排序。每条实验记录必须包含:
{
"experiment_id": "yolo44_lossv2_full_e01",
"taxonomy_id": "contest60_common_fresh_yolov2_probe_v1",
"train_manifest_sha256": "...",
"dev_manifest_sha256": "...",
"input_contract": "RGB float32 NCHW center-crop 224",
"decoder": "center_decode_without_plus_0_5",
"loss": "ignore_focal_ciou_v2",
"nms_iou": 0.30,
"match_iou": 0.50,
"research_only": true
}
如果缺少这些字段,两个数字即使都叫 mAP50,也不一定可比较。
20. YOLO 实验中出现的流程错误
20.1 同时启动多个训练进程
对话曾经发现两个 train_yolov2_44c.py 进程同时运行,并且使用了不同 Python 环境。这样会造成:
- GPU 和磁盘竞争;
- 同一输出目录的 checkpoint 覆盖或顺序混乱;
- 日志无法对应模型;
- 无法确认某个指标究竟来自哪一个进程。
训练前应先确认:
Get-CimInstance Win32_Process -Filter "name = 'python.exe'" |
Where-Object { $_.CommandLine -match 'train_yolo' } |
Select-Object ProcessId, CommandLine
正式实验要求:一个实验 ID、一个输出目录、一个训练进程、一个 Python 环境。
20.2 恢复训练必须恢复完整状态
不能只加载 model_state 就叫“续训”。至少要核对:
payload["epoch"]
payload["model_state"]
payload["optimizer_state"]
payload.get("scheduler_state")
payload.get("scaler_state")
payload["manifest_sha256"]
如果冻结层、学习率、优化器动量或数据清单改变,应该新建实验 ID,而不是继续覆盖旧实验。
21. 真实产品验收表
| 验收层 | 必须回答的问题 | 不能替代的证据 |
|---|---|---|
| PC FP32 | 模型本身是否学到目标 | 不能替代 K210 |
| ONNX Runtime | 导出是否保持数值一致 | 不能替代量化 |
| nncase | KModel 能否运行、输出是否有限 | 不能替代实机摄像头 |
| K210 目标场景 | 香蕉、蔬菜等是否稳定识别 | 不能替代拒识 |
| K210 拒识场景 | 空桌、盒子、手、包装是否不输出食物 | 不能只测一张背景 |
| 连续 30 帧 | 是否稳定而不是偶然命中 | 不能只看单帧截图 |
| 产品 UI | 识别、入库、回退、UNKNOWN 是否闭环 | 不能由模型加载成功推出 |
目标场景可以使用:
banana_top1_frames >= 24 / 30
拒识场景至少应满足:
false_accept_frames == 0 / 30
这两个条件必须在多个不同场景重复,而不是只对一张固定图片验收。
22. 最终判定矩阵
| 方案 | 是否保留 | 原因 |
|---|---|---|
| 旧闭集分类 + 阈值 | 否 | 产品逻辑不匹配,无法可靠拒识 |
| SigLIP2 直接部署 K210 | 否 | 资源和算子不满足端侧约束 |
| Fruits-360 增量蒸馏 | 否 | 独立迁移 Top-1 约 13.89% |
| 40 类分类子集 | 研究保留 | 只用于快速验证,不代表完整产品 |
| ReLU6 学生分类 | 候选保留 | PC 86.28%,K210 74.96%,仍需端侧验收 |
| 旧 YOLOv2 loss | 否 | mAP50 约 0.12,负样本处理错误 |
| loss v2 小数据集拟合 | 诊断保留 | 只能证明容量,不能证明泛化 |
| loss v2 全量 YOLO | 继续实验 | 必须等待独立 dev 和实机拒识结果 |
23. 本页对应的实际文件
分类模型:
E:\embedded\pretrained_food_pc\student_k210\train_student.py
E:\embedded\pretrained_food_pc\student_k210\export_student.py
E:\embedded\pretrained_food_pc\student_k210\eval_onnx_manifest.py
E:\embedded\pretrained_food_pc\student_k210\prepare_calibration.py
E:\embedded\pretrained_food_pc\student_k210\verify_kmodel_runtime.py
YOLO 模型:
E:\embedded\pretrained_food_pc\student_k210\research\yolo_common_20260922\build_yolov2_probe.py
E:\embedded\pretrained_food_pc\student_k210\research\yolo_common_20260922\train_yolov2_44c.py
E:\embedded\pretrained_food_pc\student_k210\research\yolo_common_20260922\train_yolov2_lossv2_smoke.py
E:\embedded\pretrained_food_pc\student_k210\research\yolo_common_20260922\diagnostics\assignment_ignore_audit.py
E:\embedded\pretrained_food_pc\student_k210\research\yolo_common_20260922\board_scene_acceptance.py
模型兼容与部署:
E:\embedded\pretrained_food_pc\student_k210\make_nncase_v4_compatible_onnx.py
E:\embedded\pretrained_food_pc\student_k210\build_relu6_v3_input01_runtime.py
E:\embedded\pretrained_food_pc\student_k210\runs\alpha05_relu6_from_relu_v1\runtime_v3_input01_scalar_floatfc\V3_RUNTIME_REPORT.json
附录保留代码,是为了让以后能从报告数字追溯到训练器、解码器、导出器和实机验收脚本;如果代码和报告不一致,报告结论自动降级为“不可复现”。