|
| 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. |
0 commit comments