
简介基于人体姿态识别的舞蹈评分安卓应用包含完整开发源码与可直接安装的APK面向移动端AI开发者、舞蹈教学应用研究者及对姿态估计落地感兴趣的Android工程师。项目采用卷积神经网络处理图像数据实现动作采集、姿态估计、标准动作比对与实时打分全流程展示了深度学习算法在移动端应用的典型工程结构。压缩包共72个文件约38.49MB以Java源码、XML界面与配置、Gradle构建文件、so动态库及JAR依赖为主另有PNG/JPG资源图片、protobuf模型文件、README与LICENSE说明文档目录划分清晰。已有42人学习下载。除APK可直接体验外源码中模型与算法模块独立完整适合学习姿态识别数据流设计、Android端模型集成与实时评分逻辑也可作为毕业设计或二次开发的基础框架。资源仅供学习交流请勿商用。1. 人体姿态识别驱动的舞蹈评分安卓应用这个标题到底在解决什么问题“人体姿态识别驱动的舞蹈评分安卓应用开发源码及APK”看起来像一句需求描述但拆开看其实是一条非常完整的产出线安卓应用端接收摄像头画面用人体姿态识别模型提取骨骼关键点坐标把坐标折算成关节角度再把角度序列与标准动作模板做时间维度的匹配最终输出一个可解释的舞蹈分数。它服务的场景不是“识别出人在跳舞”而是“这一遍动作跳得准不准、幅度够不够、节奏对不对”。我在很多舞蹈考级、艺考陪练和健身镜项目里看到过同类需求用户照着示范视频跳完一段系统要告诉他哪个动作不到位而不是一句干巴巴的“完成度80%”。这篇文章会把模型选型、安卓端接入、评分算法、打包验证整个链路讲清楚源码和 APK 只是交付物真正的难点在姿态数据如何变成分数。适合正在做舞蹈教育 App、体感游戏或是想给健身直播加自动打分能力的安卓工程师。2. 技术选型为什么安卓端首选 MediaPipe Pose而不是 OpenPose 或 MoveNet2.1 三个候选方案模型尺寸、关键点质量和安卓适配度安卓端做人体姿态识别绕不开的三条路是 OpenPose、MoveNet 和 MediaPipe Pose。我最早在 PC 上用 OpenPose 做动作捕捉模型效果确实好官方模型一个分支就几百 MB桌面 GPU 跑起来都紧巴巴放到手机里第一轮就被否掉了。MoveNet 是 TensorFlow 官方出的轻量模型单帧推理极快但它的 17 个关键点在腿部交叉、身体遮挡时经常漂移用来给舞蹈打分不够稳。最后用的是 MediaPipe Pose它背后的模型是 BlazePose输出 33 个关键点在腿部、手部做了针对性优化并且 Google 官方把它封装成了 PoseLandmarker直接通过 Task Vision API 调用省去了自己拼 TFLite 模型和前处理后处理的麻烦。一个实际可用的舞蹈评分安卓应用姿态识别部分用 MediaPipe Pose 在工程上是风险最低的选择。方案模型体积安卓端实时性关键点数量遮挡/交叉时稳定性OpenPose数百 MB基本不可用18/25好MoveNet约 5 MB很流畅17一般腿部漂移MediaPipe Pose约 5–10 MB流畅GPU 加速33好适合全身动作2.2 33 个关键点里舞蹈评分真正用到的 12 个点MediaPipe Pose 输出的 33 个点覆盖了脸、手、躯干和四肢但舞蹈评分最关心的其实是肩、肘、腕、髋、膝、踝这六个关节的左右两侧也就是下表中的 12 个点。关节组左侧索引右侧索引在舞蹈评分中的作用肩1112手臂动作幅度、上身姿态肘1314手臂弯曲程度、动作到位度腕1516手臂末端位置的延伸髋2324重心位置、下肢姿态膝2526腿部弯曲、下蹲幅度踝2728脚部位置与重心支撑鼻子索引 0可以用来判断人物朝向和身体正不正但我不建议把它放进评分公式里因为它对手臂和腿部的动作质量没有直接贡献反而会引入头部晃动带来的噪声。33 个关键点里真正参与后续角度计算的就是这 12 个点剩下的点可以在可视化时画出来但不影响分数。2.3 为什么评分输入用关节角度而不是关键点坐标距离这是整个评分方案里最容易翻车的一个设计点。很多第一版 Demo 会把关键点坐标直接拿去做距离匹配比如计算用户手腕和模板手腕的欧氏距离然后映射成分数。问题是坐标值受到身高、镜头距离、焦距的强烈影响一个 150cm 的孩子和一个 180cm 的成年人跳同样的动作坐标差天然就大分数完全不可比。关节角度就不一样。角度由三个关键点计算得出比如左肘角度是左肩、左肘、左腕三个点的夹角它是平移不变和尺度不变的身高再高、离镜头再远只要手臂弯曲的弧度一样算出来的角度就一样。这就是我坚持全部使用角度序列作为评分特征的根本原因。后续模板生成、DTW 距离计算也都建立在角度序列之上整个链路里不会出现任何一个原始像素坐标直接进评分器。3. 安卓端接入用 CameraX MediaPipe Pose Landmarker 跑通实时姿态解析3.1 工程基础Gradle 依赖、minSdk 和 ABI 过滤搭建安卓工程时第一步是引入 MediaPipe Task Vision 库。这个库包含了 PoseLandmarker 的完整实现依赖声明如下android { compileSdk 34 defaultConfig { applicationId com.example.dancescore minSdk 21 targetSdk 34 versionCode 1 versionName 1.0 ndk { abiFilters arm64-v8a, armeabi-v7a } } } dependencies { implementation com.google.mediapipe:tasks-vision:0.10.x implementation androidx.camera:camera-core:1.3.4 implementation androidx.camera:camera-camera2:1.3.4 implementation androidx.camera:camera-lifecycle:1.3.4 implementation androidx.camera:camera-view:1.3.4 }注意 MiniSdk 设到 21 是为了覆盖多数安卓机targetSdk 按当季 Google Play 要求调整。ABI 过滤这里我只保留了 arm64-v8a 和 armeabi-v7ax86 和 x86_64 直接去掉既能减小 APK 体积又避免了老模拟器上 GPU 驱动缺失导致的黑屏问题。MediaPipe 在 Maven 中央仓库有发布版本号我习惯锁定到具体的小版本避免上游升级带来 API 不兼容。模型文件需要放到src/main/assets目录下文件名可以是pose_landmarker_lite.task。Lite 版帧率更高Full 版精度更好在舞蹈评分场景里我推荐先用 Lite 版本把流程跑通再根据真机表现决定要不要换 Full。3.2 CameraX 预览与分析流水线把每一帧交给 PoseLandmarkerCameraX 是安卓官方相机库比 Camera2 的样板代码少很多。我这里用一个ImageAnalysis.Analyzer持续接收相机帧把它包装成 MediaPipe 的InputImage再送入PoseLandmarker的detectForVideo方法。核心代码是这里class PoseAnalyzer( private val poseLandmarker: PoseLandmarker ) : ImageAnalysis.Analyzer { override fun analyze(imageProxy: ImageProxy) { val mediaImage imageProxy.image val rotationDegrees imageProxy.imageInfo.rotationDegrees if (mediaImage null) { imageProxy.close() return } val inputImage InputImage.fromMediaImage(mediaImage, rotationDegrees) val result poseLandmarker.detectForVideo(inputImage, SystemClock.elapsedRealtimeNanos()) val landmarks result.landmarks().firstOrNull() val angleList landmarks?.let { extractJointAngles(it) } ScoreBuffers.push(angleList) imageProxy.close() } }这里的detectForVideo是专门给连续视频帧用的接口它要求帧时间戳单调递增我用的是SystemClock.elapsedRealtimeNanos()这是一个从开机到现在的单调时钟不会因为手机改系统时间而回退。与detect单帧模式最大的区别就是视频模式下 PoseLandmarker 内部会复用跟踪状态帧间抖动明显更小单帧模式每帧都像第一次看到画面关键点会跳来跳去评分曲线没法看。extractJointAngles是一个自己实现的关节角度提取函数后面一节会写具体实现。注意 Analyzer 回调结束后必须调用imageProxy.close()否则 CameraX 会认为缓冲区没释放几秒后就开始丢帧。3.3 把关键点换算成关节角度并处理数值不稳定的边界前面说过评分输入是角度而非坐标。角度计算本质是一个数学函数给定三个关键点 A、B、C计算向量 BA 和 BC 的夹角。这里需要处理一个容易被忽略的边界问题点坐标是浮点数点积除以模长乘积后结果可能因为精度问题略大于 1 或略小于 -1导致acos直接返回 NaN。fun computeAngle(a: NormalizedLandmark, b: NormalizedLandmark, c: NormalizedLandmark): Float { 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 norm1 sqrt(v1x * v1x v1y * v1y) val norm2 sqrt(v2x * v2x v2y * v2y) if (norm1 0f || norm2 0f) return 0f var cosAngle dot / (norm1 * norm2) cosAngle cosAngle.coerceIn(-1f, 1f) return Math.toDegrees(acos(cosAngle.toDouble())).toFloat() } fun extractJointAngles(lm: ListNormalizedLandmark): FloatArray { return floatArrayOf( computeAngle(lm[11], lm[13], lm[15]), // 左肘 computeAngle(lm[12], lm[14], lm[16]), // 右肘 computeAngle(lm[11], lm[23], lm[25]), // 左腿根侧 computeAngle(lm[12], lm[24], lm[26]), // 右腿根侧 computeAngle(lm[23], lm[25], lm[27]), // 左膝 computeAngle(lm[24], lm[26], lm[28]) // 右膝 ) }参数上我用coerceIn(-1f, 1f)把余弦值限制在合法区间从根源上杜绝 NaN。角度返回值范围是 0 到 180 度这个数据天然适合直接进评分器。另外一个建议是在你调试时把每一帧的 6 个角度打出来看一眼确认数值范围是否合理——比如手臂伸直时肘关节角度应该在 160 度以上跳舞时手肘弯曲应该落在 30 度到 120 度之间。如果看到的角度一直不变不用怀疑是 keypoint 序号用错了。4. 舞蹈评分算法从关节角度序列到 DTW 距离打分4.1 舞蹈不是一张静态照片单帧匹配只能当“摆拍裁判”如果只是判断一个 pose 摆得像不像单帧角度对比就够了。但舞蹈是时间序列同一个动作有人卡着节拍停顿半秒有人一气呵成同样一个“大跳”起跳时机、空中停留时间、落地缓冲各有差异。直接拿用户第 3 帧和模板第 3 帧比根本不成立因为两者的帧根本没有对齐关系。我最初在这个项目上的失败版本就是逐帧对比对用户帧序列和模板帧序列做线性对齐然后算平均角度差。结果完全不行——用户稍微慢半拍整个对齐就错位分数从 90 分直接掉到 40 分。后来换成动态时间规整DTW问题才真正解决。DTW 允许两段序列在时间轴上做非线性拉伸快一点慢一点都能找到最优匹配路径这是舞蹈评分这类带节奏变化场景的常规解法。4.2 生成标准动作模板把一遍示范视频变成 JSON评分需要基准。我的做法是请一位老师或者我自己录一遍标准示范视频离线用同一个 MediaPipe 模型把视频抽成角度序列存成 JSON 放进 App 的 assets 目录里。模板生成用 Python 脚本做比在安卓端直接处理视频方便得多。import json import cv2 from mediapipe.tasks import python as mp_python from mediapipe.tasks.python import vision frame_angles [] with vision.PoseLandmarker.create_from_options( vision.PoseLandmarkerOptions( base_optionsmp_python.BaseOptions( model_asset_pathpose_landmarker_lite.task ), running_modevision.RunningMode.VIDEO, num_poses1, ) ) as landmarker: cap cv2.VideoCapture(template_dance.mp4) frame_index 0 fps cap.get(cv2.CAP_PROP_FPS) while cap.isOpened(): ret, frame cap.read() if not ret: break mp_image mp_python.Image(image_formatmp_python.ImageFormat.SRGB, dataframe) result landmarker.detect_for_video(mp_image, int(frame_index * 1000)) if result.landmarks: lm result.landmarks[0] angles [ compute_angle(lm[11], lm[13], lm[15]), compute_angle(lm[12], lm[14], lm[16]), compute_angle(lm[23], lm[25], lm[27]), compute_angle(lm[24], lm[26], lm[28]), ] frame_angles.append(angles) frame_index 1 cap.release() template {fps: 8, joint_names: [l_elbow, r_elbow, l_knee, r_knee], frames: frame_angles} with open(template.json, w) as f: json.dump(template, f)这个脚本里我做了一个关键的降采样操作模板视频原始帧率是 30fps但我只每秒取 8 帧左右的角度序列存进 JSON。舞蹈动作的有效频率远低于视频帧率大部分动作信息集中在低频段8fps 足够捕捉手臂和腿部的主要姿态变化同时把 DTW 的计算矩阵缩小到原来的四分之一手机上跑起来毫无压力。如果你做的舞蹈风格包含极快的转体或甩腿可以把 8 提升到 12 试试超过 15 基本没有额外收益。模板 JSON 的结构只有四部分fps记录采样频率joint_names记录角度顺序frames是核心数据每个元素是一个 4 维角度向量。安卓端启动时用 Gson 或 Moshi 直接解析不需要额外数据库。4.3 DTW 距离计算与得分映射DTW 算法的核心逻辑是动态规划用dp[i][j]表示用户序列第i帧到模板序列第j帧的最小累计距离递推关系取左、上、左上三个方向的最小值。我用 Kotlin 实现了一段紧凑版本可以直接嵌进安卓工程。fun dtwDistance(user: ListFloatArray, template: ListFloatArray): Double { val n user.size val m template.size val dp Array(n) { DoubleArray(m) } for (i in 0 until n) { for (j in 0 until m) { val cost angleDistance(user[i], template[j]) dp[i][j] when { i 0 j 0 - cost i 0 - dp[i][j-1] cost j 0 - dp[i-1][j] cost else - minOf(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) cost } } } return dp[n-1][m-1] } fun angleDistance(a: FloatArray, b: FloatArray): Double { var sum 0.0 for (k in a.indices) { val diff a[k] - b[k] sum diff * diff } return sqrt(sum / a.size) }angleDistance用的是欧氏距离的变体除以向量维度得到的是每个关节的平均角度偏差这样 4 个关节的误差和 6 个关节的误差可以用同一个阈值判断。DTW 计算完成后得分映射公式我用的是score 100 - dtwDistance * K score score.coerceIn(0, 100)其中K是一个需要标定的缩放系数。标定方法很朴素找三个会跳舞的人各自跳同一段模板动作记录 DTW 距离再让同一个人故意把动作做歪记录距离。把两组距离的中间值拉开到 20 分以上的差距K就选出来了。我常用的做法是先设 K 2.0然后拿三组数据回测一个上午看到正常动作得分集中在 85 到 95 之间、歪动作掉到 70 以下这个 K 值就固定下来。5. 避坑指南从黑屏到评分翻车的 5 个真实踩坑点5.1 现象相机预览正常可 Landmarker 输出全为 NaN 或 0现象是画面能出但日志里所有关键点坐标都是 NaN 或全 0角度计算直接崩溃。原因是 CameraX 回调拿到的图像是 YUV_420_888 格式传给InputImage.fromMediaImage时必须同时传入正确的旋转角度imageProxy.imageInfo.rotationDegrees在前置和后置相机上的值不同如果固定传 0MediaPipe 拿到的是一张横竖颠倒或旋转 90 度的图后处理时坐标越界输出就变成无效值。解决方法是严格使用imageProxy.imageInfo.rotationDegrees不要自己写死。另外如果detectForVideo的 timestamp 不是递增的MediaPipe 内部跟踪状态会异常输出也可能归零。检查一遍SystemClock.elapsedRealtimeNanos()是否被替换成了currentTimeMillis()。5.2 现象同一支舞跳三遍分数分别是 60、85、75这不是模型不稳定而是评分序列的对齐起点不一致。用户第一次从音乐前奏开始跳第二次从动作第一拍开始跳第三次中途停下来重跳采集到的角度序列长度和内容都不同DTW 虽然能容忍节奏差异但容忍不了整段动作的位移。解决办法是把模板和用户序列都截成“动作有效段”。我一般会在模板生成时手动剪掉视频前端和后端各 1 秒的准备动作与收势动作只保留从第一个大幅动作开始到最后一个动作结束的区间。安卓端运行时用一个简单的能量检测计算每一帧 6 个角度的方差方差超过阈值才视为动作开始低于阈值视为动作结束截取这段子序列去和模板比。这个阈值按角度单位标定通常设在 20 度左右。5.3 现象评分结果左右手反着做左手动作却反馈右手不标准问题出在 CameraX 预览做了镜像但 PoseLandmarker 的推理是在原始图像上进行的两者左右关系相反。用户对着屏幕做左手动作从原始图像看其实是右手动作模型输出的关键点坐标也是按原始图像算的所以评分器判定成了“右手错”。解决思路有两条。第一条是摄像头方向统一模板视频录制时也用前置摄像头这样模板和用户都经过同样的镜像变换左右在等价意义上是统一的。第二条是在提取关键点后做水平翻转以图像中心线为轴交换左右关键点的 x 坐标。我推荐用第一条因为改动最小只需要在录制模板时固定用前置摄像头。如果一定要用后置摄像头拍模板就对用户序列的关键点在进入角度计算前做一次左右交换。5.4 现象Debug 包运行流畅Release 包一安装就闪退这种情况通常不是代码逻辑问题而是 Release 包开启代码混淆后MediaPipe 的库类被混淆器重写了类名或方法名导致运行时找不到符号。logcat 里能看到NoSuchMethodError或ClassNotFoundException指向com.google.mediapipe包下的类。解决方法是绕开对第三方库的混淆。在proguard-rules.pro里加一行-keep class com.google.mediapipe.** { *; }或者干脆在build.gradle里把isMinifyEnabled保持为false。舞蹈评分 App 的 APK 体积大头是模型文件而不是代码逻辑开混淆的收益很小我通常直接关闭 release 的混淆和资源压缩省心。5.5 现象在模拟器或廉价平板上测试卡到只有 3fps模拟器和某些低端盒子上没有可用的 GPU 驱动MediaPipe 的 GPU delegate 初始化失败后会自动退回 CPU但某些设备连 CPU 的 TFLite 算子支持都不完整帧率跌到不可用。另一个隐藏问题是 x86 架构的模拟器上没有包含对应 ABI 的 so 库导致加载模型直接崩溃。解决方法是严格按真机调优开发测试一律用 arm64 真机调试阶段在代码里打印一下BaseOptions的 delegate 设置状态。如果确实需要在模拟器上演示可以在代码里显式设置 CPU delegate 并降低输入帧分辨率但只作为临时手段。正式打分功能必须构建在真机行为之上否则就成玄学调试了。提示以上第 5.1 和 5.3 属于边界问题很多照着示例代码改的人最早都会撞上第 5.4 则是打 release 包时的经典翻车建议你在首次打包前就把 keep 规则写好。6. 从源码到 APK打包、验证与调参的顺序6.1 用签名配置打包出可安装的 Release APK源码在 Android Studio 里跑通后打包成可侧载安装的 APK 只差一个签名配置。在build.gradle里配置签名产出带签名的 release 包这样可以直接发到手机安装。android { signingConfigs { release { storeFile file(keystore/dance_release.jks) storePassword your_keystore_password keyAlias dance keyPassword your_key_password } } buildTypes { release { isMinifyEnabled false signingConfig signingConfigs.getByName(release) ndk { abiFilters arm64-v8a } } } }这里只保留arm64-v8a一个 ABI 可以大幅缩小 APK 体积如果你的目标用户手里还有老 32 位设备再把armeabi-v7a加回来。打包完成后建议做一次“APK 逆向检查”用解压工具打开 APK确认lib目录下只有arm64-v8aassets目录下pose_landmarker_lite.task和template.json都在这两个文件如果缺失安装后会在运行时闪退。6.2 用“同框对比”验证评分灵敏度打包完之后不要急着发布先用一套系统的验证流程确认评分真的有区分度。我每次调完参数都会做三个测试测试项操作方式判断标准静态稳定性人保持同一姿势 10 秒分数波动不超过 3 分动作区分度同一动作刻意做歪一点再跳一遍分数下降超过 10 分重复一致性同一人连跳 3 遍正常动作分数标准差小于 5 分如果第一个测试就不通过问题是关键点抖动回看角度计算是否忘加平滑滤波第二个测试不通过说明K值太小分数对角度差异不敏感第三个测试不通过多半是起点截取不稳定。还有一个很好的可视化验证手段在预览画面里把模板骨架和用户骨架同时绘制出来一帧一帧对比角度曲线肉眼确认两个序列在关键帧上是否重合这一步能帮你排除绝大多数算法层的隐藏问题。我自己在这个项目上最大的教训是一开始图省事直接拿关键点坐标做欧氏距离匹配给十个测试志愿者跳同一支舞分数全部挤在 60 到 70 分之间根本分不出谁跳得好。换回角度序列加 DTW 以后分数区间才真正拉开这个血泪经验让我把“分数必须有区分度”当成了评分算法有效性的第一信号。希望帮到你。本文还有配套的精品资源点击获取