教程:VENTUNO Q/实时人脸网格

来自WindwizPi wiki
Windwiz​(留言 | 贡献)2026年9月28日 (一) 05:58的版本 (导入 VENTUNO Q 中文教程)
(差异) ←上一版本 | 最后版本 (差异) | 下一版本→ (差异)

概述

本教程将在 Arduino® VENTUNO™ Q 上运行实时人脸检测与 468 点人脸网格演示,使用来自 Qualcomm® AI Hub 的 mediapipe_face 模型。关键点阶段在开发板 Qualcomm® Dragonwing™ QCS8275 处理器的 Qualcomm® Hexagon™ NPU 上运行,检测阶段在 CPU 上运行。

该演示是一个独立的 Python 脚本(face_mesh_camera.py),从 USB 相机读取画面,检测每张人脸,并实时叠加边界框、旋转感兴趣区域轮廓以及完整的 468 点网格。

本指南将涵盖:

  1. 为 VENTUNO Q 供电并访问开发板。
  2. 设置 Python 虚拟环境。
  3. 直接在开发板上下载模型文件。
  4. 在 CPU 与 NPU 上运行人脸网格演示。

硬件与软件要求

硬件

说明:

将 VENTUNO Q 用作 SBC 时,鼠标并非必需(但会更方便)。

软件

  • 主机上可用的 adb(Android® Platform Tools)或 ssh
  • Python 3.12(已预装在 VENTUNO Q 上)

警告:

必须先用电源为 VENTUNO Q 供电,然后再用 USB-C® 线缆连接主机,否则开发板可能崩溃。建议使用功率至少 65 W、电压范围 7–24 V 的电源。

访问开发板 Shell

在开发板通过电源圆口供电后,可使用 adb 或 ssh 访问 VENTUNO Q 上的 shell(终端)。

要通过 adb 连接,用 USB-C® 线缆连接 VENTUNO Q 与电脑,然后运行:

# Using adb (Android Debug Bridge)
adb shell

要通过 ssh 连接,请确保 VENTUNO Q 与电脑连接到同一网络,然后运行:

# Using ssh (Secure Shell)
ssh arduino@<ip-address>

若不知道开发板的 IP 地址,可连接键盘和显示器并在开发板上运行 hostname -I,或先用 sudo nmtui 配置 Wi-Fi®。

说明:

有关远程访问开发板的更多方式,请参阅 Remote Access 教程。

说明:

由于此演示会在屏幕上打开实时相机窗口,请在开发板的实际桌面会话中运行(物理显示器与键盘,或进入开发板的 VNC/X11 会话)。adb 与 ssh 仍是安装依赖并确认脚本可运行的最简便方式,然后再切换到桌面会话观看相机画面。

设置 Python 环境

本节中的所有命令都在 VENTUNO Q 上运行。

1. 创建虚拟环境

创建并激活 Python 虚拟环境以隔离依赖:

python3 -m venv ~/.venv
source ~/.venv/bin/activate

提示符中的 (.venv) 前缀表示环境已激活。每个新会话开始时请再次运行 source 命令。

2. 安装依赖

此演示需要 opencv-python、numpy 和 ai-edge-litert(后者捆绑了 TFLite 解释器以及用于 NPU 执行的 QNN HTP 委托加载器):

pip install ai-edge-litert==1.3.0 opencv-python numpy

NPU 执行还需要 QNN HTP 委托(libQnnTFLiteDelegate.so),由 Qualcomm® AI Runtime (QAIRT) 提供。它默认未安装,也不会被任何 pip 包拉取,因此请从开发板的 apt 仓库安装:

sudo apt update
sudo apt install qairt-libs qairt-dsp-binaries

继续之前请确认委托已存在:

ls /usr/lib/libQnnTFLiteDelegate.so

下载模型文件

除 Python 脚本外,演示还需要三个文件。你可以直接在开发板上下载并准备全部文件——无需从主机传输:

文件 说明 来源
face_detector-float.tflite 浮点人脸检测器,在 CPU 上运行 Qualcomm AI Hub
face_landmark_detector-w8a8.tflite 量化关键点模型,在 NPU 上运行 Qualcomm AI Hub
anchors_face_back.npy 用于解码检测器输出的锚点表 MediaPipePyTorch 参考

首先,在开发板上创建并进入工作目录:

