4 Commits
Author SHA1 Message Date
DefiantWanderer 57460f261e misc 2026-09-14 17:06:41 +08:00
DefiantWanderer 2c6d96cb11 Merge branch 'master' into YongWei's-Branch 2026-09-14 17:02:35 +08:00
DefiantWanderer 7aa47e796a version fix 2026-09-11 12:18:32 +08:00
DefiantWanderer bb0acfaa97 idk what these are 2026-09-11 11:00:50 +08:00
6 changed files with 20 additions and 170 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="temurin-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">
@@ -307,17 +307,10 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
// Combined with poseEnabled (rather than posePhase alone) so // Combined with poseEnabled (rather than posePhase alone) so
// the label can tell "pose off" (hidden) apart from "pose on // the label can tell "pose off" (hidden) apart from "pose on
// but not yet in the target posture" (amber prompt) -- both // but not yet in the target posture" (amber prompt) -- both
// cases otherwise report a null phase. poseCorrection rides // cases otherwise report a null phase.
// 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 { launch {
combine( combine(viewModel.poseEnabled, viewModel.posePhase) { enabled, phase -> enabled to phase }
viewModel.poseEnabled, .collect { (enabled, phase) -> renderPosePhase(enabled, phase) }
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 // Raw angle readout backing the label above -- its own
// collector since it's driven by a separate StateFlow // collector since it's driven by a separate StateFlow
@@ -402,24 +395,15 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
* *
* @param poseEnabled Whether pose detection is currently on at all. * @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 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?, correction: String?) { private fun renderPosePhase(poseEnabled: Boolean, phase: BowlingPhase?) {
if (!poseEnabled) { if (!poseEnabled) {
binding.textPoseFeedback.visibility = View.GONE binding.textPoseFeedback.visibility = View.GONE
return return
} }
binding.textPoseFeedback.visibility = View.VISIBLE binding.textPoseFeedback.visibility = View.VISIBLE
// Every confirmed phase gets its own label/color below; an // Every other BowlingPhase falls back to the "waiting" message too --
// unconfirmed one falls back to a specific correction when // see PosePhaseDetector's class doc, only STARTING_STANCE is detected today.
// 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) { when (phase) {
BowlingPhase.STARTING_STANCE -> { BowlingPhase.STARTING_STANCE -> {
binding.textPoseFeedback.text = getString(R.string.pose_phase_starting_stance) binding.textPoseFeedback.text = getString(R.string.pose_phase_starting_stance)
@@ -446,7 +430,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.Slide_and_release_ready)) binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.Slide_and_release_ready))
} }
else -> { else -> {
binding.textPoseFeedback.text = correction ?: getString(R.string.pose_phase_waiting) binding.textPoseFeedback.text = getString(R.string.pose_phase_waiting)
binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.Starting_stance_waiting)) binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.Starting_stance_waiting))
} }
} }
@@ -88,13 +88,6 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
//This frame's torso/knee/elbow angle readings, or null if pose detection is off. //This frame's torso/knee/elbow angle readings, or null if pose detection is off.
val poseMetrics: StateFlow<PosePhaseDetector.Metrics?> = _poseMetrics.asStateFlow() val poseMetrics: StateFlow<PosePhaseDetector.Metrics?> = _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<String?>(null)
/** @brief Live corrective instruction for the current stationary phase, or null if nothing to correct. */
val poseCorrection: StateFlow<String?> = _poseCorrection.asStateFlow()
/** @brief Steps detected so far in the current attempt, since the last reset. */ /** @brief Steps detected so far in the current attempt, since the last reset. */
val stepEvents: StateFlow<List<StepEvent>> get() = stepCountingSession.stepEvents val stepEvents: StateFlow<List<StepEvent>> get() = stepCountingSession.stepEvents
@@ -139,7 +132,6 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
posePhaseDetector.reset() posePhaseDetector.reset()
_posePhase.value = null _posePhase.value = null
_poseMetrics.value = null _poseMetrics.value = null
_poseCorrection.value = null
} }
} }
@@ -161,7 +153,6 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
val phaseResult = posePhaseDetector.update(landmarks, angles) val phaseResult = posePhaseDetector.update(landmarks, angles)
_posePhase.value = phaseResult.phase _posePhase.value = phaseResult.phase
_poseMetrics.value = phaseResult.metrics _poseMetrics.value = phaseResult.metrics
_poseCorrection.value = phaseResult.correction
// Check if the current pose matches the Starting Stance (pure posture query) // Check if the current pose matches the Starting Stance (pure posture query)
val isStartingStance = (phaseResult.phase == BowlingPhase.STARTING_STANCE) val isStartingStance = (phaseResult.phase == BowlingPhase.STARTING_STANCE)
@@ -124,16 +124,8 @@ class PosePhaseDetector(
* @brief One [update] call's outcome: the classified phase plus the raw angles it was based on. * @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 phase See [update]'s return doc.
* @param metrics This frame's raw angle readings, for display regardless of whether [phase] validated. * @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, val correction: String? = null) data class Result(val phase: BowlingPhase?, val metrics: Metrics)
/** /**
* @brief Feeds one frame's landmarks/angles into the detector. * @brief Feeds one frame's landmarks/angles into the detector.
@@ -168,11 +160,7 @@ class PosePhaseDetector(
BowlingPhase.PUSHAWAY -> BowlingPhase.BACK_SWING BowlingPhase.PUSHAWAY -> BowlingPhase.BACK_SWING
BowlingPhase.BACK_SWING -> BowlingPhase.POWER_STEP BowlingPhase.BACK_SWING -> BowlingPhase.POWER_STEP
BowlingPhase.POWER_STEP -> BowlingPhase.SLIDE_AND_RELEASE BowlingPhase.POWER_STEP -> BowlingPhase.SLIDE_AND_RELEASE
// The only BowlingPhase not already matched above -- every else -> currentPhase
// 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
} }
} }
@@ -184,6 +172,7 @@ class PosePhaseDetector(
BowlingPhase.BACK_SWING -> isBackSwingValid(metrics) BowlingPhase.BACK_SWING -> isBackSwingValid(metrics)
BowlingPhase.POWER_STEP -> isPowerStepValid(metrics) BowlingPhase.POWER_STEP -> isPowerStepValid(metrics)
BowlingPhase.SLIDE_AND_RELEASE -> isSlideAndReleaseValid(metrics) BowlingPhase.SLIDE_AND_RELEASE -> isSlideAndReleaseValid(metrics)
else -> false
} }
if (isTargetValid) { if (isTargetValid) {
@@ -221,13 +210,7 @@ class PosePhaseDetector(
consecutiveInvalidFrames = 0 consecutiveInvalidFrames = 0
} }
val correction = if (!isTargetValid && targetPhase in STATIONARY_PHASES) { return Result(currentPhase, metrics)
correctionFor(targetPhase, metrics)
} else {
null
}
return Result(currentPhase, metrics, correction)
} }
/** @brief Clears all detection state. Call at the start of a new session/attempt. */ /** @brief Clears all detection state. Call at the start of a new session/attempt. */
@@ -314,118 +297,11 @@ class PosePhaseDetector(
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
private fun isPowerStepValid(metrics: Metrics): Boolean = true private fun isPowerStepValid(metrics: Metrics): Boolean = true
/** /** @brief Placeholder validation for Slide & Release phase (Step 5). */
* @brief Checks whether this single frame's [Metrics] match the slide & @Suppress("UNUSED_PARAMETER")
* release phase (finishing position, step 5). private fun isSlideAndReleaseValid(metrics: Metrics): Boolean = true
*
* 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 { 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 its corresponding [BowlingPhase].
* *
+2 -3
View File
@@ -1,5 +1,5 @@
[versions] [versions]
agp = "9.4.0" agp = "9.1.1"
coreKtx = "1.18.0" coreKtx = "1.18.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
@@ -30,10 +30,9 @@ androidx-camera-effects = { group = "androidx.camera", name = "camera-effects",
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
mlkit-pose-detection-accurate = { group = "com.google.mlkit", name = "pose-detection-accurate", version.ref = "mlkitPoseDetection" }
mlkit-pose-detection = { group = "com.google.mlkit", name = "pose-detection", version.ref = "mlkitPoseDetection" } mlkit-pose-detection = { group = "com.google.mlkit", name = "pose-detection", version.ref = "mlkitPoseDetection" }
mlkit-pose-detection-accurate = { group = "com.google.mlkit", name = "pose-detection-accurate", version.ref = "mlkitPoseDetection" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
+2 -2
View File
@@ -1,8 +1,8 @@
#Fri Apr 10 12:25:49 SGT 2026 #Fri Apr 10 12:25:49 SGT 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=bbaeb2fef8710818cf0e261201dab964c572f92b942812df0c3620d62a529a01 distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME