教程:VENTUNO Q/ONNX Runtime:修订间差异
导入 VENTUNO Q 中文教程 |
导入 VENTUNO Q 中文教程 |
||
| 第441行: | 第441行: | ||
</blockquote> | </blockquote> | ||
https://cdn.jsdelivr.net/gh/arduino/docs-content@main/content/hardware/ventuno/boards/ventuno-q/tutorials/ai-workflows/onnx-runtime/assets/eye-movement.gif | |||
<blockquote> | <blockquote> | ||
2026年9月28日 (一) 06:01的最新版本
概述
Arduino® VENTUNO™ Q 的 NPU(Qualcomm® Hexagon™ Tensor Processor)可通过 ONNX Runtime 与 QNNExecutionProvider 加速 ONNX 模型推理。本指南覆盖完整工作流:设置运行时、从 Qualcomm AI Hub 获取兼容模型,以及在 NPU 上运行推理。
本指南将涵盖:
- 访问 VENTUNO Q 的 shell(使用
adb或ssh)。 - 创建 Python 虚拟环境。
- 安装在 NPU 上运行推理所需的特定 ONNX Runtime QNN wheel。
- 从 AI Hub 查找并下载兼容模型。
- 固定动态输入形状。
- 在 NPU 上运行推理。
最后提供完整的眼动追踪示例,以在实践中演示完整工作流。
硬件与软件要求
硬件
本指南末尾的眼动追踪示例还需要:
- 连接到任一 USB-A 口的 USB 相机
- 连接到开发板的显示器、键盘和鼠标(脚本会在屏幕上打开实时窗口)
说明:
该示例之前的设置与推理章节只需开发板和电源,可完全通过 SSH 或 ADB 完成。
软件
要访问 VENTUNO Q,主机上需要可用的 SSH 或 ADB。所有其他依赖将直接安装在开发板上。
访问开发板 Shell
本指南中的所有命令都在 VENTUNO Q 上运行。使用下列方法之一连接开发板:
- SSH:
ssh arduino@<device-ip> - ADB:
adb shell - 直接操作: 连接键盘、鼠标和显示器(VENTUNO Q 运行完整 Linux 桌面)。
设置 Python 环境
1. 创建虚拟环境
创建并激活 Python 虚拟环境以隔离依赖:
python3 -m venv .venv
source .venv/bin/activate
提示符中的 (.venv) 前缀表示环境已激活。每个新会话开始时请再次运行 activate 命令。
注意:
此处虚拟环境不是可选项。VENTUNO Q 的系统 Python 为 externally managed(PEP 668),因此在虚拟环境外执行
pip install会被直接拒绝。
2. 安装带 QNN 支持的 ONNX Runtime
启用 QNN 的 ONNX Runtime 构建不是简单的 pip install onnxruntime——将其装到开发板需要三步:安装 AI Hub models 包,移除其拉取的仅 CPU 版 ONNX Runtime,再安装 QNN wheel 取而代之(以使其能在 NPU 上运行)。
步骤 1:安装 AI Hub Models 包
pip install qai-hub-models
说明:
这将安装大量库,会占用较多内存。
步骤 2:移除默认 ONNX Runtime 包
qai-hub-models 会作为依赖拉取仅 CPU 的 onnxruntime 包,与下一步安装的 QNN 构建冲突(二者提供同一 onnxruntime 模块,因此同时只能安装一个)。请先移除它,并将 onnx 固定到 QNN wheel 所期望的版本:
pip uninstall -y onnxruntime
pip install onnx==1.18.0
步骤 3:安装 ONNX Runtime QNN Wheel
直接在 VENTUNO Q 上下载并安装预编译 wheel。这是可在 VENTUNO Q 上工作的特定构建:
wget https://cdn.edgeimpulse.com/qc-ai-docs/wheels/onnxruntime_qnn-1.23.0-cp312-cp312-linux_aarch64.whl
pip install onnxruntime_qnn-*-linux_aarch64.whl
该 wheel 包含 QNNExecutionProvider,可将推理路由到 Hexagon™ Tensor Processor(NPU)而非 CPU。
查找并下载模型
获取模型最快的方式是 qai-hub-models CLI,它会将预编译资源直接下载到开发板并为你解包:
qai-hub-models fetch eyegaze --runtime onnx --precision w8a16 --output-dir models/
预编译 ONNX 资源是设备无关的,因此无需芯片组或设备参数——--chipset 与 --device 标志仅适用于针对单一目标提前编译的运行时。若只想查看下载 URL 而不拉取,请添加 --url-only。
该命令会解压出 .onnx 图,旁边还有存放权重的单独 .data 文件:
models/eyegaze-onnx-w8a16/eyegaze.onnx
models/eyegaze-onnx-w8a16/eyegaze.data
models/eyegaze-onnx-w8a16/metadata.json
在编写任何预处理代码之前值得阅读 metadata.json——它列出每个输入与输出的确切形状、dtype,以及量化 scale 与 zero point。
说明:
fetch下载已发布资源,即使某模型的export路径不可用也能工作。若python -m qai_hub_models.models.<model>.export失败并提示Model cannot be published: no release assets available,请先尝试fetch,不要立刻认定模型不可用。
你也可以浏览 AI Hub 模型列表 并使用 Download Model 按钮,将运行时选为 ONNX Runtime,精度选 w8a8 或 w8a16。网站上的芯片组过滤器控制哪些模型显示给定硅片的性能数据——它不限制可下载哪些文件,因此即使未列出 QCS8275 数据,模型仍可能可下载。
若你在主机而非开发板上直接下载了模型,请在继续之前用 scp 或 adb push 传过去。
在下文示例中,我们使用 EyeGaze 模型。
说明:
量化模型吞吐最高、下载体积最小,因此推荐
w8a8或w8a16。不过并不排除float模型——NPU 无法执行 FP32,但 QNN 后端在加载时会将图转换为 FP16,因此 float ONNX 模型仍可在 NPU 上运行。权衡见 NPU 指南。
固定动态输入形状
从 AI Hub 导出的模型可能具有动态输入维度。QNNExecutionProvider 需要固定形状才能面向 NPU。若模型有动态输入,请在运行前转换:
python3 -m onnxruntime.tools.make_dynamic_shape_fixed \
model.onnx \
model_fixed.onnx \
--input_name "<input_name>" \
--input_shape <shape>
将 <input_name> 与 <shape> 替换为你模型中的值。可通过运行以下代码检查模型输入:
import onnxruntime as ort
sess = ort.InferenceSession("model.onnx")
for inp in sess.get_inputs():
print(inp.name, inp.shape)
说明:
部分 AI Hub 模型(包括下文使用的 EyeGaze)导出时已是静态输入形状。对它们运行固定形状转换仍然值得,因为它还会把模型的外部权重数据(通常存放在
.onnx旁的单独.data文件中)内联到单个自包含的.onnx文件中。
在 NPU 上运行推理
使用面向 "htp" 后端的 QNNExecutionProvider 加载模型(HTP 即 Hexagon™ Tensor Processor):
import onnxruntime as ort
import os
MODEL_PATH = "model_fixed.onnx"
QNN_LIB = "/usr/lib/libQnnHtp.so"
os.environ["LD_LIBRARY_PATH"] = "/usr/lib:" + os.environ.get("LD_LIBRARY_PATH", "")
so = ort.SessionOptions()
so.add_session_config_entry("session.disable_cpu_ep_fallback", "0")
providers = [("QNNExecutionProvider", {"backend_type": "htp", "library_path": QNN_LIB})]
sess = ort.InferenceSession(MODEL_PATH, sess_options=so, providers=providers)
input_name = sess.get_inputs()[0].name
output = sess.run(None, {input_name: input_data})
将 input_data 替换为匹配模型期望输入形状与 dtype 的 NumPy 数组。请在 AI Hub 的模型页面查看确切预处理步骤(输入分辨率、归一化、通道顺序与量化范围)。
说明:
加载会话后调用
sess.get_providers()通常会同时报告QNNExecutionProvider与CPUExecutionProvider。这是预期行为——少量与形状相关的操作总会回退到 CPU,而图的其余部分仍在 NPU 上运行。
每次推理都遵循的三个步骤
无论模型如何,NPU 推理始终遵循同一模式:
- 预处理 — 调整大小、归一化并格式化输入,以匹配模型期望(dtype、形状、数值范围)。
- 推理 — 使用后端为
"htp"的QNNExecutionProvider将会话输入送入推理。 - 后处理 — 将原始输出张量(边界框、类别分数、关键点等)解释为应用层结果。
示例:眼动追踪
下列示例使用来自 AI Hub 的量化眼部关键点检测模型(w8a16,ONNX runtime),根据 USB 相机画面实时追踪注视方向。
本示例需要以下硬件:
- 连接到 VENTUNO Q USB-A 口的 USB 相机
- 显示器、键盘和鼠标(脚本会在屏幕上打开窗口)
1. 安装额外依赖
在已安装的 ONNX Runtime QNN wheel 之上,脚本仅需 OpenCV 与 NumPy:
pip install opencv-python numpy
2. 下载并固定模型
下载眼部关键点模型:
qai-hub-models fetch eyegaze --runtime onnx --precision w8a16 --output-dir models/
然后对其运行固定形状转换。EyeGaze 模型已有静态 [1, 96, 160] 输入(batch × height × width),因此此步骤实际上用于将 eyegaze.data 内联到单个自包含文件:
python3 -m onnxruntime.tools.make_dynamic_shape_fixed \
models/eyegaze-onnx-w8a16/eyegaze.onnx \
model_fixed.onnx \
--input_name "image" \
--input_shape 1,96,160
model_fixed.onnx 现在同时包含图与权重,约 10.8 MB,而原始 .onnx 仅 824 KB。它是自包含的:可单独移动,无需旁边的 .data 文件。
3. 创建脚本
touch gaze_tracker.py
将以下代码添加到 gaze_tracker.py:
import numpy as np
import cv2
import onnxruntime as ort
import os
import argparse
import time # Added for timing
# 1. SETUP ARGUMENT PARSER
parser = argparse.ArgumentParser(description="Inference script with NPU toggle.")
parser.add_argument("--use-npu", action="store_true", help="Enable Qualcomm NPU (QNN HTP)")
parser.add_argument("--camera-index", type=int, default=None,
help="Force a specific /dev/videoN index instead of auto-detecting the USB camera")
args = parser.parse_args()
# --- CONFIGURATION ---
MODEL_PATH = "model_fixed.onnx"
QNN_LIB = "/usr/lib/libQnnHtp.so"
# 2. SELECT EXECUTION PROVIDER BASED ON FLAG
so = ort.SessionOptions()
if args.use_npu:
print("--- Initializing NPU (QNN HTP) ---")
os.environ["LD_LIBRARY_PATH"] = "/usr/lib:" + os.environ.get("LD_LIBRARY_PATH", "")
so.add_session_config_entry("session.disable_cpu_ep_fallback", "0")
providers = [
("QNNExecutionProvider", {
"backend_type": "htp",
"library_path": QNN_LIB
})
]
mode_text = "NPU Mode"
else:
print("--- Initializing standard CPU ---")
providers = ["CPUExecutionProvider"]
mode_text = "CPU Mode"
# Initialize Session
try:
sess = ort.InferenceSession(MODEL_PATH, sess_options=so, providers=providers)
except Exception as e:
print(f"Failed to initialize session with providers {providers}. Error: {e}")
exit(1)
input_name = sess.get_inputs()[0].name
# Global variable to store latency for display
current_latency = 0.0
def get_landmarks(eye_crop):
global current_latency
resized = cv2.resize(eye_crop, (160, 96))
gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY)
input_data = (gray.astype(np.float32) * (65535.0 / 255.0)).astype(np.uint16)
input_tensor = np.expand_dims(input_data, axis=0)
# --- TIMER START ---
start = time.perf_counter()
outputs = sess.run(None, {input_name: input_tensor})
end = time.perf_counter()
# --- TIMER END ---
current_latency = (end - start) * 1000 # Convert to ms
heatmaps = outputs[0][0, 2, :34, :, :]
points = []
for i in range(34):
_, _, _, max_loc = cv2.minMaxLoc(heatmaps[i])
points.append((max_loc[0] * 2, max_loc[1] * 2))
return np.array(points)
def find_camera_index(max_index=5):
"""/dev/video0 on the VENTUNO Q is the CSI/ISP control node, not a USB webcam,
so it opens successfully but never returns a frame. Probe indices in order and
stop at the first one that actually returns a frame."""
for index in range(max_index + 1):
cap = cv2.VideoCapture(index)
if cap.isOpened():
ret, _ = cap.read()
if ret:
return index, cap
cap.release()
return None, None
# --- MAIN LOOP ---
if args.camera_index is not None:
cap = cv2.VideoCapture(args.camera_index)
else:
found_index, cap = find_camera_index()
if cap is not None:
print(f"Using camera index {found_index}")
if cap is None or not cap.isOpened():
print("No usable camera found. Pass --camera-index N to force one (check `ls /dev/video*`).")
exit(1)
DISPLAY_W, DISPLAY_H = 800, 600
while True:
ret, frame = cap.read()
if not ret: break
h, w = frame.shape[:2]
x1, y1 = (w//2 - DISPLAY_W//2), (h//2 - DISPLAY_H//2)
y_start, y_end = max(0, y1), min(h, y1 + DISPLAY_H)
x_start, x_end = max(0, x1), min(w, x1 + DISPLAY_W)
eye_zone = frame[y_start:y_end, x_start:x_end].copy()
landmarks = get_landmarks(eye_zone)
if landmarks is not None:
actual_h, actual_w = eye_zone.shape[:2]
sx, sy = actual_w / 160.0, actual_h / 96.0
for i, pt in enumerate(landmarks):
lx, ly = int(pt[0] * sx), int(pt[1] * sy)
cv2.circle(eye_zone, (lx, ly), 3, (0, 255, 0), -1)
label = str(i)
cv2.putText(eye_zone, label, (lx + 5, ly + 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 2, cv2.LINE_AA)
cv2.putText(eye_zone, label, (lx + 5, ly + 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1, cv2.LINE_AA)
# --- LATENCY OVERLAY ---
# Draw a black box for the background
cv2.rectangle(eye_zone, (0, 0), (250, 80), (0, 0, 0), -1)
# Draw the Mode and Latency
cv2.putText(eye_zone, mode_text, (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2)
cv2.putText(eye_zone, f"{current_latency:.2f} ms", (10, 65),
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2)
cv2.imshow("Eye Tracking", eye_zone)
if cv2.waitKey(1) & 0xFF == ord('q'): break
cap.release()
cv2.destroyAllWindows()
说明:
VENTUNO Q 会暴露多个
/dev/videoN节点,且并非全部都是相机——硬件视频编解码器会注册自己的节点,USB 网络摄像头通常占用两个索引(只有第一个会输出帧)。相机落在哪个索引取决于还连接了什么,因此不能假定cv2.VideoCapture(0)。上方脚本按顺序探测索引,并选择第一个实际返回帧的设备;传入--camera-index N可强制指定设备。运行ls /dev/video*查看可用设备,用cat /sys/class/video4linux/video0/name检查给定节点是什么。
4. 运行演示
若从远程主机(adb 或 ssh)运行脚本,请先允许其在显示器上渲染:
xhost +
export DISPLAY=:0 # replace 0 with your display number
若不想假定显示编号为 0,可在开发板上运行 ls /tmp/.X11-unix/,并使用 X 后的数字(例如 X1 表示 DISPLAY=:1)。
脚本接受 --use-npu 标志,便于比较 CPU 与 NPU 执行提供程序:
python3 gaze_tracker.py # runs on the CPU
python3 gaze_tracker.py --use-npu # runs on the Hexagon NPU (QNN)
在运行 Ubuntu 24.04 的 VENTUNO Q 上,该模型在 NPU 上平均约 23 ms 每次推理,CPU 上约 190 ms——大约 8 倍加速。确切数字随相机帧率与开发板其他负载而变化。
结果
运行示例后,应在连接到 VENTUNO Q 的显示器上弹出一帧画面。该画面显示相机画面及大量点(确切为 34 个)。将相机靠近眼睛时,可看到这些点围绕眼睛形成,并跟踪虹膜/瞳孔运动。
编号 32 的点跟随瞳孔,眼球移动时它会跟随。本示例旨在演示模型如何工作,数值变化时没有触发逻辑,但提供了可继续扩展的良好视觉参考。
说明:
脚本从
heatmaps输出推导关键点位置,但模型还返回另外两个对扩展本示例有用的输出。landmarks(形状[1, 34, 2])包含已解码的相同 34 个点,为热力图坐标中的(y, x)对——反量化后交换轴并乘以 2,即可得到脚本绘制的相同像素位置。gaze_pitchyaw(形状[1, 2])以弧度直接给出注视方向,这是检测某人看向何处(而非仅眼睛位置)时应使用的量。二者均为量化uint16;下载模型文件夹中的metadata.json列出各自的 scale 与 zero point。
说明:
本示例使用低质量 USB 网络摄像头运行;使用更高质量相机时期望结果会有所不同。
The script's center-crop targets an 800 × 600 region; a low-resolution webcam (for example 640 × 480) is smaller than that in both dimensions, so the crop becomes a no-op and you see the full, uncropped frame instead — this is why moving the camera close to your eye is necessary to fill the frame on cameras like this. A higher-resolution camera will show the crop actually taking effect.
原文:https://docs.arduino.cc/tutorials/ventuno-q/onnx-runtime/ 本页为 Arduino 官方教程的中文翻译,供内部查阅。命令与代码保持原文。