目标检测模型的输出是几百个边界框 + 置信度分数。NMS(Non-Maximum Suppression)筛选这些框:保留最高分的框,删除和它重叠超过阈值的框,重复直到所有框都处理完。这个流程在 CPU 上是 O(N²K)(N 个框循环选 K 次)——当 N=1000 时,CPU 上 ~5ms。在 NPU 的推理管道里,5ms 的后处理是瓶颈——前 50 层的计算只要 2ms。

ops-cv 的 NMS 算子在 NPU 上做并行筛选——把 O(N²K) 压到 O(N²)。

NMS 的串行算法

标准 NMS(串行)
输入:boxes [N, 4], scores [N]
输出:keep_indices [M](M ≤ N)

1. 按 scores 降序排列 boxes
2. 取出最高分框 b_max → 加入 keep_indices
3. 计算 b_max 和所有未处理框的 IoU
4. 删除 IoU > threshold 的框(被抑制)
5. 如果还有未处理的框 → 回到步骤 2
6. 返回 keep_indices

步骤 2-4 循环 K 次(K 是最终保留的框数)。每次循环扫描所有未处理框计算 IoU——O(N²K)。

NPU 的并行 NMS

核心思路:把串行 NMS 的「选择 → 抑制」循环改成矩阵运算——所有框互相计算 IoU,一次性做完所有抑制。

// ops-cv/kernels/nms_parallel.cpp

__aicore__ void NMSKernel(
    GlobalTensor<float16>& boxes,     // [N, 4]  (x1,y1,x2,y2)
    GlobalTensor<float16>& scores,    // [N]
    GlobalTensor<uint8>& suppressed,   // [N] 输出:0=保留, 1=被抑制
    int N, float iou_threshold
) {
    // ===== Step 1:按 scores 排序(并行 Bitonic Sort)=====
    // 降序排列 → boxes[0] 是最高分框
    SortByScores(boxes, scores, N);

    // ===== Step 2:计算所有框对之间的 IoU(并行)=====
    // IoU 矩阵 [N, N]——每个元素是 boxes[i] 和 boxes[j] 的交并比
    // 256 个 lane 各算一个 (i,j) 对

    // 分配 L1 中的 IoU 矩阵(N×N 上三角部分,下三角是对称的)
    for (int i = 0; i < N; i++) {
        for (int j = i + 1; j < N; j++) {
            // 获取 lane_id(对应 (i,j) 对)
            int lane = (i * (N - 1) + j - 1) % 256;
            if (__lane_id__ == lane % 256) {
                // 获取两个框
                float16 x1_i = boxes[i * 4 + 0];
                float16 y1_i = boxes[i * 4 + 1];
                float16 x2_i = boxes[i * 4 + 2];
                float16 y2_i = boxes[i * 4 + 3];
                float16 x1_j = boxes[j * 4 + 0];
                float16 y1_j = boxes[j * 4 + 1];
                float16 x2_j = boxes[j * 4 + 2];
                float16 y2_j = boxes[j * 4 + 3];

                // 交集面积
                float ix1 = max(float(x1_i), float(x1_j));
                float iy1 = max(float(y1_i), float(y1_j));
                float ix2 = min(float(x2_i), float(x2_j));
                float iy2 = min(float(y2_i), float(y2_j));
                float inter_area = max(0.0f, ix2 - ix1) * max(0.0f, iy2 - iy1);

                // 并集面积
                float area_i = float(x2_i - x1_i) * float(y2_i - y1_i);
                float area_j = float(x2_j - x1_j) * float(y2_j - y1_j);
                float union_area = area_i + area_j - inter_area;

                // IoU
                float iou = inter_area / union_area;

                // ===== Step 3:判断是否抑制 =====
                // 规则:如果 boxes[i].score > boxes[j].score(因为降序)
                // 且 IoU > threshold → 抑制 boxes[j]
                if (iou > iou_threshold) {
                    suppressed[j] = 1;  // j 被 i 抑制
                }
            }
        }
    }

    // ===== Step 4:二次筛选(消除间接抑制)=====
    // 场景:box[0] 抑制 box[2],box[1] 抑制 box[3]
    // 但 box[2] 和 box[3] 之间也高 IoU → box[3] 被 box[2] 第二次抑制
    // 实际上 box[0] 已经抑制了 box[2] → box[2] 是无效的
    // box[3] 不应该被无效的 box 抑制
    // 修正:从高分到低分扫描,跳过已被抑制的框

    for (int i = 0; i < N; i++) {
        if (suppressed[i]) continue;  // 跳过已抑制的框
        for (int j = i + 1; j < N; j++) {
            if (suppressed[j]) continue;
            // 重新算 IoU 并判断
            float iou = ComputeIoU(boxes[i], boxes[j]);
            if (iou > iou_threshold) {
                suppressed[j] = 1;
            }
        }
    }
}

