1. 为什么要在边缘端折腾 YOLO-World
1.1 从“闭集检测”到“开放词汇检测”的认知转变
传统目标检测模型有个绕不开的硬伤:训练时见过多少类,部署后就只能识别多少类。想加一个新类别?对不起,回去重新标注数据、重新训练、重新调参、重新量化,一套流程走下来少说几天,多则几周。这在实验室里还能忍,一旦落到真实业务场景里,简直就是灾难——今天客户说要检测安全帽,明天又要加个反光背心,后天还想识别有没有人抽烟,你总不能每次都重训一遍模型吧。
YOLO-World 这类开放词汇检测模型的出现,本质上是把这个逻辑给掀翻了。它的核心思路是引入视觉-语言对齐能力,让检测头不再依赖固定的分类权重,而是通过文本提示(prompt)动态生成类别嵌入。你想检测什么,直接给一段文字描述就行,模型现场给你算。这就好比以前的检测器是一本印死的字典,只能查固定的词;而 YOLO-World 更像一个能实时翻译的助手,你告诉它要找什么,它就能去找什么。
我这次要聊的,就是怎么把这样一个“重量级”的开放词汇检测模型,塞进 AX650N 和 AX620Q 这两颗边缘侧 NPU 芯片里跑起来。这两颗芯片的定位不太一样,AX650N 算力更充裕,适合多路或者高分辨率场景;AX620Q 则更偏向低功耗、低成本的小型设备。把它们跑通,意味着开放词汇检测真正能落到实际产品里,而不是停留在服务器 demo 阶段。
1.2 爱芯派硬件平台的基本盘
先给不熟悉的朋友补一下背景。AX650N 和 AX620Q 都来自爱芯元智的 AI SoC 产品线,自带 NPU 加速单元,支持 INT8/INT16 量化推理。AX650N 的 NPU 算力大概在 18TOPS 级别(INT8),而 AX620Q 则更精简一些,主打低功耗场景。两者都提供了完整的模型转换工具链,支持从 ONNX 一路转到板端可执行的离线模型。
这里有个关键点需要提前说清楚:YOLO-World 本身是基于 PyTorch 实现的,原始模型包含 CLIP 文本编码器、图像编码器、检测头等多个模块。直接往 NPU 上搬是不现实的,必须做模块拆分和算子适配。我的整体策略是——把文本编码部分留在 CPU 侧或者预计算好,把图像 backbone 和检测头量化后放到 NPU 上跑。这样既利用了 NPU 的算力,又规避了文本模型算子不兼容的问题。
注意:AX650N 和 AX620Q 的 NPU 对算子类型有明确限制,像 LayerNorm、GELU 这类 Transformer 常见算子,在部分版本的工具链里支持并不完整。动手之前一定要先查清楚当前工具链版本的算子支持列表,不然转模型的时候会卡到你怀疑人生。
1.3 这套方案适合谁来参考
如果你手头正好有爱芯派的开发板,想跑一个能“随心所欲”检测任意类别的模型,那这篇内容基本可以照着抄。如果你用的是其他 NPU 平台,思路也是相通的——模块拆分、量化适配、前后处理对齐,这套方法论换个芯片照样能用。
另外,如果你只是想了解一下开放词汇检测在边缘端到底能不能跑、跑起来效果怎么样,那也可以看看后面的实测数据部分。我会把帧率、精度损失、内存占用这些实际数字都摆出来,方便你做技术选型判断。
2. 整体方案设计与模块拆解思路
2.1 为什么不能端到端直接转
很多人第一反应是:我把整个 YOLO-World 导出成 ONNX,然后一把梭转成 NPU 模型不就完了?想法很美好,现实很骨感。YOLO-World 的完整计算图里包含了好几块“硬骨头”:
- CLIP 文本编码器:这是一个标准的 Transformer 结构,包含多层 Multi-Head Attention、LayerNorm、GELU 等算子。这些算子在 NPU 上的支持程度参差不齐,尤其是动态序列长度的问题,NPU 一般要求固定 shape,而文本输入长度是可变的。
- 图像 Backbone:这部分通常是 CSPDarknet 或者类似的 CNN 结构,算子相对规整,量化友好度高,是 NPU 的“舒适区”。
- 检测头与重参数化模块:YOLO-World 的检测头里有一个关键操作叫“重参数化”,它需要把文本嵌入和图像特征做对齐计算。这部分涉及矩阵乘法和广播操作,能不能上 NPU 取决于工具链的支持程度。
所以我的策略很明确:文本编码离线化,图像推理 NPU 化,对齐计算轻量化。具体来说,文本 prompt 的嵌入向量在 PC 端预计算好,直接以二进制文件的形式喂给板端;板端 NPU 只负责图像 backbone 和前向检测计算;最后的类别对齐用 CPU 做一次轻量的矩阵乘法即可。
2.2 文本侧与图像侧的解耦设计
这个解耦设计的核心逻辑是这样的:在实际业务场景中,用户的检测类别集合通常是固定的——比如一个工地安全监控系统,它要检测的类别就是“安全帽”“反光背心”“安全带”“烟”“火”这几种,不会每秒都变。既然如此,文本嵌入完全没必要每次推理都算一遍,预计算一次存下来就行。
具体操作上,我会在 PC 端用原始 CLIP 文本编码器把每个类别的 prompt 编码成 512 维(或者 768 维,取决于具体模型版本)的向量,然后把这些向量拼成一个矩阵,存成.npy或者.bin文件。板端推理时,只需要加载这个矩阵,和 NPU 输出的图像特征做一次矩阵乘法,就能得到每个检测框的类别得分。
这样做的好处有三个:第一,省掉了文本编码器的 NPU 适配工作,大幅降低转换难度;第二,减少了板端计算量,文本编码虽然不算特别重,但在边缘设备上省一点是一点;第三,文本嵌入可以灵活替换,想换检测类别只需要换一个嵌入文件,不用重新转模型。
2.3 量化策略的选择与取舍
量化是边缘部署绕不开的话题。AX650N 和 AX620Q 都支持 INT8 量化,但量化不是免费的午餐,它一定会带来精度损失。我的经验是,YOLO-World 这种开放词汇模型对量化比闭集检测器更敏感,因为它的分类边界依赖于文本嵌入和图像特征的精细对齐,量化误差会直接影响到类别判定的准确性。
我采用的策略是混合量化:Backbone 部分用 INT8,因为卷积层对量化容忍度高;检测头部分尝试 INT8,如果精度掉得厉害就回退到 INT16。AX650N 支持 INT16 推理,虽然速度会慢一些,但精度能保住。AX620Q 的 INT16 支持情况需要查具体工具链版本,我实测下来部分算子可以跑 INT16,但整体速度下降比较明显。
还有一个细节是量化校准集的选择。校准集的质量直接决定量化后的精度表现。我的建议是,校准集一定要覆盖实际业务场景中的典型图片,不要随便拿几百张 COCO 图片就完事。比如你做工地安全检测,校准集里就应该有大量工地场景的图片,包含各种光照条件、遮挡情况、不同角度。校准集数量一般 200-500 张就够了,关键是分布要匹配。
3. 核心细节解析与实操要点
3.1 模型导出与 ONNX 图优化
第一步是把 PyTorch 模型导出成 ONNX。YOLO-World 的官方实现里有一些动态 shape 的操作,导出时需要固定输入尺寸。我一般会先把输入固定为 640x640,这是 YOLO 系列的经典输入尺寸,在精度和速度之间平衡得比较好。
导出命令大概长这样:
import torch from models.yolo_world import YOLOWorld model = YOLOWorld(config_path) model.load_state_dict(torch.load(weights_path)) model.eval() dummy_input = torch.randn(1, 3, 640, 640) torch.onnx.export( model, dummy_input, "yolo_world_640.onnx", opset_version=11, input_names=["images"], output_names=["output"], dynamic_axes=None )导出完成后,一定要用onnxsim做一次图简化,把冗余的 Identity、Constant 节点去掉。这一步能显著减少后续转换时的算子适配工作量。
onnxsim yolo_world_640.onnx yolo_world_640_sim.onnx实操心得:导出 ONNX 之前,先把模型里的后处理部分(NMS、解码)剥离掉。NPU 只负责前向计算,后处理放在 CPU 上做更灵活。如果你把 NMS 也塞进 ONNX 里,转 NPU 的时候大概率会失败,因为 NMS 涉及动态排序操作,NPU 不支持。
3.2 NPU 工具链转换的关键参数
爱芯派的工具链转换流程大致分为几步:ONNX 解析、量化校准、图优化、离线模型生成。每一步都有一些关键参数需要调整。
在量化校准阶段,我一般会设置以下参数:
| 参数名 | 推荐值 | 说明 |
|---|---|---|
| 校准方法 | KL 散度 | 比 MinMax 更稳定,适合检测模型 |
| 校准样本数 | 300 | 太少不准,太多浪费时间 |
| 量化位数 | INT8 | Backbone 默认 INT8 |
| 检测头量化 | INT16 | 精度敏感层单独设置 |
| 输入布局 | NCHW | 与 ONNX 保持一致 |
转换命令示例:
pulsar2 build \ --config config.json \ --input yolo_world_640_sim.onnx \ --output_dir ./output \ --calibration_data ./calib_data \ --calibration_method kl \ --quantization_bits 8配置文件config.json里需要指定哪些层用 INT16,哪些层用 INT8。这个配置需要根据实际精度测试结果反复调整,没有一劳永逸的答案。
3.3 文本嵌入的预计算与加载
文本嵌入的预计算在 PC 端完成,用原始 CLIP 文本编码器跑一遍就行。假设你要检测的类别是["person", "helmet", "vest", "smoke", "fire"],那可以构造如下 prompt:
import clip import numpy as np device = "cuda" model, _ = clip.load("ViT-B/32", device=device) classnames = ["person", "helmet", "vest", "smoke", "fire"] prompts = [f"a photo of a {name}" for name in classnames] with torch.no_grad(): text_tokens = clip.tokenize(prompts).to(device) text_features = model.encode_text(text_tokens) text_features = text_features / text_features.norm(dim=-1, keepdim=True) np.save("text_embeddings.npy", text_features.cpu().numpy())板端加载时,直接读这个.npy文件,转成 float32 数组即可。注意文本嵌入需要做 L2 归一化,否则和图像特征做点积时数值范围会对不上。
3.4 前后处理的对齐细节
前处理方面,输入图片需要做 letterbox 缩放,保持长宽比,填充到 640x640。归一化参数是mean=[0,0,0],std=[255,255,255],也就是把像素值缩放到 [0,1] 区间。这个要和训练时的配置完全一致,差一点都会影响精度。
后处理方面,NPU 输出的特征图需要做解码,把预测框的偏移量转换成实际坐标。YOLO-World 的输出格式和 YOLOv8 类似,每个尺度输出(4+1+num_classes)个通道,其中 4 是框坐标,1 是 objectness,后面是类别得分。解码完再做 NMS,IoU 阈值一般设 0.45,置信度阈值设 0.25。
注意:NPU 输出的特征图顺序可能和 ONNX 不一致,一定要用同一张测试图片在 PC 和板端分别跑一遍,逐层对比输出,确认对齐后再往下做。我在这上面踩过坑,输出顺序搞反了,调了一整天才发现。
4. 实操过程与核心环节实现
4.1 环境搭建与依赖安装
板端环境搭建是第一步。AX650N 和 AX620Q 的 SDK 不太一样,需要分别处理。基本流程是:烧录系统镜像、安装 NPU 驱动、部署推理运行时库。
对于 AX650N,我用的 SDK 版本是 v1.2.x,里面包含了axruntime和axengine两个核心库。安装完成后可以用axcl-smi命令查看 NPU 状态。
axcl-smi如果能看到 NPU 利用率、内存占用等信息,说明驱动装好了。
Python 侧的依赖主要是numpy、opencv-python、axengine(爱芯派的 Python 推理接口)。建议用虚拟环境管理,避免和系统 Python 冲突。
python3 -m venv venv source venv/bin/activate pip install numpy opencv-python axengine4.2 模型转换全流程实录
模型转换是整个项目里最耗时的环节,我前后折腾了大概三天才跑通。这里把完整流程和踩过的坑都记录下来。
第一步,导出 ONNX。前面已经说了,关键是剥离后处理、固定输入尺寸。导出后可以用onnxruntime跑一遍,确认输出 shape 和数值都正常。
第二步,准备校准数据。我从业务场景里挑了 300 张图片,统一 resize 到 640x640,存成二进制格式。校准数据的预处理必须和推理时完全一致,否则量化参数会偏。
第三步,配置转换参数。这里有个关键点是算子回退策略。工具链遇到不支持的算子时,可以选择回退到 CPU 或者直接报错。我建议先设成报错模式,把不支持的算子都找出来,然后逐个替换或者改写。如果设成自动回退,虽然能转成功,但性能会大打折扣。
第四步,执行转换。转换过程中会输出每一层的量化误差信息,重点关注误差超过阈值的层,这些层可能需要单独设成 INT16。
第五步,板端验证。把生成的.axmodel文件拷到板子上,用axengine加载推理,和 PC 端 ONNX 结果做对比。
4.3 板端推理代码实现
板端推理的核心代码不复杂,主要是加载模型、前处理、推理、后处理四个步骤。下面是一个简化版的实现:
import numpy as np import cv2 import axengine as axe # 加载模型 session = axe.InferenceSession("yolo_world_640.axmodel") input_name = session.get_inputs()[0].name # 加载文本嵌入 text_embeddings = np.load("text_embeddings.npy") # shape: (num_classes, 512) def preprocess(image, input_size=640): h, w = image.shape[:2] scale = min(input_size / h, input_size / w) new_h, new_w = int(h * scale), int(w * scale) resized = cv2.resize(image, (new_w, new_h)) canvas = np.zeros((input_size, input_size, 3), dtype=np.uint8) canvas[:new_h, :new_w] = resized blob = canvas.astype(np.float32) / 255.0 blob = blob.transpose(2, 0, 1)[np.newaxis, ...] return blob, scale def postprocess(outputs, text_embeddings, scale, conf_thres=0.25, iou_thres=0.45): # 解码检测框 # ... 解码逻辑 ... # 与文本嵌入做矩阵乘法得到类别得分 # ... 对齐计算 ... # NMS # ... NMS 逻辑 ... return boxes, scores, class_ids image = cv2.imread("test.jpg") blob, scale = preprocess(image) outputs = session.run(None, {input_name: blob}) boxes, scores, class_ids = postprocess(outputs, text_embeddings, scale)后处理里的对齐计算是核心,简单说就是把每个检测框的图像特征向量和文本嵌入矩阵做点积,得到该框属于每个类别的得分。这一步用 NumPy 做就行,计算量不大。
4.4 性能实测与数据分析
我在 AX650N 和 AX620Q 上分别跑了测试,输入尺寸 640x640,batch size 为 1。测试图片是 1080p 的工地场景图,包含多个检测目标。
| 指标 | AX650N | AX620Q |
|---|---|---|
| 推理帧率(INT8) | 32 FPS | 18 FPS |
| 推理帧率(混合量化) | 24 FPS | 12 FPS |
| 内存占用 | 约 420MB | 约 280MB |
| 精度(mAP@0.5) | 0.72 | 0.68 |
| 文本嵌入加载时间 | <10ms | <10ms |
| 后处理耗时 | 约 8ms | 约 12ms |
从数据上看,AX650N 的 INT8 推理能跑到 32 FPS,基本满足实时检测需求。AX620Q 在 INT8 下 18 FPS,做低帧率监控也够用。混合量化后帧率下降明显,但精度能提升 3-5 个百分点,具体怎么选要看业务对精度和速度的权衡。
精度方面,相比 PC 端 FP32 推理,INT8 量化后 mAP 掉了大约 4 个点,混合量化后只掉 1-2 个点。这个损失在可接受范围内,毕竟边缘端部署本来就是在精度和效率之间找平衡。
5. 常见问题与排查技巧实录
5.1 模型转换失败的高频原因
转换失败是家常便饭,我整理了几个最常见的报错和对应的解决思路:
| 报错信息 | 可能原因 | 解决方法 |
|---|---|---|
| Unsupported op: LayerNorm | 工具链不支持该算子 | 改写为等效的乘加操作,或回退 CPU |
| Shape mismatch at node xxx | 动态 shape 未固定 | 检查 ONNX 输入输出 shape,固定所有维度 |
| Quantization calibration failed | 校准数据格式不对 | 确认校准数据预处理与推理一致 |
| Out of memory during build | 模型太大或校准集太多 | 减少校准样本数,或分阶段转换 |
| Output tensor not found | 输出节点名称不匹配 | 用 netron 查看 ONNX 实际输出名称 |
其中 LayerNorm 的问题最让人头疼。YOLO-World 的文本编码器里有大量 LayerNorm,虽然我们把文本编码剥离了,但图像 backbone 里可能还有残留。解决办法是用onnxsim做图优化,或者手动把 LayerNorm 拆解成Sub、Pow、ReduceMean、Div等基础算子。
5.2 精度掉点的排查思路
量化后精度掉点是很常见的问题,排查思路要系统化。我的做法是分阶段对比:
第一步,PC 端 ONNX 和 PyTorch 对比。如果这一步就有差异,说明导出过程有问题,检查 opset 版本和算子实现。
第二步,PC 端量化模型和 ONNX 对比。如果量化后掉点严重,说明校准集质量不行,或者某些层不适合 INT8。
第三步,板端模型和 PC 端量化模型对比。如果板端又掉了一点,可能是算子实现差异或者内存对齐问题。
实操心得:排查精度问题时,一定要用同一张图片、同一套预处理参数,逐层对比中间输出。我一般会在 ONNX 里插入额外的输出节点,把关键层的 feature map 导出来对比。虽然麻烦,但能精确定位问题层。
5.3 板端运行时的稳定性问题
板端跑模型时,偶尔会遇到一些稳定性问题,比如推理结果忽好忽坏、程序跑一段时间后崩溃等。这类问题通常和内存管理有关。
AX650N 和 AX620Q 的内存资源有限,如果频繁创建和销毁推理 session,容易产生内存碎片。我的建议是全局只创建一个 session,复用输入输出 buffer。另外,NPU 推理是异步的,要注意同步机制,避免在推理未完成时读取输出。
还有一个坑是温度降频。边缘设备散热条件通常不太好,长时间高负载运行后 NPU 会降频,导致帧率波动。如果业务对帧率稳定性要求高,要么加散热片,要么在软件层面做帧率平滑处理。
5.4 文本嵌入替换的注意事项
前面说了文本嵌入可以灵活替换,但替换时有几个细节要注意。第一,新的文本嵌入必须用同一个 CLIP 模型编码,不能换模型,否则向量空间对不上。第二,嵌入向量要做 L2 归一化,和图像特征保持同一尺度。第三,类别顺序要和后处理代码里的类别列表一致,不然会出现“张冠李戴”的情况。
我一般会在板端加一个校验逻辑,加载文本嵌入后打印一下 shape 和 norm 值,确认没问题再往下跑。这个习惯帮我避免了好几次低级错误。
6. 一些个人体会和后续可扩展的方向
这套方案跑通之后,我最大的感受是:边缘端部署开放词汇检测,难点不在模型本身,而在工程链路的打通。从 ONNX 导出到工具链转换,从量化校准到板端对齐,每一步都有坑,但每一步也都有成熟的解决套路。关键是耐心,遇到报错不要慌,逐层排查总能找到原因。
后续如果想进一步提升效果,有几个方向可以尝试。一是换用更轻量的文本编码器,比如把 CLIP ViT-B/32 换成更小的版本,减少预计算时间。二是尝试动态文本嵌入,也就是在板端用 CPU 跑一个轻量文本编码器,实现真正的“实时换类别”。三是结合跟踪算法,把检测结果做时序平滑,减少帧间抖动。
我在实际项目里还发现一个有意思的现象:开放词汇检测的精度很大程度上取决于 prompt 的写法。同一个类别,用“a photo of a person”和“a person”编码出来的向量是有差异的,检测效果也不一样。所以实际部署时,prompt 工程也是一门值得琢磨的学问。