mkdir -p /home/arduino/face-mesh
cd /home/arduino/face-mesh

1. 从 AI Hub 获取模型文件

两个 .tflite 文件来自 Qualcomm AI Hub 上的 mediapipe_face,通过 qai-hub-models CLI 获取。请先安装该工具(体积较大,可能需要一些时间),然后拉取两种精度:

pip install qai-hub-models

# Float precision -> extracts mediapipe_face-tflite-float/
qai-hub-models fetch mediapipe_face -r tflite -p float -o .

# Quantized precision -> extracts mediapipe_face-tflite-w8a8/
qai-hub-models fetch mediapipe_face -r tflite -p w8a8 -o .

每条命令会解压出一个包含 face_detector.tflite 与 face_landmark_detector.tflite 的文件夹。我们保留 float 检测器与 w8a8 关键点模型(混合使用的原因见 已知限制),并重命名为脚本所期望的文件名:

cp mediapipe_face-tflite-float/face_detector.tflite ./face_detector-float.tflite
cp mediapipe_face-tflite-w8a8/face_landmark_detector.tflite ./face_landmark_detector-w8a8.tflite

警告:

qai-hub-models 依赖 ai-edge-litert>=2.0.2,因此安装它会升级你先前安装的 ai-edge-litert==1.3.0。2.x 版本无法与 QNN HTP 委托配合:委托会以 Failed to validate op ... Conv2d 拒绝每个卷积,并静默回退到 CPU,由于额外的委托开销,最终会比纯 CPU 执行更慢。拉取模型文件后,请将版本恢复为 1.3.0:

pip install ai-edge-litert==1.3.0

运行演示前,请确认处于可用版本:

pip show ai-edge-litert | grep Version   # must report 1.3.0

说明:

pip 会打印以 ERROR: 开头的一行,提示 qai-hub-models 需要更新的 ai-edge-litert。降级仍然会成功,之后 qai-hub-models fetch 仍可使用,因此可忽略该消息。

或者,将 qai-hub-models 放在仅用于下载模型的单独虚拟环境中,而将演示环境固定为 ai-edge-litert==1.3.0。

2. 下载锚点表

anchors_face_back.npy 锚点表未在 AI Hub 上发布。它是来自 Apache-2.0 zmurez/MediaPipePyTorch 参考仓库的静态 BlazeFace 锚点表——qai-hub-models 自身的 mediapipe_face 后处理也使用同一仓库:

git clone --depth 1 https://github.com/zmurez/MediaPipePyTorch.git /tmp/mediapipepytorch
cp /tmp/mediapipepytorch/anchors_face_back.npy ./anchors_face_back.npy

完成这些步骤后,/home/arduino/face-mesh 目录中应包含 face_detector-float.tflite、face_landmark_detector-w8a8.tflite 和 anchors_face_back.npy。

创建脚本

在 VENTUNO Q 上,进入工作目录并创建脚本文件:

cd /home/arduino/face-mesh
nano face_mesh_camera.py

将下方 代码示例 一节中的完整脚本粘贴到编辑器中。在 nano 中,用 Ctrl+X,然后 Y,再按 Enter 保存并退出。

运行演示

在虚拟环境已激活且所有文件位于 /home/arduino/face-mesh 的情况下,从开发板的桌面会话运行演示:

cd /home/arduino/face-mesh
source ~/.venv/bin/activate            # if not already active

python3 face_mesh_camera.py            # landmark model on the CPU
python3 face_mesh_camera.py --use-npu  # landmark model on the Hexagon NPU

会打开标题为 "Face Mesh" 的窗口,显示实时相机画面,对每张检测到的人脸绘制:

  • 绿色边界框,
  • 蓝色旋转 ROI 轮廓,以及
  • 468 点网格(橙色点)。

屏幕叠加层会报告各模型的 invoke 时间(detector=CPU(...)、landmark=NPU(...) 或 CPU(...))以及每帧总延迟。在窗口中按 q 退出。

在运行 Ubuntu 24.04 的 VENTUNO Q 上,对检测到人脸的帧取平均:

关键点模型运行于 检测器(始终 CPU) 关键点模型 每帧总计
CPU 19.3 ms 11.13 ms 约 30 ms
NPU 18.2 ms 0.98 ms 约 19 ms