并行 NMS 的三个阶段:

  1. 排序:Bitonic Sort 并行排序(256 个 lane 并行比较-交换)
  2. IoU 矩阵:所有 (i,j) 对并行计算 IoU(Cube 单元处理乘法)
  3. 二次筛选:消除间接抑制(串行扫描,但 O(N²) 过滤掉大量无效计算)

Bitonic Sort 并行排序

// ops-cv/kernels/bitonic_sort.cpp
// Bitonic Sort:O(log²N) 并行排序

__aicore__ void BitonicSortByScore(
    LocalTensor<float16>& scores,  // [N] 排序 key
    LocalTensor<float16>& boxes,   // [N, 4] 跟随 key 交换
    int N
) {
    // 需要填充到 2 的幂
    int padded = 1;
    while (padded < N) padded *= 2;

    // Bitonic Sort 的主循环
    for (int k = 2; k <= padded; k *= 2) {      // 阶段大小翻倍
        for (int j = k / 2; j > 0; j /= 2) {   // 比较距离折半
            // 并行比较-交换:所有 lane 同时比较
            for (int i = 0; i < N; i += k) {
                int idx = i + (i / j % 2 == 0 ? j : -j) + j % j;

                // 每个 lane 负责一对元素的比较-交换
                int lane_id = (i / k) * (k / 2) + (j > 1 ? idx % (j/2) : 0);
                if (lane_num == __lane_id__) {
                    int a_idx = i + idx;
                    int b_idx = i + idx + j;

                    // 降序比较:a_idx 对应前面的元素(应该更大)
                    if (a_idx < N && b_idx < N) {
                        if (float(scores[a_idx]) < float(scores[b_idx])) {
                            // 交换 scores
                            Swap(scores[a_idx], scores[b_idx]);
                            // 交换 boxes
                            Swap(boxes[a_idx * 4 + 0], boxes[b_idx * 4 + 0]);
                            Swap(boxes[a_idx * 4 + 1], boxes[b_idx * 4 + 1]);
                            Swap(boxes[a_idx * 4 + 2], boxes[b_idx * 4 + 2]);
                            Swap(boxes[a_idx * 4 + 3], boxes[b_idx * 4 + 3]);
                        }
                    }
                }
            }
            __sync_lanes();  // 同步所有 lane
        }
    }
}

Bitonic Sort 的并行特性:在 256 个 AI Core 上对 1024 个框排序——O(log²1024) = 100 次并行比较-交换——纯并行执行,每次比较-交换 1 cycle。

IoU 计算的精度优化

IoU = intersection / union,涉及浮点除法。FP16 除法有 0.2% 的误差,可能导致 IoU 阈值判断错误(IoU=0.501 被当成 0.499 → 导致漏抑制)。

修复:IoU 计算用乘法代替除法,避免浮点除法的精度损失

// 避免除法:浮点除法的精度损失
float iou_div = inter_area / union_area;
bool suppress = (iou_div > iou_threshold);  // FP32 误差可能 1e-7

// 用乘法:更稳定
// iou > threshold  <==>  inter_area / union_area > threshold
//                 <==>  inter_area > threshold * union_area
bool suppress = (inter_area > iou_threshold * union_area);
// FP32 乘法误差远小于除法误差

踩坑一:NPU 上的坐标格式统一

目标检测的边界框有三种常见格式:

格式 1:xywh(中心点 + 宽高)
格式 2:xyxy(左上角 + 右下角)
格式 3:cxcywh(归一化中心点 + 宽高)

