来源:notes/documents/01_dg板卡X5_BPU推理服务_原理_问答_代码.md

dg 板卡 X5 BPU 推理服务:原理、问答与代码

日期:2026-09-19

一句话结论

dg 是 D-Robotics RDK X5 开发板,板子上最值钱的算力是 BPU(Brain Processing Unit,地平线自研的神经网络加速器)。它没有摄像头、麦克风、屏幕,但只要有网络就能当一台 AI 推理服务器:图片从网络进来,检测框和分类结果从网络出去。

我把这个想法做成了一个常驻服务,部署在 /opt/bpu-service,systemd 托管,访问地址 http://10.71.48.154:8080/。它直接调用板载 34 个官方 .bin 模型,不用做任何模型转换,实测 yolov8 端到端约 52 ms/请求、板上 4 并发 37.9 req/s。

1. 我做了什么

部分 内容
推理服务 纯标准库 HTTP 服务(ThreadingHTTPServer),零额外依赖
模型来源 /opt/hobot/model/x5/basic 下 34 个官方 .bin,即插即用
解码能力 YOLOv5 锚框解码、YOLOv8/v10/v11/v12 DFL 解码、ImageNet 分类 top-k、未知模型返回原始张量统计
输入路径 编码图片(jpg/png)和原始 NV12 帧两条,后者是以后接摄像头/解码器用的
工程能力 并发限流、延迟百分位指标、批量离线处理 CLI、内置上传页面、systemd 开机自启
代码规模 服务本体 1430 行 Python(engine 442 / decode 351 / server 275 / cli 234 / preprocess 125),加测试与工具共 1731 行

HTTP 接口:

GET  /                内置上传页面(浏览器直接拖图片)
GET  /healthz         健康检查
GET  /v1/models       模型列表(含未加载的)
GET  /v1/metrics      请求数、错误数、延迟百分位
POST /v1/infer        编码图片推理,?annotate=1 返回画好框的 jpeg
POST /v1/infer/nv12   原始 NV12 帧推理(摄像头/解码器路径)
POST /v1/perf         在服务进程里跑基准测试

2. 原理

2.1 BPU 到底是什么

BPU 是地平线自研的神经网络加速器。CPU(ARM Cortex-A55)擅长逻辑控制和串行任务,GPU 擅长图形和大规模并行浮点,而 BPU 是为卷积神经网络定制的专用集成电路:把卷积、池化、激活这些算子做成硬件流水线,用定点(INT8/INT16)运算换能效比。

所以在这块板子上,正确分工是:

  • CPU 干”后勤”:解码 JPEG、缩放、颜色空间转换、后处理(反量化、解码检测框、NMS)、发 HTTP。
  • BPU 干”正事”:把 640×640 的输入张量过一遍神经网络,吐出原始输出张量。

CPU 上跑 YOLOv8 可能几百毫秒一张,BPU 上只要 10 毫秒——这就是要调用 BPU 的原因。

2.2 X5 板子的实测实况

实测值
板卡 D-Robotics RDK X5 V1.0(X5M)
系统 Ubuntu 22.04.5,RDK OS 3.5.0,内核 6.1.83
CPU 8× Cortex-A55,1.2 GHz,governor=schedutil
内存 4 GB LPDDR4(系统可见 3062 MB,可用约 2.2 GB),另有 8 GB swap
存储 根分区 57 GB,已用 20 GB,剩余 35 GB
BPU 频率 标称 500 MHz / 1 GHz 两档,实测运行在 996 MHz
模型库 /opt/hobot/model/x5/basic,34 个 .bin(检测 / 分类 / 分割 / 姿态各若干)
设备节点 /dev/bpu(10,85)、/dev/bpu_core0(10,84)、/dev/ion(10,127)
内核模块 bpu_hw_io_x5bpu_coresbpu_framework
摄像头 无。/dev/video* 不存在
麦克风/喇叭 无声卡外设。/dev/snd 能枚举到 ES8326 codec,但采集到的是一段饱和噪声(峰值削顶),属悬空输入,不可用
显示 /dev/fb*,只有 /dev/dri(GPU 渲染节点)

2.3 从我的 Python 到 BPU 硬件的完整调用链

这是”如何精准调用到 BPU”的答案。整条链路是分层的,我的代码只碰最上面一层:

我的 Python 代码
    │  from hbm_runtime import HB_HBMRuntime
    ▼
