训练完 YOLOv8,转成 OM 模型,跑在昇腾 910 上,结果发现后处理占了 30% 的时间。模型推理 8ms,后处理(NMS + bbox 解码)花了 4ms。

后处理为什么这么慢?因为 NMS 在 CPU 上跑,bbox 解码也在 CPU 上跑,NPU 算完推理结果在等后处理。

这篇文章记录后处理优化——把 NMS 和 bbox 解码搬到 NPU 上,用 ops-cv 算子加速,后处理降到 0.5ms。

第一步:理解后处理在干什么

YOLOv8 的输出是 [1, 84, 8400],要转换成人类能看懂的检测框。

输出格式解析

Output shape: [1, 84, 8400]
  - 8400 = 80×80 + 40×40 + 20×20(3 个尺度的网格数)
  - 84 = 4(bbox: cx, cy, w, h)+ 80(COCO 类别概率)

后处理的两步

第 1 步:BBox 解码

YOLOv8 输出的是中心点格式(cx, cy, w, h),要转换成角点格式(x1, y1, x2, y2)才能画框。

cx, cy = 中心点坐标(相对于网格)
w, h = 宽高(相对于 anchor)

x1 = cx - w / 2
y1 = cy - h / 2
x2 = cx + w / 2
y2 = cy + h / 2

第 2 步:NMS(非极大值抑制)

一张图里同一个目标会有多个检测框,NMS 保留置信度最高的,删掉 IoU > 阈值的重复框。

输入:所有检测框 + 置信度
输出:去重后的检测框

第二步:CPU 后处理的瓶颈

标准后处理代码(CPU 上跑):

import numpy as np
import torch

def cpu_postprocess(output, conf_thresh=0.25, iou_thresh=0.45):
    output = torch.from_numpy(output).squeeze(0)  # [84, 8400]
    
    # 1. 分割 bbox 和 class probs
    bbox = output[:4, :]           # [4, 8400]
    class_probs = output[4:, :]     # [80, 8400]
    
    # 2. 算置信度 = max(class_prob)
    max_class_prob, class_id = torch.max(class_probs, dim=0)  # [8400]
    
    # 3. 过滤低置信度
    keep = max_class_prob > conf_thresh
    bbox = bbox[:, keep]
    max_class_prob = max_class_prob[keep]
    class_id = class_id[keep]
    
    # 4. BBox 解码(中心点 → 角点)
    cx, cy, w, h = bbox[0, :], bbox[1, :], bbox[2, :], bbox[3, :]
    x1 = cx - w / 2
    y1 = cy - h / 2
    x2 = cx + w / 2
    y2 = cy + h / 2
    boxes = torch.stack([x1, y1, x2, y2], dim=1)  # [N, 4]
    
    # 5. NMS
    keep = nms_cpu(boxes, max_class_prob, iou_thresh)
    
    final_boxes = boxes[keep]
    final_scores = max_class_prob[keep]
    final_class_ids = class_id[keep]
    
    return final_boxes, final_scores, final_class_ids

def nms_cpu(boxes, scores, iou_thresh):
    """
    CPU 实现的 NMS(贪心算法)
    """
    x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
    areas = (x2 - x1) * (y2 - y1)
    
    order = scores.argsort(descending=True)
    
    keep = []
    while order.numel() > 0:
        i = order[0]
        keep.append(i.item())
        
        # 算 IoU
        xx1 = torch.max(x1[i], x1[order[1:]])
        yy1 = torch.max(y1[i], y1[order[1:]])
        xx2 = torch.min(x2[i], x2[order[1:]])
        yy2 = torch.min(y2[i], y2[order[1:]])
        
        w = torch.clamp(xx2 - xx1, min=0)
        h = torch.clamp(yy2 - yy1, min=0)
        inter = w * h
        
        iou = inter / (areas[i] + areas[order[1:]] - inter)
        
        # 保留 IoU < thresh 的
        inds = torch.where(iou <= iou_thresh)[0]
        order = order[inds + 1]
    
    return torch.tensor(keep, dtype=torch.long)

性能分析

步骤 CPU 耗时 NPU 耗时
BBox 解码 0.8ms 0.1ms
NMS 3.2ms 0.4ms
总计 4.0ms 0.5ms

CPU 后处理慢的原因:

  1. NMS 是串行的:贪心算法要循环遍历,CPU 单核算得慢
  2. 数据搬运:NPU 推理完,结果拷回 CPU(PCIe 瓶颈)
  3. Python 循环慢nms_cpu 里的 while 循环是 Python 级别,不是 C++ 级别

第三步:用 ops-cv 加速 NMS

ops-cv 在 CANN 8.0+ 提供了 NMS 算子,在 NPU 上算。

安装和验证

# 确认 ops-cv 版本(需要 8.0+)
python -c "import opcv; print(opcv.__version__)"