大多数模型输出 xywh 或 cxcywh,IoU 计算需要 xyxy

ops-cv NMS 的输入要求 xyxy 格式。用户经常忘记转换:

# 错误:直接送 xywh 进去
boxes = model_output  # shape [N, 4],但格式是 xywh
nms_output = ops.nms(boxes, scores, threshold=0.5)  # 算出的 IoU 是错的

# 正确:先转换
boxes_xyxy = ops.xywh2xyxy(boxes)  # [cx,cy,w,h] → [x1,y1,x2,y2]
nms_output = ops.nms(boxes_xyxy, scores, threshold=0.5)

踩坑二:Soft-NMS 的衰减函数选择

标准 NMS 是硬删除(IoU>threshold → 直接删除)。Soft-NMS 用衰减函数降低重叠框的分数——适合密集目标场景(行人检测、细胞检测)。

Soft-NMS 三种衰减函数

1. 线性衰减:score = score * (1 - IoU)
   当 IoU=0.9 → score *= 0.1 → 分数大幅降低

2. 高斯衰减:score = score * exp(-IoU² / σ)
   σ=0.5 → 当 IoU=0.9 → score *= exp(-0.81/0.25) = 0.04

3. 自适应衰减(ops-cv 支持)
   如果框尺寸比 > 2(一大一小),用线性衰减
   如果框尺寸比 < 2(大小接近),用高斯衰减
// ops-cv/kernels/soft_nms.cpp

float SoftNMSDecay(float iou, float box_size_ratio) {
    if (box_size_ratio > 2.0f) {
        // 一大一小 → 线性衰减
        // 小框被大框遮挡 → 分数降低,但不完全删除
        return 1.0f - iou;
    } else {
        // 大小接近 → 高斯衰减
        // 两个接近的框检测到同一个目标 → 分数快速降低
        float sigma = 0.5f;
        return expf(-iou * iou / sigma);
    }
}

踩坑三:单类 vs 多类别 NMS 的参数差异

目标检测模型有多个类别(如 COCO 的 80 类)。标准做法是「同类同阈值」——同一类别内的框用相同的 IoU 阈值做 NMS。

多类别 NMS 的并行化问题:N 个框可能属于 C 个类别——需要 C 次并行 NMS(每类一次)。但 NPU 的 block 数量有限——120 个类别就需要 120 个 block。

ops-cv 的优化:按类别分组 + 分组内并行

// 多类别 NMS:类别分组 + 组内并行
__aicore__ void MultiClassNMS(
    GlobalTensor<float16>& boxes,       // [N, 4]
    GlobalTensor<float16>& scores,      // [N]
    GlobalTensor<int>& class_ids,       // [N]
    GlobalTensor<uint8>& suppressed,    // [N]
    int N, int num_classes, float iou_threshold
) {
    // Step 1:按类别分组(并行计数排序)
    LocalTensor<int> class_counts(num_classes);
    class_counts = 0;
    for (int i = 0; i < N; i++) {
        AtomicAdd(class_counts[class_ids[i]], 1);  // 统计每类框数
    }

    // Step 2:分组内 NMS
    int offset = 0;
    for (int c = 0; c < num_classes; c++) {
        int class_N = class_counts[c];
        if (class_N == 0) continue;

        // 对该类的 class_N 个框做 NMS
        // 类内的 box 索引: offset ~ offset+class_N-1
        ClassNMS(boxes + offset, scores + offset, suppressed + offset,
                 class_N, iou_threshold);
        offset += class_N;
    }
}

分类计数的并行化——用 AtomicAdd 同时更新 class_counts——Cube 单元的原子操作保证数据正确性。


NMS 看似简单——筛选重叠框。但 NPU 上的并行实现涉及 Bitonic Sort(并行排序)、IoU 矩阵并行计算、二次筛选(消除间接抑制)、多类别分组。ops-cv 的 NMS 算子把这些技术融合进一个 kernel——从 CPU 上 5ms 的串行后处理,变成 NPU 上几十微秒的矩阵运算。NMS 不再是目标检测的推理瓶颈。

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