adjusted order

This commit is contained in:
2026-09-14 21:38:10 +08:00
parent f30bf103be
commit 3c682c1b30
@@ -64,13 +64,9 @@ class PosePhaseDetector {
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.
* The actual angles we calculate from the camera for one frame, so can show stuff like "Torso 12°".
*
* 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]).
* Each field is null if the camera isn't sure where that body part is.
*
* @param torsoTiltDegrees See [torsoTiltDegrees].
* @param leftKneeAngleDegrees Left hip-knee-ankle angle, or null.
@@ -87,23 +83,18 @@ class PosePhaseDetector {
)
/**
* @brief One [update] call's outcome: the classified phase plus the raw angles it was based on.
* get back after checking a frame: the phase and the angles.
* @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.
* Check one frame from the camera to see what the bowler is doing.
*
* @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 [validFrameProgress] has climbed to [REQUIRED_CONSECUTIVE_FRAMES],
* continuing to report it through brief invalid streaks shorter
* than [REQUIRED_INVALID_FRAMES_TO_EXIT], otherwise alongside a null phase.
* @param landmarks The x,y points of all body parts (already smoothed).
* @param angles The joint angles we already calculated for this frame.
* @return The updated phase and the raw metrics for this frame.
*/
fun update(landmarks: Map<Int, SmoothedLandmark>, angles: PoseAngles): Result {
val metrics = Metrics(
@@ -141,9 +132,6 @@ class PosePhaseDetector {
// Look through all future phases.
for (i in (currentIdx + 2) until allPhases.size) {
val p = allPhases[i]
// Never skip straight to Follow Through from nothing or very early.
// must at least reach Pushaway before Follow Through is a valid skip-to target.
if (p == BowlingPhase.FOLLOW_THROUGH && currentIdx < BowlingPhase.PUSHAWAY.ordinal) continue
val isThisValid = when (p) {
BowlingPhase.APPROACH -> isApproachValid(metrics)
@@ -218,16 +206,18 @@ class PosePhaseDetector {
* 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.
* @return true if torso lean, knees, and elbows are all in the right spot.
*/
fun isStartingStanceValid(metrics: Metrics): Boolean {
// Check if the body is mostly upright (tilt between 1 and 20 degrees).
val torsoTilt = metrics.torsoTiltDegrees ?: return false
// Table: 1-15, widened to 20 to make it easier to start
if (torsoTilt !in 1f..20f) return false
// Check if both knees are fairly straight (angle above 150 degrees).
val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees)
if (kneeAngles.isEmpty() || kneeAngles.any { it !in 150f..180f }) return false
// Check if elbows are folded (angle between 70 and 125 degrees) while holding the ball.
val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees)
if (elbowAngles.isEmpty() || elbowAngles.any { it !in 70f..125f }) return false
@@ -246,14 +236,15 @@ class PosePhaseDetector {
* @return true if torso tilt, knee angles, and elbow angles fall within range.
*/
fun isApproachValid(metrics: Metrics): Boolean {
// Check if they have a slight forward lean (5 to 30 degrees).
val torsoTilt = metrics.torsoTiltDegrees ?: return false
// Widened to 30
if (torsoTilt !in 5f..30f) return false
// Check if legs are still mostly straight during the walk.
val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees)
if (kneeAngles.isEmpty() || kneeAngles.any { it !in 145f..180f }) return false
// Widened to 140 to provide overlap with Pushaway (130-180)
// Check if arm is still close to the body (angle between 60 and 140 degrees).
val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees)
if (elbowAngles.isEmpty() || elbowAngles.any { it !in 60f..140f }) return false
@@ -264,7 +255,7 @@ class PosePhaseDetector {
* @brief Checks whether this single frame's [Metrics] match the pushaway phase.
*
* Pushaway is characterized by:
* - Torso Tilt: 5-25 degrees (more lean than stance)
* - Torso Tilt: 5-25 degrees (leaning more)
* - Knee Angle: 145-180 degrees (legs still mostly straight)
* - Elbow Angle: 130-180 degrees (bowling arm extending forward)
*
@@ -272,14 +263,14 @@ class PosePhaseDetector {
* @return true if torso tilt, knee angles, and at least one elbow angle fall within range.
*/
fun isPushawayValid(metrics: Metrics): Boolean {
// Check if torso lean is between 5 and 25 degrees.
val torsoTilt = metrics.torsoTiltDegrees ?: return false
if (torsoTilt !in 5f..25f) return false
val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees)
if (kneeAngles.isEmpty() || kneeAngles.any { it !in 145f..180f }) return false
// For Pushaway, the bowling arm extends. We look for *at least one*
// elbow to be extended (130-180), since we don't know the bowler's handedness.
// Check if at least one arm is extending forward (angle between 130 and 180 degrees).
val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees)
if (elbowAngles.isEmpty() || elbowAngles.none { it in 130f..180f }) return false
@@ -293,13 +284,14 @@ class PosePhaseDetector {
* - Torso Tilt: 15-45 degrees
* - Knee Angle: 90-150 degrees (sliding knee)
* - Elbow Angle: 150-180 degrees
* - Hand Position: Below shoulder (to distinguish from backswing)
* - Hand Position: Below shoulder (so we know it's not the backswing)
*
* @param metrics This frame's raw angle readings.
* @param landmarks Current frame's raw landmarks.
* @return true if torso tilt, at least one knee angle, and at least one elbow angle fall within range.
* @return true if they are actually releasing the ball.
*/
fun isSlideReleaseValid(metrics: Metrics, landmarks: Map<Int, SmoothedLandmark>): Boolean {
// Check for a deeper forward lean (15 to 45 degrees).
val torsoTilt = metrics.torsoTiltDegrees ?: return false
if (torsoTilt !in 15f..45f) return false
@@ -330,9 +322,9 @@ class PosePhaseDetector {
* - Elbow Angle: 140-180 degrees
* - Hand Position: Above shoulder AND in front of the body
*
* @param metrics This frame's raw angle readings.
* @param landmarks Current frame's raw landmarks.
* @return true if torso tilt, at least one knee angle, and at least one elbow angle fall within range.
* @param metrics The angles for this frame.
* @param landmarks The points of the body (to check hand position).
* @return True if arm is extended high in front of the body.
*/
fun isFollowThroughValid(metrics: Metrics, landmarks: Map<Int, SmoothedLandmark>): Boolean {
val torsoTilt = metrics.torsoTiltDegrees ?: return false
@@ -377,10 +369,10 @@ class PosePhaseDetector {
* 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.
* @return The tilt in degrees, or null if we can't see the shoulders or hips.
*/
private fun torsoTiltDegrees(landmarks: Map<Int, SmoothedLandmark>): Float? {
// Find the middle of the shoulders and the middle of the hips.
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
@@ -392,33 +384,36 @@ class PosePhaseDetector {
}
/**
* @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.
* // Calculate the angle of a knee.
* @param landmarks The smoothed points of the body.
* @param hipType Which hip to look at.
* @param kneeType Which knee to look at.
* @param ankleType Which ankle to look at.
* @return The angle in degrees, or null if the camera missed a part.
*/
private fun kneeAngle(landmarks: Map<Int, SmoothedLandmark>, hipType: Int, kneeType: Int, ankleType: Int): Float? {
// Try to get the hip, knee, and ankle from the camera data.
val hip = reliable(landmarks, hipType) ?: return null
val knee = reliable(landmarks, kneeType) ?: return null
val ankle = reliable(landmarks, ankleType) ?: return null
// Calculate the angle between those three points.
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.
* @brief Find the middle point between two body parts.
* @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.
* @return The middle (x, y), or null if the camera missed both.
*/
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 {
// If we have both parts, find the average.
first != null && second != null -> (first.x + second.x) / 2f to (first.y + second.y) / 2f
// If we only have one, just use that one.
first != null -> first.x to first.y
second != null -> second.x to second.y
else -> null
@@ -429,10 +424,11 @@ class PosePhaseDetector {
* @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.
* @return The landmark, or null if it's missing or blurry.
*/
private fun reliable(landmarks: Map<Int, SmoothedLandmark>, type: Int): SmoothedLandmark? {
val landmark = landmarks[type] ?: return null
// Only return if the camera is sure enough about where the part is.
return landmark.takeIf { it.inFrameLikelihood >= PoseSkeletonRenderer.MIN_LIKELIHOOD }
}
}