Skip to content

Commit a38602d

Browse files
authored
Merge pull request #3470 from pareenaverma/review_branch
Tech review of AI Plank Tutor LP
2 parents 0046884 + 6f31778 commit a38602d

8 files changed

Lines changed: 95 additions & 29 deletions

File tree

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ This keeps the LLM prompt small, reduces latency, and makes the behavior easier
4545

4646
## Clone the starter project
4747

48-
Clone the Learning Path code examples repository:
48+
Clone the `PlankTutor` Learning Path code example repository.
4949

5050
```console
5151
git clone https://gitlab.arm.com/learning-code-examples/code-examples.git
@@ -70,7 +70,7 @@ The starter project contains the app structure, layout, image asset, MediaPipe p
7070

7171
If Android Studio prompts you to trust the project, accept the prompt.
7272

73-
The starter app is intentionally incomplete, but it should sync successfully before you add code.
73+
The starter app is intentionally incomplete, but it should sync successfully before you add code. If the `ai-plank-tutor/android` directory is missing, check that you cloned the `PlankTutor` branch.
7474

7575
## Inspect the provided files
7676

@@ -79,12 +79,16 @@ Start by looking at the files that are already provided for you.
7979
Open `app/build.gradle` and confirm that the Android, CameraX, lifecycle, and MediaPipe dependencies are already present.
8080
Arm's AI Chat dependency is not included yet. You will add it later, when you implement local LLM inference.
8181

82+
Also note the `packaging { jniLibs { useLegacyPackaging = true } }` setting. The AI Chat library you add later uses native libraries, and this packaging setting lets the app load those libraries correctly.
83+
8284
Open `app/src/main/AndroidManifest.xml` and confirm that the app requests camera access:
8385

8486
```xml
8587
<uses-permission android:name="android.permission.CAMERA" />
8688
```
8789

90+
The app package is `com.arm.demo.AIPlankTutor`. You will use that package name later when copying the LLM model into the app-specific external files directory with `adb`.
91+
8892
Open `app/src/main/res/layout/activity_main.xml` and review the main UI. The layout already contains:
8993

9094
- An `ImageView` for the instructor plank image.
@@ -94,6 +98,6 @@ Open `app/src/main/res/layout/activity_main.xml` and review the main UI. The lay
9498

9599
Open `app/src/main/res/drawable/plank.jpg` and review the instructor reference image.
96100

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.
101+
Code is under the long path `app/src/main/java/com/arm/demo/AIPlankTutor`. Copy this path exactly, including the capitalized `AIPlankTutor` package directory. 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.
98102

99-
Android code starts from the `MainActivity.kt` file, and we will look at that in the next step.
103+
Android code starts from the `MainActivity.kt` file, and you will look at that in the next step.

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ The analyzer uses `STRATEGY_KEEP_ONLY_LATEST` because pose detection should work
8484

8585
The output image format is `RGBA_8888`, which makes the frame data easy to copy into a `Bitmap` before passing it to MediaPipe.
8686

87+
The analyzer runs on a background executor with one worker. That keeps camera frame conversion off the UI thread and serializes calls into the landmarker helper, which is useful because live-stream inference is driven by timestamped frames.
88+
8789
## Send frames to the landmarker
8890

8991
In `bindCameraUseCases()` we just set `ImageAnalysis` to call `detectPose()` for every analyzed frame. Now, replace the TODO in `detectPose()` with this code:
@@ -164,11 +166,13 @@ The app uses `RunningMode.LIVE_STREAM` because frames arrive continuously from t
164166

165167
The app also requests one pose with `setNumPoses(1)`. This keeps the example focused on a single learner.
166168

169+
The `Delegate.GPU` option asks MediaPipe to use the device GPU for the pose model. If initialization fails on a particular device, the error listener and Logcat message are the first places to check.
170+
167171
## Convert camera frames to MPImage
168172

169173
When we call from `MainActivity`, it is with a CameraX `ImageProxy`, but the MediaPipe analyzer expects an `MPImage`.
170174

171-
Replace the TODO in `detectLiveStream()` with this code to convert between the two and start `poseLandmaker`'s analysis:
175+
Replace the TODO in `detectLiveStream()` with this code to convert between the two and start the Pose Landmarker analysis:
172176

173177
```kotlin
174178
fun detectLiveStream(
@@ -216,6 +220,8 @@ The call to `imageProxy.use { ... }` closes the frame after its pixels are copie
216220

217221
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.
218222

223+
`SystemClock.uptimeMillis()` provides a monotonic timestamp for the frame. MediaPipe requires timestamps for video and live-stream inputs, and `detectAsync()` returns immediately; the result arrives later through the result listener configured above.
224+
219225
## Return the first detected pose
220226

221227
Finally, replace the TODO in `returnLiveStreamResult()` with this code:
@@ -235,6 +241,8 @@ MediaPipe returns a list of detected poses. This app asks for one pose, so it fo
235241

236242
Build and run the app on your Android device.
237243

238-
When prompted, allow camera access. You should see the front camera preview in the right side of the app.
244+
When prompted, allow camera access. Expect to see the front camera preview in the right side of the app.
245+
246+
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.
239247

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.
248+
If the preview opens but no landmarks are produced later, make sure your full body is visible in the frame and check Logcat for `PoseLandmarkerHelper`.

content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/3-plank-score.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ private fun midpoint(pointA: NormalizedLandmark, pointB: NormalizedLandmark) =
6868

6969
MediaPipe landmarks are normalized coordinates. The `x` and `y` values are relative to the input image, and `z` gives relative depth.
7070

71+
While this demo compares normalized landmarks from a fixed instructor reference, the score is still fairly view-dependent. It works best when the learner is side-on to the camera, fully visible, and at a similar orientation to the reference image.
72+
7173
## Calculate a 3D angle
7274

7375
For each angle in `extractAnglesFrom()` it uses `calculateAngleFor()` - replace its TODO with this code:
@@ -135,7 +137,7 @@ This score is a simple pose-matching signal for the demo app. It is not a clinic
135137

136138
## Emit score data from the ViewModel
137139

138-
Open `ui\viewmodels\MainViewModel.kt`.
140+
Open `ui/viewmodels/MainViewModel.kt`.
139141

140142
Replace the TODO inside the `userPoseResults` mapping block with this code:
141143

@@ -164,7 +166,7 @@ The UI wants a rounded string score. The underlying `UserPoseResult` keeps the f
164166

165167
## Display the score
166168

167-
Open `ui\MainActivity.kt`.
169+
Open `ui/MainActivity.kt`.
168170

169171
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:
170172

@@ -194,4 +196,6 @@ Build and run the app on your Android device.
194196

195197
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.
196198

199+
If the score stays blank, verify that `onResults()` is receiving landmarks and that your body remains inside the camera frame. If the score jumps sharply, try a steadier side-on camera angle.
200+
197201
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.

content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/4-data-to-prompt.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ I'm doing Plank pose. My joints have the following significant angle differences
2626

2727
This keeps the prompt small and gives the model only the facts it needs to produce one coaching cue.
2828

29+
This is the main design pattern in the app: the vision model turns camera frames into structured pose data, deterministic Kotlin code turns that data into a small set of facts, and the LLM takes these facts and returns appropriate advice.
30+
2931
## Format the largest angle differences
3032

3133
Open `ui/landmarker/PoseScoreHelper.kt`.
@@ -61,7 +63,7 @@ This function pairs each reference angle with the corresponding learner angle, c
6163
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.
6264

6365
{{% 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.
66+
The sign of the difference is important. `difference = reference - learner`, so a positive value means the learner's angle is smaller than the reference and needs to straighten. A negative value means the learner's angle is larger than the reference and needs to bend more.
6567
{{% /notice %}}
6668

6769
## Generate the LLM prompt
@@ -98,7 +100,7 @@ The fallback prompt handles the case where the learner is already close to the r
98100

99101
## Emit prompts from the ViewModel
100102

101-
Open `ui\viewmodels\MainViewModel.kt`.
103+
Open `ui/viewmodels/MainViewModel.kt`.
102104

103105
Replace the TODO in `userPosePrompt` with this code:
104106

@@ -120,6 +122,8 @@ The prompt is paired with the score because fuller app versions might use both v
120122

121123
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.
122124

125+
The `flowOn(Dispatchers.Default)` call keeps prompt formatting off the main thread. The work is small, but keeping camera, scoring, prompt generation, LLM inference, and speech on clear execution paths makes the pipeline easier to tune.
126+
123127
## Check the prompt in Logcat
124128

125129
The local LLM is added in the next section. For now, add a temporary collector so you can see the prompt in Logcat.
@@ -148,12 +152,14 @@ This Logcat collector is only for checking this section. In the next section, yo
148152

149153
Build and run the app on your Android device.
150154

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.
155+
Open Logcat and filter for `MainActivity`. As you move in front of the camera, expect short prompts that describe the largest differences from the reference plank pose.
156+
157+
This Logcat check is the validation step for the prompt stage.
152158

153159
The app now has the complete input side of the tutor pipeline:
154160

155161
```text
156162
Camera frame -> pose landmarks -> angle score -> text prompt
157163
```
158164

159-
In the next section, you will add Arm's AI Chat library and send this prompt to a local LLM.
165+
In the next section, you will add Arm's AI Chat library and send this prompt to a local LLM.

content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/5-llm-ai-chat.md

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ You will:
1818
- Load the model and set the tutor system prompt.
1919
- Send the pose prompt from the previous section to the local LLM.
2020
- Convert streamed LLM tokens into complete `Sentence` objects.
21+
- Release the native inference engine when the ViewModel is cleared.
2122

2223
At the end of this section, the app will produce short text coaching corrections and show them as captions. Speech output is added in the next section.
2324

@@ -35,6 +36,8 @@ Sync the project with Gradle.
3536

3637
The AI Chat library provides the Android inference API used by this project and by the [Arm AI Chat app](https://play.google.com/store/apps/details?id=com.arm.aichat&hl=en_GB). It uses `llama.cpp` for GGUF inference, and `llama.cpp` integrates Arm [KleidiAI](https://developer.arm.com/ai/kleidi-libraries) kernels. Q4_0 GGUF models work particularly well with these kernels and can get strong acceleration on phones with [SME2](https://www.arm.com/technologies/sme2), SVE2, and Neon support.
3738

39+
The starter project already has `mavenCentral()` in `settings.gradle` and native library packaging configured in `app/build.gradle`, so adding the dependency is the only Gradle change needed here.
40+
3841
## Add the model file to the device
3942

4043
The app expects a Q4_0 GGUF model file named:
@@ -43,27 +46,46 @@ The app expects a Q4_0 GGUF model file named:
4346
Phi-4-mini-instruct-Q4_0.gguf
4447
```
4548

46-
Download [microsoft_Phi-4-mini-instruct-Q4_0.gguf](https://huggingface.co/bartowski/microsoft_Phi-4-mini-instruct-GGUF/blob/main/microsoft_Phi-4-mini-instruct-Q4_0.gguf) from Hugging Face. The model is 3.8B parameters, and around 2.2GB. Remove the "microsoft_" off the front of the name.
49+
Download [microsoft_Phi-4-mini-instruct-Q4_0.gguf](https://huggingface.co/bartowski/microsoft_Phi-4-mini-instruct-GGUF/blob/main/microsoft_Phi-4-mini-instruct-Q4_0.gguf) from Hugging Face. The model is 3.8B parameters, and about 2.3 GB. Rename the downloaded file by removing the leading `microsoft_` prefix.
50+
51+
Keep at least 5 GB free on the device. The app imports the model from external app storage and then copies it into internal app storage the first time it loads, so the device temporarily needs room for both copies.
4752

4853
The provided `LlmModelStore.kt` helper looks for the model in a few import locations. The most useful location while developing is the app-specific external files directory:
4954

5055
```text
5156
/sdcard/Android/data/com.arm.demo.AIPlankTutor/files/llm/Phi-4-mini-instruct-Q4_0.gguf
5257
```
5358

54-
With your device connected over USB, create the directory:
59+
With your device connected over USB, first confirm that `adb` can see the phone:
60+
61+
```console
62+
adb devices
63+
```
64+
65+
Expect a device listed as `device`:
66+
67+
```output
68+
List of devices attached
69+
<device-id> device
70+
```
71+
72+
If the device is listed as `unauthorized`, unlock the phone and accept the USB debugging prompt.
73+
74+
Next, create the app-specific external directory. This is the import location that `LlmModelStore` checks before copying the model into internal app storage:
5575

5676
```console
5777
adb shell mkdir -p /sdcard/Android/data/com.arm.demo.AIPlankTutor/files/llm
5878
```
5979

60-
Then push the model file:
80+
Then push the model file from the directory where you downloaded and renamed it:
6181

6282
```console
6383
adb push Phi-4-mini-instruct-Q4_0.gguf /sdcard/Android/data/com.arm.demo.AIPlankTutor/files/llm/
6484
```
6585

66-
When the app first loads the model, `LlmModelStore` copies it into the app's internal files directory. Later runs use the internal copy.
86+
The push can take a few minutes because the GGUF file is large. Do not disconnect the device while the transfer is running.
87+
88+
When the app first loads the model, `LlmModelStore` checks that the file is readable and at least 2 GB, then copies it into the app's internal files directory. Later runs use the internal copy.
6789

6890
{{% notice Note %}}
6991
If the model cannot be found, check Logcat for `LlmModelStore`. The error message lists every model path that was checked.
@@ -100,7 +122,7 @@ private val llmViewModel: LlmViewModel by viewModels {
100122
}
101123
```
102124

103-
`AiChat.getInferenceEngine()` creates the inference engine used by the ViewModel. `LlmModelStore` is the helper that finds or imports the GGUF model file. Now we need to make the `LlmViewModel` accept those parameters.
125+
`AiChat.getInferenceEngine()` creates a native-backed inference engine used by the ViewModel.`LlmModelStore` is the helper that finds or imports the GGUF model file. Now make the `LlmViewModel` accept those parameters.
104126

105127
## Add AI Chat to LlmViewModel
106128

@@ -155,6 +177,8 @@ private suspend fun loadModelAndSystemPrompt() {
155177

156178
You can read `TUTOR_SYSTEM_PROMPT` at the bottom of the file. The system prompt tells the model to act as a concise yoga teacher, use the joint-angle facts, and return one short correction.
157179

180+
`loadModel()` parses the GGUF file and allocates the model in memory, so it can take noticeable time on the first run. The system prompt is set once after the model loads so each later user prompt can stay focused on the live pose differences.
181+
158182
## Send a prompt to AI Chat
159183

160184
Replace the TODO in `sendUserPrompt()` with this code:
@@ -226,6 +250,19 @@ val sentences: Flow<Sentence> = _tokens
226250

227251
The ViewModel now exposes a `Flow<Sentence>` instead of raw tokens. Page 6 will collect this Flow and send each sentence to Android text-to-speech.
228252

253+
## Release the inference engine
254+
255+
The AI Chat engine owns native resources. Add this cleanup function inside `LlmViewModel`:
256+
257+
```kotlin
258+
override fun onCleared() {
259+
inferenceEngine.destroy()
260+
super.onCleared()
261+
}
262+
```
263+
264+
Android calls `onCleared()` when the ViewModel is no longer used, which gives the app a clear place to release the native inference engine.
265+
229266
## Send pose prompts to the LLM
230267

231268
Return to `MainActivity.kt`.
@@ -252,7 +289,7 @@ launch {
252289
}
253290
```
254291

255-
This prevents the app from generating a new prompt for every camera frame while the LLM is already responding.
292+
This prevents the app from generating a new prompt for every camera frame while the LLM is already responding. On-device LLM generation is much slower than camera frame delivery, so this backpressure step is what keeps the feedback loop understandable.
256293

257294
Finally, add a temporary sentence collector so you can show the generated correction in the caption area and check it in Logcat:
258295

@@ -270,10 +307,10 @@ The next section will replace this direct caption update with `SpeechManager`, s
270307

271308
## Run the app
272309

273-
Build and run the app on your Android device. You should see the tutor's advice now at the bottom of the screen.
310+
Build and run the app on your Android device. Expect the tutor's advice at the bottom of the screen.
274311
275312
To see what's happening behind the scenes, open Logcat and filter for `LlmViewModel` or `MainActivity`. The app should load the model, send pose prompts, and print short tutor corrections.
276313

277-
The first run can take longer because the model file may be copied into the app's internal storage and loaded into memory. Later runs should start faster.
314+
The first run can take longer because the model file may be copied into the app's internal storage and loaded into memory. Later runs should start faster. If model loading fails, also filter Logcat for `LlmModelStore`; it prints the exact import paths it checked.
278315
279316
At this point, the app can generate local text feedback. The next section uses Android `TextToSpeech` to speak each completed sentence and show it as a caption.

content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/6-android-tts.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ You will:
2020

2121
At the end of this section, the app will speak each complete correction and show the same text as a caption while it is being spoken.
2222

23+
Android `TextToSpeech` uses the TTS engine and voices installed on the device. The exact voice, language support, and quality can vary by phone, but the app code is the same.
24+
2325
## Initialize TextToSpeech
2426

2527
Open `ui/SpeechManager.kt`.
@@ -86,6 +88,8 @@ fun launchSpeechGenerationAndPlaybackJob(speech: String) {
8688

8789
Each phrase is queued with an utterance ID. `SpeechManager` keeps a small map from utterance ID to caption text so the app can show the caption only when that phrase starts speaking.
8890

91+
The `speak()` call is asynchronous. A successful return value means Android accepted the phrase into the TTS queue, not that speech has already finished. The progress listener is what tells the app when playback starts and ends.
92+
8993
## Finish each utterance
9094

9195
Replace the TODO in `finishUtterance()` with this code:
@@ -155,4 +159,6 @@ Build and run the app on your Android device.
155159

156160
Move into a plank position in front of the camera. The score should update, the local LLM should generate a short correction, and Android TTS should speak it. The same correction should appear as a caption while it is being spoken.
157161

162+
If captions appear but you do not hear speech, check the device media volume and confirm that a TTS voice is installed for the default language.
163+
158164
At this point, the app has the full on-device pipeline: camera input, pose landmarks, joint-angle scoring, local LLM feedback, and spoken output.

0 commit comments

Comments
 (0)