diff --git a/app/src/main/java/com/example/jnicpp/bowling/BowlingCameraActivity.kt b/app/src/main/java/com/example/jnicpp/bowling/BowlingCameraActivity.kt index 9c6b4cf..fe49ec0 100644 --- a/app/src/main/java/com/example/jnicpp/bowling/BowlingCameraActivity.kt +++ b/app/src/main/java/com/example/jnicpp/bowling/BowlingCameraActivity.kt @@ -450,6 +450,10 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback { binding.textPoseFeedback.text = getString(R.string.pose_phase_slide_and_release) binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.Slide_and_release_ready)) } + BowlingPhase.FOLLOW_THROUGH -> { + binding.textPoseFeedback.text = getString(R.string.pose_phase_follow_through) + binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.Follow_through_ready)) + } else -> { binding.textPoseFeedback.text = correction ?: getString(R.string.pose_phase_waiting) binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.Starting_stance_waiting)) diff --git a/app/src/main/java/com/example/jnicpp/bowling/CameraXController.kt b/app/src/main/java/com/example/jnicpp/bowling/CameraXController.kt index c0cf60f..69106fb 100644 --- a/app/src/main/java/com/example/jnicpp/bowling/CameraXController.kt +++ b/app/src/main/java/com/example/jnicpp/bowling/CameraXController.kt @@ -407,6 +407,7 @@ class CameraXController( BowlingPhase.BACK_SWING -> R.string.pose_phase_back_swing BowlingPhase.POWER_STEP -> R.string.pose_phase_power_step BowlingPhase.SLIDE_AND_RELEASE -> R.string.pose_phase_slide_and_release + BowlingPhase.FOLLOW_THROUGH -> R.string.pose_phase_follow_through } ) val colorRes = when (phase) { @@ -416,6 +417,7 @@ class CameraXController( BowlingPhase.BACK_SWING -> R.color.Back_swing_ready BowlingPhase.POWER_STEP -> R.color.Power_step_ready BowlingPhase.SLIDE_AND_RELEASE -> R.color.Slide_and_release_ready + BowlingPhase.FOLLOW_THROUGH -> R.color.Follow_through_ready } val textPaint = overlayTextPaint.apply { textSize = 14f * scale } val textWidth = textPaint.measureText(label) diff --git a/app/src/main/java/com/example/jnicpp/bowling/PosePhaseDetector.kt b/app/src/main/java/com/example/jnicpp/bowling/PosePhaseDetector.kt index b91cc16..966914c 100644 --- a/app/src/main/java/com/example/jnicpp/bowling/PosePhaseDetector.kt +++ b/app/src/main/java/com/example/jnicpp/bowling/PosePhaseDetector.kt @@ -11,10 +11,12 @@ 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. + * [BACK_SWING] and [POWER_STEP] are declared but not targeted by + * [PosePhaseDetector]'s live state machine (see [PosePhaseDetector.update]'s + * doc) -- they're kept only because [PosePhaseDetector.stepForPhase]/ + * [PosePhaseDetector.phaseForStep] still use them to describe the team's + * full 5-step terminology to [StepCountingSession] and + * [BowlingCameraActivity]'s step labels. */ enum class BowlingPhase { STARTING_STANCE, @@ -22,7 +24,8 @@ enum class BowlingPhase { PUSHAWAY, BACK_SWING, POWER_STEP, - SLIDE_AND_RELEASE + SLIDE_AND_RELEASE, + FOLLOW_THROUGH } /** @@ -34,61 +37,85 @@ enum class BowlingPhase { * 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. + * completely independent state, and neither calls into the other -- + * [CameraViewModel] does cross-reference [stepForPhase]/[phaseForStep] to + * flag a [StepEvent] as pose-confirmed, but that's a read-only comparison + * after the fact, not a dependency between the two detectors themselves. * * [update] is fed one frame's landmarks/angles at a time, in recording (or * live-preview) order. A posture only "counts" once a decaying progress - * counter (see [validFrameProgress]) climbs to [requiredConsecutiveFrames] + * counter (see [validFrameProgress]) climbs to [REQUIRED_CONSECUTIVE_FRAMES] * -- 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) while still tolerating the * occasional single-frame jitter a held stance sees in practice (see * [update]'s doc for why this decays rather than resets outright). Once * confirmed, it keeps reporting that phase through any invalid streak - * shorter than [requiredInvalidFramesToExit], reverting to null only once + * shorter than [REQUIRED_INVALID_FRAMES_TO_EXIT], reverting to null only once * that streak runs longer. - * - * @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 Target value for [validFrameProgress] - * (which increments by 1 on a valid frame, decrements by 1 -- not - * reset to 0 -- on an invalid one) before [update] starts reporting - * [BowlingPhase.STARTING_STANCE]. Despite the name, this is no - * longer a strict run of consecutive valid frames; see [update]'s doc. - * @param requiredInvalidFramesToExit How many consecutive frames the - * posture must fail to validate before [update] stops reporting - * [BowlingPhase.STARTING_STANCE] once it's already been confirmed. - * Deliberately separate from [requiredConsecutiveFrames] -- angle - * readings jitter a couple of degrees frame-to-frame even when the - * bowler is genuinely holding still, so reverting to null on the very - * first out-of-range frame (as opposed to requiring several in a - * row, same as confirming the stance in the first place) makes the - * label flicker between "confirmed" and "waiting" on that jitter - * alone rather than on an actual change of posture. */ -class PosePhaseDetector( - private val torsoTiltMinDegrees: Float = 1f, - private val torsoTiltMaxDegrees: Float = 15f, - private val kneeAngleMinDegrees: Float = 150f, - private val kneeAngleMaxDegrees: Float = 180f, - private val elbowAngleMinDegrees: Float = 70f, - private val elbowAngleMaxDegrees: Float = 125f, - private val requiredConsecutiveFrames: Int = 8, - private val requiredInvalidFramesToExit: Int = 5, -) { - // Shared parameters for all phases (consecutive frames, etc) could be - // split out, but for now they're reused from the constructor. +class PosePhaseDetector { + // Timing constants for stability -- see update() for how they are used. + companion object { + private const val REQUIRED_CONSECUTIVE_FRAMES = 8 + private const val REQUIRED_INVALID_FRAMES_TO_EXIT = 5 + + /** + * @brief The phases a bowler holds still in long enough for + * frame-by-frame angle correction to be meaningful -- see + * [correctionFor]'s doc for why the rest are excluded. + */ + private val STATIONARY_PHASES = setOf( + BowlingPhase.STARTING_STANCE, + BowlingPhase.PUSHAWAY, + BowlingPhase.SLIDE_AND_RELEASE, + ) + + /** + * @brief Maps a 5-step approach step count (0..5) to the [BowlingPhase] + * the bowler should be in once that step has landed. + * + * Follows the team's 5-step terminology: starting position -> 1/2 step + * -> preparation step -> push away & backswing -> power step -> slide + * -> finishing position. The 1/2 and preparation steps are both + * [BowlingPhase.APPROACH] (ball still held, CG moving forward); the + * ball is pushed away into the swing on step 3, reaches the peak of + * the backswing as the very short power step lands on step 4, and is + * released during the slide on step 5. + * + * step 0 -> STARTING_STANCE (starting position) + * steps 1-2 -> APPROACH (1/2 step, preparation step) + * step 3 -> PUSHAWAY (push away & backswing; BACK_SWING shares this step) + * step 4 -> POWER_STEP + * step 5+ -> SLIDE_AND_RELEASE (slide, finishing position) + */ + fun phaseForStep(stepCount: Int): BowlingPhase = when { + stepCount <= 0 -> BowlingPhase.STARTING_STANCE + stepCount <= 2 -> BowlingPhase.APPROACH + stepCount == 3 -> BowlingPhase.PUSHAWAY + stepCount == 4 -> BowlingPhase.POWER_STEP + else -> BowlingPhase.SLIDE_AND_RELEASE + } + + /** + * @brief The first step of a 5-step approach at which [phase] begins + * -- see [phaseForStep] for the full mapping. APPROACH spans + * steps 1-2, and PUSHAWAY/BACK_SWING both begin on step 3. + * [BowlingPhase.FOLLOW_THROUGH] maps to the same step as + * [BowlingPhase.SLIDE_AND_RELEASE] -- it's a posture held + * right after the step 5 release, not a phase reached by a + * new footstep of its own. + */ + fun stepForPhase(phase: BowlingPhase): Int = when (phase) { + BowlingPhase.STARTING_STANCE -> 0 + BowlingPhase.APPROACH -> 1 + BowlingPhase.PUSHAWAY -> 3 + BowlingPhase.BACK_SWING -> 3 + BowlingPhase.POWER_STEP -> 4 + BowlingPhase.SLIDE_AND_RELEASE -> 5 + BowlingPhase.FOLLOW_THROUGH -> 5 + } + } // Decaying progress toward confirming a phase -- see update()'s // doc for why this decays by one on an invalid frame rather than @@ -138,14 +165,28 @@ class PosePhaseDetector( /** * @brief Feeds one frame's landmarks/angles into the detector. * + * The live state machine only ever targets/holds + * [BowlingPhase.STARTING_STANCE], [BowlingPhase.APPROACH], + * [BowlingPhase.PUSHAWAY], [BowlingPhase.SLIDE_AND_RELEASE], and + * [BowlingPhase.FOLLOW_THROUGH] in sequence -- [BowlingPhase.BACK_SWING] + * and [BowlingPhase.POWER_STEP] are skipped entirely (see + * [BowlingPhase]'s doc) since they're too brief to reliably catch a + * held, camera-visible posture for. A "skip-ahead" pass also checks + * every phase later than the immediate next one each frame, so a + * bowler moving faster than the camera's sample rate (missing the + * immediate next phase's held moment) still gets picked up once they + * reach whichever later phase actually validates, rather than getting + * stuck waiting for a phase that already passed. + * * @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 [requiredConsecutiveFrames], - * continuing to report it through brief invalid streaks shorter - * than [requiredInvalidFramesToExit], otherwise alongside a null phase. + * @return This frame's [Metrics] alongside the currently-confirmed + * [BowlingPhase] 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. */ fun update(landmarks: Map, angles: PoseAngles): Result { val metrics = Metrics( @@ -156,39 +197,54 @@ class PosePhaseDetector( rightElbowAngleDegrees = angles.rightElbow ) - // If the posture matches starting stance, target starting stance even if currently in another phase - val isStartingValid = isStartingStanceValid(metrics) - val targetPhase = if ((isStartingValid && currentPhase != BowlingPhase.STARTING_STANCE)) { - BowlingPhase.STARTING_STANCE - } else { - when (currentPhase) { - null -> BowlingPhase.STARTING_STANCE - BowlingPhase.STARTING_STANCE -> BowlingPhase.APPROACH - BowlingPhase.APPROACH -> BowlingPhase.PUSHAWAY - BowlingPhase.PUSHAWAY -> BowlingPhase.BACK_SWING - BowlingPhase.BACK_SWING -> BowlingPhase.POWER_STEP - BowlingPhase.POWER_STEP -> BowlingPhase.SLIDE_AND_RELEASE - // The only BowlingPhase not already matched above -- every - // other case (including null) is explicit, so reaching here - // means currentPhase is SLIDE_AND_RELEASE, there's no phase - // after it to advance to. - else -> BowlingPhase.SLIDE_AND_RELEASE - } + // Identify the next phase we are looking for in the sequence. + val targetPhase = when (currentPhase) { + null -> BowlingPhase.STARTING_STANCE + BowlingPhase.STARTING_STANCE -> BowlingPhase.APPROACH + BowlingPhase.APPROACH -> BowlingPhase.PUSHAWAY + BowlingPhase.PUSHAWAY -> BowlingPhase.SLIDE_AND_RELEASE + BowlingPhase.SLIDE_AND_RELEASE -> BowlingPhase.FOLLOW_THROUGH + else -> currentPhase } // 1. Check if the user is in the NEXT phase. - val isTargetValid = when (targetPhase) { - BowlingPhase.STARTING_STANCE -> isStartingValid + var isTargetValid = when (targetPhase) { + BowlingPhase.STARTING_STANCE -> isStartingStanceValid(metrics) BowlingPhase.APPROACH -> isApproachValid(metrics) BowlingPhase.PUSHAWAY -> isPushawayValid(metrics) - BowlingPhase.BACK_SWING -> isBackSwingValid(metrics) - BowlingPhase.POWER_STEP -> isPowerStepValid(metrics) - BowlingPhase.SLIDE_AND_RELEASE -> isSlideAndReleaseValid(metrics) + BowlingPhase.SLIDE_AND_RELEASE -> isSlideReleaseValid(metrics, landmarks) + BowlingPhase.FOLLOW_THROUGH -> isFollowThroughValid(metrics, landmarks) + else -> false + } + + // SKIP-AHEAD: check if the user jumped to a LATER phase in the + // sequence (camera sample rate missed the immediate next phase's + // held moment) -- see update()'s doc. + val allPhases = BowlingPhase.entries + val currentIdx = currentPhase?.ordinal ?: -1 + + for (i in (currentIdx + 2) until allPhases.size) { + val p = allPhases[i] + + val isThisValid = when (p) { + BowlingPhase.APPROACH -> isApproachValid(metrics) + BowlingPhase.PUSHAWAY -> isPushawayValid(metrics) + BowlingPhase.SLIDE_AND_RELEASE -> isSlideReleaseValid(metrics, landmarks) + BowlingPhase.FOLLOW_THROUGH -> isFollowThroughValid(metrics, landmarks) + else -> false + } + if (isThisValid) { + currentPhase = p + validFrameProgress = REQUIRED_CONSECUTIVE_FRAMES + consecutiveInvalidFrames = 0 + isTargetValid = true + break + } } if (isTargetValid) { - validFrameProgress = (validFrameProgress + 1).coerceAtMost(requiredConsecutiveFrames) - if (validFrameProgress >= requiredConsecutiveFrames) { + validFrameProgress = (validFrameProgress + 1).coerceAtMost(REQUIRED_CONSECUTIVE_FRAMES) + if (validFrameProgress >= REQUIRED_CONSECUTIVE_FRAMES) { currentPhase = targetPhase validFrameProgress = 0 consecutiveInvalidFrames = 0 @@ -198,14 +254,18 @@ class PosePhaseDetector( } // 2. Check if the user has broken their CURRENT confirmed phase. + // BACK_SWING/POWER_STEP can never be currentPhase (the state + // machine above never targets or skip-ahead-targets them), so they + // fail unconditionally here rather than needing their own check. val isCurrentStillValid = when (currentPhase) { BowlingPhase.STARTING_STANCE -> isStartingStanceValid(metrics) BowlingPhase.APPROACH -> isApproachValid(metrics) BowlingPhase.PUSHAWAY -> isPushawayValid(metrics) - BowlingPhase.BACK_SWING -> isBackSwingValid(metrics) - BowlingPhase.POWER_STEP -> isPowerStepValid(metrics) - BowlingPhase.SLIDE_AND_RELEASE -> isSlideAndReleaseValid(metrics) - else -> true + BowlingPhase.BACK_SWING -> false + BowlingPhase.POWER_STEP -> false + BowlingPhase.SLIDE_AND_RELEASE -> isSlideReleaseValid(metrics, landmarks) + BowlingPhase.FOLLOW_THROUGH -> isFollowThroughValid(metrics, landmarks) + else -> true // If null, only care about progress toward STARTING_STANCE. } if (isCurrentStillValid || isTargetValid) { @@ -215,7 +275,7 @@ class PosePhaseDetector( } // 3. Handle resets: If we lose the current posture for too long, reset to null. - if (consecutiveInvalidFrames >= requiredInvalidFramesToExit) { + if (consecutiveInvalidFrames >= REQUIRED_INVALID_FRAMES_TO_EXIT) { currentPhase = null validFrameProgress = 0 consecutiveInvalidFrames = 0 @@ -251,35 +311,35 @@ class PosePhaseDetector( */ fun isStartingStanceValid(metrics: Metrics): Boolean { val torsoTilt = metrics.torsoTiltDegrees ?: return false - if (torsoTilt !in torsoTiltMinDegrees..torsoTiltMaxDegrees) return false + if (torsoTilt !in 1f..20f) return false val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees) - if (kneeAngles.isEmpty() || kneeAngles.any { it !in kneeAngleMinDegrees..kneeAngleMaxDegrees }) return false + if (kneeAngles.isEmpty() || kneeAngles.any { it !in 150f..180f }) return false val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees) - return elbowAngles.isNotEmpty() && elbowAngles.all { it in elbowAngleMinDegrees..elbowAngleMaxDegrees } + return elbowAngles.isNotEmpty() && elbowAngles.all { it in 70f..125f } } /** * @brief Checks whether this single frame's [Metrics] match the approach phase. * * Approach is characterized by: - * - Torso Tilt: 5-20 degrees + * - Torso Tilt: 5-30 degrees * - Knee Angle: 145-180 degrees - * - Elbow Angle: 60-130 degrees + * - Elbow Angle: 60-140 degrees * * @param metrics This frame's raw angle readings. * @return true if torso tilt, knee angles, and elbow angles fall within range. */ private fun isApproachValid(metrics: Metrics): Boolean { val torsoTilt = metrics.torsoTiltDegrees ?: return false - if (torsoTilt !in 5f..20f) return false + if (torsoTilt !in 5f..30f) return false val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees) if (kneeAngles.isEmpty() || kneeAngles.any { it !in 145f..180f }) return false val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees) - return elbowAngles.isNotEmpty() && elbowAngles.all { it in 60f..130f } + return elbowAngles.isNotEmpty() && elbowAngles.all { it in 60f..140f } } /** @@ -306,17 +366,9 @@ class PosePhaseDetector( return elbowAngles.isNotEmpty() && elbowAngles.any { it in 130f..180f } } - /** @brief Placeholder validation for Backswing phase (Step 3). */ - @Suppress("UNUSED_PARAMETER") - private fun isBackSwingValid(metrics: Metrics): Boolean = true - - /** @brief Placeholder validation for Power Step phase (Step 4). */ - @Suppress("UNUSED_PARAMETER") - private fun isPowerStepValid(metrics: Metrics): Boolean = true - /** - * @brief Checks whether this single frame's [Metrics] match the slide & - * release phase (finishing position, step 5). + * @brief Checks whether this single frame's [Metrics]/[landmarks] match + * the slide & release phase (finishing position, step 5). * * Slide & release is characterized by: * - Torso Tilt: 15-45 degrees (the deepest forward lean of any phase -- @@ -325,13 +377,17 @@ class PosePhaseDetector( * sliding/front leg lowers the body through the release; requiring * only one, not both, since we don't know which leg is forward) * - Elbow Angle: at least one elbow extended to 150-180 degrees (the - * swing arm straightens through the release -- same release cue - * [PoseStageAdvisor] already uses for its own final-step check) + * swing arm straightens through the release) + * - Wrist position: at least one wrist below shoulder height, so a + * straight-arm backswing (which also passes the angle checks above) + * doesn't get mistaken for the release. * * @param metrics This frame's raw angle readings. - * @return true if torso tilt, at least one bent knee, and at least one extended elbow all fall within range. + * @param landmarks This frame's raw landmarks, for the wrist/shoulder height check. + * @return true if torso tilt, at least one bent knee, at least one + * extended elbow, and at least one lowered wrist all hold. */ - private fun isSlideAndReleaseValid(metrics: Metrics): Boolean { + private fun isSlideReleaseValid(metrics: Metrics, landmarks: Map): Boolean { val torsoTilt = metrics.torsoTiltDegrees ?: return false if (torsoTilt !in 15f..45f) return false @@ -339,7 +395,61 @@ class PosePhaseDetector( if (kneeAngles.isEmpty() || kneeAngles.none { it in 90f..150f }) return false val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees) - return elbowAngles.isNotEmpty() && elbowAngles.any { it in 150f..180f } + if (elbowAngles.isEmpty() || elbowAngles.none { it in 150f..180f }) return false + + val shoulderY = midpoint(landmarks, PoseLandmark.LEFT_SHOULDER, PoseLandmark.RIGHT_SHOULDER)?.second ?: return false + val leftWristY = reliable(landmarks, PoseLandmark.LEFT_WRIST)?.y + val rightWristY = reliable(landmarks, PoseLandmark.RIGHT_WRIST)?.y + val anyWristBelowShoulder = (leftWristY != null && leftWristY > shoulderY) || (rightWristY != null && rightWristY > shoulderY) + return anyWristBelowShoulder + } + + /** + * @brief Checks whether this single frame's [Metrics]/[landmarks] match the follow-through phase. + * + * Follow through is characterized by: + * - Torso Tilt: 10-40 degrees + * - Knee Angle: at least one knee at 100-165 degrees (sliding knee) + * - Elbow Angle: at least one elbow at 140-180 degrees + * - Wrist position: at least one wrist above shoulder height *and* in + * front of the body (facing direction inferred from shoulder-vs-hip + * x position), distinguishing the follow-through's raised arm from + * the starting stance's held-low one. + * + * @param metrics This frame's raw angle readings. + * @param landmarks This frame's raw landmarks, for the wrist/shoulder/hip position check. + * @return true if torso tilt, at least one bent knee, at least one + * extended elbow, and a raised-and-forward wrist all hold. + */ + private fun isFollowThroughValid(metrics: Metrics, landmarks: Map): Boolean { + val torsoTilt = metrics.torsoTiltDegrees ?: return false + if (torsoTilt !in 10f..40f) return false + + val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees) + if (kneeAngles.isEmpty() || kneeAngles.none { it in 100f..165f }) return false + + val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees) + if (elbowAngles.isEmpty() || elbowAngles.none { it in 140f..180f }) return false + + val shoulderPos = midpoint(landmarks, PoseLandmark.LEFT_SHOULDER, PoseLandmark.RIGHT_SHOULDER) ?: return false + val shoulderY = shoulderPos.second + val shoulderX = shoulderPos.first + + val hipPos = midpoint(landmarks, PoseLandmark.LEFT_HIP, PoseLandmark.RIGHT_HIP) ?: return false + val hipX = hipPos.first + + // Facing direction: if the shoulder sits to the right of the hip, + // the bowler is facing right (+x), and vice versa. + val facingRight = shoulderX > hipX + + val leftWrist = reliable(landmarks, PoseLandmark.LEFT_WRIST) + val rightWrist = reliable(landmarks, PoseLandmark.RIGHT_WRIST) + + return listOfNotNull(leftWrist, rightWrist).any { wrist -> + val isHigh = wrist.y < shoulderY + val isInFront = if (facingRight) wrist.x > shoulderX else wrist.x < shoulderX + isHigh && isInFront + } } /** @@ -348,11 +458,14 @@ class PosePhaseDetector( * * Only covers [STATIONARY_PHASES] -- the three phases a bowler actually * holds still in long enough for frame-by-frame angle feedback to be - * meaningful. The remaining phases (approach, backswing, power step) are - * mid-motion by nature, so a per-frame "here's what's wrong" cue would - * either be stale by the time it's read or just describe normal - * transitional movement as an error; those are left to [update]'s - * existing pass/fail phase label instead. + * meaningful. The remaining phases (approach, the skipped back-swing/ + * power-step, follow-through) are mid-motion or too brief by nature, so + * a per-frame "here's what's wrong" cue would either be stale by the + * time it's read or just describe normal transitional movement as an + * error; those are left to [update]'s existing pass/fail phase label + * instead. Deliberately metrics-only (doesn't see the wrist-position + * check [isSlideReleaseValid] added) -- a bowler failing only that + * check gets no correction text rather than a misleading one. * * Checks each phase's conditions in the same order as its `isXValid` * counterpart and returns on the first one that fails, so the bowler @@ -371,13 +484,13 @@ class PosePhaseDetector( val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees) when { torsoTilt == null -> null - torsoTilt > torsoTiltMaxDegrees -> "Stand up straighter" - torsoTilt < torsoTiltMinDegrees -> "Relax your stance slightly" + torsoTilt > 20f -> "Stand up straighter" + torsoTilt < 1f -> "Relax your stance slightly" kneeAngles.isEmpty() -> null - kneeAngles.any { it < kneeAngleMinDegrees } -> "Straighten your legs" + kneeAngles.any { it < 150f } -> "Straighten your legs" elbowAngles.isEmpty() -> null - elbowAngles.any { it > elbowAngleMaxDegrees } -> "Bring the ball in closer to your body" - elbowAngles.any { it < elbowAngleMinDegrees } -> "Relax your arms a little" + elbowAngles.any { it > 125f } -> "Bring the ball in closer to your body" + elbowAngles.any { it < 70f } -> "Relax your arms a little" else -> null } } @@ -414,59 +527,6 @@ class PosePhaseDetector( else -> null } - companion object { - /** - * @brief The phases a bowler holds still in long enough for - * frame-by-frame angle correction to be meaningful -- see - * [correctionFor]'s doc for why the rest are excluded. - */ - private val STATIONARY_PHASES = setOf( - BowlingPhase.STARTING_STANCE, - BowlingPhase.PUSHAWAY, - BowlingPhase.SLIDE_AND_RELEASE, - ) - - /** - * @brief Maps a 5-step approach step count (0..5) to the [BowlingPhase] - * the bowler should be in once that step has landed. - * - * Follows the team's 5-step terminology: starting position -> 1/2 step - * -> preparation step -> push away & backswing -> power step -> slide - * -> finishing position. The 1/2 and preparation steps are both - * [BowlingPhase.APPROACH] (ball still held, CG moving forward); the - * ball is pushed away into the swing on step 3, reaches the peak of - * the backswing as the very short power step lands on step 4, and is - * released during the slide on step 5. - * - * step 0 -> STARTING_STANCE (starting position) - * steps 1-2 -> APPROACH (1/2 step, preparation step) - * step 3 -> PUSHAWAY (push away & backswing; BACK_SWING shares this step) - * step 4 -> POWER_STEP - * step 5+ -> SLIDE_AND_RELEASE (slide, finishing position) - */ - fun phaseForStep(stepCount: Int): BowlingPhase = when { - stepCount <= 0 -> BowlingPhase.STARTING_STANCE - stepCount <= 2 -> BowlingPhase.APPROACH - stepCount == 3 -> BowlingPhase.PUSHAWAY - stepCount == 4 -> BowlingPhase.POWER_STEP - else -> BowlingPhase.SLIDE_AND_RELEASE - } - - /** - * @brief The first step of a 5-step approach at which [phase] begins - * -- see [phaseForStep] for the full mapping. APPROACH spans - * steps 1-2, and PUSHAWAY/BACK_SWING both begin on step 3. - */ - fun stepForPhase(phase: BowlingPhase): Int = when (phase) { - BowlingPhase.STARTING_STANCE -> 0 - BowlingPhase.APPROACH -> 1 - BowlingPhase.PUSHAWAY -> 3 - BowlingPhase.BACK_SWING -> 3 - BowlingPhase.POWER_STEP -> 4 - BowlingPhase.SLIDE_AND_RELEASE -> 5 - } - } - /** * @brief Forward/backward torso lean from vertical, from the * shoulder-midpoint-to-hip-midpoint vector. diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 08f947d..a1bf9a5 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -21,5 +21,6 @@ #CC9C27B0 #CCFF9800 #CCE91E63 + #CC00BCD4 #FFFFD600 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd0c3fd..41126d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -57,6 +57,7 @@ @string/pose_phase_pushaway Power Step Slide & Finishing Position + Follow Through Waiting for starting position… ½ Step Preparation Step diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1668e1a..cffa3b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.4.0" +agp = "9.3.0" coreKtx = "1.18.0" junit = "4.13.2" junitVersion = "1.3.0"