# 确认 NMS 算子可用
python -c "from opcv import nms; print('NMS available')"

如果报错 ImportError: cannot import name 'nms',说明 ops-cv 版本太旧,升级到 CANN 8.5+。

NMS 算子 API

from opcv import nms

# NMS 算子调用
keep = nms(
    boxes,          # [N, 4],角点格式(x1, y1, x2, y2)
    scores,         # [N],置信度
    iou_threshold=0.45,
    max_output_size=300,
    score_threshold=0.25
)

参数说明:

参数 类型 作用
boxes Tensor [N, 4] 检测框(角点格式)
scores Tensor [N] 置信度
iou_threshold float IoU 阈值(通常 0.45~0.5)
max_output_size int 最多保留多少个框
score_threshold float 置信度阈值(过滤低置信度)

⚠️ 踩坑boxes 必须是角点格式(x1, y1, x2, y2),不能是中心点格式。如果输入是中心点格式,要先解码。

第四步:BBox 解码(NPU 加速)

ops-cv 没有提供独立的 bbox 解码算子,但可以用 bbox_decode 自定义实现。

自定义 BBox 解码(NPU 上算)

import torch

def bbox_decode_npu(bbox, format="center"):
    """
    BBox 解码(NPU 上算)
    bbox: [4, N],中心点格式(cx, cy, w, h)
    """
    if format == "center":
        cx, cy, w, h = bbox[0, :], bbox[1, :], bbox[2, :], bbox[3, :]
        
        x1 = cx - w / 2
        y1 = cy - h / 2
        x2 = cx + w / 2
        y2 = cy + h / 2
        
        boxes = torch.stack([x1, y1, x2, y2], dim=0)  # [4, N]
        return boxes.permute(1, 0)  # [N, 4]
    else:
        raise ValueError(f"Unsupported format: {format}")

这个函数在 NPU 上算(输入是 NPU 张量),比 CPU 快 8 倍。

第五步:融合后处理(BBox 解码 + NMS)

标准后处理是两步:

  1. BBox 解码(NPU)
  2. NMS(NPU)

两步之间有显存读写。融合成一步,省掉中间显存访问。

ops-cv 的融合后处理

from opcv import fused_postprocess

# 融合后处理(一步完成)
keep = fused_postprocess(
    output,               # [1, 84, 8400],模型输出
    conf_thresh=0.25,
    iou_thresh=0.45,
    max_output_size=300,
    bbox_format="center",  # YOLOv8 是中心点格式
    output_format="corner"  # 输出角点格式
)

⚠️ 注意fused_postprocess 的 API 是示例,实际 API 名称可能需要查 ops-cv 官方文档。如果报错,用分开的 bbox_decode_npu + nms

第六步:完整后处理代码(优化版)

优化前(CPU 后处理)

def postprocess_cpu(output):
    # 1. 拷回 CPU
    output = torch.from_numpy(output).cpu()
    
    # 2. BBox 解码(CPU)
    bbox = output[0, :4, :]
    class_probs = output[0, 4:, :]
    max_class_prob, class_id = torch.max(class_probs, dim=0)
    
    keep = max_class_prob > 0.25
    bbox = bbox[:, keep]
    scores = max_class_prob[keep]
    class_id = class_id[keep]
    
    # 3. 中心点 → 角点(CPU)
    cx, cy, w, h = bbox[0], bbox[1], bbox[2], bbox[3]
    x1, y1, x2, y2 = cx - w/2, cy - h/2, cx + w/2, cy + h/2
    boxes = torch.stack([x1, y1, x2, y2], dim=1)
    
    # 4. NMS(CPU)
    keep = nms_cpu(boxes, scores, 0.45)
    
    return boxes[keep], scores[keep], class_id[keep]

优化后(NPU 后处理)

import torch
from opcv import nms

def postprocess_npu(output):
    # 1. 留在 NPU 上(不拷回 CPU)
    output = torch.from_numpy(output).npu()  # [1, 84, 8400]
    
    # 2. 分割 bbox 和 class probs
    bbox = output[0, :4, :]           # [4, 8400]
    class_probs = output[0, 4:, :]     # [80, 8400]
    
    # 3. 算置信度
    max_class_prob, class_id = torch.max(class_probs, dim=0)  # [8400]
    
    # 4. 过滤低置信度
    keep = max_class_prob > 0.25
    bbox = bbox[:, keep]
    scores = max_class_prob[keep]
    class_id = class_id[keep]
    
    # 5. BBox 解码(中心点 → 角点,NPU 上算)
    cx, cy, w, h = bbox[0, :], bbox[1, :], bbox[2, :], bbox[3, :]
    x1 = cx - w / 2
    y1 = cy - h / 2
    x2 = cx + w / 2
    y2 = cy + h / 2
    boxes = torch.stack([x1, y1, x2, y2], dim=1)  # [N, 4]
    
    # 6. NMS(NPU 上算)
    keep = nms(
        boxes.npu(),
        scores.npu(),
        iou_threshold=0.45,
        max_output_size=300,
        score_threshold=0.25
    )
    
    return boxes[keep].cpu(), scores[keep].cpu(), class_id[keep].cpu()

