冰箱视觉识别:从分类到检测与 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 以及多个量化候选报告。结论必须拆成三层:

  1. 主机/模拟器层面有通过项: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%。
  2. 真实板端仍是阻塞项:该交付门审计明确标记 board_verified=false。完整 dev 只有 1,130 张、覆盖 53 个类别,88.50% 的数字不能解释成 100 类产品准确率。
  3. 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. 必须否定的路线

  1. 继续给旧闭集分类器调阈值,解决不了开放世界误识别。
  2. 把 PC 端 SigLIP2 简单裁剪后直接当作 K210 模型。
  3. 把教师模型预测结果直接当作真实标签。
  4. 只追加一小批数据、只训练一个 epoch,就断言“加数据没有用”。
  5. 把同训同测 mAP50=0.85 当作产品准确率。
  6. 把不同 decoder、输入契约、类别数和数据集的指标混合比较。
  7. 多个训练进程同时写同一实验目录,然后挑一个更好看的结果。
  8. 仅凭编译成功、KModel 加载成功或输出维度正确,就宣布实机识别成功。
  9. 为了凑够 100~200 类而强行合并有歧义的类别。
  10. 在数据版权、来源和标签域没有核验前,把外部数据直接纳入产品训练。

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.py
  • E:\embedded\pretrained_food_pc\student_k210\research\yolo_common_20260922\train_yolov2_lossv2_smoke.py
  • E:\embedded\pretrained_food_pc\student_k210\build_relu6_v3_input01_runtime.py
  • E:\embedded\pretrained_food_pc\student_k210\verify_kmodel_runtime.py
  • E:\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

附录保留代码,是为了让以后能从报告数字追溯到训练器、解码器、导出器和实机验收脚本;如果代码和报告不一致,报告结论自动降级为“不可复现”。