0%

RKYOLO诞生记 (三):和模型输出玩“配对游戏”——折腾出一个能自适应变化的笨办法

上一篇文章里,我们好不容易打赢了“数值之战”,让模型终于能看清楚东西了。我美滋滋地想,导师给的玉米穗模型部署任务,这下总该顺利了吧?

于是,我满心期待地把模型从 COCO 的yolo11n.rknn换成了自己的MTDC-UAV.rknn。结果……程序又扑街了。

新副本:从“看错”到“崩溃”

这次倒不是瞎指认了,而是干脆利落的**段错误(Segmentation Fault)**——程序在推理成功后,刚进后处理函数就当场崩溃。

吃一堑长一智,我第一反应就是:模型结构又作什么妖了?赶紧加日志,把模型真实的输入输出张量数量打印出来瞅瞅。果然:

  • COCO yolo11n.rknn 模型:9 个输出
  • 我的 MTDC-UAV.rknn 模型:7 个输出

破案了。我之前写的后处理代码,和那些 C++示例一样,天真地以为模型永远会吐出 9 个输出。当它试图去访问我模型里压根不存在的第 8、第 9 个输出张量时,就直接一头撞墙上——内存访问越界了。

这时候我才算彻底明白,为啥当初 C++示例跑不通我的模型,为啥我非得折腾这个重构项目。真不是我闲得慌非要追新,是这坑就摆在这,不填平它,任务根本进行不下去啊!

关键破局点:别猜了,让模型自己“交代”

我悟了:不能再模型长啥样了,得写个代码让它自己交代。我需要个“模型侦察兵”,而“侦察情报”就是模型自己的元数据。

我没瞎琢磨,而是干了件挺有用的事:数据驱动调试。我让程序分别加载两个模型,把它们所有输出张量的属性(维度、名称等)全打印出来。

情报一:YOLOv11n (COCO) 的输出张量属性

1
2
3
4
5
6
--- 输出张量属性诊断 ---
- Attr 0: ... dims=[1, 64, 80, 80] (Box @ 80x80)
- Attr 1: ... dims=[1, 80, 80, 80] (Score @ 80x80, 80 classes)
... (中间省略) ...
- Attr 8: ... dims=[1, 1, 20, 20] (Score_sum @ 20x20)
--------------------------

情报二:YOLOv11n (玉米穗) 的输出张量属性

1
2
3
4
5
6
--- 输出张量属性诊断 ---
- Attr 0: ... dims=[1, 64, 80, 80] (Box @ 80x80)
- Attr 1: ... dims=[1, 1, 80, 80] (Score @ 80x80, 1 class)
... (中间省略) ...
- Attr 6: ... dims=[1, 64, 20, 20] (Box @ 20x20)
--------------------------

把这两份“情报”放一块,谜底揭晓了:

  1. “套路”还是那个套路:虽然输出总数不同,但它们都遵循一个基本模式——在同一个空间尺寸(比如80x80)上,总会有一组相关的张量。
  2. 张量的“身份证”:我摸到一个还算靠谱的启发式规则来区分它们。负责预测框的Box 张量,其通道数dims[1]是个固定值(比如64);而负责预测类别的Score 张量,其通道数dims[1]恰恰就是类别总数(COCO 是 80,玉米穗就 1 个)。
  3. “被优化”了:最有趣的是,玉米穗模型在20x20这个尺度上,只有 Box 张量,没对应的 Score 张量!怪不得少俩输出。估计是 ONNX 转 RKNN 时,优化器觉得这小目标检测分支对我的任务没啥用,顺手就给“剪”了。

算法的演进:从“写死”到“动态配对”

真相大白。不能依赖任何固定的输出数量或顺序,唯一可靠的,就是“相同空间尺寸的 Box 和 Score 张量是一对儿”这个基本原理。

基于这,我琢磨出一个新的、能自适应的后处理算法。我不再关心总共是 7 个还是 9 个输出,我的代码现在像是在玩一个动态的“配对游戏”:

第一步:给所有张量发“身份证”(动态识别)

我遍历所有输出张量,根据刚摸清的规则,给它们贴上“身份”标签,然后塞进一个列表里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// rkyolo-core/src/postprocess.rs
#[derive(Debug)]
enum TensorInfo {
Box { h: u32, w: u32, index: usize },
Score { h: u32, w: w: u32, index: usize },
}
// ... 遍历所有输出张量的属性 ...
let c = attr.dims[1]; // 通道数
let h = attr.dims[2];
let w = attr.dims[3];

// 就用通道数这个土办法,先区分看看
if c == 64 {
identified_tensors.push(TensorInfo::Box { h, w, index: i });
} else {
identified_tensors.push(TensorInfo::Score { h, w, index: i });
}

第二步:按尺寸配对并处理(动态配对与调用)

接下来是核心部分。代码不再写死循环,而是遍历所有被认出是Box的,然后为每一个去找尺寸一模一样的Score。只要配上一对,就立马把它们塞给解码函数去处理。

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
26
27
28
29
30
// rkyolo-core/src/postprocess.rs

// 2. 动态配对:遍历所有Box,给它们找对象(尺寸一样的Score)
for tensor_info in &identified_tensors {
if let &TensorInfo::Box {
h,
w,
index: box_idx,
} = tensor_info
{
// 给它找个尺寸一样的Score
let paired_score_info = identified_tensors.iter().find(|&t| {
if let &TensorInfo::Score { h: sh, w: sw, .. } = t {
sh == h && sw == w
} else {
false
}
});

// 3. 找到了就送去解码
if let Some(&TensorInfo::Score {
index: score_idx, ..
}) = paired_score_info
{
// ... (这里拿到对应的数据,然后调用解码函数) ...
let branch_detections = decode_branch(...);
all_detections.extend(branch_detections);
}
}
}

这代码妙就妙在它的描述性。它没死命令式地去“访问第 0、1、3 个……”张量,而是描述了个匹配规则:“给我找个 Box,再给它配个一样大的 Score,然后你俩一起处理去”。这么一来,张量数量变没变、顺序乱没乱,它根本不在乎。

小结与收获:从“搞定”到“学乖”

当我用这套新的“动态配对”算法换掉旧的硬编码后,嘿,还真成了!

无论是 9 个输出的 COCO 模型,还是被“剪枝”成 7 个输出的玉米穗模型,甚至以后别的什么妖魔鬼怪模型,我这后处理代码一个字都不用改,大概率都能蒙混过关。

这次折腾下来,最大的感触就是:

  • 别脑补,看日志:与其瞎猜,不如让数据(模型元数据)自己说话,让它指导代码该怎么写。
  • 拥抱变化:写死的代码最怕变化。一个皮实的系统,得能优雅地处理各种意外(比如模型被优化了)。
  • 挖到底:只有挖到问题的根上(模型输出的内在模式),才能想出“一劳永逸”的笨办法,而不是不停打补丁。

这个过程让我学到的,不止是修好一个程序,更是对怎么写“抗造”的代码有了点感觉。算是从一个只会埋头解决问题的“码农”,朝一个能预感到变化、会写适应性代码的“码农 plus”挪了一小步吧。