比赛要求在停车区识别地面的序列号,序列号只有 1、2、3 三种,读出来显示在屏上。K210 上跑的是 MNIST 手写数字模型,识别结果通过串口发给 STM32,STM32 侧再从 NUM: 后面把数字抠出来上屏。
第一次联调就出问题了。地面喷的是 2,K210 的屏上时不时蹦出个 5,有时候是 6,有时候是 8,同一个数字连看几次结果都不一样。2 偏偏是三个序列号里最容易出错的那个。
排查是从图像预处理开始的:
img_mnist1 = img.to_grayscale(1)
img_mnist2 = img_mnist1.resize(112, 112)
img_mnist2.invert()
img_mnist2.strech_char(1)
img_mnist2.pix_to_ai()
灰度化、缩放、反色、拉伸,每一步都对着 MaixPy 的文档核了一遍,没看出毛病。然后我把 kpu.run_with_output 的原始输出直接打到串口,原因就露出来了:输入是 2 的时候,类别 2 和类别 5、6、8 的输出值经常只差零点零几,top-1 随时会翻。MNIST 本身就是手写数字数据集,2、5、6、8 的字形挨得近,摄像头角度或者光照稍微一变结果就跳。1 和 3 几乎不出问题,出问题的全是 2。
主循环最后有个 time.sleep_ms(500),也就是每秒差不多两帧。屏上数字跳来跳去,跟这个频率也对得上。
比赛在即,重训模型来不及。我的处理比较粗暴,序列号反正只有 1、2、3,2 又总被认成 5、6、8,那就在推理后处理里把这三个输出统一拉回 2:
if index_mnist == 6:
index_mnist = 2
if index_mnist == 5:
index_mnist = 2
if index_mnist == 8:
index_mnist = 2
三个 if 写在 out.index(max(out)) 之后、分数判断之前。代价很明确,地面真出现 5、6、8 的时候系统一样报 2,不过比赛只用 1、2、3,这几个数本来就不会出现。
后来才发现这段改得不干净。5、6、8 在前面已经全被改成 2 了,下面分发用的 elif 链里却还留着 index_mnist == 5 和 index_mnist == 6 两个分支,这两支永远进不去。功能上没影响,但读代码的时候容易误以为 5 和 6 有单独的处理逻辑,我自己隔了几天再看就愣了一下。
阈值那块也踩了一下。我把置信度门槛往上调之后,不管怎么试都触发不了,一度怀疑模型没加载成功,重烧了两遍固件,排查了半个多小时才发现阈值被写成了大于 1.0。
score = KPU.sigmoid(max_mnist)
if score > 0.999:
sigmoid 的输出范围是 0 到 1,大于 1.0 的条件永远不成立,那一支等于被彻底关掉。代码注释里那句「修复原错误阈值>1.0」就是当时留下的记录。门槛最后定在 0.999,现场拿着地面数字一个个试,真实 2 的置信度基本都在 0.99 以上,0.999 偶尔会把几个 2 也滤掉,但漏检比误报好受。
顺带提一句,数字 0 那一支的门槛是 0.5,比 2、3、5、6 这几支松得多,这个不一致到现在还留在 main.py 里。
STM32 那边收到 NUM 之后的动作是固定的。八路灰度里超过 1000 的有 5 路以上,就判定到了停车区,然后停车 500ms、左转 500ms、停车 2 秒并把编号显示到屏上,再回转,回到正常循迹。这套动作对 1、2、3 完全一样,编号只负责显示,不影响动作,所以识别这一环的意义就是把号码读对。
事后想,更靠谱的做法是训一个只分 1、2、3 的三分类器,类别数从 10 降到 3,2 和 5、6、8 的混淆从根上就不会出现。当时没这个时间,三个 if 是唯一能在比赛前跑通的写法。