关键点模型在 NPU 上大约快 11 倍。由于检测阶段始终在 CPU 上运行(见 已知限制),两种情况下它都主导帧时间——因此端到端提升小于关键点单独加速,进一步优化也须针对检测器。

若从远程主机(adb 或 ssh)启动脚本,需要先允许其在显示器上渲染。

在开发板本地(不使用 adb 或 ssh)打开终端并运行以下命令:

xhost +
export DISPLAY=:0   # replace 0 with your display number

这将允许远程主机在显示器上运行应用程序。若不执行此步骤,从远程主机运行脚本时会出现与 xcb 相关的错误。

代码示例

下方给出完整、自包含的演示。请将其保存为工作目录中与三个模型文件同级的 face_mesh_camera.py。

import sys
import time

import cv2
import numpy as np
from ai_edge_litert.interpreter import Interpreter, load_delegate

# --- SETUP ---
# NOTE: the detector runs on float (CPU) rather than the w8a8 NPU asset on purpose.
# AI Hub's published mediapipe_face w8a8 TFLite detector is poorly calibrated --
# verified by A/B against this float model over identical frames at the same score
# threshold: float returns one stable box per frame at 0.93-0.96, while w8a8 returns
# 2-9 boxes per frame at 0.81-0.97 whose best box sits a median 294px (225-345px)
# off the real face, often partly outside the frame. The landmark model's w8a8 asset
# does not have this problem and runs on the NPU normally.
DETECTOR_MODEL_PATH = 'face_detector-float.tflite'
LANDMARK_MODEL_PATH = 'face_landmark_detector-w8a8.tflite'
ANCHORS_PATH = 'anchors_face_back.npy'

DETECT_SCORE_THRESHOLD = 0.8   # min_detector_face_box_score (MediaPipeFaceApp default)
DETECT_NMS_IOU = 0.3           # nms_iou_threshold
DETECT_SCORE_CLIP = 100.0      # DETECT_SCORE_CLIPPING_THRESHOLD
DETECT_BOX_SCALE = 1.1         # DETECT_DSCALE -- enlarge detector box before landmark crop
LEFT_EYE_KEYPOINT_INDEX = 0
RIGHT_EYE_KEYPOINT_INDEX = 1
LANDMARK_SCORE_THRESHOLD = 0.5  # min_landmark_score

use_npu = True if len(sys.argv) >= 2 and sys.argv[1] == '--use-npu' else False

def make_interpreter(model_path, use_delegate):
    experimental_delegates = []
    if use_delegate:
        experimental_delegates = [load_delegate("libQnnTFLiteDelegate.so", options={"backend_type": "htp"})]
    interp = Interpreter(model_path=model_path, experimental_delegates=experimental_delegates)
    interp.allocate_tensors()
    return interp

# Detector always runs on CPU (see NOTE above); only the landmark model uses --use-npu.
detector = make_interpreter(DETECTOR_MODEL_PATH, use_delegate=False)
landmark_net = make_interpreter(LANDMARK_MODEL_PATH, use_delegate=use_npu)

det_in = detector.get_input_details()
det_out = detector.get_output_details()
lm_in = landmark_net.get_input_details()
lm_out = landmark_net.get_output_details()

_, DET_H, DET_W, _ = det_in[0]['shape']
_, LM_H, LM_W, _ = lm_in[0]['shape']

# det_out/lm_out are indexed positionally below (verified against the exported
# model's output order): det_out = [box_coords_1, box_coords_2, box_scores_1,
# box_scores_2]; lm_out = [scores, landmarks].

# Anchor table: (896, 4) = [x_center, y_center, w, h], normalized [0, 1].
anchors = np.load(ANCHORS_PATH).astype(np.float32).reshape(-1, 2, 2)
NUM_ANCHORS = anchors.shape[0]

# --- NPU WARMUP (landmark model only -- detector runs on CPU) ---
if use_npu:
    print("Warming up NPU...")
    dummy_lm = np.zeros(lm_in[0]['shape'], dtype=lm_in[0]['dtype'])
    for _ in range(3):
        landmark_net.set_tensor(lm_in[0]['index'], dummy_lm)
        landmark_net.invoke()
    print("Warmup done.")

def quantize(values01, detail):
    """values01: float array in the model's natural [0,1]-ish range -> quantized input tensor."""
    scale, zero_point = detail['quantization']
    dtype = detail['dtype']
    if scale:
        q = np.rint(values01 / scale) + zero_point
        info = np.iinfo(dtype)
        return np.clip(q, info.min, info.max).astype(dtype)
    return values01.astype(dtype)

