Skip to content

Commit 450bcf2

Browse files
authored
Merge pull request #3448 from BmanClark/main
AI Plank Tutor
2 parents a94fdc8 + ba3273d commit 450bcf2

10 files changed

Lines changed: 1369 additions & 0 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
---
2+
title: AI Plank Tutor
3+
weight: 2
4+
5+
### FIXED, DO NOT MODIFY
6+
layout: learningpathall
7+
---
8+
9+
## What you will build
10+
11+
In this Learning Path, you will build a simple on-device AI fitness tutor for Android.
12+
13+
The app watches a learner hold a plank, compares their body position with a stored instructor reference, asks a local LLM for one short correction, and speaks the correction using Android text-to-speech.
14+
15+
This project is based on the [AI Yoga Tutor](https://developer.arm.com/community/arm-community-blogs/b/ai-blog/posts/ai-yoga-tutor) demo. The Learning Path keeps the same core pipeline, but narrows the app to one static pose so you can focus on how a pipeline that includes Android camera, pose detector, local LLM, and speech output fits together.
16+
17+
![AI Plank Tutor final UI alt-text#center](screenshot.jpg "Figure 1: AI Plank Tutor showing the instructor plank image, live camera view, score, and spoken correction caption.")
18+
19+
The finished app has two main visual areas:
20+
21+
- An instructor plank image on the left.
22+
- A live front-camera preview on the right.
23+
24+
The app overlays a pose score and a short caption that matches the spoken coaching feedback.
25+
26+
This Learning Path starts with a "shell" project with MediaPipe and camera integration mostly setup. If you wish to learn about that setup from an empty project, you could try another Learning Path - [Build a Hands-Free Selfie Android Application with MediaPipe](https://learn.arm.com/learning-paths/mobile-graphics-and-gaming/build-android-selfie-app-using-mediapipe-multimodality/) is a good example.
27+
28+
## App pipeline
29+
30+
The app uses a small pipeline of on-device components:
31+
32+
```text
33+
reference image and camera view
34+
-> CameraX live frames
35+
-> Pose landmarks
36+
-> joint-angle scoring
37+
-> compact text prompt
38+
-> Arm AI Chat + LLM
39+
-> Text-To-Speech
40+
```
41+
42+
Each stage passes structured data to a subsequent stage. The LLM does not receive camera frames or images. It receives a short text prompt describing the largest joint-angle differences between the learner and the reference plank pose.
43+
44+
This keeps the LLM prompt small, reduces latency, and makes the behavior easier to tune.
45+
46+
## Clone the starter project
47+
48+
Clone the Learning Path code examples repository:
49+
50+
```console
51+
git clone https://gitlab.arm.com/learning-code-examples/code-examples.git
52+
```
53+
54+
The starter app for this Learning Path is in:
55+
56+
```text
57+
code-examples/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/android
58+
```
59+
60+
{{% notice Note %}}
61+
The starter project contains the app structure, layout, image asset, MediaPipe pose model, and several Kotlin shell files. You will fill in the missing code over the next pages.
62+
{{% /notice %}}
63+
64+
## Open the project in Android Studio
65+
66+
1. Start Android Studio.
67+
2. Select **Open**.
68+
3. Open `code-examples/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/android`.
69+
4. Wait for Gradle sync to finish.
70+
71+
If Android Studio prompts you to trust the project, accept the prompt.
72+
73+
The starter app is intentionally incomplete, but it should sync successfully before you add code.
74+
75+
## Inspect the provided files
76+
77+
Start by looking at the files that are already provided for you.
78+
79+
Open `app/build.gradle` and confirm that the Android, CameraX, lifecycle, and MediaPipe dependencies are already present.
80+
Arm's AI Chat dependency is not included yet. You will add it later, when you implement local LLM inference.
81+
82+
Open `app/src/main/AndroidManifest.xml` and confirm that the app requests camera access:
83+
84+
```xml
85+
<uses-permission android:name="android.permission.CAMERA" />
86+
```
87+
88+
Open `app/src/main/res/layout/activity_main.xml` and review the main UI. The layout already contains:
89+
90+
- An `ImageView` for the instructor plank image.
91+
- A `PreviewView` for the live camera.
92+
- A score label.
93+
- A caption label for spoken feedback.
94+
95+
Open `app/src/main/res/drawable/plank.jpg` and review the instructor reference image.
96+
97+
Code is under the long path `app/src/main/java/com/arm/demo/AIPlankTutor`. Under that, open `data/PlankPoseData.kt` and note the hard-coded plank reference data. This file contains the instructor's reference landmarks and angle weights used by the scoring step. This was generated from the reference plank image in an offline step so it doesn't need any runtime compute.
98+
99+
Android code starts from the `MainActivity.kt` file, and we will look at that in the next step.
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
---
2+
title: Get pose landmarks from camera
3+
weight: 3
4+
5+
### FIXED, DO NOT MODIFY
6+
layout: learningpathall
7+
---
8+
9+
## Objective
10+
11+
In this section, you will connect the Android camera to MediaPipe Pose Landmarker.
12+
13+
You will:
14+
15+
- Bind a CameraX preview to the app UI.
16+
- Add an `ImageAnalysis` use case for live camera frames.
17+
- Configure MediaPipe Pose Landmarker in live-stream mode.
18+
- Convert each CameraX `ImageProxy` into a MediaPipe `MPImage`.
19+
- Send the first detected pose landmark list to `MainViewModel`.
20+
21+
At the end of this section, the app opens the front camera and passes live pose landmarks into the app. The score will still be incomplete until you add pose scoring in the next section.
22+
23+
## Configure CameraX
24+
25+
Open `ui/MainActivity.kt`.
26+
27+
The starter project already requests camera permission and calls `setUpCamera()` from `onCreate()`. Replace the TODO in `setUpCamera()` with the following code:
28+
29+
```kotlin
30+
private fun setUpCamera() {
31+
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
32+
cameraProviderFuture.addListener(
33+
{
34+
cameraProvider = cameraProviderFuture.get()
35+
bindCameraUseCases()
36+
}, Dispatchers.Main.asExecutor()
37+
)
38+
}
39+
```
40+
41+
`ProcessCameraProvider` owns the camera use cases for the activity. When the provider is ready, the app stores it and calls `bindCameraUseCases()`.
42+
43+
## Bind preview and image analysis
44+
45+
The app needs two CameraX use cases:
46+
47+
- `Preview`, which displays the camera feed in the `PreviewView`.
48+
- `ImageAnalysis`, which receives frames for pose detection.
49+
50+
In `MainActivity.kt`, replace the TODO at the end of `bindCameraUseCases()` with this code:
51+
52+
```kotlin
53+
val preview = Preview.Builder()
54+
.setResolutionSelector(resolutionSelector)
55+
.setTargetRotation(targetRotation)
56+
.build()
57+
58+
val imageAnalyzer = ImageAnalysis.Builder()
59+
.setResolutionSelector(resolutionSelector)
60+
.setTargetRotation(targetRotation)
61+
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
62+
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)
63+
.build()
64+
.also {
65+
it.setAnalyzer(
66+
Dispatchers.Default.limitedParallelism(1).asExecutor()
67+
) { image -> detectPose(image) }
68+
}
69+
70+
cameraProvider.unbindAll()
71+
72+
try {
73+
cameraProvider.bindToLifecycle(
74+
this, cameraSelector, preview, imageAnalyzer
75+
)
76+
77+
preview.surfaceProvider = cameraPreview.surfaceProvider
78+
} catch (exc: Exception) {
79+
Log.e(TAG, "Use case binding failed", exc)
80+
}
81+
```
82+
83+
The analyzer uses `STRATEGY_KEEP_ONLY_LATEST` because pose detection should work on the most recent frame. If the device is busy, old frames are dropped instead of queued.
84+
85+
The output image format is `RGBA_8888`, which makes the frame data easy to copy into a `Bitmap` before passing it to MediaPipe.
86+
87+
## Send frames to the landmarker
88+
89+
In `bindCameraUseCases()` we just set `ImageAnalysis` to call `detectPose()` for every analyzed frame. Now, replace the TODO in `detectPose()` with this code:
90+
91+
```kotlin
92+
private fun detectPose(imageProxy: ImageProxy) {
93+
if (!this::poseLandmarkerHelper.isInitialized || poseLandmarkerHelper.isClosed) {
94+
imageProxy.close()
95+
return
96+
}
97+
98+
if (imageAnalysisEnabled) {
99+
poseLandmarkerHelper.detectLiveStream(
100+
imageProxy = imageProxy,
101+
isFrontCamera = true
102+
)
103+
} else {
104+
imageProxy.close()
105+
}
106+
}
107+
```
108+
109+
This code checks that the MediaPipe helper is ready before using it. If the helper is not ready, it closes the `ImageProxy` immediately.
110+
111+
{{% notice Note %}}
112+
Every `ImageProxy` from CameraX must be closed. In this app, `PoseLandmarkerHelper.detectLiveStream()` closes the image after copying its pixels. If the frame is skipped, `detectPose()` closes it directly.
113+
{{% /notice %}}
114+
115+
Now replace the TODO in `onResults()` with this code:
116+
117+
```kotlin
118+
override fun onResults(landmarks: List<NormalizedLandmark>?) {
119+
mainViewModel.handleUserPose(landmarks)
120+
}
121+
```
122+
123+
This is a callback from `PoseLandmarkerHelper` and sends the live landmarks onto the ViewModel. The next page will convert those landmarks into joint angles and a pose score.
124+
125+
## Configure MediaPipe Pose Landmarker
126+
127+
What happens between `detectPose()` and `onResults()`? Open `ui/landmarker/PoseLandmarkerHelper.kt`.
128+
129+
The starter file already contains the MediaPipe imports, model path, confidence values, and the `LandmarkerListener` interface for the callbacks to `MainActivity`.
130+
131+
Replace the TODO in `setupPoseLandmarker()` with this code:
132+
133+
```kotlin
134+
fun setupPoseLandmarker(context: Context) {
135+
try {
136+
val baseOptions = BaseOptions.builder()
137+
.setDelegate(Delegate.GPU)
138+
.setModelAssetPath(MODEL_PATH)
139+
.build()
140+
141+
val options = PoseLandmarker.PoseLandmarkerOptions.builder()
142+
.setBaseOptions(baseOptions)
143+
.setNumPoses(1)
144+
.setMinPoseDetectionConfidence(MIN_POSE_DETECTION_CONFIDENCE)
145+
.setMinTrackingConfidence(MIN_POSE_TRACKING_CONFIDENCE)
146+
.setMinPosePresenceConfidence(MIN_POSE_PRESENCE_CONFIDENCE)
147+
.setRunningMode(RunningMode.LIVE_STREAM)
148+
.setResultListener(this::returnLiveStreamResult)
149+
.setErrorListener(this::returnLiveStreamError)
150+
.build()
151+
152+
poseLandmarker = PoseLandmarker.createFromOptions(context, options)
153+
} catch (exception: IllegalStateException) {
154+
listener.onError("Pose Landmarker failed to initialize. See logs for details.")
155+
Log.e(TAG, "MediaPipe failed to load the pose landmarker", exception)
156+
} catch (exception: RuntimeException) {
157+
listener.onError("Pose Landmarker failed to initialize. See logs for details.")
158+
Log.e(TAG, "MediaPipe failed to create the pose landmarker", exception)
159+
}
160+
}
161+
```
162+
163+
The app uses `RunningMode.LIVE_STREAM` because frames arrive continuously from the camera. In this mode, MediaPipe returns results through callbacks instead of returning them directly from the detection call.
164+
165+
The app also requests one pose with `setNumPoses(1)`. This keeps the example focused on a single learner.
166+
167+
## Convert camera frames to MPImage
168+
169+
When we call from `MainActivity`, it is with a CameraX `ImageProxy`, but the MediaPipe analyzer expects an `MPImage`.
170+
171+
Replace the TODO in `detectLiveStream()` with this code to convert between the two and start `poseLandmaker`'s analysis:
172+
173+
```kotlin
174+
fun detectLiveStream(
175+
imageProxy: ImageProxy,
176+
isFrontCamera: Boolean
177+
) {
178+
val frameTime = SystemClock.uptimeMillis()
179+
val imageWidth = imageProxy.width
180+
val imageHeight = imageProxy.height
181+
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
182+
183+
val bitmapBuffer = Bitmap.createBitmap(
184+
imageWidth,
185+
imageHeight,
186+
Bitmap.Config.ARGB_8888
187+
)
188+
189+
imageProxy.use { image ->
190+
bitmapBuffer.copyPixelsFromBuffer(image.planes[0].buffer)
191+
}
192+
193+
val matrix = Matrix().apply {
194+
postRotate(rotationDegrees.toFloat())
195+
if (isFrontCamera) {
196+
postScale(-1f, 1f, imageWidth.toFloat(), imageHeight.toFloat())
197+
}
198+
}
199+
200+
val rotatedBitmap = Bitmap.createBitmap(
201+
bitmapBuffer,
202+
0,
203+
0,
204+
bitmapBuffer.width,
205+
bitmapBuffer.height,
206+
matrix,
207+
true
208+
)
209+
210+
val mpImage = BitmapImageBuilder(rotatedBitmap).build()
211+
poseLandmarker?.detectAsync(mpImage, frameTime)
212+
}
213+
```
214+
215+
The call to `imageProxy.use { ... }` closes the frame after its pixels are copied.
216+
217+
The matrix rotates the image using the camera frame metadata. For the front camera, it also mirrors the image so the detected pose matches the preview the learner sees.
218+
219+
## Return the first detected pose
220+
221+
Finally, replace the TODO in `returnLiveStreamResult()` with this code:
222+
223+
```kotlin
224+
private fun returnLiveStreamResult(
225+
result: PoseLandmarkerResult,
226+
@Suppress("UNUSED_PARAMETER") input: MPImage
227+
) {
228+
listener.onResults(result.landmarks().firstOrNull())
229+
}
230+
```
231+
232+
MediaPipe returns a list of detected poses. This app asks for one pose, so it forwards only the first landmark list.
233+
234+
## Run the app
235+
236+
Build and run the app on your Android device.
237+
238+
When prompted, allow camera access. You should see the front camera preview in the right side of the app.
239+
240+
The score will not update yet. At this point, the app is collecting pose landmarks and passing them to `MainViewModel`; the scoring logic is added in the next section.

0 commit comments

Comments
 (0)