**用 Android ML Kit 姿势检测 (Pose Detection) 完全可以实现 俯卧撑、仰卧起坐的自动计数与动作标准度检测。
核心原理:追踪 33 个骨骼点 → 计算关键角度 → 判断动作阶段(上 / 下)→ 状态机计数。
关键骨骼点(健身用):
NOSE, LEFT_SHOULDER, RIGHT_SHOULDER
LEFT_ELBOW, RIGHT_ELBOW
LEFT_WRIST, RIGHT_WRIST
LEFT_HIP, RIGHT_HIP
LEFT_KNEE, RIGHT_KNEE
LEFT_ANKLE, RIGHT_ANKLE
**
1. 依赖
java
implementation "androidx.camera:camera-core:1.3.0"
implementation "androidx.camera:camera-camera2:1.3.0"
implementation "androidx.camera:camera-lifecycle:1.3.0"
implementation "androidx.camera:camera-view:1.3.0"
implementation 'com.google.mlkit:pose-detection:18.0.0-beta3'
2. 权限 AndroidManifest.xml
java
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
3. 布局 activity_main.xml
java
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.camera.view.PreviewView
android:id="@+id/previewView"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="30dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent">
<TextView
android:id="@+id/tvPushUp"
android:text="俯卧撑: 0"
android:textSize="24sp"
android:textColor="#ffffff"/>
<TextView
android:id="@+id/tvSitUp"
android:text="仰卧起坐: 0"
android:textSize="24sp"
android:textColor="#ffffff"
android:layout_marginTop="8dp"/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
4. 角度计算工具类(Java)
工具:计算三点夹角
java
import com.google.mlkit.vision.pose.PoseLandmark;
import static java.lang.Math.acos;
import static java.lang.Math.sqrt;
public class AngleUtil {
public static float calculateAngle(PoseLandmark a, PoseLandmark b, PoseLandmark c) {
float baX = a.getX() - b.getX();
float baY = a.getY() - b.getY();
float bcX = c.getX() - b.getX();
float bcY = c.getY() - b.getY();
float dot = baX * bcX + baY * bcY;
float mag1 = sqrt(baX * baX + baY * baY);
float mag2 = sqrt(bcX * bcX + bcY * bcY);
if (mag1 == 0 || mag2 == 0) return 0;
float cosAngle = dot / (mag1 * mag2);
cosAngle = Math.max(Math.min(cosAngle, 1.0f), -1.0f);
return (float) Math.toDegrees(acos(cosAngle));
}
}
5. 俯卧撑计数器 PushUpCounter.java
俯卧撑计数逻辑(核心)
判定标准
高位(撑起):手肘角 > 160°(手臂接近伸直)
低位(下压):手肘角 < 100°(胸部接近地面)
计数:高 → 低 → 高 算一次
java
import com.google.mlkit.vision.pose.Pose;
import com.google.mlkit.vision.pose.PoseLandmark;
public class PushUpCounter {
private String state = "HIGH";
public int count = 0;
public void update(Pose pose) {
PoseLandmark shoulder = pose.getPoseLandmark(PoseLandmark.LEFT_SHOULDER);
PoseLandmark elbow = pose.getPoseLandmark(PoseLandmark.LEFT_ELBOW);
PoseLandmark wrist = pose.getPoseLandmark(PoseLandmark.LEFT_WRIST);
if (shoulder.getInFrameLikelihood() < 0.5f) return;
if (elbow.getInFrameLikelihood() < 0.5f) return;
if (wrist.getInFrameLikelihood() < 0.5f) return;
float angle = AngleUtil.calculateAngle(shoulder, elbow, wrist);
if (state.equals("HIGH")) {
if (angle < 100) {
state = "LOW";
}
} else if (state.equals("LOW")) {
if (angle > 160) {
state = "HIGH";
count++;
}
}
}
}
6. 仰卧起坐计数器 SitUpCounter.java
仰卧起坐计数逻辑(核心)
判定标准
平躺:髋 - 背 - 膝角度 > 150°(身体平直)
坐起:髋 - 背 - 膝角度 < 80°(上身抬起)
计数:躺 → 起 → 躺 算一次
java
import com.google.mlkit.vision.pose.Pose;
import com.google.mlkit.vision.pose.PoseLandmark;
public class SitUpCounter {
private String state = "LAYING";
public int count = 0;
public void update(Pose pose) {
PoseLandmark hip = pose.getPoseLandmark(PoseLandmark.LEFT_HIP);
PoseLandmark shoulder = pose.getPoseLandmark(PoseLandmark.LEFT_SHOULDER);
PoseLandmark knee = pose.getPoseLandmark(PoseLandmark.LEFT_KNEE);
if (hip.getInFrameLikelihood() < 0.5f) return;
if (shoulder.getInFrameLikelihood() < 0.5f) return;
if (knee.getInFrameLikelihood() < 0.5f) return;
float angle = AngleUtil.calculateAngle(knee, hip, shoulder);
if (state.equals("LAYING")) {
if (angle < 85) {
state = "SITTING";
}
} else if (state.equals("SITTING")) {
if (angle > 140) {
state = "LAYING";
count++;
}
}
}
}
7. 主页面 MainActivity.java(Java 完整版)
java
import android.Manifest;
import android.content.pm.PackageManager;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.camera.core.*;
import androidx.camera.lifecycle.ProcessCameraProvider;
import androidx.camera.view.PreviewView;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.mlkit.vision.common.InputImage;
import com.google.mlkit.vision.pose.Pose;
import com.google.mlkit.vision.pose.PoseDetection;
import com.google.mlkit.vision.pose.PoseDetector;
import com.google.mlkit.vision.pose.PoseDetectorOptions;
import android.widget.TextView;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class MainActivity extends AppCompatActivity {
private PreviewView previewView;
private TextView tvPushUp, tvSitUp;
private ExecutorService cameraExecutor;
private PushUpCounter pushUpCounter;
private SitUpCounter sitUpCounter;
private PoseDetector poseDetector;
private static final int REQUEST_CAMERA = 10;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
previewView = findViewById(R.id.previewView);
tvPushUp = findViewById(R.id.tvPushUp);
tvSitUp = findViewById(R.id.tvSitUp);
cameraExecutor = Executors.newSingleThreadExecutor();
pushUpCounter = new PushUpCounter();
sitUpCounter = new SitUpCounter();
PoseDetectorOptions options = new PoseDetectorOptions.Builder()
.setDetectorMode(PoseDetectorOptions.STREAM_MODE)
.build();
poseDetector = PoseDetection.getClient(options);
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED) {
startCamera();
} else {
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.CAMERA}, REQUEST_CAMERA);
}
}
private void startCamera() {
ListenableFuture<ProcessCameraProvider> future
= ProcessCameraProvider.getInstance(this);
future.addListener(() -> {
try {
ProcessCameraProvider provider = future.get();
Preview preview = new Preview.Builder().build();
preview.setSurfaceProvider(previewView.getSurfaceProvider());
ImageAnalysis imageAnalysis = new ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build();
imageAnalysis.setAnalyzer(cameraExecutor, imageProxy -> {
if (imageProxy.getImage() == null) {
imageProxy.close();
return;
}
InputImage image = InputImage.fromMediaImage(
imageProxy.getImage(),
imageProxy.getImageInfo().getRotationDegrees()
);
poseDetector.process(image)
.addOnSuccessListener(poses -> {
if (poses!=null) {
Pose pose = poses;
pushUpCounter.update(pose);
sitUpCounter.update(pose);
runOnUiThread(() -> {
tvPushUp.setText("俯卧撑: " + pushUpCounter.count);
tvSitUp.setText("仰卧起坐: " + sitUpCounter.count);
});
}
imageProxy.close();
})
.addOnFailureListener(e -> imageProxy.close());
});
CameraSelector selector = CameraSelector.DEFAULT_FRONT_CAMERA;
provider.unbindAll();
provider.bindToLifecycle(this, selector, preview, imageAnalysis);
} catch (Exception e) {
e.printStackTrace();
}
}, ContextCompat.getMainExecutor(this));
}
@Override
public void onRequestPermissionsResult(int requestCode,
@NonNull String[] permissions,
@NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == REQUEST_CAMERA) {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
startCamera();
}
}
}
@Override
protected void onDestroy() {
super.onDestroy();
cameraExecutor.shutdown();
}
}
8 新增:自定义绘制 View(画骨骼)
java
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.View;
import com.google.mlkit.vision.pose.Pose;
import com.google.mlkit.vision.pose.PoseLandmark;
import java.util.List;
public class PoseOverlayView extends View {
private Pose pose;
private final Paint pointPaint;
private final Paint linePaint;
public PoseOverlayView(Context context, AttributeSet attrs) {
super(context, attrs);
pointPaint = new Paint();
pointPaint.setColor(Color.GREEN);
pointPaint.setStyle(Paint.Style.FILL);
pointPaint.setAntiAlias(true);
pointPaint.setStrokeWidth(8f);
linePaint = new Paint();
linePaint.setColor(Color.YELLOW);
linePaint.setStyle(Paint.Style.STROKE);
linePaint.setAntiAlias(true);
linePaint.setStrokeWidth(4f);
}
public void setPose(Pose pose) {
this.pose = pose;
invalidate(); // 重绘
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (pose == null) return;
List<PoseLandmark> landmarks = pose.getAllPoseLandmarks();
for (PoseLandmark landmark : landmarks) {
if (landmark.getInFrameLikelihood() > 0.5f) {
canvas.drawCircle(landmark.getX(), landmark.getY(), 8f, pointPaint);
}
}
drawConnect(canvas, PoseLandmark.NOSE, PoseLandmark.LEFT_SHOULDER);
drawConnect(canvas, PoseLandmark.NOSE, PoseLandmark.RIGHT_SHOULDER);
drawConnect(canvas, PoseLandmark.LEFT_SHOULDER, PoseLandmark.LEFT_ELBOW);
drawConnect(canvas, PoseLandmark.LEFT_ELBOW, PoseLandmark.LEFT_WRIST);
drawConnect(canvas, PoseLandmark.RIGHT_SHOULDER, PoseLandmark.RIGHT_ELBOW);
drawConnect(canvas, PoseLandmark.RIGHT_ELBOW, PoseLandmark.RIGHT_WRIST);
drawConnect(canvas, PoseLandmark.LEFT_SHOULDER, PoseLandmark.LEFT_HIP);
drawConnect(canvas, PoseLandmark.RIGHT_SHOULDER, PoseLandmark.RIGHT_HIP);
drawConnect(canvas, PoseLandmark.LEFT_HIP, PoseLandmark.LEFT_KNEE);
drawConnect(canvas, PoseLandmark.RIGHT_HIP, PoseLandmark.RIGHT_KNEE);
drawConnect(canvas, PoseLandmark.LEFT_KNEE, PoseLandmark.LEFT_ANKLE);
drawConnect(canvas, PoseLandmark.RIGHT_KNEE, PoseLandmark.RIGHT_ANKLE);
drawConnect(canvas, PoseLandmark.LEFT_SHOULDER, PoseLandmark.RIGHT_SHOULDER);
drawConnect(canvas, PoseLandmark.LEFT_HIP, PoseLandmark.RIGHT_HIP);
}
private void drawConnect(Canvas canvas, int from, int to) {
PoseLandmark a = pose.getPoseLandmark(from);
PoseLandmark b = pose.getPoseLandmark(to);
if (a.getInFrameLikelihood() > 0.5f && b.getInFrameLikelihood() > 0.5f) {
canvas.drawLine(a.getX(), a.getY(), b.getX(), b.getY(), linePaint);
}
}
}
修改布局:把绘制层盖在相机上 activity_main.xml 加入:
<com.yourpackage.PoseOverlayView
android:id="@+id/overlayView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
MainActivity 里加入绘制
顶部声明:private PoseOverlayView overlayView;
overlayView = findViewById(R.id.overlayView);
在 poseDetector.process(image)
.addOnSuccessListener方法最后加一句:
pushUpCounter.update(pose);
sitUpCounter.update(pose);
runOnUiThread(() -> {
tvPushUp.setText("俯卧撑: " + pushUpCounter.count);
tvSitUp.setText("仰卧起坐: " + sitUpCounter.count);
overlayView.setPose(pose); // 绘制骨骼
});
效果
绿色圆点 = 关节点
黄色连线 = 骨骼
实时跟随人体运动
不影响计数
使用建议
光线充足
手机 侧放、全身入镜
距离 1.5~2 米