/usr/local/lib/python3.10/dist-packages/hbm_runtime/HB_HBMRuntime.so
    │  pybind11 编译出的 aarch64 扩展,把 C++ 类暴露成 Python 类
    │  仅 NEEDED: libdnn.so(没有 RPATH,靠系统 ld.so 缓存找)
    ▼
/lib/libdnn.so            ← 地平线 DNN 运行时,提供 hbDNN* C API
    ├─ /usr/hobot/lib/libcnn_intf.so.1
    ├─ /usr/hobot/lib/libhbmem.so.1     ← BPU 专用共享内存分配(ion)
    └─ /lib/libhbrt_bayes_aarch64.so    ← hbrt(BPU Runtime),版本 3.15.55
    ▼
Linux 内核模块:bpu_framework → bpu_cores → bpu_hw_io_x5
    ▼
字符设备 /dev/bpu_core0(10,84)、/dev/bpu(10,85)、/dev/ion(10,127)
    ▼
BPU 硬件(BPU core array)

ldd 可以验证这条链:

ldd /usr/local/lib/python3.10/dist-packages/hbm_runtime/HB_HBMRuntime.so
#   libdnn.so => /lib/libdnn.so
#   libcnn_intf.so.1 => /usr/hobot/lib/libcnn_intf.so.1
#   libhbmem.so.1 => /usr/hobot/lib/libhbmem.so.1
#   libhbrt_bayes_aarch64.so => /lib/libhbrt_bayes_aarch64.so

lsmod | grep -i bpu
#   bpu_hw_io_x5   24576  0
#   bpu_cores      28672  3  bpu_hw_io_x5
#   bpu_framework  77824  4  bpu_hw_io_x5,bpu_cores

2.4 五个”契约点”——为什么这样写就能精准命中 BPU

调用 BPU 并不是”写对一行 import”就完了,真正的难点是让数据元信息都符合运行时的约定。我踩过、并且必须同时满足的契约有五个:

契约一:模型必须是 BPU 编译后的 .bin BPU 不认识 PyTorch 的 .pt / ONNX。模型必须先用地平线 OpenExplorer 工具链做量化、编译、图优化,产出 .bin(里面是 BPU 指令 + 权重)。这一步决定了”能不能上 BPU”。

契约二:张量名不能硬编码,必须运行时读取。 input_names / output_names 只能从 HB_HBMRuntime 实例里读,因为不同模型的命名不同。我的代码里这一层是懒加载的:

class RuntimeSlot:
    """A loaded HB_HBMRuntime plus the metadata needed to build inputs."""

    def __init__(self, spec: ModelSpec):
        from hbm_runtime import HB_HBMRuntime

        self.rt = HB_HBMRuntime(spec.path)
        self.model_name = self.rt.model_names[0]
        self.input_names = list(self.rt.input_names[self.model_name])
        self.output_names = list(self.rt.output_names[self.model_name])

契约三:输入不是 [1,3,640,640] 的 RGB 张量,而是展平的 packed NV12。 这是最容易掉进去的坑,下一节单独讲。运行时报告的 input_shape 只是”逻辑形状”,真正要喂的 buffer 长度是 W*H*3/2,并且要包成 {模型名: {输入名: buffer}}

契约四:量化的输出必须按量化参数反量化。 BPU 用定点运算,输出的可能是 int32/int8 加一个逐通道 scale。不是转成 float 就完事,必须按量化参数还原。

契约五:调度参数用默认值即可,但不能假设单实例。 同进程里对同一个 .bin 第二次建 HB_HBMRuntime,runtime 会打印 has loaded, Discard the latest 并去重——它不会给你两个独立句柄。所以并发模型必须是”单实例 + 信号量 + 多线程调 run()“。

2.5 一张 JPEG 的完整数据流

HTTP 请求体(JPEG bytes,137 KB)
  ① cv2.imdecode                      → BGR ndarray 810×1080×3
  ② letterbox 等比缩放 + 填 114       → BGR 640×640×3(记录 scale / pad,用于把框映射回原图)
  ③ BGR → YUV_I420 → Y+交错UV         → packed NV12,长度 640*640*3/2 = 614400 字节
  ④ {model_name: {input_name: nv12}} → HB_HBMRuntime.run()
  ⑤ BPU 前向                          → 原始输出张量(int32 / int8 / f32)
  ⑥ 按 output_quants 反量化           → float32
  ⑦ head 解码 + sigmoid + NMS         → 检测框(模型坐标系)
  ⑧ 按 letterbox 参数逆映射回原图      → 原图像素坐标
  ⑨ JSON 序列化                       → HTTP 响应

