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 ac1aed8..aca1e38 100644 --- a/app/src/main/java/com/example/jnicpp/bowling/PosePhaseDetector.kt +++ b/app/src/main/java/com/example/jnicpp/bowling/PosePhaseDetector.kt @@ -36,12 +36,16 @@ enum class BowlingPhase { * 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. + * live-preview) order. A posture only "counts" once a decaying progress + * counter (see [validFrameProgress]) climbs to [requiredConsecutiveFrames] + * -- 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 + * that streak runs longer. * * @param torsoTiltMinDegrees Minimum forward torso lean from vertical * (shoulder-midpoint-to-hip-midpoint vector vs. vertical) still @@ -56,19 +60,37 @@ enum class BowlingPhase { * 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]. + * @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 = 10f, + private val torsoTiltMinDegrees: Float = 2f, private val torsoTiltMaxDegrees: Float = 15f, - private val kneeAngleMinDegrees: Float = 160f, - private val kneeAngleMaxDegrees: Float = 175f, + private val kneeAngleMinDegrees: Float = 150f, + private val kneeAngleMaxDegrees: Float = 180f, private val elbowAngleMinDegrees: Float = 70f, private val elbowAngleMaxDegrees: Float = 110f, - private val requiredConsecutiveFrames: Int = 8 + private val requiredConsecutiveFrames: Int = 8, + private val requiredInvalidFramesToExit: Int = 5 ) { - private var consecutiveValidFrames = 0 + // Decaying progress toward requiredConsecutiveFrames -- see update()'s + // doc for why this decays by one on an invalid frame rather than + // resetting to 0 outright. + private var validFrameProgress = 0 + private var consecutiveInvalidFrames = 0 private var currentPhase: BowlingPhase? = null /** @@ -109,9 +131,9 @@ class PosePhaseDetector( * [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. + * once [validFrameProgress] has climbed to [requiredConsecutiveFrames], + * continuing to report it through brief invalid streaks shorter + * than [requiredInvalidFramesToExit], otherwise alongside a null phase. */ fun update(landmarks: Map, angles: PoseAngles): Result { val metrics = Metrics( @@ -122,14 +144,39 @@ class PosePhaseDetector( rightElbowAngleDegrees = angles.rightElbow ) val isValid = isStartingStanceValid(metrics) - consecutiveValidFrames = if (isValid) consecutiveValidFrames + 1 else 0 - currentPhase = if (consecutiveValidFrames >= requiredConsecutiveFrames) BowlingPhase.STARTING_STANCE else null + if (isValid) { + // Capped at the threshold rather than left to grow unbounded, so + // a long-held stance doesn't need an equally long invalid streak + // to ever start climbing back down once it's fully confirmed. + validFrameProgress = (validFrameProgress + 1).coerceAtMost(requiredConsecutiveFrames) + consecutiveInvalidFrames = 0 + } else { + // A step back, not a hard reset to 0 -- torso/knee/elbow angles + // all have to validate *simultaneously* every frame, and with + // five independent noisy readings it's easy for one to blip out + // of range for a single frame even while the bowler holds + // genuinely still. Resetting to 0 on that alone meant progress + // could almost never reach requiredConsecutiveFrames; decaying + // by one instead still requires a mostly-valid run to confirm, + // just without one blip erasing everything before it. + validFrameProgress = (validFrameProgress - 1).coerceAtLeast(0) + consecutiveInvalidFrames++ + } + + currentPhase = when { + validFrameProgress >= requiredConsecutiveFrames -> BowlingPhase.STARTING_STANCE + // Already confirmed -- a short invalid streak alone (jitter, + // not necessarily a real change of posture) doesn't clear it. + currentPhase == BowlingPhase.STARTING_STANCE && consecutiveInvalidFrames < requiredInvalidFramesToExit -> 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 + validFrameProgress = 0 + consecutiveInvalidFrames = 0 currentPhase = null } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 174854b..9c2adea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,7 +18,7 @@ Camera unavailable: %1$s Recording failed: %1$s Pose detector error: %1$s - ✓ Starting pose + Starting pose Get into starting pose Torso %1$s · Knee L%2$s R%3$s · Elbow L%4$s R%5$s \ No newline at end of file