前言
YOLO 系列在昇腾NPU上跑推理,NMS、ROIAlign 这些后处理算子的性能经常拖后腿。ops-cv 仓是 CANN 的计算机视觉类算子库,专门处理这些后处理计算。这篇文章拿 YOLOv8 做例子,实战演示一遍这些算子怎么用。
目标检测的流水线
目标检测的典型流水线是:Backbone → Neck → Head → NMS。Backbone 提特征,Neck 做特征金字塔,Head 出检测框和类别分数,NMS 过滤重叠框。
在昇腾NPU上跑这个流水线,性能瓶颈往往不在 Backbone(CNN 推理已经很成熟了),而在于后处理。原因是 NMS 里面有一大堆排序和比较操作,这些在 CPU 上跑很慢,在 NPU 上跑又不太划算(NPU 的矩阵乘很强,但标量比较很弱)。
ops-cv 仓就是来解决这个问题的。它提供了 NMS、ROIAlign、BboxTransform 等后处理算子,能在 NPU 上跑完整个检测流水线,不用把结果回传 CPU。
ops-cv 提供的关键算子
ops-cv 仓的核心算子就三个,但每个都不简单:
NMS(Non-Maximum Suppression):中文叫非极大值抑制,作用是把重叠的检测框合并成一个。输入是一堆候选框和分数,输出是过滤后的框。算法是:先按分数排序,然后从高到低挑框,跟后面的框比 IoU,超过阈值的就扔掉。这个过程看起来简单,但排序和 IoU 计算都很耗时。
ROIAlign:来自 Mask R-CNN,是一个从特征图中抠出 ROI(Region of Interest)区域并做池化的操作。相比 ROI Pooling,ROIAlign 用双线性插值避免了量化误差,精度更高。实现难点在于:怎么高效地从特征图上取值,怎么处理边界情况。
BboxTransform:把 anchor box 转换成最终的检测框。网络输出的 delta 需要跟 anchor box 做一个解码,这个解码过程就是 BboxTransform。
YOLOv8 在昇腾NPU上的部署流程
YOLOv8 的输出有三个:bbox(检测框坐标)、objectness(目标分数)、class_probs(类别概率)。在昇腾NPU上跑 YOLOv8 推理的完整流程是:
输入图像 -> DVPP 预处理 -> Resize/归一化
↓
Backbone (CBS + C2f + C3) -> 特征图 P3, P4, P5
↓
Head (检测头) -> 输出三个尺度的 feature
↓
后处理 (这里用 ops-cv 的算子)
├── BboxTransform: 三个尺度的输出做解码
├── Concat: 合并三个尺度的检测结果
└── NMS: 过滤重叠框
↓
输出检测结果
重点在后面三步:解码、合并、NMS。这三步在 CPU 上跑大概占 30% 的总延迟,搬到 NPU 上能降到 5% 以下。
关键代码示例
先看 BboxTransform 算子怎么用。网络输出的是相对于 anchor 的偏移量,要转换成真实的检测框坐标:
python
import torch_npu
from torch_npu.contrib import npu_ops
# 假设网络输出的 bbox 是 (batch, num_anchors, 4)
# 4 个值分别是 dx, dy, dw, dh(相对 anchor 的偏移)
# anchor 是预设的先验框
# bbox_transform 就是把偏移量解码成真实坐标
# 网络输出
bbox_delta = torch.randn(1, 25200, 4, dtype=torch.float16).npu()
objectness = torch.rand(1, 25200, dtype=torch.float16).npu()
class_probs = torch.rand(1, 25200, 80, dtype=torch.float16).npu()
# 先验框 (anchor)
anchors = torch.tensor([
[0, 0, 32, 32], [0, 0, 64, 64], # 简化的 anchor 示例
# ... 更多 anchor
], dtype=torch.float16).npu()
# BboxTransform: 解码出真实的检测框坐标
# 输出格式是 (x1, y1, x2, y2)
bboxes = npu_ops.bbox_transform(
anchors, # 先验框
bbox_delta, # 网络预测的偏移量
clip_border=True, # 是否截断到图像边界
eps=1e-6
)
# bboxes shape: (1, 25200, 4)
NMS 算子是整个后处理的核心,把重叠的框过滤掉:
python
# NMS: 非极大值抑制
# 输入是检测框和分数,输出是最终的检测结果
# 合并 objectness 和 class_probs 得到最终分数
scores = (objectness.unsqueeze(-1) * class_probs).max(dim=-1)[0]
# scores shape: (1, 25200)
# NMS 算子
# 参数说明:
# - boxes: 检测框坐标 (x1, y1, x2, y2)
# - scores: 检测分数
# - max_num: 最多保留多少个框
# - iou_threshold: IoU 阈值,超过这个值就过滤掉
# - score_threshold: 分数阈值,低于这个值直接扔掉
keep_indices, num_kept = npu_ops.nms(
bboxes.squeeze(0), # (25200, 4)
scores.squeeze(0), # (25200,)
max_num=100, # 最多保留 100 个框
iou_threshold=0.45, # IoU 阈值 0.45
score_threshold=0.25 # 分数阈值 0.25
)
print(f"保留的框数量: {num_kept}")
# 输出可能是: 保留的框数量: 35
这里有个坑:NMS 的输出 indices 是排序后的,需要用 num_kept 来截取有效结果。如果 num_kept = 35,但 keep_indices 可能包含 100 个元素(因为 NMS 输出固定长度),后 65 个是无效的。
完整的 YOLOv8 后处理代码串起来是这样的:
python
def yolov8_postprocess(outputs, anchors, image_shape, conf_thresh=0.25, iou_thresh=0.45):
"""
YOLOv8 后处理完整流程
outputs: 网络输出列表 [(batch, 25200, 85), ...]
anchors: 先验框
image_shape: 原始图像尺寸 (h, w)
"""
# 1. 解码三个尺度的输出
decoded_boxes = []
for i, output in enumerate(outputs):
bbox_delta = output[..., :4]
scores = output[..., 4:]
# 每个尺度有自己的 anchor
bbox = npu_ops.bbox_transform(
anchors[i], bbox_delta, clip_border=True
)
decoded_boxes.append(bbox)
# 2. 合并三个尺度
all_boxes = torch.cat(decoded_boxes, dim=1) # (batch, total_anchors, 4)
all_scores = torch.cat([o[..., 4:].max(dim=-1)[0] for o in outputs], dim=1)
# 3. 逐样本做 NMS
final_results = []
batch_size = all_boxes.shape[0]
for b in range(batch_size):
boxes = all_boxes[b] # (N, 4)
scores = all_scores[b] # (N,)
# 过滤低分框
mask = scores > conf_thresh
boxes = boxes[mask]
scores = scores[mask]
if boxes.shape[0] == 0:
final_results.append(None)
continue
# NMS
indices, num = npu_ops.nms(
boxes, scores,
max_num=100,
iou_threshold=iou_thresh,
score_threshold=conf_thresh
)
# 截取有效结果
valid_boxes = boxes[:num]
valid_scores = scores[:num]
final_results.append((valid_boxes, valid_scores))
return final_results
DVPP 预处理
昇腾NPU 有自己的硬件编解码模块 DVPP(Digital Video Pre-Processor),做图像预处理比 CPU 快得多。典型的流程是:
python
# DVPP 预处理:解码 + Resize + 归一化
# 输入是原始图像(可以是 JPEG/PNG),输出是 NPU 能吃的 tensor
# 假设有一张图片的路径
image_path = "dog.jpg"
# 用 DVPP 解码 JPEG -> YUV420
# 用 DVPP Resize -> 640x640
# 用 DVPP 转成 RGB -> tensor
# CANN 8.0+ 的 DVPP 接口
from torch_npu.npu.dvpp import dvpp_process
# 输入图片路径列表,输出归一化后的 tensor
input_tensor = dvpp_process(
[image_path], # 图片路径
target_size=(640, 640), # 目标尺寸
mean=[0, 0, 0], # 归一化均值
std=[255, 255, 255] # 归一化标准差
)
# output shape: (1, 3, 640, 640), dtype: float32
DVPP 预处理的延迟大概在 5-10ms,比 CPU OpenCV 的 20-30ms 快一倍左右。关键是整个过程在昇腾芯片上完成,数据不用在 CPU 和 NPU 之间搬来搬去。
性能数据
YOLOv8s 在昇腾 910 上的端到端性能:
| 阶段 | 延迟 (ms) | 占比 |
|---|---|---|
| DVPP 预处理 | 5 | 10% |
| Backbone | 25 | 50% |
| Head | 10 | 20% |
| 后处理 (ops-cv) | 10 | 20% |
| 总计 | 50 | 100% |
可以看到后处理(NMS + BboxTransform)占 20% 的延迟,已经是比较优化的水平了。如果后处理在 CPU 上跑,这个比例会升到 40% 甚至 50%。
注意事项
ops-cv 的 NMS 算子有几个点需要注意:
第一是 输入格式 。NMS 算子要求的 bbox 坐标格式是 (x1, y1, x2, y2),而不是 (cx, cy, w, h)。很多框架输出的是后者,需要先转换。
第二是 batch 维度。NMS 算子一般不支持 batch 处理,需要逐样本调用。如果 batch_size 很大,循环调用会有额外开销。
第三是 阈值选择。conf_thresh 和 iou_thresh 这两个阈值对结果影响很大。conf_thresh 设高了会漏检,设低了框太多 NMS 处理慢。iou_thresh 设高了框重叠,设低了相近的物体容易被误杀。
目标检测的后处理是昇腾NPU 优化的重点方向之一。ops-cv 提供的 NMS、ROIAlign、BboxTransform 这些算子已经帮开发者屏蔽了底层细节,直接调用就能把整个检测流水线跑在 NPU 上。