教程:VENTUNO Q/ONNX Runtime

来自WindwizPi wiki
Windwiz​(留言 | 贡献)2026年9月28日 (一) 05:58的版本 (导入 VENTUNO Q 中文教程)
(差异) ←上一版本 | 最后版本 (差异) | 下一版本→ (差异)
此页面具有访问限制。如果您看见此消息,则说明您没有权限访问此页面。

概述

Arduino® VENTUNO™ Q 的 NPU(Qualcomm® Hexagon™ Tensor Processor)可通过 ONNX Runtime 与 QNNExecutionProvider 加速 ONNX 模型推理。本指南覆盖完整工作流:设置运行时、从 Qualcomm AI Hub 获取兼容模型,以及在 NPU 上运行推理。

本指南将涵盖:

  1. 访问 VENTUNO Q 的 shell(使用 adb 或 ssh)。
  2. 创建 Python 虚拟环境。
  3. 安装在 NPU 上运行推理所需的特定 ONNX Runtime QNN wheel。
  4. 从 AI Hub 查找并下载兼容模型。
  5. 固定动态输入形状。
  6. 在 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 推理始终遵循同一模式:

  1. 预处理 — 调整大小、归一化并格式化输入,以匹配模型期望(dtype、形状、数值范围)。
  2. 推理 — 使用后端为 "htp" 的 QNNExecutionProvider 将会话输入送入推理。
  3. 后处理 — 将原始输出张量(边界框、类别分数、关键点等)解释为应用层结果。

示例:眼动追踪

下列示例使用来自 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。

<img src="eye-movement.gif" alt="带标注的眼动" />

说明:

本示例使用低质量 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 官方教程的中文翻译,供内部查阅。命令与代码保持原文。