def dequantize(tensor, detail):
    scale, zero_point = detail['quantization']
    if scale:
        return (tensor.astype(np.float32) - float(zero_point)) * float(scale)
    return tensor.astype(np.float32)

def sigmoid(x):
    return 1.0 / (1.0 + np.exp(-np.clip(x, -30, 30)))

def letterbox(frame_rgb, dst_h, dst_w):
    """Resize (preserving aspect ratio) + center-pad. Mirrors qai_hub_models.utils.image_processing.resize_pad."""
    h, w = frame_rgb.shape[:2]
    scale = min(dst_h / h, dst_w / w)
    new_h, new_w = int(h * scale), int(w * scale)

    resized = cv2.resize(frame_rgb, (new_w, new_h))
    canvas = np.zeros((dst_h, dst_w, 3), dtype=np.uint8)

    pad_top = (dst_h - new_h) // 2
    pad_left = (dst_w - new_w) // 2
    canvas[pad_top:pad_top + new_h, pad_left:pad_left + new_w] = resized

    return canvas, scale, (pad_left, pad_top)

def undo_letterbox(points_xy, scale, pad):
    """Inverse of letterbox(): maps (x, y) points in the padded canvas back to original-frame coordinates."""
    pad_left, pad_top = pad
    out = points_xy.copy()
    out[..., 0] = (points_xy[..., 0] - pad_left) / scale
    out[..., 1] = (points_xy[..., 1] - pad_top) / scale
    return out

def decode_detections(box_coords, box_scores, img_size):
    """
    Hand-ported from qai_hub_models._shared.mediapipe.utils.mediapipe_detector_postprocess
    + decode_preds_from_anchors.

    box_coords: (NUM_ANCHORS, 16) raw dequantized model output.
        Layout: [box_cx, box_cy, box_w, box_h, kp0_x, kp0_y, ..., kp5_x, kp5_y]
    box_scores: (NUM_ANCHORS,) raw dequantized logits.

    Returns boxes_xyxy (N,4), keypoints (N,6,2), scores (N,) for boxes passing
    the score threshold (NMS is applied separately).
    """
    scores = sigmoid(np.clip(box_scores, -DETECT_SCORE_CLIP, DETECT_SCORE_CLIP))

    coords = box_coords.reshape(NUM_ANCHORS, 8, 2)
    h_size, w_size = img_size
    offset = anchors[:, 0:1, :] * np.array([w_size, h_size], dtype=np.float32)
    scale = anchors[:, 1:2, :]
    mask = (np.arange(8) != 1).reshape(8, 1).astype(np.float32)
    decoded = coords * scale + offset * mask

    flat = decoded.reshape(NUM_ANCHORS, 16)
    cx, cy, bw, bh = flat[:, 0], flat[:, 1], flat[:, 2], flat[:, 3]
    boxes_xyxy = np.stack([cx - bw / 2, cy - bh / 2, cx + bw / 2, cy + bh / 2], axis=-1)
    keypoints = flat[:, 4:].reshape(NUM_ANCHORS, 6, 2)

    keep = scores >= DETECT_SCORE_THRESHOLD
    return boxes_xyxy[keep], keypoints[keep], scores[keep]

def iou(box, boxes):
    xA = np.maximum(box[0], boxes[:, 0])
    yA = np.maximum(box[1], boxes[:, 1])
    xB = np.minimum(box[2], boxes[:, 2])
    yB = np.minimum(box[3], boxes[:, 3])
    inter = np.maximum(0, xB - xA) * np.maximum(0, yB - yA)
    area_a = np.maximum(0, box[2] - box[0]) * np.maximum(0, box[3] - box[1])
    area_b = np.maximum(0, boxes[:, 2] - boxes[:, 0]) * np.maximum(0, boxes[:, 3] - boxes[:, 1])
    return inter / (area_a + area_b - inter + 1e-6)

def nms(boxes, scores, iou_threshold):
    order = np.argsort(-scores)
    keep = []
    while order.size > 0:
        i = order[0]
        keep.append(i)
        if order.size == 1:
            break
        ious = iou(boxes[i], boxes[order[1:]])
        order = order[1:][ious <= iou_threshold]
    return keep