关键改动:

  1. 不拷回 CPUoutput.npu() 留在 NPU 上算
  2. NMS 用 NPU 算子opcv.nms() 在 NPU 上算
  3. BBox 解码在 NPU 上算:用 PyTorch 算子(自动在 NPU 上算)

第七步:性能对比

YOLOv8-nano,昇腾 910,测试 1000 张图片:

后处理方式 后处理耗时 端到端延迟 吞吐量
CPU(numpy) 4.0ms 12ms 83 FPS
CPU(PyTorch) 3.2ms 11.2ms 89 FPS
NPU(分开算) 0.8ms 8.8ms 114 FPS
NPU(融合) 0.5ms 8.5ms 118 FPS

提速 8 倍

第八步:生产环境优化

生产环境要求低延迟。两个优化方向:

1. 批量后处理

单张后处理 NPU 利用率低。改成批量后处理,吞吐量翻倍。

from opcv import batch_nms

# 批量 NMS(一次处理 32 张图的后处理结果)
batch_boxes, batch_scores, batch_class_ids = batch_postprocess(
    batch_output,     # [32, 84, 8400]
    conf_thresh=0.25,
    iou_thresh=0.45,
    max_output_size=300
)

2. 异步后处理

模型推理和后处理可以流水线——推理下一张图的时候,后处理上一张图的结果。

import threading

class AsyncPostprocess:
    def __init__(self):
        self.queue = []
        self.lock = threading.Lock()
        self.thread = threading.Thread(target=self._worker)
        self.thread.daemon = True
        self.thread.start()
    
    def _worker(self):
        while True:
            with self.lock:
                if len(self.queue) == 0:
                    continue
                output = self.queue.pop(0)
            
            # 后处理(NPU 上算)
            boxes, scores, class_ids = postprocess_npu(output)
            
            # 存结果
            self.result = (boxes, scores, class_ids)
    
    def submit(self, output):
        with self.lock:
            self.queue.append(output)
    
    def get_result(self):
        return self.result

# 使用
postprocessor = AsyncPostprocess()

# 推理
output = model.infer(image)
postprocessor.submit(output)

# 后处理(后台线程算)
boxes, scores, class_ids = postprocessor.get_result()

第九步:调试技巧

问题 1:NMS 结果不对

原因:IoU 阈值设太高(>0.5),删掉了正确的重复框。

解决:COCO 数据集用 iou_threshold=0.45,自定义数据集重新调。

问题 2:bbox 解码结果偏移

原因:YOLOv8 的输出是归一化坐标(0~1),要乘以原图尺寸才是像素坐标。

# 解码后,乘回原图尺寸
boxes[:, [0, 2]] *= orig_width
boxes[:, [1, 3]] *= orig_height

问题 3:NMS 算子报错 Input tensor must be on NPU

原因:boxesscores 在 CPU 上,要 .npu() 搬过去。

# 错误
keep = nms(boxes.cpu(), scores.cpu(), ...)

# 正确
keep = nms(boxes.npu(), scores.npu(), ...)

性能数据:完整对比表

YOLOv8-nano,昇腾 910,端到端性能:

优化阶段 预处理 推理 后处理 总延迟 吞吐量
基线(CPU 全链路) 12ms 8ms 4ms 24ms 42 FPS
+ ops-cv 预处理 2ms 8ms 4ms 14ms 71 FPS
+ NPU 推理(OM) 2ms 8ms 4ms 14ms 71 FPS
+ NPU 后处理 2ms 8ms 0.5ms 10.5ms 95 FPS

最终提速 2.3 倍

下一步

如果你要优化目标检测后处理:

  1. 先 profile:用 msprof 看后处理占比,低于 10% 就不用优化了
  2. NMS 搬上 NPU:从 opcv.nms 开始,改动最小
  3. BBox 解码搬上 NPU:用 PyTorch 算子(自动在 NPU 上算)
  4. 批量后处理:batch_size > 1 时,用 batch_nms
  5. 异步后处理:推理和后处理流水线,利用率提到 90%

ops-cv 仓库有完整的后处理示例:

https://atomgit.com/cann/ops-cv/blob/master/examples/postprocess/
https://atomgit.com/cann/ops-cv/blob/master/docs/nms_api.md

有问题去社区提 Issue,附上你的模型输出格式(中心点 or 角点),CANN 团队会帮你定位问题。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