FitGuru 在 Android 端用 MediaPipe 姿态识别和关节角度状态机分析动作、统计次数,摄像头视频不上传云端。训练结束后,认知模块仅接收数值进行复盘,并结合 Gemini 与 ElevenLabs 提供反馈。
description: "我们在 Innovhacks 4.0 上打造了一款运行在手机端的健身教练,结合 MediaPipe 姿态识别、关节角度状态机、Gemini 训练复盘和 ElevenLabs 语音,全程无需将摄像头视频传到云端。"
tags: ai, genai, agentaichallenge, kotlin

Innovhacks 4.0 · ARKA 团队 · Android(Kotlin)· 多模态 AI
一组高强度训练刚做了三次。你没法看屏幕,也无法判断自己的深蹲是否足够深、胸部是否塌下去了,或者上一次动作是否完成了伸展锁定、到底算不算一次。
大多数“智能”健身应用的解决办法,是把摄像头画面传到服务器。但要用户上传自己在健身房锻炼的视频,这个要求实在不低。
在 Innovhacks 4.0 上,ARKA 团队开发了 FitGuru,想验证一个更聚焦的想法:
手机能否观察你的动作、统计这一组的次数、用语音提醒你纠正错误,并在结束后进行复盘,同时让视频始终留在设备上?
简短的答案是:可以,前提是把教练拆成两个大脑。一个开销很低的反射引擎,处理摄像头的每一帧;另一个认知引擎,只在一组训练结束后才启动,而且只看数字。
演示:见原文演示视频
代码:见原文代码仓库
APK:apk/FitGuru-Innovhacks4.apk
FitGuru 是我们通过 Major League Hacking(MLH)提交给 Innovhacks 4.0 的参赛作品。我们只有一个周末,还定下了一条硬性规则:视频绝不离开手机。这也是为什么最终架构采用了两个大脑,而不是一个巨型模型。
我们还使用了 MLH 黑客松经常向开发者提供的两款工具:
Gemini 2.5 Flash,用于生成每组训练结束后的生物力学报告和恢复方案
ElevenLabs,用于生成教练语音
第一天就确定的约束
只要摄像头画面离开手机,我们就已经输了。
这一条规则决定了整个架构:
无需账号,没有云端视频处理流水线。即使训练过程中断网,教练仍然能计数,也仍然能说话。
FitGuru 目前能做什么
打开应用,选择一个训练动作,把手机放在能拍到你的位置,然后开始这一组训练。
实时分析 18 种训练动作的姿势
深蹲、俯卧撑、硬拉、引体向上、反手引体向上、弓步蹲、平板支撑、二头肌弯举、跑步步频、卧推/推胸、肩推、腿举/腿屈伸/腿弯举、高位下拉、坐姿划船、肱三头肌下压。
通过关节角度状态机自动统计次数。深蹲次数不是猜出来的。只有当膝关节和髋关节角度越过“下蹲”阈值,再回到“起身”阈值之外,才算完成一次。
语音纠正,让你在一组训练中完全不用看屏幕。训练过程中给出简短提示。当你说出“finish set”后,再提供更完整的语音复盘。
免手动操作的语音指令:finish set、smart rest、form check、switch to deadlift、weight 40。
本地训练历史:次数、动作评分、训练总量、热量消耗、平均心率,全部存储在 Room 中。仪表盘上还提供每日挑战和每周次数目标。
面向新手的 20 种健身器械指南:器械的用途、如何调整、常见错误,以及 FitGuru 是否能实时指导对应动作。
还有一个 Web 配套应用,在浏览器中运行同样的 MediaPipe 处理循环,以及一个使用 Apple Vision 的 iOS SwiftUI 客户端。
两个大脑,而不是一个巨型模型
黑客松里一个很诱人的做法是:把视频帧持续传给 LLM,然后问它“这个深蹲做得好吗?”
这种做法会同时在三个方面出问题。
延迟。语言模型无法在摄像头一帧的 33 ms 时间窗口内给出回答。
隐私。你刚刚上传了一段用户锻炼的视频。
内存。我们试过在实时摄像头运行的同时运行一个真正的端侧 LLM。进程直接崩了。后面会详细讲。
所以,FitGuru 的多模态设计按职责分工,而不是把所有传感器数据都塞进同一个提示词。
Camera ──► MediaPipe Pose (on-device)
│
▼
33 landmarks / frame
│
▼
Reflex Engine (always on)
• joint angles
• state machine (ready → down → up)
• form faults + 2.5s cue cooldown
• Gym Shield (lock onto you, ignore walkers)
│
┌──────────┴──────────┐
▼ ▼
ElevenLabs / TTS Room (history)
│
▼ only after the set
Gemini 2.5 Flash
(JSON of numbers, never video)
│
▼
Spoken debrief + recovery protocol
反射引擎靠的是三角函数和阈值,开销低到足以处理每一帧。认知引擎则是 Gemini。它看到的是一次训练的摘要,连一个像素都看不到。
这种分工,就是产品的核心。
从像素到膝关节角度
Android 上的摄像头处理链路是 CameraX → MediaPipe Pose Landmarker(pose_landmarker_full.task),采用 LIVE_STREAM 模式。每次输出包含 33 个归一化的三维关键点:鼻子、肩、肘、腕、髋、膝、踝、脚趾。
我们从不问模型“刚才是不是一次深蹲”,而是计算关节处的夹角。
// angle at landmark B, formed by A–B–C
fun calculateAngle(a: Landmark, b: Landmark, c: Landmark): Double {
val v1x = a.x() - b.x(); val v1y = a.y() - b.y()
val v2x = c.x() - b.x(); val v2y = c.y() - b.y()
val dot = v1x * v2x + v1y * v2y
val mag1 = hypot(v1x, v1y)
val mag2 = hypot(v2x, v2y)
if (mag1 * mag2 == 0.0) return 180.0
val cos = (dot / (mag1 * mag2)).coerceIn(-1.0, 1.0)
return Math.toDegrees(acos(cos))
}
对于深蹲,B 是膝,A 是髋,C 是踝。其他训练动作也使用同一个辅助函数,只是换成不同的关节:
叠加层会在屏幕上绘制骨架和实时角度。你可以不看 HUD,教练会在耳边指导你。
深蹲是一个状态机,而不是分类器
如果视觉模型只凭单帧画面就喊“完成一次!”,就会重复计数、漏掉伸展锁定,还会把半程动作算成完整动作。
每种训练动作都是一个小型自动机。以深蹲为例:
ready ──(knee < 105° AND hip < 115°)──► down
down ──(knee > 155° AND hip > 155°)──► up (+1 rep)
只有在上升沿才计数:你先蹲下,再站起来。同样的角度也用于检查动作:
在深蹲最低点,髋关节角度缩小到 75° 以下 → “挺起胸部。”
俯卧撑时髋部折叠 → “抬高髋部。”
弯举时上臂摆动超过 35° → “保持肘部固定。”
硬拉时杠铃还在低位,髋部却快速上抬 → “髋部抬得太快。”
语音提示有 2.5 秒的冷却时间。教练可以提供帮助,但不能喋喋不休。
平板支撑是个例外:它不计次数。我们用姿势正确的秒数/总秒数来评分。跑步评估的是步频,而不是次数。
这一整层就是反射引擎。它始终运行,不需要等待 Gemini。
Gym Shield:为什么摄像头不会突然跟踪你身后经过的人
健身房对姿态模型很不友好。有人从你身后走过,MediaPipe 检测到第二个人体,结果你突然就“完成”了对方做的三次深蹲。
我们让关键点检测器同时检测多个人的姿态,再锁定你。
根据躯干大小和距离画面中心的远近,为检测到的每个人体评分。
在最显著的躯干上锁定一个跟踪锚点。
如果这个人体暂时消失,比如有人从前面经过,就冻结计数状态 45 帧,约 1.5 秒。既不增加次数,也不切换训练者。
只有在宽限窗口结束后,才重新选择主要跟踪对象。
HUD 会显示 Gym Shield: Passerby Filtered • Holding Rep State。这行提示之所以存在,是因为在写出这套逻辑之前,陌生人曾经搅乱过我们的整组计数。
本地优先不是口号,而是数据流向。
视频绝不离开手机,关键点也绝不离开手机。训练历史存储在设备上的 Room 数据表中:
@Entity(tableName = "workout_history")
data class WorkoutSession(
val date: Date,
val exerciseType: String,
val exerciseName: String,
val reps: Int,
val score: Int, // form accuracy 0–100
val calories: Int = 0,
val avgHeartRate: Int = 0,
val weightKg: Double = 0.0
)
每周目标和每日挑战通过日期范围查询读取这张表。仪表盘上的日历、连续训练记录,以及“本周 200 次”进度条,全都在本地运行。
只有当你完成一组训练,并且粘贴了 Gemini API key 时,我们才会调用云端模型。发送的内容只是一段由数字组成的文本:
Exercise: Squats
Completed Reps: 12
Form Accuracy Score: 88%
Weight Lifted: 60 kg (Total Volume: 720 kg)
Primary Biomechanical Fault: Keep your chest up!
Key Measured Joint Angle: 82°
没有视频帧,没有关键点列表,没有人脸。只有大约 4 KB 的文本。
如果没有 API key,或者请求失败,系统仍会生成一份基于规则的报告:动作问题诊断、三条提示,以及两项活动度练习。一组训练的结果绝不会被网络卡住。
为训练复盘引入语言模型
一组训练进行到一半时,语言模型并不适合介入。但训练结束后,它就很合适了。
GeminiService 调用 Gemini 2.5 Flash,并以 1.5 Flash 作为备用。我们要求模型严格输出 JSON;如果模型兴致来了,额外加上 Markdown 代码块标记,就把这些标记去掉。
{
"diagnosis": "…root cause in two sentences…",
"correctiveCues": ["cue 1", "cue 2", "cue 3"],
"accessoryDrills": ["drill 1", "drill 2"]
}
{
"recoveryWindowHours": 36,
"strainAssessment": "…",
"proteinGrams": 35,
"hydrationMl": 800,
"activeStretches": ["…", "…", "…"]
}
训练总结底部面板会同时展示这两项内容。点击 Hear Coach Review,ElevenLabs 就会用你选择的声音角色朗读诊断结果和指导提示。
这就是用户真正能感受到的多模态闭环:视觉统计这一组的次数 → 数据交给 Gemini → 语音说出训练计划。
我们没有做的事:把视频发送给 Gemini。模型扮演的是查看统计表的运动科学专家,而不是摄像师。
为教练赋予声音
Android TTS 可以说出“第八次”。但它无法让你相信,房间里真的有一位教练。
我们使用 ElevenLabs eleven_turbo_v2_5,并提供三种声音角色:
关于延迟,我们得到的教训很简单:如果每做一次动作都合成一句“深蹲深度很棒!”,你就会错过下一次动作。
因此,语音栈以缓存优先,而不是流式传输优先。
对 voiceId + phrase 计算哈希(MD5)。
如果 cacheDir/elevenlabs_cache/<hash>.mp3 存在,就直接播放。对于计数和常用提示,这就是 0 毫秒的路径。
如果缓存未命中,就通过 POST 发送文本,写入 MP3,然后播放。
如果发生超时、返回 4xx,或者没有 API 密钥,就回退到 Android TextToSpeech。
我们下载完整的音频片段,而不是以流式方式传输音频分块。首次出现的语句需要等待网络请求(连接超时 8 秒,读取超时 12 秒)。重复使用的健身用语——“俯卧撑做得不错”“休息结束”“保持挺胸”——在首次请求之后就能即时播放。
离线时,教练的声音会变得平淡一些。但它不会沉默。
解放双手,因为你的手正握着杠铃
VoiceCommandManager 封装了 Android SpeechRecognizer,并通过循环重启持续监听。
第一次在嘈杂的健身房测试后,我们不得不加上两道护栏:
回声锁定。如果 ElevenLabs 正在说话,就忽略麦克风输入。否则,教练会听到自己的声音,并“结束”你的这一组训练。
2 秒防抖。部分识别结果会频繁触发。同一条命令不应该执行三次。
心率是第二种感知能力,而不是第二个应用
视觉能看到关节。但它看不出你的心率仍然高达 168 BPM。
Wear OS / Galaxy Watch 客户端可以通过 Data Layer 的 /heart_rate 路径推送 BPM。DataLayerListenerService 会通过广播,将这些数据传入实时训练 HUD。
Smart Rest 面板对心率的使用刻意保持简单:如果心率已经降到 120 或以下,我们会告诉你可以继续训练;否则,我们会提醒你调整呼吸,并允许你增加 30 秒休息时间。计时器归零时,教练会说“休息结束”。
心率也会随 WorkoutSession 一同传入 Gemini 的恢复建议提示词。手表是可选的。摄像头教练不依赖它。
没有教练在身旁指导时,健身房里的其他需求
第一次练力量的人,需要的不只是一个深蹲计数器。他们还需要知道,高位下拉器械的配重插销该插在哪一档。
gym_catalog.json 是一份涵盖 20 种器械与训练项目的指南:高位下拉、坐姿划船、推胸、肩推、腿举/腿屈伸/腿弯举、绳索器械、Smith 器械、深蹲架、哑铃、训练凳、蝴蝶机、跑步机、健身车、划船机、训练垫、硬拉台、引体向上杆、跑步步频。
每张卡片都包含项目介绍、目标肌群标签、设置步骤、动作执行方法、常见错误,以及摄像头使用提示。如果该动作已实现状态机,卡片就能启动实时指导。
问候语(无需登录,姓名保存在 SharedPreferences 中)
由 Room 存储支持的每周动作次数目标
按一年中的日期每日轮换的俯卧撑挑战
通往分析页面(动作质量/训练量/心率)和健身房器械目录的快捷入口
个人资料和历史记录都存储在同一个数据库中,无需云端账户。
浏览器中(以及 iOS 上)的同一位教练
评委不应该还得安装 Android Studio。
配套 Web 应用是一个 Node/Express 应用,在页面中运行 MediaPipe:支持摄像头、骨架叠加显示、同一套提示语词典,以及可穿戴设备 BPM 模拟器。它以 Docker 容器形式部署(仓库中包含 DigitalOcean App Platform 的配置文件)。
iOS 端是一个 SwiftUI 客户端:在设备上运行 Apple Vision(VNDetectHumanBodyPoseRequest),使用同样的 ElevenLabs + Gemini 服务,内置健身房器械目录,并将历史记录保存在本地存储中。
Android 端的实现最为深入。各个平台的思路一致:在设备上识别姿态,在一组结束后进行语言分析,以语音作为交互界面。
出了哪些问题(真正有用的部分)
我们原本想在设备上运行 Gemma,用于训练过程中的语言指导。但在 CameraX + MediaPipe 运行时,同时运行一个约 1.3 GB 的模型,导致了原生层内存崩溃。
PoseLandmarkerHelper 现在提供 pause() / resume(),因此我们可以销毁姿态检测器、释放缓冲区、运行高负载推理,然后恢复运行。在当前 Android 版本中,训练过程里的“Gemma AI”提示来自一个基于模板的认知层(错误类型 → 更详细的提示),并带有一个短暂的思考状态指示,而不是让一个 2B 模型与摄像头一起驻留在 RAM 中。真正的 LLM 调用使用 Gemini,在这一组结束后,基于 JSON 摘要进行。
兜底的是 Reflex Engine。即使智能分析层正在思考,或者已经失效,动作次数仍然会继续统计。
第一版手表数据接入使用了 Sensor SDK。构建失败,而且 API 表现不一致。我们改用了 Wearable Data Layer(/heart_rate → 本地广播)。配对手表后,心率会显示在 HUD 和 Smart Rest 中。它是可选功能,而不是硬性依赖。
knee < 105° 对许多人来说是一个不错的深蹲判断条件,但对另一些人来说却很糟糕。股骨长度、摄像头高度和站姿都会改变各个角度。我们收紧了深蹲的判定条件,同时考虑膝关节和髋关节角度,要求完全站直后才计数,并加入 2.5 秒的提示冷却时间,避免角度噪声把教练变成 TED 演讲者。针对每位用户进行校准,仍然是我们下一步需要解决的问题。我们还没有推出这项功能。
第一次将 ElevenLabs 与 SpeechRecognizer 配合使用时,麦克风听到了扬声器传出的“finish set”。防抖加上“说话时不监听”,解决了这个问题。
在加入 Gym Shield 之前,从你身后走过的人可能会成为姿态检测器的跟踪目标。主体显著性评分加上遮挡时保持跟踪 45 帧,让它从一个演示程序变成了真正能在健身房使用的应用。
我们以 Google 官方的 MediaPipe Pose Landmarker Android 示例为起点,保留了 CameraX 与姿态检测器之间的接入逻辑。这省下了我们原本要花在 YUV 缓冲区上的一周时间。
基于该示例构建的所有上层功能都由我们开发:运动状态机、Gym Shield、Room 历史记录、挑战、健身房器械目录、Gemini 训练总结、ElevenLabs 缓存和声音角色、语音命令、Smart Rest、分析功能、配套 Web 应用,以及 iOS 客户端。
应当认可这个示例的贡献。但不要把它与产品混为一谈。
利用我们已经打通的暂停/恢复路径,运行真正的端侧 LLM(Gemma / MediaPipe LLM Inference),在无需 API 密钥的情况下提供离线训练总结。
融合实时心率,让它影响这一组训练,而不只是休息计时器。如果 BPM 上升时动作质量评分下降,就在训练过程中提醒用户。
针对每位用户进行角度校准。拍摄一组标准动作,根据这套骨架数据调整阈值。
训练时序数据(以分钟为尺度观察动作质量评分与心率的关系)。目前,我们在 Analytics 中本地计算聚合数据。完善的连续数据存储仍有待实现——我们没有硬塞进一个实际上根本不会查询的时序数据库。
可选密钥(Settings / SharedPreferences):Gemini 用于组后报告,ElevenLabs 用于录音棚级语音。统计深蹲次数不需要其中任何一个。
用一行概括技术栈:Kotlin · CameraX · MediaPipe Pose · 基于关节角度的 Reflex Engine · Room · Android SpeechRecognizer · ElevenLabs turbo + TTS 回退 · Gemini 2.5 Flash · Wear Data Layer · Node 配套 Web 应用 · SwiftUI + Vision。
由 Team ARKA 的 Allan Fernandes 和 Atharva Chaudhari 为 Innovhacks 4.0 构建。
教练就在你的口袋里。视频也始终留在那里。
部分评论可能仅对已登录的访客可见。登录后即可查看所有评论。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为