def compute_roi_corners(box_xyxy, keypoints):
    """
    From a detector box + its keypoints, compute the rotated ROI (4 corners) fed to
    the landmark model. Hand-ported from compute_vector_rotation, box_xyxy_to_xywh,
    and compute_box_corners_with_rotation (DETECT_DXY == 0 for this model, so the
    apply_directional_box_offset step is a no-op and is skipped).
    """
    left_eye = keypoints[LEFT_EYE_KEYPOINT_INDEX]
    right_eye = keypoints[RIGHT_EYE_KEYPOINT_INDEX]
    theta = np.arctan2(right_eye[1] - left_eye[1], right_eye[0] - left_eye[0])

    x0, y0, x1, y1 = box_xyxy
    xc, yc = (x0 + x1) / 2, (y0 + y1) / 2
    w, h = (x1 - x0) * DETECT_BOX_SCALE, (y1 - y0) * DETECT_BOX_SCALE

    unit_square = np.array([[-1, -1], [-1, 1], [1, -1], [1, 1]], dtype=np.float32)
    pts = unit_square * np.array([w / 2, h / 2], dtype=np.float32)
    c, s = np.cos(theta), np.sin(theta)
    rot = np.array([[c, -s], [s, c]], dtype=np.float32)
    corners = (rot @ pts.T).T + np.array([xc, yc], dtype=np.float32)
    return corners  # order: top-left, bottom-left, top-right, bottom-right

def run_landmarks(frame_rgb, roi_corners):
    """Crop+rotate the ROI to the landmark model's input size, run it, and map the
    predicted landmarks back to frame_rgb's coordinate space."""
    src = roi_corners[:3]
    dst = np.array([[0, 0], [0, LM_H - 1], [LM_W - 1, 0]], dtype=np.float32)
    affine = cv2.getAffineTransform(src, dst)
    crop = cv2.warpAffine(frame_rgb, affine, (LM_W, LM_H))

    lm_input = quantize(crop.astype(np.float32) / 255.0, lm_in[0])[np.newaxis, ...]
    landmark_net.set_tensor(lm_in[0]['index'], lm_input)
    t0 = time.perf_counter()
    landmark_net.invoke()
    lm_invoke_ms = (time.perf_counter() - t0) * 1000

    score = dequantize(landmark_net.get_tensor(lm_out[0]['index']), lm_out[0]).reshape(-1)[0]
    landmarks = dequantize(landmark_net.get_tensor(lm_out[1]['index']), lm_out[1]).reshape(-1, 3)

    if score < LANDMARK_SCORE_THRESHOLD:
        return None, lm_invoke_ms

    landmarks[:, 0] *= LM_W
    landmarks[:, 1] *= LM_H

    inv_affine = cv2.invertAffineTransform(affine)
    xy = landmarks[:, :2]
    mapped = (inv_affine[:, :2] @ xy.T + inv_affine[:, 2:]).T
    landmarks[:, :2] = mapped
    return landmarks, lm_invoke_ms

def process_frame(frame_bgr):
    frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
    canvas, scale, pad = letterbox(frame_rgb, DET_H, DET_W)

    det_input = quantize(canvas.astype(np.float32) / 255.0, det_in[0])[np.newaxis, ...]
    detector.set_tensor(det_in[0]['index'], det_input)
    t0 = time.perf_counter()
    detector.invoke()
    det_invoke_ms = (time.perf_counter() - t0) * 1000

    c1 = dequantize(detector.get_tensor(det_out[0]['index']), det_out[0]).reshape(-1, 16)
    c2 = dequantize(detector.get_tensor(det_out[1]['index']), det_out[1]).reshape(-1, 16)
    s1 = dequantize(detector.get_tensor(det_out[2]['index']), det_out[2]).reshape(-1)
    s2 = dequantize(detector.get_tensor(det_out[3]['index']), det_out[3]).reshape(-1)
    box_coords = np.concatenate([c1, c2], axis=0)
    box_scores = np.concatenate([s1, s2], axis=0)

    boxes, keypoints, scores = decode_detections(box_coords, box_scores, (DET_H, DET_W))
    keep = nms(boxes, scores, DETECT_NMS_IOU)
    boxes, keypoints = boxes[keep], keypoints[keep]

    # Map detector-space (letterboxed canvas) coordinates back to the original frame.
    boxes = undo_letterbox(boxes.reshape(-1, 2, 2), scale, pad).reshape(-1, 4)
    keypoints = undo_letterbox(keypoints, scale, pad)

    faces = []
    lm_invoke_ms_total = 0.0
    for box, kp in zip(boxes, keypoints):
        roi_corners = compute_roi_corners(box, kp)
        landmarks, lm_ms = run_landmarks(frame_rgb, roi_corners)
        lm_invoke_ms_total += lm_ms
        faces.append((box, roi_corners, landmarks))

    return faces, det_invoke_ms, lm_invoke_ms_total

