Skip to content

Commit 2c63496

Browse files
committed
p1 updated, p2 now done
1 parent 3af0212 commit 2c63496

3 files changed

Lines changed: 255 additions & 12 deletions

File tree

content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/1-introduce-plank-tutor.md

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,7 @@ The app watches a learner hold a plank, compares their body position with a stor
1414

1515
This project is based on the [AI Yoga Tutor](https://developer.arm.com/community/arm-community-blogs/b/ai-blog/posts/ai-yoga-tutor) prototype. 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.
1616

17-
<!-- TODO: Add final app screenshot here.
18-
Suggested image path: images/final-ui.png
19-
Suggested caption: Figure 1: AI Plank Tutor showing the instructor plank image, live camera view, score, and spoken correction caption.
20-
-->
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.")
2118

2219
The finished app has two main visual areas:
2320

@@ -44,15 +41,19 @@ Each stage passes structured data to a subsequent stage. The LLM does not receiv
4441

4542
This keeps the LLM prompt small, reduces latency, and makes the behavior easier to tune.
4643

47-
## Get the starter project
44+
## Clone the starter project
4845

49-
Download the starter project from:
46+
Clone the Learning Path code examples repository:
5047

51-
```text
52-
TODO: <starter app download URL>
48+
```console
49+
git clone https://gitlab.arm.com/learning-code-examples/code-examples.git
5350
```
5451

55-
Unzip the starter project and open the Android project in Android Studio.
52+
The starter app for this Learning Path is in:
53+
54+
```text
55+
code-examples/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/android
56+
```
5657

5758
{{% notice Note %}}
5859
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,7 +63,7 @@ The starter project contains the app structure, layout, image asset, MediaPipe p
6263

6364
1. Start Android Studio.
6465
2. Select **Open**.
65-
3. Open the starter project's `android` folder.
66+
3. Open `code-examples/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/android`.
6667
4. Wait for Gradle sync to finish.
6768

6869
If Android Studio prompts you to trust the project, accept the prompt.
@@ -89,6 +90,8 @@ Open `app/src/main/res/layout/activity_main.xml` and review the main UI. The lay
8990
- A score label.
9091
- A caption label for spoken feedback.
9192

92-
Open `app/src/main/assets/videos/plank.jpg` and review the instructor reference image.
93+
Open `app/src/main/res/drawable/plank.jpg` and review the instructor reference image.
94+
95+
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.
9396

94-
Open `PlankPoseData.kt` and note the hard-coded plank reference data. This file contains the reference landmarks and angle weights used by the scoring step. This was generated from the image in an offline step so it doesn't need any runtime compute.
97+
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 `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.
285 KB
Loading

0 commit comments

Comments
 (0)