You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
@@ -70,7 +70,7 @@ The starter project contains the app structure, layout, image asset, MediaPipe p
70
70
71
71
If Android Studio prompts you to trust the project, accept the prompt.
72
72
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.
74
74
75
75
## Inspect the provided files
76
76
@@ -79,12 +79,16 @@ Start by looking at the files that are already provided for you.
79
79
Open `app/build.gradle` and confirm that the Android, CameraX, lifecycle, and MediaPipe dependencies are already present.
80
80
Arm's AI Chat dependency is not included yet. You will add it later, when you implement local LLM inference.
81
81
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
+
82
84
Open `app/src/main/AndroidManifest.xml` and confirm that the app requests camera access:
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
+
88
92
Open `app/src/main/res/layout/activity_main.xml` and review the main UI. The layout already contains:
89
93
90
94
- 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
94
98
95
99
Open `app/src/main/res/drawable/plank.jpg` and review the instructor reference image.
96
100
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.
98
102
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.
Copy file name to clipboardExpand all lines: content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/2-camera-pose-landmarks.md
+11-3Lines changed: 11 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -84,6 +84,8 @@ The analyzer uses `STRATEGY_KEEP_ONLY_LATEST` because pose detection should work
84
84
85
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
86
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
+
87
89
## Send frames to the landmarker
88
90
89
91
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
164
166
165
167
The app also requests one pose with `setNumPoses(1)`. This keeps the example focused on a single learner.
166
168
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
+
167
171
## Convert camera frames to MPImage
168
172
169
173
When we call from `MainActivity`, it is with a CameraX `ImageProxy`, but the MediaPipe analyzer expects an `MPImage`.
170
174
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:
172
176
173
177
```kotlin
174
178
fundetectLiveStream(
@@ -216,6 +220,8 @@ The call to `imageProxy.use { ... }` closes the frame after its pixels are copie
216
220
217
221
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
222
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
+
219
225
## Return the first detected pose
220
226
221
227
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
235
241
236
242
Build and run the app on your Android device.
237
243
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.
239
247
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`.
MediaPipe landmarks are normalized coordinates. The `x` and `y` values are relative to the input image, and `z` gives relative depth.
70
70
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
+
71
73
## Calculate a 3D angle
72
74
73
75
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
135
137
136
138
## Emit score data from the ViewModel
137
139
138
-
Open `ui\viewmodels\MainViewModel.kt`.
140
+
Open `ui/viewmodels/MainViewModel.kt`.
139
141
140
142
Replace the TODO inside the `userPoseResults` mapping block with this code:
141
143
@@ -164,7 +166,7 @@ The UI wants a rounded string score. The underlying `UserPoseResult` keeps the f
164
166
165
167
## Display the score
166
168
167
-
Open `ui\MainActivity.kt`.
169
+
Open `ui/MainActivity.kt`.
168
170
169
171
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
172
@@ -194,4 +196,6 @@ Build and run the app on your Android device.
194
196
195
197
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
198
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
+
197
201
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.
Copy file name to clipboardExpand all lines: content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/4-data-to-prompt.md
+10-4Lines changed: 10 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -26,6 +26,8 @@ I'm doing Plank pose. My joints have the following significant angle differences
26
26
27
27
This keeps the prompt small and gives the model only the facts it needs to produce one coaching cue.
28
28
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
+
29
31
## Format the largest angle differences
30
32
31
33
Open `ui/landmarker/PoseScoreHelper.kt`.
@@ -61,7 +63,7 @@ This function pairs each reference angle with the corresponding learner angle, c
61
63
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
64
63
65
{{% 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 learnerneeds 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.
65
67
{{% /notice %}}
66
68
67
69
## Generate the LLM prompt
@@ -98,7 +100,7 @@ The fallback prompt handles the case where the learner is already close to the r
98
100
99
101
## Emit prompts from the ViewModel
100
102
101
-
Open `ui\viewmodels\MainViewModel.kt`.
103
+
Open `ui/viewmodels/MainViewModel.kt`.
102
104
103
105
Replace the TODO in `userPosePrompt` with this code:
104
106
@@ -120,6 +122,8 @@ The prompt is paired with the score because fuller app versions might use both v
120
122
121
123
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
124
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
+
123
127
## Check the prompt in Logcat
124
128
125
129
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
148
152
149
153
Build and run the app on your Android device.
150
154
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.
152
158
153
159
The app now has the complete input side of the tutor pipeline:
154
160
155
161
```text
156
162
Camera frame -> pose landmarks -> angle score -> text prompt
157
163
```
158
164
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.
Copy file name to clipboardExpand all lines: content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/5-llm-ai-chat.md
+45-8Lines changed: 45 additions & 8 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -18,6 +18,7 @@ You will:
18
18
- Load the model and set the tutor system prompt.
19
19
- Send the pose prompt from the previous section to the local LLM.
20
20
- Convert streamed LLM tokens into complete `Sentence` objects.
21
+
- Release the native inference engine when the ViewModel is cleared.
21
22
22
23
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.
23
24
@@ -35,6 +36,8 @@ Sync the project with Gradle.
35
36
36
37
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.
37
38
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
+
38
41
## Add the model file to the device
39
42
40
43
The app expects a Q4_0 GGUF model file named:
@@ -43,27 +46,46 @@ The app expects a Q4_0 GGUF model file named:
43
46
Phi-4-mini-instruct-Q4_0.gguf
44
47
```
45
48
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.
47
52
48
53
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:
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:
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.
67
89
68
90
{{% notice Note %}}
69
91
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 {
100
122
}
101
123
```
102
124
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.
104
126
105
127
## Add AI Chat to LlmViewModel
106
128
@@ -155,6 +177,8 @@ private suspend fun loadModelAndSystemPrompt() {
155
177
156
178
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, andreturn one short correction.
157
179
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
+
158
182
## Send a prompt to AIChat
159
183
160
184
Replace the TODOin `sendUserPrompt()` with this code:
@@ -226,6 +250,19 @@ val sentences: Flow<Sentence> = _tokens
226
250
227
251
TheViewModel now exposes a `Flow<Sentence>` instead of raw tokens. Page6 will collect thisFlowand send each sentence to Android text-to-speech.
228
252
253
+
## Release the inference engine
254
+
255
+
TheAIChat engine owns native resources. Addthis cleanup function inside `LlmViewModel`:
256
+
257
+
```kotlin
258
+
overridefunonCleared() {
259
+
inferenceEngine.destroy()
260
+
super.onCleared()
261
+
}
262
+
```
263
+
264
+
Android calls `onCleared()` when the ViewModelis no longer used, which gives the app a clear place to release the native inference engine.
265
+
229
266
## Send pose prompts to the LLM
230
267
231
268
Return to `MainActivity.kt`.
@@ -252,7 +289,7 @@ launch {
252
289
}
253
290
```
254
291
255
-
This prevents the app from generating a new prompt for every camera frame while the LLMis already responding.
292
+
This prevents the app from generating a new prompt for every camera frame while the LLMis already responding.On-device LLM generation is much slower than camera frame delivery, so this backpressure step is what keeps the feedback loop understandable.
256
293
257
294
Finally, add a temporary sentence collector so you can show the generated correction in the caption area and check it inLogcat:
258
295
@@ -270,10 +307,10 @@ The next section will replace this direct caption update with `SpeechManager`, s
270
307
271
308
## Run the app
272
309
273
-
Buildand run the app on your Android device. You should see the tutor's advice now at the bottom of the screen.
310
+
Buildand run the app on your Android device. Expectthe tutor's advice at the bottom of the screen.
274
311
275
312
To see what's happening behind the scenes, open Logcatand filter for `LlmViewModel` or `MainActivity`. The app should load the model, send pose prompts, and print short tutor corrections.
276
313
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.
278
315
279
316
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.
Copy file name to clipboardExpand all lines: content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/6-android-tts.md
+6Lines changed: 6 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -20,6 +20,8 @@ You will:
20
20
21
21
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.
22
22
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
+
23
25
## Initialize TextToSpeech
24
26
25
27
Open `ui/SpeechManager.kt`.
@@ -86,6 +88,8 @@ fun launchSpeechGenerationAndPlaybackJob(speech: String) {
86
88
87
89
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.
88
90
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
+
89
93
## Finish each utterance
90
94
91
95
Replace the TODO in `finishUtterance()` with this code:
@@ -155,4 +159,6 @@ Build and run the app on your Android device.
155
159
156
160
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.
157
161
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
+
158
164
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