starting pose detection

This commit is contained in:
2026-09-05 22:33:13 +08:00
parent afb9d800ff
commit cb941006c5
9 changed files with 424 additions and 2 deletions
+7
View File
@@ -4,6 +4,13 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-09-05T10:57:53.251732600Z">
<Target type="DEFAULT_BOOT">
<template>
<DeviceId pluginId="FirebaseDirectAccess" type="TEMPLATE" identifier="model_id=pa1qksx/36" />
</template>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="temurin-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
@@ -17,9 +17,11 @@ import androidx.camera.core.CameraSelector
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.core.content.ContextCompat
import com.example.jnicpp.R
import com.example.jnicpp.databinding.ActivityBowlingCameraBinding
import com.google.mlkit.vision.pose.PoseLandmark
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import java.util.Locale
import java.util.concurrent.ExecutorService
@@ -184,6 +186,24 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
}
}
}
// Deliberately its own collector, independent of stepEvents
// above -- delivery-phase feedback and step counting are
// separate concerns (see PosePhaseDetector's class doc).
// Combined with poseEnabled (rather than posePhase alone) so
// the label can tell "pose off" (hidden) apart from "pose on
// but not yet in the target posture" (amber prompt) -- both
// cases otherwise report a null phase.
launch {
combine(viewModel.poseEnabled, viewModel.posePhase) { enabled, phase -> enabled to phase }
.collect { (enabled, phase) -> renderPosePhase(enabled, phase) }
}
// Raw angle readout backing the label above -- its own
// collector since it's driven by a separate StateFlow
// (poseMetrics is null on its own whenever pose detection is
// off, so no need to combine with poseEnabled here).
launch {
viewModel.poseMetrics.collect { metrics -> renderPoseMetrics(metrics) }
}
}
}
}
@@ -235,6 +255,66 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
}
}
/**
* @brief Shows or hides the delivery-phase feedback label, and colors/labels
* it for whether [phase] currently validates.
*
* Visible for the entire time [poseEnabled] is on -- not just at the
* moment a phase is confirmed -- so the bowler gets a continuous
* "not yet"/"confirmed" signal to line themselves up against, rather
* than a label that silently disappears whenever they drift out of
* position. Independent of [renderRecordingState]/the step counter --
* see [PosePhaseDetector]'s class doc for why phase feedback and step
* counting are kept as separate concerns.
*
* @param poseEnabled Whether pose detection is currently on at all.
* @param phase The bowler's current delivery phase, or null if none currently validates.
*/
private fun renderPosePhase(poseEnabled: Boolean, phase: BowlingPhase?) {
if (!poseEnabled) {
binding.textPoseFeedback.visibility = View.GONE
return
}
binding.textPoseFeedback.visibility = View.VISIBLE
// Every other BowlingPhase falls back to the "waiting" message too --
// see PosePhaseDetector's class doc, only STARTING_STANCE is detected today.
if (phase == BowlingPhase.STARTING_STANCE) {
binding.textPoseFeedback.text = getString(R.string.pose_phase_starting_stance)
binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.pose_feedback_ready))
} else {
binding.textPoseFeedback.text = getString(R.string.pose_phase_waiting)
binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.pose_feedback_waiting))
}
}
/**
* @brief Shows or hides the raw torso/knee/elbow angle readout backing [renderPosePhase]'s label.
* @param metrics This frame's angle readings, or null to hide the readout (pose detection off).
*/
private fun renderPoseMetrics(metrics: PosePhaseDetector.Metrics?) {
if (metrics == null) {
binding.textPoseMetrics.visibility = View.GONE
return
}
binding.textPoseMetrics.visibility = View.VISIBLE
binding.textPoseMetrics.text = getString(
R.string.pose_metrics_format,
angleText(metrics.torsoTiltDegrees),
angleText(metrics.leftKneeAngleDegrees),
angleText(metrics.rightKneeAngleDegrees),
angleText(metrics.leftElbowAngleDegrees),
angleText(metrics.rightElbowAngleDegrees)
)
}
/**
* @brief Formats one angle reading for display.
* @param degrees The angle in degrees, or null if that landmark wasn't confidently detected this frame.
* @return e.g. "12°", or "--" if [degrees] is null.
*/
private fun angleText(degrees: Float?): String =
if (degrees == null) "--" else "${degrees.toInt()}°"
/** @brief Hides the permission-rationale screen, revealing the camera UI underneath. */
private fun showCameraUi() {
binding.layoutPermissionRationale.visibility = View.GONE
@@ -82,6 +82,19 @@ class CameraViewModel : ViewModel() {
// several practice approaches back to back.
private val liveStepDetector = LiveStepDetector()
// Live delivery-phase classification (starting stance, approach, etc)
private val posePhaseDetector = PosePhaseDetector()
private val _posePhase = MutableStateFlow<BowlingPhase?>(null)
//The bowler's current delivery phase, or null if no phase currently validates.
val posePhase: StateFlow<BowlingPhase?> = _posePhase.asStateFlow()
// Raw torso/knee/elbow angle readings behind posePhase above, for
// showing the bowler the actual numbers rather than just a pass/fail
// signal -- see PosePhaseDetector.Metrics.
private val _poseMetrics = MutableStateFlow<PosePhaseDetector.Metrics?>(null)
//This frame's torso/knee/elbow angle readings, or null if pose detection is off.
val poseMetrics: StateFlow<PosePhaseDetector.Metrics?> = _poseMetrics.asStateFlow()
// Steps detected so far in the current attempt (since the last reset,
// whether that reset was a new recording starting or the bowler
// returning to a stationary stance mid-recording -- see
@@ -119,7 +132,12 @@ class CameraViewModel : ViewModel() {
*/
fun onPoseToggled(enabled: Boolean) {
_poseEnabled.value = enabled
if (!enabled) _poseAngles.value = null
if (!enabled) {
_poseAngles.value = null
posePhaseDetector.reset()
_posePhase.value = null
_poseMetrics.value = null
}
}
/**
@@ -135,6 +153,9 @@ class CameraViewModel : ViewModel() {
*/
fun onPoseFrameUpdated(landmarks: Map<Int, SmoothedLandmark>, angles: PoseAngles) {
_poseAngles.value = angles
val phaseResult = posePhaseDetector.update(landmarks, angles) //for pose detector
_posePhase.value = phaseResult.phase
_poseMetrics.value = phaseResult.metrics
if (_recordingState.value is RecordingState.Recording) {
val smoothedAnkleHip = ankleHipSmoother.smooth(landmarks)
val frame = buildPoseFrame(
@@ -0,0 +1,224 @@
/**
* @file PosePhaseDetector.kt
* @brief Incremental detector for which phase of the bowling delivery the bowler is currently in.
*/
package com.example.jnicpp.bowling
import com.google.mlkit.vision.pose.PoseLandmark
import kotlin.math.abs
import kotlin.math.atan2
/**
* @brief The delivery phases this app distinguishes, in the order a bowler moves through them.
*
* Only [BowlingPhase.STARTING_STANCE] has detection logic today (see
* [PosePhaseDetector]); the rest are declared up front so callers (state,
* UI) can already model "which of the 5 phases" without a later enum
* change, and get filled in one at a time.
*/
enum class BowlingPhase {
STARTING_STANCE,
APPROACH,
PUSHAWAY,
SLIDE_RELEASE,
FOLLOW_THROUGH
}
/**
* @brief Incrementally classifies the bowler's current posture into a
* [BowlingPhase], entirely independent of [LiveStepDetector]/[StepDetector].
*
* Deliberately a separate detector rather than folded into the step
* counter: step counting only cares about ankle-y peaks, while phase
* detection classifies overall posture (torso lean, knee bend, elbow bend)
* against a per-phase reference range. The two run side by side off the
* same per-frame data (see [CameraViewModel.onPoseFrameUpdated]) but track
* completely independent state, and neither calls into the other.
*
* [update] is fed one frame's landmarks/angles at a time, in recording (or
* live-preview) order. A posture only "counts" once it holds for
* [requiredConsecutiveFrames] frames in a row -- this filters out a
* momentary, correct-looking pose caught mid-transition (e.g. a fleeting
* instant during the approach where the knee angle briefly passes through
* the starting-stance range) -- and keeps reporting that phase for as long
* as the posture keeps validating, reverting to null the instant it doesn't.
*
* @param torsoTiltMinDegrees Minimum forward torso lean from vertical
* (shoulder-midpoint-to-hip-midpoint vector vs. vertical) still
* considered a starting-stance lean, in degrees.
* @param torsoTiltMaxDegrees Maximum forward torso lean from vertical still
* considered a starting-stance lean, in degrees.
* @param kneeAngleMinDegrees Minimum hip-knee-ankle angle still considered
* a near-straight standing leg, in degrees.
* @param kneeAngleMaxDegrees Maximum hip-knee-ankle angle still considered
* a near-straight standing leg, in degrees.
* @param elbowAngleMinDegrees Minimum shoulder-elbow-wrist angle still
* considered "holding the ball in front", in degrees.
* @param elbowAngleMaxDegrees Maximum shoulder-elbow-wrist angle still
* considered "holding the ball in front", in degrees.
* @param requiredConsecutiveFrames How many consecutive frames the posture
* must validate before [update] starts reporting [BowlingPhase.STARTING_STANCE].
*/
class PosePhaseDetector(
private val torsoTiltMinDegrees: Float = 10f,
private val torsoTiltMaxDegrees: Float = 15f,
private val kneeAngleMinDegrees: Float = 160f,
private val kneeAngleMaxDegrees: Float = 175f,
private val elbowAngleMinDegrees: Float = 70f,
private val elbowAngleMaxDegrees: Float = 110f,
private val requiredConsecutiveFrames: Int = 8
) {
private var consecutiveValidFrames = 0
private var currentPhase: BowlingPhase? = null
/**
* @brief The raw angle readings [update] computed for one frame, for
* callers that want to show the bowler the actual numbers (e.g.
* "Torso 12°") rather than just a pass/fail phase.
*
* Each field is null exactly when the landmarks it depends on weren't
* confidently detected that frame (see [reliable]) -- same meaning as a
* null field elsewhere in this codebase (e.g. [PoseAngles]).
*
* @param torsoTiltDegrees See [torsoTiltDegrees].
* @param leftKneeAngleDegrees Left hip-knee-ankle angle, or null.
* @param rightKneeAngleDegrees Right hip-knee-ankle angle, or null.
* @param leftElbowAngleDegrees Left shoulder-elbow-wrist angle (from [PoseAngles]), or null.
* @param rightElbowAngleDegrees Right shoulder-elbow-wrist angle (from [PoseAngles]), or null.
*/
data class Metrics(
val torsoTiltDegrees: Float?,
val leftKneeAngleDegrees: Float?,
val rightKneeAngleDegrees: Float?,
val leftElbowAngleDegrees: Float?,
val rightElbowAngleDegrees: Float?
)
/**
* @brief One [update] call's outcome: the classified phase plus the raw angles it was based on.
* @param phase See [update]'s return doc.
* @param metrics This frame's raw angle readings, for display regardless of whether [phase] validated.
*/
data class Result(val phase: BowlingPhase?, val metrics: Metrics)
/**
* @brief Feeds one frame's landmarks/angles into the detector.
*
* @param landmarks Smoothed landmarks for this frame, keyed by ML Kit's `PoseLandmark` type constant.
* @param angles Joint angles computed for this same frame (see
* [PoseAngleCalculator]) -- elbow angles are reused from here
* rather than recomputed, so this detector doesn't duplicate that math.
* @return This frame's [Metrics] alongside [BowlingPhase.STARTING_STANCE]
* once the posture has validated for [requiredConsecutiveFrames]
* frames in a row and keeps validating on this frame too,
* otherwise alongside a null phase.
*/
fun update(landmarks: Map<Int, SmoothedLandmark>, angles: PoseAngles): Result {
val metrics = Metrics(
torsoTiltDegrees = torsoTiltDegrees(landmarks),
leftKneeAngleDegrees = kneeAngle(landmarks, PoseLandmark.LEFT_HIP, PoseLandmark.LEFT_KNEE, PoseLandmark.LEFT_ANKLE),
rightKneeAngleDegrees = kneeAngle(landmarks, PoseLandmark.RIGHT_HIP, PoseLandmark.RIGHT_KNEE, PoseLandmark.RIGHT_ANKLE),
leftElbowAngleDegrees = angles.leftElbow,
rightElbowAngleDegrees = angles.rightElbow
)
val isValid = isStartingStanceValid(metrics)
consecutiveValidFrames = if (isValid) consecutiveValidFrames + 1 else 0
currentPhase = if (consecutiveValidFrames >= requiredConsecutiveFrames) BowlingPhase.STARTING_STANCE else null
return Result(currentPhase, metrics)
}
/** @brief Clears all detection state. Call at the start of a new session/attempt. */
fun reset() {
consecutiveValidFrames = 0
currentPhase = null
}
/**
* @brief Checks whether this single frame's [Metrics] match the starting stance.
*
* Every check below requires the angle it needs to actually be
* available -- a null reading (landmark not confidently detected) fails
* the check rather than being silently skipped, so a frame with too
* little of the body visible can't falsely confirm a stance it didn't
* actually see.
*
* @param metrics This frame's raw angle readings.
* @return true if torso tilt, both visible knee angles, and both visible elbow angles all fall within range.
*/
private fun isStartingStanceValid(metrics: Metrics): Boolean {
val torsoTilt = metrics.torsoTiltDegrees ?: return false
if (torsoTilt !in torsoTiltMinDegrees..torsoTiltMaxDegrees) return false
val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees)
if (kneeAngles.isEmpty() || kneeAngles.any { it !in kneeAngleMinDegrees..kneeAngleMaxDegrees }) return false
val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees)
if (elbowAngles.isEmpty() || elbowAngles.any { it !in elbowAngleMinDegrees..elbowAngleMaxDegrees }) return false
return true
}
/**
* @brief Forward/backward torso lean from vertical, from the
* shoulder-midpoint-to-hip-midpoint vector.
*
* @param landmarks Smoothed landmarks for this frame.
* @return The tilt in degrees (always >= 0, direction-agnostic), or
* null if neither shoulder or neither hip is reliably detected.
*/
private fun torsoTiltDegrees(landmarks: Map<Int, SmoothedLandmark>): Float? {
val shoulderMid = midpoint(landmarks, PoseLandmark.LEFT_SHOULDER, PoseLandmark.RIGHT_SHOULDER) ?: return null
val hipMid = midpoint(landmarks, PoseLandmark.LEFT_HIP, PoseLandmark.RIGHT_HIP) ?: return null
val dx = shoulderMid.first - hipMid.first
val dy = shoulderMid.second - hipMid.second
// atan2 against the vertical axis; abs() folds left/right lean
// direction into an unsigned magnitude, same convention as
// PoseAngleCalculator.calculateAngle.
return Math.toDegrees(atan2(abs(dx.toDouble()), abs(dy.toDouble()))).toFloat()
}
/**
* @brief Hip-knee-ankle angle for one leg, gated by confidence.
* @param landmarks Smoothed landmarks for this frame.
* @param hipType Landmark type constant for that leg's hip.
* @param kneeType Landmark type constant for that leg's knee.
* @param ankleType Landmark type constant for that leg's ankle.
* @return The angle in degrees, or null if any of the three landmarks isn't reliably detected.
*/
private fun kneeAngle(landmarks: Map<Int, SmoothedLandmark>, hipType: Int, kneeType: Int, ankleType: Int): Float? {
val hip = reliable(landmarks, hipType) ?: return null
val knee = reliable(landmarks, kneeType) ?: return null
val ankle = reliable(landmarks, ankleType) ?: return null
return PoseAngleCalculator.calculateAngle(hip, knee, ankle)
}
/**
* @brief Midpoint of two landmarks, gated by confidence, falling back to
* whichever single one is reliable if only one is.
* @param landmarks Smoothed landmarks for this frame.
* @param firstType Landmark type constant for the first side.
* @param secondType Landmark type constant for the second side.
* @return The midpoint as (x, y), or null if neither landmark is reliable.
*/
private fun midpoint(landmarks: Map<Int, SmoothedLandmark>, firstType: Int, secondType: Int): Pair<Float, Float>? {
val first = reliable(landmarks, firstType)
val second = reliable(landmarks, secondType)
return when {
first != null && second != null -> (first.x + second.x) / 2f to (first.y + second.y) / 2f
first != null -> first.x to first.y
second != null -> second.x to second.y
else -> null
}
}
/**
* @brief Looks up one landmark, gated by [PoseSkeletonRenderer.MIN_LIKELIHOOD].
* @param landmarks Smoothed landmarks for this frame.
* @param type Landmark type constant to look up.
* @return The landmark, or null if missing or below the confidence bar.
*/
private fun reliable(landmarks: Map<Int, SmoothedLandmark>, type: Int): SmoothedLandmark? {
val landmark = landmarks[type] ?: return null
return landmark.takeIf { it.inFrameLikelihood >= PoseSkeletonRenderer.MIN_LIKELIHOOD }
}
}
@@ -86,6 +86,48 @@
android:textStyle="bold" />
</LinearLayout>
<!-- Delivery-phase feedback (e.g. "Starting stance"), separate from the
step counter above - see CameraViewModel#posePhase. Shown whenever
pose detection is on, live preview or recording, unlike the step
counter which is recording-only. -->
<TextView
android:id="@+id/text_pose_feedback"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/overlay_scrim"
android:paddingHorizontal="12dp"
android:paddingVertical="6dp"
android:textColor="@color/white"
android:textSize="20sp"
android:textStyle="bold"
android:visibility="gone"
tools:visibility="visible"
tools:text="Get into starting stance"
app:layout_constraintTop_toBottomOf="@id/btn_back"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp" />
<!-- Raw torso/knee/elbow angle readout backing text_pose_feedback above
- see CameraViewModel#poseMetrics. Stacked directly below it. -->
<TextView
android:id="@+id/text_pose_metrics"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/overlay_scrim"
android:paddingHorizontal="12dp"
android:paddingVertical="4dp"
android:textColor="@color/white"
android:textSize="14sp"
android:fontFamily="monospace"
android:visibility="gone"
tools:visibility="visible"
tools:text="Torso 12° · Knee L168° R170° · Elbow L85° R90°"
app:layout_constraintTop_toBottomOf="@id/text_pose_feedback"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp" />
<!--
Mirrored onto the start edge at the same vertical center as btn_record
on the end edge. Live pose overlay + baked-in-recording toggle; only
@@ -85,6 +85,48 @@
android:textStyle="bold" />
</LinearLayout>
<!-- Delivery-phase feedback (e.g. "Starting stance"), separate from the
step counter above - see CameraViewModel#posePhase. Shown whenever
pose detection is on, live preview or recording, unlike the step
counter which is recording-only. -->
<TextView
android:id="@+id/text_pose_feedback"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/overlay_scrim"
android:paddingHorizontal="12dp"
android:paddingVertical="6dp"
android:textColor="@color/white"
android:textSize="20sp"
android:textStyle="bold"
android:visibility="gone"
tools:visibility="visible"
tools:text="Get into starting stance"
app:layout_constraintTop_toBottomOf="@id/btn_back"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp" />
<!-- Raw torso/knee/elbow angle readout backing text_pose_feedback above
- see CameraViewModel#poseMetrics. Stacked directly below it. -->
<TextView
android:id="@+id/text_pose_metrics"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/overlay_scrim"
android:paddingHorizontal="12dp"
android:paddingVertical="4dp"
android:textColor="@color/white"
android:textSize="14sp"
android:fontFamily="monospace"
android:visibility="gone"
tools:visibility="visible"
tools:text="Torso 12° · Knee L168° R170° · Elbow L85° R90°"
app:layout_constraintTop_toBottomOf="@id/text_pose_feedback"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp" />
<!-- Live pose overlay + baked-in-recording toggle. Only togglable while
not recording (see BowlingCameraActivity#renderRecordingState) since
the recording pipeline picks its pose mode once at start. -->
+2
View File
@@ -13,4 +13,6 @@
<color name="skeleton_joint">#FF00E5FF</color>
<color name="skeleton_bone">#FF76FF03</color>
<color name="overlay_scrim">#99000000</color>
<color name="pose_feedback_ready">#CC00C853</color> //green
<color name="pose_feedback_waiting">#CCFFA000</color> //orange
</resources>
+3
View File
@@ -18,4 +18,7 @@
<string name="error_camera_unavailable">Camera unavailable: %1$s</string>
<string name="error_recording_failed">Recording failed: %1$s</string>
<string name="error_pose_detector">Pose detector error: %1$s</string>
<string name="pose_phase_starting_stance">✓ Starting pose</string>
<string name="pose_phase_waiting">Get into starting pose</string>
<string name="pose_metrics_format">Torso %1$s · Knee L%2$s R%3$s · Elbow L%4$s R%5$s</string>
</resources>