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 e90b25c..9c6b4cf 100644 --- a/app/src/main/java/com/example/jnicpp/bowling/BowlingCameraActivity.kt +++ b/app/src/main/java/com/example/jnicpp/bowling/BowlingCameraActivity.kt @@ -312,10 +312,17 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback { // 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. + // cases otherwise report a null phase. poseCorrection rides + // along the same collector (rather than its own, like + // poseMetrics below) since it directly changes what + // renderPosePhase puts in the label -- see that function. launch { - combine(viewModel.poseEnabled, viewModel.posePhase) { enabled, phase -> enabled to phase } - .collect { (enabled, phase) -> renderPosePhase(enabled, phase) } + combine( + viewModel.poseEnabled, + viewModel.posePhase, + viewModel.poseCorrection, + ) { enabled, phase, correction -> Triple(enabled, phase, correction) } + .collect { (enabled, phase, correction) -> renderPosePhase(enabled, phase, correction) } } // Raw angle readout backing the label above -- its own // collector since it's driven by a separate StateFlow @@ -400,15 +407,24 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback { * * @param poseEnabled Whether pose detection is currently on at all. * @param phase The bowler's current delivery phase, or null if none currently validates. + * @param correction A specific "here's what to fix" instruction from + * [PosePhaseDetector] when [phase] is null and the bowler is + * being measured against one of the stationary phases (starting + * stance, pushaway, slide & release) -- shown in place of the + * generic "waiting" message when present, so the bowler knows + * exactly what to adjust instead of just that they're not there yet. */ - private fun renderPosePhase(poseEnabled: Boolean, phase: BowlingPhase?) { + private fun renderPosePhase(poseEnabled: Boolean, phase: BowlingPhase?, correction: String?) { 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. + // Every confirmed phase gets its own label/color below; an + // unconfirmed one falls back to a specific correction when + // PosePhaseDetector has one (see its class doc), otherwise the + // generic "waiting" message -- e.g. mid-approach, where per-frame + // correction isn't meaningful. when (phase) { BowlingPhase.STARTING_STANCE -> { binding.textPoseFeedback.text = getString(R.string.pose_phase_starting_stance) @@ -435,7 +451,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback { binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.Slide_and_release_ready)) } else -> { - binding.textPoseFeedback.text = getString(R.string.pose_phase_waiting) + 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/CameraViewModel.kt b/app/src/main/java/com/example/jnicpp/bowling/CameraViewModel.kt index 4450564..cf47bf0 100644 --- a/app/src/main/java/com/example/jnicpp/bowling/CameraViewModel.kt +++ b/app/src/main/java/com/example/jnicpp/bowling/CameraViewModel.kt @@ -88,6 +88,13 @@ class CameraViewModel(application: Application) : AndroidViewModel(application) //This frame's torso/knee/elbow angle readings, or null if pose detection is off. val poseMetrics: StateFlow = _poseMetrics.asStateFlow() + // Specific "here's what to fix" instruction while holding one of the + // stationary phases (starting stance, pushaway, slide & release) -- see + // PosePhaseDetector.correctionFor's doc for why only those three. + private val _poseCorrection = MutableStateFlow(null) + /** @brief Live corrective instruction for the current stationary phase, or null if nothing to correct. */ + val poseCorrection: StateFlow = _poseCorrection.asStateFlow() + /** @brief Steps detected so far in the current attempt, since the last reset. */ val stepEvents: StateFlow> get() = stepCountingSession.stepEvents @@ -132,6 +139,7 @@ class CameraViewModel(application: Application) : AndroidViewModel(application) posePhaseDetector.reset() _posePhase.value = null _poseMetrics.value = null + _poseCorrection.value = null } } @@ -153,6 +161,7 @@ class CameraViewModel(application: Application) : AndroidViewModel(application) val phaseResult = posePhaseDetector.update(landmarks, angles) _posePhase.value = phaseResult.phase _poseMetrics.value = phaseResult.metrics + _poseCorrection.value = phaseResult.correction // Check if the current pose matches the Starting Stance (pure posture query) val isStartingStance = (phaseResult.phase == BowlingPhase.STARTING_STANCE) 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 df5252c..d30ac10 100644 --- a/app/src/main/java/com/example/jnicpp/bowling/PosePhaseDetector.kt +++ b/app/src/main/java/com/example/jnicpp/bowling/PosePhaseDetector.kt @@ -124,8 +124,16 @@ class PosePhaseDetector( * @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. + * @param correction A specific corrective instruction (e.g. "Straighten + * your legs") when the bowler is being measured against one of + * [STATIONARY_PHASES] but doesn't currently match it, or null + * when there's nothing to correct -- either because the current + * posture already validates, the target phase is one of the + * moving phases this detector doesn't give live corrections for + * (see [correctionFor]'s doc), or a needed angle wasn't + * confidently read this frame. */ - data class Result(val phase: BowlingPhase?, val metrics: Metrics) + data class Result(val phase: BowlingPhase?, val metrics: Metrics, val correction: String? = null) /** * @brief Feeds one frame's landmarks/angles into the detector. @@ -160,7 +168,11 @@ class PosePhaseDetector( BowlingPhase.PUSHAWAY -> BowlingPhase.BACK_SWING BowlingPhase.BACK_SWING -> BowlingPhase.POWER_STEP BowlingPhase.POWER_STEP -> BowlingPhase.SLIDE_AND_RELEASE - else -> currentPhase + // 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 } } @@ -172,7 +184,6 @@ class PosePhaseDetector( BowlingPhase.BACK_SWING -> isBackSwingValid(metrics) BowlingPhase.POWER_STEP -> isPowerStepValid(metrics) BowlingPhase.SLIDE_AND_RELEASE -> isSlideAndReleaseValid(metrics) - else -> false } if (isTargetValid) { @@ -210,7 +221,13 @@ class PosePhaseDetector( consecutiveInvalidFrames = 0 } - return Result(currentPhase, metrics) + val correction = if (!isTargetValid && targetPhase in STATIONARY_PHASES) { + correctionFor(targetPhase, metrics) + } else { + null + } + + return Result(currentPhase, metrics, correction) } /** @brief Clears all detection state. Call at the start of a new session/attempt. */ @@ -297,12 +314,120 @@ class PosePhaseDetector( @Suppress("UNUSED_PARAMETER") private fun isPowerStepValid(metrics: Metrics): Boolean = true - /** @brief Placeholder validation for Slide & Release phase (Step 5). */ - @Suppress("UNUSED_PARAMETER") - private fun isSlideAndReleaseValid(metrics: Metrics): Boolean = true + /** + * @brief Checks whether this single frame's [Metrics] 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 -- + * the bowler is bent into the slide) + * - Knee Angle: at least one knee bent to 90-150 degrees (the + * 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) + * + * @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. + */ + private fun isSlideAndReleaseValid(metrics: Metrics): Boolean { + val torsoTilt = metrics.torsoTiltDegrees ?: return false + if (torsoTilt !in 15f..45f) return false + + val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees) + 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 } + } + + /** + * @brief Produces a specific corrective instruction for why [metrics] + * doesn't currently match [targetPhase]. + * + * 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. + * + * Checks each phase's conditions in the same order as its `isXValid` + * counterpart and returns on the first one that fails, so the bowler + * gets one actionable instruction at a time rather than a list. + * + * @param targetPhase Which stationary phase to check [metrics] against. Must be one of [STATIONARY_PHASES]. + * @param metrics This frame's raw angle readings. + * @return A short corrective instruction, or null if [metrics] already + * validates for [targetPhase] (nothing to correct) or a needed + * angle wasn't confidently read this frame (nothing useful to say yet). + */ + private fun correctionFor(targetPhase: BowlingPhase, metrics: Metrics): String? = when (targetPhase) { + BowlingPhase.STARTING_STANCE -> { + val torsoTilt = metrics.torsoTiltDegrees + val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees) + val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees) + when { + torsoTilt == null -> null + torsoTilt > torsoTiltMaxDegrees -> "Stand up straighter" + torsoTilt < torsoTiltMinDegrees -> "Relax your stance slightly" + kneeAngles.isEmpty() -> null + kneeAngles.any { it < kneeAngleMinDegrees } -> "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" + else -> null + } + } + BowlingPhase.PUSHAWAY -> { + val torsoTilt = metrics.torsoTiltDegrees + val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees) + val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees) + when { + torsoTilt == null -> null + torsoTilt < 5f -> "Lean forward slightly as you push away" + torsoTilt > 25f -> "Don't lean too far forward yet" + kneeAngles.isEmpty() -> null + kneeAngles.any { it < 145f } -> "Keep your legs mostly straight here" + elbowAngles.isEmpty() -> null + elbowAngles.none { it in 130f..180f } -> "Push the ball further out" + else -> null + } + } + BowlingPhase.SLIDE_AND_RELEASE -> { + val torsoTilt = metrics.torsoTiltDegrees + val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees) + val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees) + when { + torsoTilt == null -> null + torsoTilt < 15f -> "Bend forward more into the slide" + torsoTilt > 45f -> "Don't lean in too far" + kneeAngles.isEmpty() -> null + kneeAngles.none { it in 90f..150f } -> "Bend your sliding knee more" + elbowAngles.isEmpty() -> null + elbowAngles.none { it in 150f..180f } -> "Extend your swing arm fully" + else -> null + } + } + 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 its corresponding [BowlingPhase]. * @brief Maps a 5-step approach step count (0..5) to the [BowlingPhase] * the bowler should be in once that step has landed. * diff --git a/docs/proposal.md b/docs/proposal.md index 6947543..d9986dd 100644 --- a/docs/proposal.md +++ b/docs/proposal.md @@ -5,11 +5,30 @@ ## Suggested sections to include - Problem statement / motivation +Provide an app for users to improve their bowling by learning hwo to get into the correct positions. - Target users +Anyone who is able to bowl - Proposed solution and key features +Able to see their mistakes +Able to see instructions on how to correct their mistakes +Able to hear instructions on how to correct their mistakes +Able to see the ideal form - Scope (in-scope vs. out-of-scope for this project) +Five step bowling only +Right handed bowling only +Starting position correction and finishing position only - Success criteria +User is able to get into the correct position with help from the app - Team members and roles +Lu Yong Wei (Product Owner) +Gabriel Low (Programmer/ Version control manager) +Harine S/O Sumen (Tech Lead) +Liu Jingwen (Programmer/ Serialization manager) +Auvik Kumar Biswas (Programmer/ Feedback manager) +Chan Qi Ying (Programmer/ UI lead) +Low Yu Sheng Javier (Product Manager) +Khalil Belabadia (Programmer/ Audio Lead) + ## Working summary (placeholder, derived from the current codebase — replace with the real proposal) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ebfd8cb..fc3da41 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ #Fri Apr 10 12:25:49 SGT 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 +distributionSha256Sum=bbaeb2fef8710818cf0e261201dab964c572f92b942812df0c3620d62a529a01 distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip networkTimeout=10000 validateDistributionUrl=true