Project AIRI 中的 MediaPipe 单人体感动捕管线:从摄像头帧到 VRM 驱动的架构与实践
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
本篇技术指南聚焦 Project AIRI 仓库中的实验性单人体感动捕(mocap)管线包@proj-airi/model-driver-mediapipe。它以camera frame → @mediapipe/tasks-vision → PerceptionState → overlay / VRM的闭环为骨架,讲清引擎调度、丢帧策略、任务级限速与媒体后端边界,并给出从摄像头采集到骨骼姿态应用至 VRM 模型的完整可运行方案。读完你将掌握一套可直接复用的浏览器端单人动捕架构,以及将 2D/3D 关键点转换为 VRM 骨骼驱动方向的工程化实现。
包定位与设计意图
packages/model-driver-mediapipe/AGENTS.md 将该包定义为"实验性单人体感动捕管线(experimental single-person mocap pipeline)",服务对象是 stage-web 开发者工具(devtools)。其核心数据流是一条极简闭环:
camera frame → @mediapipe/tasks-vision → PerceptionState → overlay在 packages/model-driver-mediapipe/README.md 中,这一闭环进一步明确为 stage-web 可直接消费的最小回路,并给出了实际体验入口:
- 开发工具页:apps/stage-web/src/pages/devtools/model-driver-mediapipe.vue
- 菜单入口:Settings → System → Developer → "MediaPipe Workshop"(路由注册见 apps/stage-web/src/pages/settings/system/developer.vue)
包本身位于 packages/model-driver-mediapipe/package.json,依赖@mediapipe/tasks-vision、@pixiv/three-vrm、three、es-toolkit与@moeru/std,通过postinstall脚本自动准备推理任务资产。
设计约定:函数式、窄契约与干净的后端边界
AGENTS.md 明确了几条对整个实现有约束力的编码约定,读懂它们有助于理解后续每一层代码的结构:
- 偏好函数式编程(FP)与纯函数:有状态模块用工厂函数 + 闭包实现,例如
createMocapEngine();除非是扩展浏览器 API 或被外部库要求,避免使用 class。这与 src/engine.ts 中createStats、createScheduler、createMocapEngine三个工厂函数一一对应。 - 保持后端边界干净:引擎/调度器不得 import
@mediapipe/tasks-vision,MediaPipe 相关细节全部收拢在 src/backends/ 下。事实上,src/backends/mediapipe.ts 是唯一的后端实现。 - 类型稳定且窄:stage 消费方以 src/types.ts 为契约,新增字段必须可选且向后兼容。
中层契约:PerceptionState 与配置类型
src/types.ts 是整个管线的"中间层契约",也是 stage 消费方唯一依赖的类型面。它把底层 MediaPipe 结果归一化为三种感知状态与一套质量指标:
PoseState:landmarks2d(归一化 2D 关键点)与worldLandmarks(3D 世界关键点),分别对应NormalizedLandmark与Landmark;HandState:handedness('Left' | 'Right')、21 个 2D 关键点及置信度score;FaceState:hasFace与 468 个 2D 关键点;PerceptionQuality:fps、latencyMs、droppedFrames,并标记backend: 'mediapipe'与mode: 'split-tasks';PerceptionPartial:上述三个子状态均为可选,用于支持"部分合并"(partial merge)。
运行配置MocapConfig的关键字段为:
| 字段 | 类型 | 含义 |
|---|---|---|
enabled | Record<MocapJob, boolean> | 每个任务(pose/hands/face)的开关 |
hz | Record<MocapJob, number> | 每个任务的调度速率(Hz),用于限流 |
maxPeople | 1 | 固定为单人 |
同时类型层定义了后端与引擎的接口:MocapBackend(init/isBusy/run)与MocapEngine(init/start/stop/updateConfig/resetState),这正是"引擎不依赖 MediaPipe 具体实现"得以成立的基础。完整导出见 src/index.ts。
引擎调度:限速、丢帧策略与状态合并
src/engine.ts 实现了调度 + 丢帧策略 + 部分合并三件事,全程采用函数式写法。
帧率统计(createStats):以requestAnimationFrame的时间戳为基准计算瞬时 FPS,并用指数滑动平均(smoothedFps * 0.9 + fps * 0.1)平滑抖动。
任务调度器(createScheduler):每个任务(pose/hands/face)独立记录上次执行时刻lastRun,plan(nowMs)按1000 / hz判断本轮是否轮到某个任务,从而把每一帧的推理开销控制在用户配置的速率之下——这是对"同步detectForVideo()阻塞主线程"的直接对冲手段。
引擎主循环(createMocapEngine):
const tick = async () => { const frame = source.getFrame() const now = performance.now() // Skip this frame if the backend is still busy. if (backend.isBusy()) { droppedFrames++ rafId = requestAnimationFrame(tick) return } const jobs = scheduler.plan(now) const t0 = performance.now() const partial = jobs.length > 0 ? await backend.run(frame, jobs, now) : {} const latencyMs = performance.now() - t0 lastPartial = { ...lastPartial, ...partial } onState({ t: now, ...lastPartial, quality: { fps, latencyMs, droppedFrames, backend: 'mediapipe', mode: 'split-tasks' } }) rafId = requestAnimationFrame(tick) }值得注意的实现细节:
- 丢帧策略:当后端
isBusy()返回 true(上一帧推理尚未完成),当前帧直接跳过并累加droppedFrames,绝不排队积压,避免 UI 线程被无限阻塞; - 部分合并(partial merge):
lastPartial = { ...lastPartial, ...partial },只更新本轮实际推理过的任务子状态,其余子状态沿用上一帧,使各任务可以以不同速率独立刷新; - 错误隔离:异常会停止主循环并回调
options.onError,避免错误风暴,消费方可决定是否重启(apps/stage-web/src/pages/devtools/model-driver-mediapipe.vue 中的onError正是这一设计的使用方)。
MediaPipe 后端:模型加载与同步推理
src/backends/mediapipe.ts 是@mediapipe/tasks-vision的适配层,要点如下:
- 惰性加载:
init()动态import('@mediapipe/tasks-vision')并通过FilesetResolver.forVisionTasks(visionTaskWasmRoot)解析 WASM 文件集,仅首次调用时执行; - 按需创建 landmarker:
ensurePoseLandmarker/ensureHandLandmarker/ensureFaceLandmarker三个工厂各自缓存实例,配置分别为:
// 姿态 PoseLandmarker.createFromOptions(vision, { baseOptions: { modelAssetPath: visionTaskAssets.pose }, runningMode: 'VIDEO', numPoses: 1, }) // 手部 HandLandmarker.createFromOptions(vision, { baseOptions: { modelAssetPath: visionTaskAssets.hands }, runningMode: 'VIDEO', numHands: 2, }) // 面部 FaceLandmarker.createFromOptions(vision, { baseOptions: { modelAssetPath: visionTaskAssets.face }, runningMode: 'VIDEO', numFaces: 1, })- 同步推理:
run()内以es-toolkit的Semaphore(1)保证并发安全,置位busy标志供引擎查询,再按任务列表依次调用detectForVideo(frame, nowMs)。正如 references/tasks-vision-api.md 所记录的,detectForVideo()是同步调用、可能阻塞主线程,这正是引擎限速与丢帧策略存在的根本原因; - 结果归一化:姿态取
res.landmarks[0]与res.worldLandmarks[0](单人场景);手部取res.handedness[i][0].categoryName判定左右手并附带置信度score;面部只保留hasFace与关键点,作为"仅存在性"处理(468 个点开销较大,参考任务注释)。
任务资产准备:tasks/prepare-tasks.ts 是postinstall触发的资产准备脚本:从 MediaPipe 官方模型存储下载pose_landmarker_lite、hand_landmarker、face_landmarker三个.task文件,并把node_modules/@mediapipe/tasks-vision/wasm复制到包内assets/wasm。资产路径在 tasks/tasks.ts 中以new URL('./assets/...', import.meta.url)的方式声明,保证浏览器端可解析;脚本内置withRetry重试与已存在文件的跳过逻辑。
调试叠加层:overlay 渲染器
src/utils/overlay.ts 提供 canvas 叠加渲染。它使用 MediaPipe 的DrawingUtils,分别绘制:
- 姿态:
PoseLandmarker.POSE_CONNECTIONS连接线 + 关键点; - 手部:
HandLandmarker.HAND_CONNECTIONS连接线 + 关键点,左右手使用不同调色板区分(左手指向 palette 索引 1,右手索引 2); - 面部:468 个点用小半径(
facePointRadius: 2)绘制以减少视觉杂乱。
调色板与线宽/点半径常量以"面向 devtools 可读性"为原则做了手工调优,drawOverlay支持按enabled参数单独开关三类绘制,绘制前先clearRect清空画布,逐帧重绘。
动捕到 VRM:姿态骨骼驱动
管线并不止步于叠加可视化,而是进一步把 MediaPipe 的姿态输出映射为 VRM 骨骼驱动方向,这是它区别于纯演示的核心价值所在。相关实现位于 src/three/:
pose → VRM 目标(pose-to-vrm.ts)
poseToVrmTargets()以世界关键点为主、归一化关键点为兜底,输出VrmPoseTargets——一组"方向 + pole 向量"目标,覆盖hips、spine、chest、左右肩/大臂/小臂/大腿/小腿等 13 个骨骼。工程细节包括:
- 轴重映射:
axis选项(x/y/z各取1 | -1)把 MediaPipe 世界坐标系映射到 three/VRM 空间,默认{1,1,1},页面端可通过 flip 开关翻转(见 model-driver-mediapipe.vue 的vrmMapping); - 置信度门控:
confidence.minVisibility(默认0.5)/minPresence基于关键点的visibility/presence字段过滤;当visibility缺失时直接不输出该关键点依赖的目标,避免噪声驱动; - 躯干朝向推导:由
hipCenter → shoulderCenter求 up、leftShoulder → rightShoulder求 right,再以right × up得 forward,并做符号校正; - 稳定化:
stabilize.previousTargets/previousForward用于避免因极点歧义导致的 180° 翻转,具体手段是当上一帧 pole 与当前 pole 点积为负时取反; - 下肢保守策略:腿只在大腿/膝盖/脚踝三者齐备时才输出目标,减少下半身离屏时的幻觉翻转。
应用至 VRM(apply-pose-to-vrm.ts)
createVrmPoseApplier()返回applyPoseDirectionsToVrm/applyPoseTargetsToVrm两个函数,把上述目标应用到@pixiv/three-vrm的人形骨骼。关键机制:
- 骨骼链映射:
CHAINS表把 13 个姿态键映射到 VRM 骨骼名及其候选子骨骼(如spine的子骨骼候选为chest / upperChest / neck),骨骼不存在时优雅降级; - 静止方向/极点缓存:首次应用时按骨骼世界坐标与子骨骼差向量推算 rest 方向与 rest pole,并在局部空间缓存;
- 方向应用:无 pole 时用
Quaternion.setFromUnitVectors(currentDir, targetDir)求增量旋转;有 pole 时构建 rest/target 两组 basis 矩阵(makeBasis(dir, y, pole))求相对旋转矩阵,把目标方向与极点同时映射到骨骼; - 翻转拒绝:
minDotBeforeReject(默认-0.2,约拒绝 >101°)基于上一帧目标方向而非当前骨骼姿态判断瞬时 180° 翻转;minPoleDotBeforeReject对 pole 做同样的保护; - 平滑插值:
alpha(默认0.35,[0..1])对最终局部四元数做slerp,alpha 越高越跟手、越低越平滑。
端到端接线:MediaPipe Workshop 页面
apps/stage-web/src/pages/devtools/model-driver-mediapipe.vue 把以上所有模块串成可运行闭环,可作为读者整合各层的参考范式:
startCamera()通过navigator.mediaDevices.getUserMedia({ video: true })获取摄像头流,绑定<video>元素并play();startPipeline()依次createMediaPipeBackend()→createMocapEngine(backend, config)→engine.init()→engine.start({ getFrame: () => video }, onState, { onError }),其中FrameSource.getFrame直接返回视频元素;onState回调中做三件事:更新页面状态摘要(enabled/hz/fps/latency/dropped);调用poseToVrmTargets(state.pose, { axis, confidence, stabilize })生成 VRM 目标并通过vrmPoseApplier.applyPoseTargetsToVrm(vrm, targets)在ThreeScene的帧钩子中驱动模型;最后drawOverlay(ctx, state, enabled)把关键点画到 canvas 叠加层;- 页面
watch(config, ...)深监听配置变化并调用engine.updateConfig(),实现运行中动态调整任务开关与 Hz 限速; stop()设计上考虑了"MediaPipe 可能仍在处理进行中帧"的情况,通过ignoreErrorsUntil窗口吞掉停止期间的瞬时错误,再释放摄像头轨道与清空画布。
页面默认开启 pose/hands/face 三个任务且各 30Hz、maxPeople: 1、姿态过滤minVisibility: 0.5,并在onMounted自动启动,方便直接体验。
上游 API 备忘与适用前提
references/tasks-vision-api.md 是包内维护的"最小 API 备忘",记录了三项对本实现最关键的结论,可视为理解整条管线的速查卡:
- 初始化:
FilesetResolver.forVisionTasks(wasmRoot)后以createFromOptions(vision, { baseOptions, runningMode: 'VIDEO', numPoses: 1 })创建 landmarker; - 同步推理:
detectForVideo(videoEl, nowMs)同步执行、可能阻塞主线程,因此引擎必须限速与丢帧; - 结果形状(单人场景):姿态
res.landmarks/res.worldLandmarks取[0],x/y归一化到[0..1];手部res.landmarks每只手 21 点、res.handedness[i][0]提供左右手与置信度;面部res.faceLandmarks为 468 点、开销大,默认仅作存在性使用。
需要说明的适用前提与限制:本包是实验性单人管线,maxPeople固定为 1;依赖浏览器端 WASM 推理,性能受设备与所选模型(姿态使用 lite 版)影响;手部支持最多 2 只手;仓库未提供对多人、离线编译模型或非浏览器运行环境的支持。这些边界在 README.md 的 "Backend assumptions" 一节中亦有明确声明。
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考