# --- MAIN LOOP ---
landmark_mode = "NPU" if use_npu else "CPU"
cap = cv2.VideoCapture(0)

while True:
    ret, frame = cap.read()
    if not ret:
        break

    t_start = time.perf_counter()
    faces, det_invoke_ms, lm_invoke_ms = process_frame(frame)
    total_ms = (time.perf_counter() - t_start) * 1000

    for box, roi_corners, landmarks in faces:
        x0, y0, x1, y1 = box.astype(int)
        cv2.rectangle(frame, (x0, y0), (x1, y1), (0, 255, 0), 2)

        quad = roi_corners[[0, 2, 3, 1]].astype(int)  # TL, TR, BR, BL -> valid polygon order
        cv2.polylines(frame, [quad], isClosed=True, color=(255, 0, 0), thickness=1)

        if landmarks is not None:
            for (lx, ly, _lz) in landmarks:
                cv2.circle(frame, (int(lx), int(ly)), 1, (0, 128, 255), -1)

    label = (f"detector=CPU({det_invoke_ms:.1f}ms)  landmark={landmark_mode}({lm_invoke_ms:.1f}ms)  "
             f"total={total_ms:.1f}ms  faces={len(faces)}")
    cv2.putText(frame, label, (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)

    cv2.imshow('Face Mesh', frame)
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

cap.release()
cv2.destroyAllWindows()

工作原理

每一帧经过两个模型阶段:

  1. 检测(CPU)。 帧经 letterbox 到检测器输入尺寸,再送入浮点人脸检测器。原始输出对照锚点表解码,经分数阈值过滤,再用非极大值抑制(NMS)得到每张人脸一个框与一组关键点。
  2. 关键点网格(NPU)。 对每张检测到的人脸,用眼睛关键点计算旋转感兴趣区域。将该区域裁剪并变换到关键点模型输入尺寸,再在 Hexagon™ NPU 上通过量化关键点模型,得到映射回原始帧的 468 个关键点。

已知限制

  • 检测器始终在 CPU 上运行。 AI Hub 为检测子模型发布的 w8a8(NPU 量化)资源校准有误。在相同的 20 帧相机画面上,以相同的 DETECT_SCORE_THRESHOLD 0.8 运行两种检测器时,浮点模型每帧恰好返回一个位置稳定的框,分数为 0.93–0.96;而 w8a8 模型每帧返回 2 到 9 个框,分数 0.81–0.97,得分最高的框与浮点模型结果的中位偏移达 294 px(集合内 225–345 px),且常常部分落在画面外。因此问题不是可用更高阈值过滤的低置信度——错误的框反而很自信。由于该子模型没有已发布的原生 NPU 替代方案,检测器在 CPU 上使用浮点权重,而关键点模型(其 w8a8 资源正常)在 NPU 上运行。
  • 嘴部关键点位置在某些人脸上可能略有偏差。初步测试中,刮净胡须的人脸嘴部关键点像素级准确,但少数脸的唇部关键点会略低于可见胡须。尚未完全定位根因,且看起来不像 ROI 或仿射计算中的系统性问题。

总结

在本教程中,你配置了 VENTUNO Q,传输了 mediapipe_face 模型文件,并运行了在 Qualcomm® Hexagon™ NPU 上加速关键点阶段的实时人脸检测与 468 点人脸网格估计独立 Python 脚本。在此基础上,你可以直接在 Dragonwing™ QCS8275 处理器上利用网格输出——例如驱动表情跟踪、人脸滤镜或注视感知界面。


原文:https://docs.arduino.cc/tutorials/ventuno-q/face-mesh/ 本页为 Arduino 官方教程的中文翻译,供内部查阅。命令与代码保持原文。