0%

RKYOLO诞生记 (二):模型“指鹿为马”?我的Bug侦探笔记

上一篇文章,我们聊了为什么我选择用 Rust 来为 RKNN 库构建一个安全、独立的地基。当地基打好,我信心满满地将所有模块串联起来,加载了官方示例中那张经典的bus.jpg,期待着一次完美的推理。

程序没有崩溃,有输出了!我兴奋地将结果绘制出来,然后……我傻眼了。

案发现场:一切混乱的开始

眼前的景象让我大跌眼镜。这哪里是目标检测,这简直是抽象艺术……

图一:最初的错乱图
图一:最初的错乱图
图注:最初的推理结果,大量的框重叠、错位,甚至把远处的建筑识别成了“truck”

大量的检测框毫无逻辑地堆叠在一起,置信度都低得可怜。很明显,有什么东西从根上就错了。

第一轮审讯:NMS 与置信度的“嫌疑”

我的第一反应是:是不是后处理的参数太松了?我尝试调整 NMS(非极大值抑制)的阈值和置信度阈值,希望能过滤掉这些杂乱的框。经过一番调整,我得到了一张稍微“干净”一点的图,但问题也更清晰了:

图二:初步清理后的结果
图二:初步清理后的结果
提高阈值后,大部分杂乱的框消失了,但暴露了两个核心问题

这张图暴露了两个致命问题:

  1. 那么大个 Bus 呢? 画面中最显眼的主体——公交车,完全没有被识别出来。
  2. 诡异的检测框:中间那个黑衣人,检测框从半个脑袋一直延伸到了脚底,明显是错位的。

我当时陷入了一个错误的猜想,但这个猜想推动了调查的进行:“是不是我的 NMS 算法和官方的有出入,导致它抑制错了框?比如,其实模型已经预测出了很多个完美的框,但我的 NMS 算法选了一个最差的留了下来?”

案情转折:一个无法忽视的线索

为了验证这个猜想,我继续调整代码,并设法让公交车的框出现了。但结果却让我更加困惑:

图三:公交车出现,但身份成谜
图三:公交车出现,但身份成谜
图注:公交车终于被检测到了,但它的身份却是“truck”,且置信度低得可怜。

1
2
3
4
5
6
--- 检测结果 (4 个) ---
类别: 0 (person), 置信度: 0.9054, ...
类别: 0 (person), 置信度: 0.7923, ...
类别: 0 (person), 置信度: 0.6791, ...
类别: 7 (truck), 置信度: 0.4254, 框: BoundingBox { ... } <-- 在这里!
--------------------------

这个结果让我瞬间否定了之前对 NMS 的怀疑。NMS 只会抑制或保留检测框,它不可能改变一个物体的类别。公交车被识别成了卡车,这绝对不是后处理能干出来的事!

我当时就断定:一定是输入模型的图像就有问题!

这个想法如同一道闪电,让我瞬间跳出了后-处理的思维定势,开始审视整个数据流的源头——图像预处理

最终审判:揭开“黑盒”的真相

我决定做一个对照实验来最终确认我的猜想。我用 Python 和 OpenCV(被认为是“黄金标准”)生成了一份预处理好的“完美”输入数据,然后让我的 Rust 程序去加载这份数据进行推理。

结果,程序在调用后处理函数后,就“沉默”了,一个检测结果都没打印出来。

这意味着什么?这意味着,即使喂给模型的数据在几何层面(缩放、灰边)是完美的,模型推理出的所有置信度分数都低得可怜,全部都被我的阈值过滤掉了。

案件的真相至此水落石出。问题不在于后处理,也不在于image-rs库的几何变换,而在于那个我一直忽略的、也是唯一的“黑盒”:RKNN 驱动内部,从 UINT8 到模型实际需要的 INT8 类型的隐式转换。

我一直天真地以为,只要把[0, 255]u8像素喂给驱动,它就能“自动”转换成模型需要的i8数据。事实证明,这个“自动转换”的行为和我的预期完全不符。

胜利时刻:放弃幻想,掌控一切

在追求极致性能和确定性的嵌入式 AI 领域,我们不能信任任何“黑盒”。我必须亲手实现这个转换,确保送入 NPU 的数据在数值层面是 100%正确的。

正确的流程应该是:
u8 [0, 255] -> 归一化为 f32 [0.0, 1.0] -> 再用模型自身的 scalezp 参数量化为 i8 [-128, 127]

我立刻重写了我的预处理函数,将归一化手动量化这两个关键步骤加了进去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// rkyolo-core/src/lib.rs (重构后的位置)
pub fn preprocess_letterbox_quantize(
// ...
) -> Result<(Vec<i8>, LetterboxInfo), image::ImageError> {
// 1. Letterbox 几何变换 (已验证无误)
// ...
let u8_data = canvas.into_raw();

// 2. 【制胜关键】手动归一化并量化
let i8_data: Vec<i8> = u8_data
.into_iter()
.map(|val_u8| {
// 决胜一步:先将u8像素归一化到0-1的浮点数
let f_val = val_u8 as f32 / 255.0;

// 再根据模型自身的参数进行量化
let q_val = (f_val / scale + zp as f32).round() as i32;

// 最后裁剪到i8范围
q_val.clamp(i8::MIN as i32, i8::MAX as i32) as i8
})
.collect();

Ok((i8_data, info))
}

当我用这套全新的预处理流程再次运行bus.jpg时——“王者归来”。

(图四:最终的正确结果)
图四:最终的正确结果
图注:在修正了数值问题后,公交车被高置信度地正确识别,所有可见的人物也都被精准框出。

复盘总结

这次艰难的调试经历,教会了我三件事:

  1. 数据是第一原则:当代码逻辑看似无懈可击时,首先要怀疑数据的“纯净度”,尤其是在数值层面。
  2. 别信“自动挡”:在底层交互中,任何看似方便的“黑盒”都可能隐藏着与你预期不符的行为。掌控每一个转换环节,才能获得确定性的结果。
  3. 调试是一门科学:通过观察现象、大胆假设、设计实验、层层排除,再复杂的 Bug 也终将水落石出。

此战之后,我的项目不仅走上了正轨,更重要的是,我真正理解了“模型部署”这四个字的重量。我们不再是 API 的调用者,而是能够深入数值细节,确保数据在整个流水线中正确流动的系统构建者。