教程: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 点网格。
本指南将涵盖:
- 为 VENTUNO Q 供电并访问开发板。
- 设置 Python 虚拟环境。
- 直接在开发板上下载模型文件。
- 在 CPU 与 NPU 上运行人脸网格演示。
硬件与软件要求
硬件
- Arduino® VENTUNO™ Q
- Arduino® USB-C Power Supply (65W),或通过电源圆口接入 7–24 V 直流电源
- 连接到 USB-A 口的 USB 相机,设备路径为
/dev/video0 - 连接到开发板的显示器、键盘和鼠标\*(脚本会在屏幕上打开实时窗口)
说明:
将 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()
工作原理
每一帧经过两个模型阶段:
- 检测(CPU)。 帧经 letterbox 到检测器输入尺寸,再送入浮点人脸检测器。原始输出对照锚点表解码,经分数阈值过滤,再用非极大值抑制(NMS)得到每张人脸一个框与一组关键点。
- 关键点网格(NPU)。 对每张检测到的人脸,用眼睛关键点计算旋转感兴趣区域。将该区域裁剪并变换到关键点模型输入尺寸,再在 Hexagon™ NPU 上通过量化关键点模型,得到映射回原始帧的 468 个关键点。
已知限制
- 检测器始终在 CPU 上运行。 AI Hub 为检测子模型发布的 w8a8(NPU 量化)资源校准有误。在相同的 20 帧相机画面上,以相同的
DETECT_SCORE_THRESHOLD0.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 官方教程的中文翻译,供内部查阅。命令与代码保持原文。