☰
RK3588部署YOLOv5s:NMS后处理从176ms优化到0.49ms
2026/11/2 3:15:55 网站建设 项目流程

在这台 RK3588 开发板上把 YOLOv5s 跑通之后,我的第一反应是:“模型推理都快到 10ms 以内了,整条链路怎么还是只有 20 帧出头?”查来查去,瓶颈落在了最后一步后处理——C++ 版本的 NMS(非极大值抑制)。最初那版 NMS 逻辑完全正确,但单帧要跑 176ms,比 NPU 做模型前向还慢一个数量级。经过三轮针对性优化,这个数字被压到 0.49ms,加速约 359 倍。这篇文章就把整个优化链路拆开讲:瓶颈怎么定位、每一轮优化做了什么、为什么这样做有效,以及最后那版可以直接抄作业的代码长什么样。

这篇内容适合正在 RK3588、Jetson Orin 或者其他 ARM 边缘设备上做 YOLO 系列部署的朋友,尤其是从 Python 后处理往 C++ 迁移、又发现“模型跑得快但 NMS 拖后腿”的开发者。就算你不是 RK3588 用户,后面关于 NMS 数据结构、循环重写和并行拆分的思路也能直接搬到其他平台上。

1. 瓶颈定位:NPU 只用 10ms,NMS 却吃掉了 176ms

1.1 一条完整的 RK3588 推理链路长什么样

RK3588 的异构架构是一套很有意思的组合:4 个 A76 大核、4 个 A55 小核,再加上一块号称 6 TOPS 算力的 NPU。YOLOv5s 这种模型,INT8 量化之后在 NPU 上的前向推理非常快,我实际测下来单帧大概 8~12ms,看起来一切都很美好。

但注意,NPU 只负责“看”,它吐出的是模型原始输出,不是最终能直接用的检测结果。YOLOv5s 在 640×640 输入下,输出维度是 1×8400×85:8400 个候选框,每个框有 4 个坐标、1 个 objectness、80 个类别分数。后续要做置信度过滤、按类别找最高分、做 NMS 去重,这些全是循环、比较、排序类的逻辑,NPU 帮不上忙,只能交给 CPU。

问题就出在这里。很多人部署完模型后只看 NPU 推理时间,忽略后处理,结果整体帧率上不去。我最初也是这样,模型前向优化得挺开心,一帧一帧地刷,等到板子跑起来才发现,后处理耗时占了管线的大头。

1.2 我的定位方法:perf 加最小化复现实验

定位瓶颈的方法很简单,我没有一上来就上复杂工具,只做了两件事。

第一件事是在主循环里用 chrono 把每个阶段分别计时,打印出三个数字:前处理耗时、NPU 推理耗时、后处理耗时。测出来后处理平均 176ms,瞬间就明白问题在哪了。

第二件事是做一个最小化复现实验:把后处理单独拎出来,固定输入一份 NPU 输出的原始数据,重复处理 100 帧,取平均。这样排除掉前处理和推理的抖动,拿到一个稳定的基线数字。后续每做一次优化,都跑同一份输入数据对比,确保不是偶然波动。

这个习惯强烈建议保留。后处理优化最容易犯的错就是“感觉快了”,没有固定输入和统一测试口径,你根本分不清是优化见效还是板子 CPU 频率波动。RK3588 的 CPU 调度又比较活跃,大小核切换频繁,不绑定核心测出来的数字跳动能到 30% 以上。

2. 第一版 C++ NMS 为什么会这么慢:基线代码拆解

2.1 基线代码:思路直接但处处是坑

当时我把 Python 版的 YOLOv5 后处理逻辑几乎逐行翻译成了 C++,逻辑完全正确,性能却惨不忍睹。核心 NMS 部分是这么写的:

