Skip to content

Commit 8bfd16e

Browse files
committed
Finish plank tutor learning path
1 parent 2c63496 commit 8bfd16e

7 files changed

Lines changed: 952 additions & 2 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
title: Introduction
2+
title: AI Plank Tutor
33
weight: 2
44

55
### FIXED, DO NOT MODIFY
@@ -23,6 +23,8 @@ The finished app has two main visual areas:
2323

2424
The app overlays a pose score and a short caption that matches the spoken coaching feedback.
2525

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, like [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/)
27+
2628
## App pipeline
2729

2830
The app uses a small pipeline of on-device components:

content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/2-camera-pose-landmarks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ This is a callback from `PoseLandmarkerHelper` and sends the live landmarks onto
124124

125125
## Configure MediaPipe Pose Landmarker
126126

127-
What happens between `detectPose()` and `onResults()`? Open `landmarker/PoseLandmarkerHelper.kt`.
127+
What happens between `detectPose()` and `onResults()`? Open `ui/landmarker/PoseLandmarkerHelper.kt`.
128128

129129
The starter file already contains the MediaPipe imports, model path, confidence values, and the `LandmarkerListener` interface for the callbacks to `MainActivity`.
130130

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
---
2+
title: Score the plank pose
3+
weight: 4
4+
5+
### FIXED, DO NOT MODIFY
6+
layout: learningpathall
7+
---
8+
9+
## Objective
10+
11+
In this section, you will turn MediaPipe pose landmarks into a simple plank score.
12+
13+
You will:
14+
15+
- Calculate joint angles from pose landmarks.
16+
- Compare the learner's live angles with the provided instructor reference.
17+
- Convert the weighted angle differences into a score from 0 to 100.
18+
- Display the score in the app UI.
19+
20+
The starter project already includes `PlankPoseData.kt`. This file contains the reference landmarks for the instructor plank image and a list of angle weights. The weights let the app make some joints more important than others when calculating the final score.
21+
22+
## Review the scoring data
23+
24+
Open `data/PlankPoseData.kt`.
25+
26+
The file provides three pieces of data:
27+
28+
- `POSE_NAME`, which is the name used later in the LLM prompt.
29+
- `referenceLandmarks`, which stores the instructor plank pose as MediaPipe landmarks.
30+
- `angleWeights`, which stores the relative importance of each measured angle.
31+
32+
You do not need to edit this file. In a larger app, you would capture this data from an instructor image or video as a preprocessing step, and load for different poses as needed. In this Learning Path, it is hard-coded so the Android app can stay focused on live inference and feedback.
33+
34+
## Extract joint angles
35+
36+
Open `ui/landmarker/PoseScoreHelper.kt`.
37+
38+
The file already contains landmark index constants and `KEY_JOINTS_COORDINATES`. Each entry in `KEY_JOINTS_COORDINATES` identifies three landmarks that form one angle. For example, an elbow angle uses shoulder, elbow, and wrist landmarks.
39+
40+
Replace the TODO in `extractAnglesFrom()` with this code:
41+
42+
```kotlin
43+
fun extractAnglesFrom(landmarks: List<NormalizedLandmark>): List<Double> {
44+
val angles = KEY_JOINTS_COORDINATES.map { coords ->
45+
calculateAngleFor(landmarks[coords[0]], landmarks[coords[1]], landmarks[coords[2]])
46+
}.toMutableList()
47+
48+
val neck = midpoint(landmarks[IDX_L_SHOULDER], landmarks[IDX_R_SHOULDER])
49+
val midHips = midpoint(landmarks[IDX_L_HIP], landmarks[IDX_R_HIP])
50+
angles.add(calculateAngleFor(landmarks[IDX_NOSE], neck, midHips))
51+
52+
return angles
53+
}
54+
```
55+
56+
The main list extracts wrist, elbow, shoulder, knee, ankle, and hip angles. The final angle estimates neck alignment by creating a midpoint between the shoulders and a midpoint between the hips, with the nose as the third landmark.
57+
58+
Replace the TODO in `midpoint()` with this code for the neck joint helper function:
59+
60+
```kotlin
61+
private fun midpoint(pointA: NormalizedLandmark, pointB: NormalizedLandmark) =
62+
NormalizedLandmark.create(
63+
(pointA.x() + pointB.x()) / 2,
64+
(pointA.y() + pointB.y()) / 2,
65+
(pointA.z() + pointB.z()) / 2
66+
)
67+
```
68+
69+
MediaPipe landmarks are normalized coordinates. The `x` and `y` values are relative to the input image, and `z` gives relative depth.
70+
71+
## Calculate a 3D angle
72+
73+
For each angle in `extractAnglesFrom()` it uses `calculateAngleFor()` - replace its TODO with this code:
74+
75+
```kotlin
76+
private fun calculateAngleFor(
77+
pointA: NormalizedLandmark,
78+
pointB: NormalizedLandmark,
79+
pointC: NormalizedLandmark
80+
): Double {
81+
val bax = pointA.x() - pointB.x()
82+
val bay = pointA.y() - pointB.y()
83+
val baz = pointA.z() - pointB.z()
84+
85+
val bcx = pointC.x() - pointB.x()
86+
val bcy = pointC.y() - pointB.y()
87+
val bcz = pointC.z() - pointB.z()
88+
89+
val dotProduct = bax * bcx + bay * bcy + baz * bcz
90+
val magnitudeBA = sqrt(bax.pow(2) + bay.pow(2) + baz.pow(2))
91+
val magnitudeBC = sqrt(bcx.pow(2) + bcy.pow(2) + bcz.pow(2))
92+
val cosine = (dotProduct / (magnitudeBA * magnitudeBC)).toDouble().coerceIn(-1.0, 1.0)
93+
94+
return Math.toDegrees(acos(cosine))
95+
}
96+
```
97+
98+
This function calculates the angle at `pointB`. It treats `pointA - pointB` and `pointC - pointB` as two 3D vectors, then uses their dot product to calculate the angle between them.
99+
100+
The `coerceIn(-1.0, 1.0)` call protects the `acos()` calculation from small floating-point rounding errors.
101+
102+
## Calculate a weighted score
103+
104+
Replace the TODO in `calculatePoseScore()` with this code:
105+
106+
```kotlin
107+
fun calculatePoseScore(
108+
correctAngles: List<Double>,
109+
userAngles: List<Double>,
110+
angleWeights: List<Float>,
111+
maxDifference: Double = MAX_ANGLE_DIFFERENCE
112+
): Double {
113+
require(correctAngles.isNotEmpty()) { "Correct angles cannot be empty." }
114+
require(correctAngles.size == userAngles.size) { "Correct and user angle counts must match." }
115+
require(correctAngles.size == angleWeights.size) { "Angle and weight counts must match." }
116+
117+
val weightedScoreSum = correctAngles.indices.sumOf { index ->
118+
val difference = abs(correctAngles[index] - userAngles[index])
119+
val normalizedScore = ((maxDifference - difference) / maxDifference)
120+
.coerceIn(0.0, 1.0) * HIGHEST_SCORE
121+
normalizedScore * angleWeights[index]
122+
}
123+
124+
return weightedScoreSum / angleWeights.sum().toDouble()
125+
}
126+
```
127+
128+
For each angle, the score starts at 100 and drops as the learner's angle differs from the reference angle. Differences at or above `MAX_ANGLE_DIFFERENCE` contribute zero for that angle.
129+
130+
The final score is the weighted average across all measured angles.
131+
132+
{{% notice Note %}}
133+
This score is a simple pose-matching signal for the demo app. It is not a clinical, safety, or fitness-quality assessment.
134+
{{% /notice %}}
135+
136+
## Emit score data from the ViewModel
137+
138+
Open `ui\viewmodels\MainViewModel.kt`.
139+
140+
Replace the TODO inside the `userPoseResults` mapping block with this code:
141+
142+
```kotlin
143+
val referenceAngles = extractAnglesFrom(PlankPoseData.referenceLandmarks)
144+
val userAngles = extractAnglesFrom(userLandmarks)
145+
val score = calculatePoseScore(
146+
correctAngles = referenceAngles,
147+
userAngles = userAngles,
148+
angleWeights = PlankPoseData.angleWeights
149+
)
150+
151+
UserPoseResult(referenceAngles, userAngles, score)
152+
```
153+
154+
This code calculates angles for the instructor reference and the learner's live pose, then stores both angle lists with the score. The next page will reuse the angle lists to build the LLM prompt.
155+
156+
Now replace the TODO in `userPoseScore` with this code:
157+
158+
```kotlin
159+
val userPoseScore: Flow<String> =
160+
sharedUserPoseResults.map { it.scoreVal.roundToInt().toString() }
161+
```
162+
163+
The UI wants a rounded string score. The underlying `UserPoseResult` keeps the full `Double` value for later use.
164+
165+
## Display the score
166+
167+
Open `ui\MainActivity.kt`.
168+
169+
The starter project keeps `collectAppState()` as a safe no-op so the app can run before scoring and speech are implemented. Replace that function with this version:
170+
171+
```kotlin
172+
private suspend fun collectAppState() = coroutineScope {
173+
launch {
174+
mainViewModel.userPoseScore.collect {
175+
score.text = getString(R.string.score) + it
176+
}
177+
}
178+
}
179+
```
180+
181+
Add this import near the other coroutine imports:
182+
183+
```kotlin
184+
import kotlinx.coroutines.coroutineScope
185+
```
186+
187+
The existing `repeatOnLifecycle(Lifecycle.State.STARTED)` block already calls `collectAppState()`. Changing the function to `suspend` is valid because `repeatOnLifecycle` runs its block from a coroutine.
188+
189+
Later sections will add more `launch { ... }` collectors inside this same `coroutineScope` block.
190+
191+
## Run the app
192+
193+
Build and run the app on your Android device.
194+
195+
When you move in front of the camera, the score should update as MediaPipe detects your landmarks. The score will vary with camera angle and distance from the device, but mainly with how closely your body position matches the reference plank pose.
196+
197+
At this point, the app can detect and score the plank pose. The next section converts the largest angle differences into a short text prompt for the local LLM.
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
---
2+
title: Turn pose data into a prompt
3+
weight: 5
4+
5+
### FIXED, DO NOT MODIFY
6+
layout: learningpathall
7+
---
8+
9+
## Objective
10+
11+
In this section, you will turn pose and score data into a compact text prompt for the local LLM.
12+
13+
You will:
14+
15+
- Compare the instructor and learner angle lists.
16+
- Sort the largest joint-angle differences.
17+
- Filter out small differences so the prompt stays short.
18+
- Format the remaining differences as plain text.
19+
- Expose the generated prompt from `MainViewModel`.
20+
21+
The LLM will not receive images or raw landmarks. It will receive a short list of the largest pose differences, such as:
22+
23+
```text
24+
I'm doing Plank pose. My joints have the following significant angle differences to the teacher's: Left hip flexion: -18, Right shoulder: 12
25+
```
26+
27+
This keeps the prompt small and gives the model only the facts it needs to produce one coaching cue.
28+
29+
## Format the largest angle differences
30+
31+
Open `ui/landmarker/PoseScoreHelper.kt`.
32+
33+
The file already contains `KEY_ANGLE_NAMES`, which maps each calculated angle to a human-readable joint name. The order of `KEY_ANGLE_NAMES` matches the order of the angles returned by `extractAnglesFrom()`.
34+
35+
Replace the TODO in `angleDifference()` with this code:
36+
37+
```kotlin
38+
fun angleDifference(
39+
correctAngles: List<Double>,
40+
userAngles: List<Double>,
41+
filter: Double = -1.0,
42+
maxEntries: Int = Int.MAX_VALUE
43+
): Map<String, String> {
44+
require(correctAngles.isNotEmpty()) { "Correct angles cannot be empty." }
45+
require(correctAngles.size == userAngles.size) { "Correct and user angle counts must match." }
46+
47+
return correctAngles.zip(userAngles)
48+
.zip(KEY_ANGLE_NAMES) { (correct, user), name ->
49+
val difference = correct - user
50+
Triple(name, difference, "%.0f".format(difference))
51+
}
52+
.filter { (_, difference, _) -> filter <= 0 || abs(difference) >= filter }
53+
.sortedByDescending { abs(it.second) }
54+
.take(maxEntries)
55+
.associate { (name, _, formatted) -> name to formatted }
56+
}
57+
```
58+
59+
This function pairs each reference angle with the corresponding learner angle, calculates the difference, and sorts by the absolute size of that difference.
60+
61+
The returned map uses the joint name as the key and a rounded string angle difference as the value. The string value is useful because the next function can insert it directly into the prompt.
62+
63+
{{% notice Note %}}
64+
The sign of the difference is important. A positive value means the learner needs to straighten that joint compared with the reference. A negative value means the learner needs to bend it more.
65+
{{% /notice %}}
66+
67+
## Generate the LLM prompt
68+
69+
Replace the TODO in `generateLlmPromptFrom()` with this code:
70+
71+
```kotlin
72+
fun generateLlmPromptFrom(
73+
referenceAngles: List<Double>,
74+
userAngles: List<Double>,
75+
poseName: String
76+
): String {
77+
val filteredAngles = angleDifference(
78+
referenceAngles,
79+
userAngles,
80+
JOINT_DIFF_FILTER,
81+
MAX_ENTRIES
82+
)
83+
84+
if (filteredAngles.isEmpty()) {
85+
return "I'm doing $poseName pose. My joint angles are close to the teacher's reference; give me one brief plank alignment cue."
86+
}
87+
88+
val angleSummary = filteredAngles.entries.joinToString { (name, angleDiff) ->
89+
"$name: $angleDiff"
90+
}
91+
return "I'm doing $poseName pose. My joints have the following significant angle differences to the teacher's: $angleSummary"
92+
}
93+
```
94+
95+
This function calls `angleDifference()` with values that ensure it filters out small differences using `JOINT_DIFF_FILTER` and limits the prompt to `MAX_ENTRIES` joint differences.
96+
97+
The fallback prompt handles the case where the learner is already close to the reference pose. The LLM still creates a useful instruction, but it is not forced to invent a large correction from tiny angle changes. In the full AI Yoga Tutor this fallback won't be hit as a different "praise" prompt is given when the score is high (greater than 70).
98+
99+
## Emit prompts from the ViewModel
100+
101+
Open `ui\viewmodels\MainViewModel.kt`.
102+
103+
Replace the TODO in `userPosePrompt` with this code:
104+
105+
```kotlin
106+
val userPosePrompt: Flow<Pair<String, Double>> =
107+
sharedUserPoseResults
108+
.filter { !_isSpeaking.value }
109+
.map { userResult ->
110+
generateLlmPromptFrom(
111+
referenceAngles = userResult.referenceAngles,
112+
userAngles = userResult.userAngles,
113+
poseName = poseName
114+
) to userResult.scoreVal
115+
}
116+
.flowOn(Dispatchers.Default)
117+
```
118+
119+
The prompt is paired with the score because fuller app versions might use both values when deciding what feedback is spoken. In this Learning Path, the LLM step uses the prompt text.
120+
121+
The `_isSpeaking` filter prevents the app from generating more feedback while the previous correction is still being spoken. Speech is added later, but keeping the filter here makes the data flow ready for that final step.
122+
123+
## Check the prompt in Logcat
124+
125+
The local LLM is added in the next section. For now, add a temporary collector so you can see the prompt in Logcat.
126+
127+
Open `MainActivity.kt` and update `collectAppState()` so it also collects `userPosePrompt`:
128+
129+
```kotlin
130+
private suspend fun collectAppState() = coroutineScope {
131+
launch {
132+
mainViewModel.userPoseScore.collect {
133+
score.text = getString(R.string.score) + it
134+
}
135+
}
136+
137+
launch {
138+
mainViewModel.userPosePrompt.collect { prompt ->
139+
Log.d(TAG, "Pose prompt: ${prompt.first}")
140+
}
141+
}
142+
}
143+
```
144+
145+
This Logcat collector is only for checking this section. In the next section, you will replace it with code that sends the prompt to the local LLM.
146+
147+
## Run the app
148+
149+
Build and run the app on your Android device.
150+
151+
Open Logcat and filter for `MainActivity`. As you move in front of the camera, you should see short prompts that describe the largest differences from the reference plank pose.
152+
153+
The app now has the complete input side of the tutor pipeline:
154+
155+
```text
156+
Camera frame -> pose landmarks -> angle score -> text prompt
157+
```
158+
159+
In the next section, you will add Arm's AI Chat library and send this prompt to a local LLM.

0 commit comments

Comments
 (0)