第 ③ 步是整套代码里最”反直觉”的一步。

2.6 为什么输入必须是 packed NV12

模型报告 input_shape = [1, 3, 640, 640],看起来像 RGB 三通道。但 X5 的 BPU 实际吃的是 NV12(一种 YUV 4:2:0 半平面格式):

  • Y 平面:640×640 字节(亮度)
  • UV 平面:640×320 字节,U 和 V 交错存放(色度,分辨率减半)
  • 总计 640×640×3/2 = 614400 字节

也就是说,3 个”通道”在内存里不是 RGB 平面,而是 Y + 交错 UV。如果直接塞一个 [1,3,640,640] 的 RGB 数组,尺寸和排布都对不上。

我的转换代码:

def bgr_to_nv12(img: np.ndarray) -> np.ndarray:
    """Convert a BGR image to packed NV12 (Y plane followed by interleaved UV)."""
    height, width = img.shape[:2]
    if height % 2 or width % 2:
        raise ValueError("NV12 requires even width/height")
    area = height * width
    yuv420p = cv2.cvtColor(img, cv2.COLOR_BGR2YUV_I420).reshape(area * 3 // 2)
    y = yuv420p[:area]
    u = yuv420p[area:area + area // 4].reshape(height // 2, width // 2)
    v = yuv420p[area + area // 4:].reshape(height // 2, width // 2)
    uv = np.stack((u, v), axis=-1).reshape(-1)     # U/V 交错
    return np.concatenate([y, uv]).astype(np.uint8)


def build_input_tensor(model_name: str, input_name: str, packed_nv12: np.ndarray) -> dict:
    """Wrap a packed NV12 buffer the way hbm_runtime.run() expects it."""
    return {model_name: {input_name: np.ascontiguousarray(packed_nv12, dtype=np.uint8)}}

顺带一个实测教训:竖图必须用 resize=stretch,不能用 letterbox。 一张 1279×1920 的扣篮照片,letterbox 会把它缩成 640×426、上下各填 107 像素黑边,目标变得很小,最高置信度只有 0.65;改成 stretch 直接拉成 640×640 后,同一目标升到 0.86。

2.7 输出反量化

BPU 的输出张量可能是定点的。以 yolov8 的 box 分支为例,它是 int32 加逐通道 scaleaxis=3),必须先还原成 float 再解码:

def dequantize(tensor: np.ndarray, quant) -> np.ndarray:
    """Convert a quantized output tensor to float32 using its QuantParams."""
    if quant is None:
        return tensor.astype(np.float32, copy=False)
    qtype = str(getattr(quant, "quant_type", "")).split(".")[-1]
    if qtype not in ("SCALE", "1"):
        return tensor.astype(np.float32, copy=False)
    scale = np.asarray(getattr(quant, "scale", np.array([])), dtype=np.float32)
    zero_point = np.asarray(getattr(quant, "zero_point", np.array([])), dtype=np.float32)
    if scale.size == 0:
        return tensor.astype(np.float32, copy=False)
    t = tensor.astype(np.float32)
    if scale.size == 1:
        return (t - float(zero_point.reshape(-1)[0])) * float(scale.reshape(-1)[0])
    axis = int(getattr(quant, "axis", 0))
    shape = [1] * t.ndim
    shape[axis % t.ndim] = -1
    return (t - zero_point.reshape(shape)) * scale.reshape(shape)

2.8 三种解码器

BPU 只负责吐张量,张量到”人看得懂的结果”这一步全靠 CPU 后处理。我实现了三种:

  • YOLOv5:锚框 + 网格。head 通道数 255 = 3 锚框 × (5 + 80 类),所以类别数是 channels/3 - 5,不是 channels - 5。写错会让所有类别整体错位。
  • YOLOv8 / v10 / v11 / v12:DFL(Distribution Focal Loss)布局。box 分支最后一维是 4 × 16 = 64,用 softmax 对 16 个 bin 求期望得到 ltrb 距离。做了 sigmoid 预筛优化:sigmoid 单调递增,先用 inv_sigmoid(阈值) 过滤 logit,再做激活,单请求后处理从 35 ms 降到约 5 ms。
  • 分类:找 (1, C, 1, 1) 形状的 head 做 top-k。这里有个坑:板上的 ImageNet 模型输出已经过 softmax(sum≈1),不能再算一次,否则置信度会塌到 0.002。我的代码会自动判断:
if mode == "auto":
    total = float(values.sum())
    mode = "none" if values.min() >= -1e-3 and 0.9 <= total <= 1.1 else "softmax"

YOLOv8 解码的核心:

logits = cls_t.reshape(-1, num_classes)
# sigmoid 单调,所以 max-logit >= inv_sigmoid(thr) 等价于命中
raw_thr = _logit_threshold(score_thres)
cand = np.flatnonzero(logits.max(axis=1) >= raw_thr)
probs = sigmoid(logits[cand])
cls_ids = probs.argmax(axis=1)
scores = probs[np.arange(probs.shape[0]), cls_ids]
keep = cand[scores >= score_thres]
# DFL:对 16 个 bin 做 softmax 求期望,得到 left/top/right/bottom 距离
dist = box_t.reshape(-1, 4, DFL_BINS)[keep]
ltrb = np.sum(softmax(dist, axis=2) * np.arange(DFL_BINS), axis=2)
centers = gen_anchor(grid)[keep]
boxes = np.hstack([centers - ltrb[:, 0:2], centers + ltrb[:, 2:4]]) * stride

2.9 并发模型:为什么是”单实例 + 信号量”

BPU 是单核的。我实测过纯 BPU 前向吞吐:

线程数 yolov8 FPS p50 延迟
1 100.1 9.8 ms
2 99.7 20.1 ms
4 99.4 30.0 ms

线程数从 1 加到 4,吞吐几乎不变,但单次延迟线性上涨——说明 BPU 已经跑满,多出来的线程只是在排队。而且同一个 .bin 在同一进程里重复加载会被运行时去重,拿不到第二个独立句柄。所以正确的并发模型是:

class ModelRunner:
    def ensure_loaded(self) -> None:
        if self.slot is not None:
            return
        with self._load_lock:
            if self.slot is None:
                self.slot = RuntimeSlot(self.spec)
                self._sem = threading.BoundedSemaphore(max(1, self.spec.workers))

    def infer(self, tensors, timeout: float = 20.0):
        self.ensure_loaded()
        if not self._sem.acquire(timeout=timeout):     # 限流,防止无限排队
            raise BusyError(f"{self.spec.name} saturated")
        try:
            outputs = self.slot.run(tensors)           # 多线程并发调 run() 是安全的
        finally:
            self._sem.release()

实测多线程并发调 run() 结果完全一致(0 处偏差),所以单实例不会引入竞态问题。默认 workers=2:既能吃到一点 I/O 与 CPU 后处理的重叠,又不会让排队延迟失控。

3. 实测结果

3.1 性能

纯 BPU 前向cli bench,零拷贝输入,板上本机):

模型 1 线程 2 线程 4 线程
yolov8_640x640 100 FPS (9.8 ms) 99.7 FPS 99.4 FPS
yolov5s_v6_640x640 49.8 FPS (20.1 ms) 50.2 FPS 50.4 FPS
yolo11m_640x640 25.0 FPS (40.0 ms) 24.9 FPS 25.3 FPS
ssd_mobilenetv1_300 252 FPS (3.9 ms) 243 FPS 235 FPS

端到端 HTTP(含 JPEG 解码、letterbox、NV12 打包、反量化、解码、NMS、JSON):

场景 单请求 1 客户端 4 客户端 8 客户端
板上本机 yolov8 46-52 ms 17.3 req/s 37.9 req/s 37.5 req/s
跨 ZeroTier yolov8 330-353 ms 3.6 req/s 14.2 req/s 25.1 req/s

跨 ZeroTier 的差距几乎全部来自网络:请求体 137 KB 要过一条跨公网的虚拟局域网,单张图光传输就占掉几百毫秒。服务本身的算力瓶颈不在 BPU,而在 Python 侧(GIL + OpenCV 后处理)

服务常驻加载 3 个模型约 97 MB 内存。

3.2 图片处理结果

第一张:官方 COCO 测试图 bus.jpg(810×1080,137 KB)

模型 结果 推理耗时
yolo11m bus 0.9255,person 0.9042 / 0.8951 / 0.8896 / 0.7761(5 个目标) 43.2 ms
yolov12n bus 0.8648,person 0.8185 / 0.7761 / 0.7460 / 0.5249 25.4 ms
yolov8 bus 0.8193,person 0.7449 / 0.6944 / 0.6743 / 0.2721 10.0 ms
yolov5s_v6 person 0.7773 / 0.7729 / 0.7192,bus 0.7443 20.2 ms
mobilenetv2(分类) minibus 0.4805,police van 0.1538 2.6 ms
resnet18(分类) minibus 0.4095 4.2 ms
googlenet(分类) minibus 0.4485 3.1 ms

结论:检测模型全部认出了巴士和人,大模型(yolo11m)置信度最高、小模型(yolov12n)速度更快。分类模型把”一辆大巴”归类为 minibus(小巴)也属合理。

第二张:SBA 2026 扣篮照片(1279×1920,331 KB,竖图)

模型 结果 备注
yolo11m(stretch) 空中扣篮者 person 0.8645,篮下球员 person 0.7673,场边 4 人 0.6789 / 0.5634 / 0.5449 / 0.4527,另有 backpack 0.2607、tvmonitor 0.2588(误检) 38.5 ms
yolo11m(letterbox) 最高只有 person 0.6473 竖图被压缩,明显劣化
mobilenetv2(分类) scoreboard 0.5416,jigsaw puzzle 0.1291 2.4 ms
googlenet(分类) scoreboard 0.1217,jigsaw puzzle 0.1006,ballplayer 0.0833 3.1 ms

分类结果里 googlenet 能给出 ballplayer(棒球/篮球运动员),说明模型确实抓到了运动场景特征。

标注图已保存在 dg-bpu-service/tests/annotated_bus.jpgannotated_dunk.jpg,两张图都已通过 lark-cli 发到飞书。

4. 你问过的问题与解答

Q1:dg 机器是地瓜机器人 X5,帮我看一下它的 BPU 可以拿来做什么?

BPU 是地平线为自家芯片设计的神经网络加速器。在 X5 上它可以做四类事:

  1. 目标检测:YOLO 系列(v5/v8/v10/v11/v12)、SSD、FCOS、CenterNet,输出检测框。
  2. 图像分类:MobileNet、ResNet、GoogLeNet、EfficientNet 系列,输出 top-k 类别。
  3. 语义分割:DeepLabv3+、STDC,输出逐像素类别。
  4. 多模态大模型:X5 上有适配的 CLIP 文本编码、LLaMA.cpp 等 TROS 包(tros-humble-hobot-clip-encode-texttros-humble-hobot-llamacpp)。

而 X5 的 BPU 真正有优势的地方是:算力比 CPU 高一个量级,功耗只有几瓦,适合做常驻的视觉感知。板载模型库 /opt/hobot/model/x5/basic 里有 34 个已经编译好的 .bin,开箱即用。

Q2:现在 dg 机器上没有摄像头、麦克风、扩音器,它现在可以做什么?

实测确认:/dev/video* 不存在(无摄像头),/dev/snd 能枚举到 ES8326 codec 但采集到的是饱和噪声(悬空输入,不是有效麦克风),也没有 /dev/fb* 显示设备。

所以”感知输入”这条腿是断的,但”算力”这条腿是好的,可以做的事:

  • 网络推理服务(我做的就是这个):图片从 HTTP 进来,结果从 HTTP 出去。任何能发 HTTP 的设备都能把 X5 当 AI 后端。
  • 离线批量处理cli watch <图片目录> 把目录里的图一张张跑完,输出 JSONL。适合”把一批历史图片过一遍”。
  • 边缘计算节点:放在网络里,给其他没有 AI 算力的设备提供推理能力。
  • 网络音视频流(后续):/v1/infer/nv12 接口已经预留——只要以后接上摄像头或用 RTSP 流解码出 NV12 帧,不用改解码层就能直接推。
  • 传感器/控制类:板子有 40-pin GPIO、I2C、SPI、UART,可以接传感器、电机、继电器做控制类项目,这部分不依赖摄像头。

一句话:现在它是一台”纯算力服务器”,输入输出都走网络。

Q3:你写的代码是如何精准调用到地瓜的 BPU 的?

不是靠猜路径,是靠分层调用 + 五个契约点

  1. 我的 Python 只做一件事:from hbm_runtime import HB_HBMRuntime。这是地平线官方发布的 Python 包,导入时 Python 按标准 sys.path 机制在 /usr/local/lib/python3.10/dist-packages/hbm_runtime/ 找到它。
  2. HB_HBMRuntime 是一个用 pybind11 编译出来的 aarch64 原生扩展(HB_HBMRuntime.so),它在 C++ 层调用 libdnn.sohbDNN* 系列 C API。
  3. libdnn.so 往下依赖 libhbrt_bayes_aarch64.so(BPU Runtime)、libhbmem.so.1(BPU 共享内存)、libcnn_intf.so.1
  4. 这些符号最后由 Linux 内核模块 bpu_framework / bpu_cores / bpu_hw_io_x5 接管,通过字符设备 /dev/bpu_core0/dev/bpu/dev/ion 操作硬件。
  5. “精准”的关键不在调用,而在数据:模型必须是 BPU 编译的 .bin;张量名必须运行时读取;输入必须是 packed NV12;量化输出必须按 output_quants 反量化;并发必须走单实例 + 信号量。

详细分层图见第 2.3 节,五个契约点见第 2.4 节。

Q4:所有是地平线封装了官方的 Python 库吗?

是。hbm_runtime 就是地平线官方提供的 Python 封装,不是我自己写的,也不是第三方。

证据链:

dpkg -l | grep hobot-spdev
# ii  hobot-spdev  3.0.9  Python and C/C++ Development Interface

dpkg -L hobot-spdev | grep whl
# /usr/lib/hobot_spdev/hbm_runtime-3.0.9-py3-none-any.whl
# /usr/lib/hobot_spdev/hobot_dnn-3.0.9-py3-none-any.whl
# /usr/lib/hobot_spdev/hobot_vio-3.0.9-py3-none-any.whl

grep pip3 /var/lib/dpkg/info/hobot-spdev.postinst
# pip3 install /usr/lib/hobot_spdev/hbm_runtime*.whl

分工是:hobot-dnn(BPU Runtime Support Package)提供底层的 libdnn.so / libhbrthobot-spdev(Python and C/C++ Development Interface)提供 Python wheel,安装时自动 pip3 installdist-packages。我现在用的版本都是 3.0.9

Q5:那你写的代码,是如何找到官方封装的库的?

我没有做任何路径 hack。查找分两层,都是 Linux/Python 的标准机制:

Python 层hobot-spdev 的 postinst 脚本在装包时执行 pip3 install /usr/lib/hobot_spdev/hbm_runtime*.whl,wheel 被装进 /usr/local/lib/python3.10/dist-packages/hbm_runtime/。这个目录本来就在 Python 默认的 sys.path 里,所以 import hbm_runtime 直接命中。可以在板上验证:

python3 -c "import hbm_runtime, os; print(hbm_runtime.__file__)"
# /usr/local/lib/python3.10/dist-packages/hbm_runtime/__init__.py

原生库层HB_HBMRuntime.so 自己的 NEEDED 条目里只写了 libdnn.so没有 RPATH,所以它靠系统的动态链接器配置找库。/etc/ld.so.conf.d/ 下有地平线装的配置:

# /etc/ld.so.conf.d/hobot-dnn.conf
/usr/lib/hbbpu

# /etc/ld.so.conf.d/hobot-meltimedia.conf
/usr/hobot/lib
/usr/hobot/lib/sensor

ldconfig 把这些目录写进缓存,ldd 就能解析出 /lib/libdnn.so/usr/hobot/lib/libhbmem.so.1 等。

我代码里跟”找库”有关的代码只有一行懒加载 import:

class RuntimeSlot:
    def __init__(self, spec: ModelSpec):
        from hbm_runtime import HB_HBMRuntime      # ← 唯一的入口,无路径 hack
        self.rt = HB_HBMRuntime(spec.path)

Q6:你现在代码写在哪里?代码量大吗?

  • 开发副本/home/admin/code/cc-connect-work-space/dg-bpu-service/
  • 板上部署dg:/opt/bpu-service/,由 systemd 单元 bpu-service.service 托管(enabled + active,User=root)
  • 同步方式rsync -az --delete --exclude '__pycache__' ./ dg:/opt/bpu-service/

代码量(wc -l):

文件 行数 职责
bpu_service/engine.py 442 模型注册、加载、并发限流、指标
bpu_service/decode.py 351 反量化、YOLOv5/v8 解码、NMS、分类 top-k
bpu_service/server.py 275 HTTP API + 内置上传页面
bpu_service/cli.py 234 serve / models / infer / bench / watch / selftest
bpu_service/preprocess.py 125 图片解码、letterbox/stretch、BGR→NV12
bpu_service/__init__.py 3 版本号
服务本体小计 1430  
tests/smoke_test.py 177 解码 + 并发 + HTTP 全链路自检
tests/load_test.py 62 HTTP 并发压测
tools/probe_runtime.py 29 探测单个模型的输入/输出/量化信息
tools/probe_all.py 33 批量探测整个模型库
合计 1731  

不算大:一个 1400 行的纯标准库服务,没有第三方 Web 框架、没有数据库、没有前端构建链。

5. 关键代码

5.1 模型加载与懒注册(engine.py

配置里没列出的模型也能调——只要 models_dir 下有这个 .bin,第一次请求时自动注册:

def runner(self, name: str, auto_register: bool = True) -> ModelRunner:
    with self._lock:
        runner = self._runners.get(name)
        if runner is not None:
            return runner
        spec = self.specs.get(name)
        if spec is None:
            if not auto_register:
                raise UnknownModelError(name)
            path = os.path.join(self.models_dir, f"{name}.bin")
            if not os.path.exists(path):
                raise UnknownModelError(name)
            spec = ModelSpec.from_config(name, {"path": path}, self.defaults)
            self.specs[name] = spec
        runner = ModelRunner(spec)
        self._runners[name] = runner
        return runner

5.2 图片推理主流程(engine.py

def infer_image(self, name: str, image, opts: Optional[dict] = None) -> dict:
    """Inference on encoded image bytes or a BGR ndarray."""
    opts = dict(opts or {})
    runner = self.runner(name)
    runner.ensure_loaded()
    img = pre.decode_image(bytes(image)) if isinstance(image, (bytes, bytearray, memoryview)) else image
    input_h, input_w = int(runner.slot.input_shape[2]), int(runner.slot.input_shape[3])
    mode = opts.get("resize") or runner.spec.resize
    packed, meta = pre.preprocess(img, input_w, input_h, mode)        # BGR → packed NV12
    outputs, elapsed = runner.infer({runner.slot.input_names[0]: packed},
                                    timeout=float(opts.get("timeout", 20.0)))
    decoded = self._decode(runner, outputs, meta, img, opts)          # 反量化 → 解码 → NMS
    decoded.update({
        "model": name,
        "runtime_model": runner.model_name,
        "infer_ms": round(elapsed, 2),
        "image": {"width": meta.ori_w, "height": meta.ori_h, "resize": mode},
    })
    return decoded

5.3 任务类型自动推断(engine.py

不需要人工给每个模型标”这是检测还是分类”,按输出张量形状推断:

def kind(self) -> str:
    """Resolve the decode task, inspecting tensor shapes when the config says 'auto'."""
    if self.spec.kind != "auto":
        return self.spec.kind
    self.ensure_loaded()
    shapes = self.slot.output_shapes
    if any(len(s) == 4 and s[0] == 1 and s[1] > 1 and s[-1] == 1 and s[-2] == 1 for s in shapes.values()):
        return "classification"
    if dec.is_yolov8_family(shapes):
        return "yolov8"
    # ... 再尝试 yolov5 / generic

5.4 letterbox 与坐标还原(preprocess.py

if mode == "stretch":
    resized = cv2.resize(img, (input_w, input_h), interpolation=cv2.INTER_LINEAR)
    return resized, ResizeMeta(ori_w, ori_h, input_w, input_h, "stretch")

scale = min(input_w / ori_w, input_h / ori_h)
new_w, new_h = int(round(ori_w * scale)), int(round(ori_h * scale))
resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
pad_w = (input_w - new_w) / 2.0
pad_h = (input_h - new_h) / 2.0
top, left = int(round(pad_h - 0.1)), int(round(pad_w - 0.1))
bottom, right = input_h - new_h - top, input_w - new_w - left
out = cv2.copyMakeBorder(resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value=(114,) * 3)

对应的逆变换(把模型坐标的框映射回原图):

def scale_back(self, boxes: np.ndarray) -> np.ndarray:
    out = boxes.copy()
    if self.mode == "letterbox":
        out[:, [0, 2]] = (out[:, [0, 2]] - self.pad_w) / self.scale
        out[:, [1, 3]] = (out[:, [1, 3]] - self.pad_h) / self.scale
    else:
        out[:, [0, 2]] *= self.ori_w / self.input_w
        out[:, [1, 3]] *= self.ori_h / self.input_h
    return out

5.5 HTTP 入口(server.py

def do_POST(self) -> None:  # noqa: N802
    parsed = urllib.parse.urlparse(self.path)
    query = urllib.parse.parse_qs(parsed.query)
    path = parsed.path.rstrip("/")
    if not self._authorized(query):
        return self._send_json({"error": "unauthorized"}, 401)
    model = (query.get("model") or [""])[0]
    try:
        if path == "/v1/infer":
            return self._handle_infer(model, query)
        if path == "/v1/infer/nv12":
            return self._handle_infer_nv12(model, query)
        if path == "/v1/perf":
            return self._handle_perf(model, query)
    except UnknownModelError as exc:
        return self._send_json({"error": "unknown model", "model": str(exc)}, 404)
    except BusyError as exc:
        return self._send_json({"error": "busy", "detail": str(exc)}, 503)

5.6 原始 NV12 输入(engine.py,为未来接摄像头预留)

def infer_nv12(self, name: str, buffer: bytes, width: int, height: int, opts=None) -> dict:
    """Inference straight from a packed NV12 frame (camera / video path)."""
    runner = self.runner(name)
    runner.ensure_loaded()
    exp_w, exp_h = pre.validate_nv12_size(tuple(runner.slot.input_shape), width, height)
    arr = np.frombuffer(buffer, dtype=np.uint8)
    need = exp_w * exp_h * 3 // 2
    if arr.size < need:
        raise ValueError(f"nv12 buffer too small: {arr.size} < {need}")
    meta = pre.ResizeMeta(exp_w, exp_h, exp_w, exp_h, "stretch")
    outputs, elapsed = runner.infer({runner.slot.input_names[0]: arr[:need]})
    ...

6. 踩坑清单(按踩到的顺序)

  1. input_shape 骗人[1,3,640,640] 但实际要喂 614400 字节的 packed NV12。第一版直接喂 RGB 数组,报维度错。
  2. 重复加载被去重:同进程对同一 .bin 建第二个 runtime,日志打印 has loaded, Discard the latest,拿不到独立句柄。改成单实例 + 信号量。
  3. YOLOv5 head 通道数255 = 3×(5+80),类别数是 channels/3 - 5。一开始写成 channels - 5,类别全错位。
  4. 分类头重复 softmax:板上模型输出已归一化(sum=1),再过 softmax 置信度从 0.48 塌到 0.002。改成自动判断。
  5. 竖图 letterbox 掉点:1279×1920 竖图 letterbox 后最高 0.65,改 stretch 后 0.86。
  6. 后处理太慢:全量 sigmoid 后处理要 35 ms。用 sigmoid 单调性先按 inv_sigmoid(阈值) 预筛 logit,降到约 5 ms。
  7. 量化输出没还原:YOLOv8 box 分支是 int32 + 逐通道 scale(axis=3),不反量化框全是乱的。
  8. hbrt 版本告警:板端 hbrt 3.15.55 与部分模型 build 版本(3.15.47/3.15.49)不一致,日志刷 Inconsistency between the hbrt library version,实测不影响结果。
  9. HTTP 不支持 chunkedBaseHTTPRequestHandler 读的是 Content-Length,没有它就读不到 body。用 curl --data-binary 这类会带 Content-Length 的方式没问题。

7. 相关文件

路径 说明
/home/admin/code/cc-connect-work-space/dg-bpu-service/ 开发副本(完整源码、README、测试、工具)
dg:/opt/bpu-service/ 板上部署目录,systemd 托管
dg-bpu-service/README.md 服务 README(API、配置、性能、踩坑)
dg-bpu-service/tests/bus.jpg / annotated_bus.jpg 第一张测试图与标注结果
dg-bpu-service/tests/annotated_dunk.jpg 第二张扣篮图标注结果
dg-bpu-service/config/models.json 模型注册表与服务配置
dg-bpu-service/deploy/bpu-service.service systemd 单元文件

服务地址:http://10.71.48.154:8080/(ZeroTier 内网,浏览器可直连)。

8. HTML 可视化版

上面这些内容的可视化 HTML 版本(调用链分层图、数据流图、性能对比、问答卡片)见配套笔记:

notes/documents/02_dg板卡X5_BPU推理服务_可视化HTML.md

那一篇是纯 HTML 内嵌 Markdown的页面,只在博客渲染(飞书文档导入会丢弃 <div> / <style> 这类原生 HTML 标签,实测确认),用于直观展示整套系统的结构。