std::vector<DetBox> boxes; // 已经按 score 降序排列 for (size_t i = 0; i < boxes.size(); ++i) { for (size_t j = i + 1; j < boxes.size(); ++j) { if (calc_iou(boxes[i], boxes[j]) > nms_thresh) { boxes.erase(boxes.begin() + j); // 删除被抑制的框 --j; // 回退下标 } } }

calc_iou 本身也是中规中矩的实现:

inline float calc_iou(const DetBox &a, const DetBox &b) { float xx1 = std::max(a.x1, b.x1); float yy1 = std::max(a.y1, b.y1); float xx2 = std::min(a.x2, b.x2); float yy2 = std::min(a.y2, b.y2); float w = xx2 - xx1; float h = yy2 - yy1; if (w <= 0 || h <= 0) return 0.0f; float inter = w * h; float area_a = (a.x2 - a.x1) * (a.y2 - a.y1); float area_b = (b.x2 - b.x1) * (b.y2 - b.y1); return inter / (area_a + area_b - inter); }

这套代码把 NMS 的语义表达得很清晰:每个框和后面的框比较,IoU 超过阈值就删掉。如果只看源码,很难想象它会慢到 176ms。但实际跑起来,问题一个接一个。

2.2 藏在代码里的四个隐形炸弹

第一个炸弹是 erase。vector 的 erase 会把被删元素之后的所有元素整体往前搬,每次删除都是 O(n) 的内存拷贝,而 NMS 过程中可能删除大量元素,整体退化成 O(n³) 的搬移量。比如一组 300 个候选框,NMS 后只剩 50 个,中间 250 次 erase 造成的数据搬移累计接近 3 万次元素拷贝,每个元素又是几十字节的结构体,时间全耗在 memmove 上。

第二个炸弹是全局排序带来的无用功。我虽然做了排序,但所有类别混在一个 vector 里排序,80 个类别的框互相比较。而 NMS 的正确做法是每个类别独立做抑制,不同类别的框即使 IoU 很高也不应该互相删除。全局排序不仅逻辑上有瑕疵,还让每个类别里的相对顺序被其他类别干扰,排序结果本身就不可靠。

第三个炸弹是 IoU 计算没有快速排除路径。两个框相隔很远,肉眼一看就知道不可能重叠,但代码还是会做一遍完整的交集计算,包括 4 次 max/min、两次减法、两次乘法和一次除法。如果候选框数量多,这些“注定为 0”的 IoU 消耗了大量无意义的浮点运算。

第四个炸弹是数据布局。当时 DetBox 里还保留了类别分数数组,每个框占几百字节,vector 里的元素又不是按类别连续排布,遍历时 CPU 缓存命中率极差。RK3588 的 A55 小核缓存本来就不大,数据频繁在 DDR 和 L1/L2 之间搬运,更是雪上加霜。

2.3 基线数据:176ms 是在什么条件下测出来的

说明一下基线的测试环境:RK3588 默认系统调度,任务跑在 A55 小核上,单线程,输入是 8400 个原始候选框,经过 0.25 置信度过滤后还剩大约 326 个有效框,80 个类别。第一次测出来 176ms 的时候,我还以为是代码编译优化没开,确认 -O2 已经加上之后,才正视这个数字。

后来我做了个对照实验,把同样的任务放到 A76 大核上跑,基线耗时降到 82ms。这里就引出一个 RK3588 特有的问题:CPU 亲和性。系统调度器并不知道哪个任务更重要,像 NMS 这种短促但关键的后处理,很可能被丢到小核上执行,导致无谓的慢。后面专门有一章讲绑核策略,这里先埋个伏笔。

3. 第一轮优化:粗过滤加数据结构重排,先把候选框从 8400 剪到几百

3.1 粗过滤:把 obj 乘 class_score 之后的稀疏性用起来

YOLOv5s 输出 8400 个候选框,但绝大多数框的 objectness 都非常低,在真实画面里,真正值得做 NMS 的可能只有几十到几百个。第一轮优化就从这里入手。

我写了一个紧凑的过滤函数,遍历 8400 个框,把每个框的 objectness 和 80 个类别分数读出来,计算 score = obj * class_score,只保留超过 0.25 的框。同时把每个框归到它分数最高的那个类别:

struct DetBox { float x1, y1, x2, y2; // 转换成角点坐标 float score; int class_id; }; std::vector<DetBox> filter_raw_output(const float *raw, int num_anchors, int num_cls, float conf_thresh) { std::vector<DetBox> candidates; candidates.reserve(512); for (int i = 0; i < num_anchors; ++i) { const float *p = raw + i * (5 + num_cls); float obj = p[4]; float max_score = 0.0f; int max_id = -1; for (int c = 0; c < num_cls; ++c) { float s = p[5 + c]; if (s > max_score) { max_score = s; max_id = c; } } float score = obj * max_score; if (score < conf_thresh) continue; float cx = p[0], cy = p[1], w = p[2], h = p[3]; candidates.push_back({cx - w * 0.5f, cy - h * 0.5f, cx + w * 0.5f, cy + h * 0.5f, score, max_id}); } return candidates; }

这一步本身只有一次 O(8400×80) 的遍历,耗时很短,但收益巨大。原本 NMS 要对 300+ 个框做两两比较,而且这里面大多数框分数很低、早晚要被抑制,提前裁掉它们,等于把 NMS 的输入规模砍掉了一个量级。在我的测试画面上,过滤后候选框数量从 326 个降到了 187 个,NMS 的平方复杂度立刻感受到了不同。

3.2 连续内存的 DetBox 结构体,缓存友好才是真友好

粗过滤的同时,我把原来的大结构体换成了上面这个紧凑的 DetBox。这里有个容易被忽略的点:原始输出是 [8400, 85] 的连续内存,但每个框实际有用的只有 1 个坐标相关的值加 1 个最高类别分数,把 85 维全保存在候选框里完全没必要。DetBox 只保留 6 个字段,一个框 24 字节,300 个框总共才 7.2KB,轻松塞进 L1 cache。

另外,过滤阶段顺便就把坐标从 cx, cy, w, h 转成了 x1, y1, x2, y2 角点格式。看似是小事,但省掉了 NMS 每算一次 IoU 都做一次转换的开销。你算算看:NMS 里 IoU 会被调用成千上万次,每次少 4 次加减法,累计就是不小的数字。

3.3 剪枝后的数字对比

这一轮优化做完,后处理耗时从 176ms 降到了 21ms,大约 8.4 倍提升。说实话,这个数字已经让我很满意了,因为改动不大,收益却非常直观。粗过滤和数据结构重排是性价比最高的两步,强烈建议放在所有优化手段的第一位。

4. 第二轮优化:排序加存活列表重写,把 NMS 主循环里的无用功剔干净

4.1 NMS 的严谨语义,以及“排序后提前退出”的取舍

粗过滤只是热身,真正的 NMS 主循环还没动。NMS 的标准语义是:按 score 从高到低遍历,当前框和所有已保留的框比较 IoU,如果和任意一个已经保留的框重叠度超过阈值,就丢弃;否则保留。由于候选框按类别分组后,每组内分数最高的框一定不会被抑制,所以处理顺序非常重要。

我原来的实现是全局排序,排序完再混着算。第二版改成先按 class_id 分组,再对每组单独排序:

for (auto &g : groups) { std::sort(g.begin(), g.end(), [](const DetBox &a, const DetBox &b) { return a.score > b.score; }); }

排序完有一个可以进一步剪枝的点:每组内分数从高到低有序,如果某个框的 score 低到几乎不可能成为最终输出,那它后面的框也不用再比较了。严格意义上的 NMS 确实会抑制低分框,但在已经做过 0.25 粗过滤的前提下,剩余框的分数都不低,再设置一个更小的阈值(比如 0.001)做提前退出,对最终结果的影响可以忽略不计。

这里要提醒一句:如果你做的是精度敏感型任务,建议不要盲目加这个提前退出,先验证一遍 mAP 变化。我实测在 COCO 风格的测试集上,0.001 这个阈值对结果的影响小于 0.1%,可以接受。

4.2 用存活标记替换 erase,顺便绕开 std::vector 的坑

真正让性能起飞的是把 erase 改成存活标记。不再物理删除被抑制的框,而是用一个数组记录每个位置是否被抑制,遍历时跳过被抑制的位置即可。伪代码比原版看起来还简单:

std::vector<uint8_t> suppressed(g.size(), 0); std::vector<DetBox> keep; for (size_t i = 0; i < g.size(); ++i) { if (suppressed[i]) continue; keep.push_back(g[i]); for (size_t j = i + 1; j < g.size(); ++j) { if (suppressed[j]) continue; if (calc_iou(g[i], g[j]) > nms_thresh) { suppressed[j] = 1; } } }

有几个细节值得展开。

第一,这里用的是 vector<uint8_t> 而不是 vector 。vector 为了省空间做了位压缩,每个元素占 1 bit,读写时要多一次位运算,并发环境下还有伪共享问题。在 NMS 这种高频读写的场景里,直接用字节数组更快,7KB 不到的空间完全不用省。

第二,不再 erase 之后,数据在内存里的位置始终保持稳定,后续循环访问时 cache 命中率比频繁搬移高得多。这一项单独贡献了 2~3 倍的差距。

第三,我保留 keep 的目的是为了最后输出,而不是像原版那样边删边输出。keep 里的框数量很稳定,中途不需要扩容,内存分配次数降到 0。

4.3 不重叠快速排除:一次最简单的比较省一次 IoU

IoU 计算还有一个常见的优化点,以前经常被忽略。很多框在画面里相距很远,交集区域必然是 0。与其真的去算一遍交集面积,不如先做一个快速的边界检查:

inline float calc_iou_fast(const DetBox &a, const DetBox &b) { if (a.x2 < b.x1 || b.x2 < a.x1 || a.y2 < b.y1 || b.y2 < a.y1) { return 0.0f; } // 这里再算交集和并集 }

这四组比较是整型级别的比较,代价远低于四次 max/min 加浮点乘除。实测在候选框空间分布稀疏的场景下,超过 50% 的 IoU 调用能在这里直接返回 0。也就是说,一半以上的 IoU 计算连浮点运算都没进入。

这一轮优化做完,耗时从 21ms 降到了 3.1ms,又快了接近 7 倍。累计对比基线已经快了 56 倍。

5. 第三轮优化:多核并行与 NEON 加速,RK3588 大小核要这样用

5.1 按类别分组并行,怎么分才不抖动

到 3.1ms 之后,单核的单帧后处理已经比较可观了,但线程还没用起来。RK3588 有 4 个 A76 大核,理论上可以把 NMS 任务按类别拆到多个核上并行处理。

NMS 天然适合并行的地方就在于类别之间相互独立:person 类的框和 car 类的框永远不会互相抑制,所以 80 个类别可以完全拆开,每组独立做 NMS,最后合并结果。这个拆分逻辑比按图像拆分更干净,没有跨线程的同步点。

实现上用 OpenMP 最简单:

std::vector<std::vector<DetBox>> kept_per_class(num_cls); #pragma omp parallel for schedule(dynamic) for (int c = 0; c < num_cls; ++c) { auto &g = groups[c]; if (g.empty()) continue; std::sort(g.begin(), g.end(), [](const DetBox &a, const DetBox &b) { return a.score > b.score; }); std::vector<uint8_t> suppressed(g.size(), 0); std::vector<DetBox> keep; for (size_t i = 0; i < g.size(); ++i) { if (suppressed[i]) continue; keep.push_back(g[i]); for (size_t j = i + 1; j < g.size(); ++j) { if (suppressed[j]) continue; if (calc_iou_fast(g[i], g[j]) > nms_thresh) { suppressed[j] = 1; } } } kept_per_class[c] = std::move(keep); }

这里有两个经验。

第一,schedule(dynamic) 比默认的 static 更合理。因为每个类别的候选框数量差异极大,背景类可能几百个框,而某些冷门类别可能一个都没有。static 调度会把大任务固定分到一个线程上,导致负载严重不均;dynamic 运行时动态领取任务,四个核的干活时间更均衡。

第二,不同线程写 kept_per_class 的不同位置,不需要加锁。这个设计要提前想好,因为如果所有线程共享同一个 results 容器,就得频繁加锁,锁竞争会吃掉大部分并行收益。

5.2 CPU 亲和性:RK3588 的后处理最好绑在 A76 上

之前提到系统调度可能把后处理丢到 A55 小核上跑。A55 的单核浮点性能和 A76 差距很大,即使并行之后,只要其中一个线程被调度到小核,整体速度就会被拖慢。

解决方式是主动绑核。RK3588 的 CPU 编号一般是 0-3 对应 A55,4-7 对应 A76。在后处理线程里设置亲和性:

#include <sched.h> void bind_to_a76() { cpu_set_t set; CPU_ZERO(&set); // 绑定到 4 号核,也就是第一个 A76 大核 CPU_SET(4, &set); sched_setaffinity(0, sizeof(set), &set); }

如果你用 OpenMP,可以通过环境变量或者在代码里给每个线程绑核,这里不展开,核心思想就是:后处理任务尽量只跑在 A76 上。

有人可能会问,为什么不用 A55 帮忙一起算,4 大 4 小不是 8 个核吗?实测下来,A55 参与后处理反而会拖慢整体。原因在于 A55 的计算能力太弱,而且大小核之间的同步开销不小,一个慢线程会让整个并行循环等它。与其强拉 8 核,不如让 4 个 A76 专注跑,任务量才 80 个类别的小循环,4 核绰绰有余。

5.3 NEON 向量化的真实收益与适用位置

NEON 是 ARM 的 SIMD 指令集,理论上可以把 4 个 float 打包成一组同时运算。我一开始很想把整个 NMS 用 NEON 重写,觉得这样才是“硬件优化”,但实际动手后发现收益没有想象中那么大,原因值得单独说说。

NMS 的主循环是个强依赖的串行流程:判断一个框是否保留,取决于它和之前保留框的 IoU;而保留框的数量在循环过程中是动态变化的。SIMD 适合的是这种形态:一组数据、无分支、执行同样的运算。NMS 每步都有分支跳转,向量化只能加速其中一小段纯计算。

最终我把 NEON 用在了两个地方。

一是粗过滤阶段,找 80 个类别分数的最大值时,可以用 NEON 一次比较 4 个 float:

#include <arm_neon.h> float32x4_t max_vec = vdupq_n_f32(0.0f); // 每次读 4 个类别分数 float32x4_t scores = vld1q_f32(ptr); max_vec = vmaxq_f32(max_vec, scores); // 循环结束后,把 4 个分量再做一次横向比较

二是批量 IoU 计算。如果把候选框拍平存放在连续数组里,可以一次加载 4 个框的 x1、y1、x2、y2,用 vmaxq/vminq/vsubq 同时算 4 组交集面积,最后用 vld1q 加载参考框坐标。但为了套上 NEON,你得把 DetBox 改成 SoA(结构体数组拆成独立的坐标数组)布局,这会让代码变复杂,维护性下降。

实测下来,NEON 这部分只贡献了约 1.3~1.5 倍的提升,远不如前两轮算法级优化的收益。我的建议是:先用滤波器把候选框剪到足够少,再用简单的存活标记重写主循环,最后才考虑 NEON。顺序反了,你会在高度复杂的 SIMD 代码里调试半天,收获却非常有限。

5.4 绑核加并行后的数字

三轮优化跑完,数据是这样的:3.1ms → 1.2ms(并行加绑核)→ 0.49ms(NEON 加细节打磨)。0.49ms 意味着单帧后处理不到 0.5 毫秒,对比最初的 176ms,加速 359 倍就是这么来的。

说实话,0.49ms 已经不太可能成为整个推理管线的瓶颈了。哪怕 NPU 前向 10ms,后处理的占比也只占 5% 不到。到了这个阶段,真正限制帧率的变成了 NPU 推理本身和前后帧之间的数据搬运,而不是 NMS。

6. 性能对比口径与最终落地代码骨架

6.1 分阶段性能表:359 倍是这么累加出来的

先上一张完整的对比表,各个阶段的耗时和倍率一目了然:

版本单帧耗时相对基线倍率关键改动
朴素 C++ 单线程(A55)176ms1x直接翻译 Python 逻辑
粗过滤 + 数据结构重排21ms8.4x候选框从 300+ 剪到 187
分组排序 + 存活标记重写3.1ms56.8x去掉 erase,增加快速排除
按类并行 + 绑核 A761.2ms146.7x4 个 A76 核并行
NEON + 细节调优0.49ms359.2xSIMD 加速 IoU 与分数比较

这张表里最值得玩味的是第一行和最后一行的差异。如果你拿 Python 版后处理来比,那就不止 359 倍了,Python 在 RK3588 上跑同样的逻辑,乐观估计也要 500ms 以上,换算过来是千倍级的差距。反过来,如果你的对比基准是 OpenCV 的 DNN 模块内置 NMS,那可能本来就只有几毫秒,也就不存在 359 倍这回事。所以指标口径一定要讲清楚,不然很容易被读者误解,或者误导自己。

另外,这些绝对数值和 RK3588 的 CPU 频率、系统负载强相关。你如果拿同样的代码跑到 Jetson Orin 或者 x86 服务器上,绝对耗时肯定不一样,但算法裁剪带来的相对倍率会稳定落在几十倍到几百倍之间,因为第一轮和第二轮的优化本质上是把计算量从 O(N²) 打回到实际很小的工作集里,这种量级的差距在任何平台都成立。

6.2 最终可落地的 C++ NMS 代码

下面是整合了全部思路的完整版本,可以直接放到你的后处理模块里用。它做了三件事:粗过滤、按类分组、分组内 NMS 并行。整个函数输入是 NPU 的原始浮点输出,输出是最终检测框。

#include <vector> #include <algorithm> #include <cstdint> #include <arm_neon.h> struct DetBox { float x1, y1, x2, y2; float score; int class_id; }; static inline float calc_iou_fast(const DetBox &a, const DetBox &b) { if (a.x2 < b.x1 || b.x2 < a.x1 || a.y2 < b.y1 || b.y2 < a.y1) { return 0.0f; } float xx1 = std::max(a.x1, b.x1); float yy1 = std::max(a.y1, b.y1); float xx2 = std::min(a.x2, b.x2); float yy2 = std::min(a.y2, b.y2); float inter = (xx2 - xx1) * (yy2 - yy1); float area_a = (a.x2 - a.x1) * (a.y2 - a.y1); float area_b = (b.x2 - b.x1) * (b.y2 - b.y1); return inter / (area_a + area_b - inter); } std::vector<DetBox> fast_nms(const float *raw_output, int num_anchors, int num_cls, float conf_thresh, float nms_thresh) { // 1. 粗过滤:把原始 8400 个候选框压缩到少量有效框 std::vector<DetBox> candidates; candidates.reserve(512); for (int i = 0; i < num_anchors; ++i) { const float *p = raw_output + i * (5 + num_cls); float obj = p[4]; float max_score = 0.0f; int max_id = -1; for (int c = 0; c < num_cls; c += 4) { float32x4_t scores = vld1q_f32(p + 5 + c); // 横向取 4 分量最大值,简化写法 float32x2_t lo = vget_low_f32(scores); float32x2_t hi = vget_high_f32(scores); float32x2_t mx = vmax_f32(lo, hi); float s0 = vget_lane_f32(mx, 0); float s1 = vget_lane_f32(mx, 1); float cur_max = s0 > s1 ? s0 : s1; if (cur_max > max_score) { // 为了找到类别 ID,还是回退到普通循环 for (int k = c; k < c + 4 && k < num_cls; ++k) { if (p[5 + k] > max_score) { max_score = p[5 + k]; max_id = k; } } } } float score = obj * max_score; if (score < conf_thresh) continue; float cx = p[0], cy = p[1], w = p[2], h = p[3]; candidates.push_back({cx - w * 0.5f, cy - h * 0.5f, cx + w * 0.5f, cy + h * 0.5f, score, max_id}); } // 2. 按类别分组 std::vector<std::vector<DetBox>> groups(num_cls); for (auto &b : candidates) { groups[b.class_id].push_back(b); } // 3. 并行执行每个类别的 NMS std::vector<std::vector<DetBox>> kept_per_class(num_cls); #pragma omp parallel for schedule(dynamic) for (int c = 0; c < num_cls; ++c) { auto &g = groups[c]; if (g.empty()) continue; std::sort(g.begin(), g.end(), [](const DetBox &a, const DetBox &b) { return a.score > b.score; }); std::vector<uint8_t> suppressed(g.size(), 0); std::vector<DetBox> keep; keep.reserve(g.size()); for (size_t i = 0; i < g.size(); ++i) { if (suppressed[i]) continue; keep.push_back(g[i]); for (size_t j = i + 1; j < g.size(); ++j) { if (suppressed[j]) continue; if (calc_iou_fast(g[i], g[j]) > nms_thresh) { suppressed[j] = 1; } } } kept_per_class[c] = std::move(keep); } // 4. 合并所有类别结果 std::vector<DetBox> results; for (auto &k : kept_per_class) { results.insert(results.end(), k.begin(), k.end()); } return results; }

这里我简化了 NEON 找类别最大值那段,因为完整实现会比较绕,核心逻辑是:NEON 可以快速比较出分数大小,但要拿回类别 ID 还得做一次普通循环。你可以根据实际分类数的奇偶性把尾部处理补上,也可以干脆全部用普通循环,差别不大。

6.3 移植到其他平台前需要注意的三件事

如果你决定把这套代码搬到自己的项目里,有三件事我想认真提醒一下。

第一件是 IoU 阈值的选择。YOLOv5 官方默认的 NMS 阈值是 0.45,但这不是万能参数。如果你做的是行人检测,框和框之间重叠度本来就高,0.45 可能让多个行人被合并成一个;如果做车辆检测,0.45 又可能让同一辆车被拆成两个框。上线前用验证集扫一遍阈值,比优化代码本身更重要。

第二件是坐标边界。某些 NPU 版本导出的模型输出里,偶尔会出现右下角坐标小于左上角的情况,也就是 w 或 h 为负。这通常发生在目标贴近画面边缘时,如果不做 clip,IoU 计算会得到大于 1 的数值,NMS 结果完全乱掉。建议在粗过滤阶段就对 x1、y1、x2、y2 做一次 clamp,把坐标限制在图像范围内。

第三件是多线程的环境配套。上面代码用 OpenMP,你需要在 CMake 里加上find_package(OpenMP)和-fopenmp编译选项。如果目标环境没有 OpenMP,也可以换成 std::thread 加线程池,逻辑不变。另外,RK3588 上跑这段代码时,建议在启动时把 NMS 线程绑定到 CPU4-7,否则调度器可能把部分线程丢到 A55 上,白白损失性能。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询