|
|
@@ -11,10 +11,12 @@ import kotlin.math.atan2
|
|
|
|
/**
|
|
|
|
/**
|
|
|
|
* @brief The delivery phases this app distinguishes, in the order a bowler moves through them.
|
|
|
|
* @brief The delivery phases this app distinguishes, in the order a bowler moves through them.
|
|
|
|
*
|
|
|
|
*
|
|
|
|
* Only [BowlingPhase.STARTING_STANCE] has detection logic today (see
|
|
|
|
* [BACK_SWING] and [POWER_STEP] are declared but not targeted by
|
|
|
|
* [PosePhaseDetector]); the rest are declared up front so callers (state,
|
|
|
|
* [PosePhaseDetector]'s live state machine (see [PosePhaseDetector.update]'s
|
|
|
|
* UI) can already model "which of the 5 phases" without a later enum
|
|
|
|
* doc) -- they're kept only because [PosePhaseDetector.stepForPhase]/
|
|
|
|
* change, and get filled in one at a time.
|
|
|
|
* [PosePhaseDetector.phaseForStep] still use them to describe the team's
|
|
|
|
|
|
|
|
* full 5-step terminology to [StepCountingSession] and
|
|
|
|
|
|
|
|
* [BowlingCameraActivity]'s step labels.
|
|
|
|
*/
|
|
|
|
*/
|
|
|
|
enum class BowlingPhase {
|
|
|
|
enum class BowlingPhase {
|
|
|
|
STARTING_STANCE,
|
|
|
|
STARTING_STANCE,
|
|
|
@@ -22,7 +24,8 @@ enum class BowlingPhase {
|
|
|
|
PUSHAWAY,
|
|
|
|
PUSHAWAY,
|
|
|
|
BACK_SWING,
|
|
|
|
BACK_SWING,
|
|
|
|
POWER_STEP,
|
|
|
|
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)
|
|
|
|
* detection classifies overall posture (torso lean, knee bend, elbow bend)
|
|
|
|
* against a per-phase reference range. The two run side by side off the
|
|
|
|
* against a per-phase reference range. The two run side by side off the
|
|
|
|
* same per-frame data (see [CameraViewModel.onPoseFrameUpdated]) but track
|
|
|
|
* 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
|
|
|
|
* [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
|
|
|
|
* 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
|
|
|
|
* -- this filters out a momentary, correct-looking pose caught mid-transition
|
|
|
|
* (e.g. a fleeting instant during the approach where the knee angle briefly
|
|
|
|
* (e.g. a fleeting instant during the approach where the knee angle briefly
|
|
|
|
* passes through the starting-stance range) while still tolerating the
|
|
|
|
* passes through the starting-stance range) while still tolerating the
|
|
|
|
* occasional single-frame jitter a held stance sees in practice (see
|
|
|
|
* occasional single-frame jitter a held stance sees in practice (see
|
|
|
|
* [update]'s doc for why this decays rather than resets outright). Once
|
|
|
|
* [update]'s doc for why this decays rather than resets outright). Once
|
|
|
|
* confirmed, it keeps reporting that phase through any invalid streak
|
|
|
|
* 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.
|
|
|
|
* 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(
|
|
|
|
class PosePhaseDetector {
|
|
|
|
private val torsoTiltMinDegrees: Float = 1f,
|
|
|
|
// Timing constants for stability -- see update() for how they are used.
|
|
|
|
private val torsoTiltMaxDegrees: Float = 15f,
|
|
|
|
companion object {
|
|
|
|
private val kneeAngleMinDegrees: Float = 150f,
|
|
|
|
private const val REQUIRED_CONSECUTIVE_FRAMES = 8
|
|
|
|
private val kneeAngleMaxDegrees: Float = 180f,
|
|
|
|
private const val REQUIRED_INVALID_FRAMES_TO_EXIT = 5
|
|
|
|
private val elbowAngleMinDegrees: Float = 70f,
|
|
|
|
|
|
|
|
private val elbowAngleMaxDegrees: Float = 125f,
|
|
|
|
/**
|
|
|
|
private val requiredConsecutiveFrames: Int = 8,
|
|
|
|
* @brief The phases a bowler holds still in long enough for
|
|
|
|
private val requiredInvalidFramesToExit: Int = 5,
|
|
|
|
* frame-by-frame angle correction to be meaningful -- see
|
|
|
|
) {
|
|
|
|
* [correctionFor]'s doc for why the rest are excluded.
|
|
|
|
// Shared parameters for all phases (consecutive frames, etc) could be
|
|
|
|
*/
|
|
|
|
// split out, but for now they're reused from the constructor.
|
|
|
|
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
|
|
|
|
// Decaying progress toward confirming a phase -- see update()'s
|
|
|
|
// doc for why this decays by one on an invalid frame rather than
|
|
|
|
// 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.
|
|
|
|
* @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 landmarks Smoothed landmarks for this frame, keyed by ML Kit's `PoseLandmark` type constant.
|
|
|
|
* @param angles Joint angles computed for this same frame (see
|
|
|
|
* @param angles Joint angles computed for this same frame (see
|
|
|
|
* [PoseAngleCalculator]) -- elbow angles are reused from here
|
|
|
|
* [PoseAngleCalculator]) -- elbow angles are reused from here
|
|
|
|
* rather than recomputed, so this detector doesn't duplicate that math.
|
|
|
|
* rather than recomputed, so this detector doesn't duplicate that math.
|
|
|
|
* @return This frame's [Metrics] alongside [BowlingPhase.STARTING_STANCE]
|
|
|
|
* @return This frame's [Metrics] alongside the currently-confirmed
|
|
|
|
* once [validFrameProgress] has climbed to [requiredConsecutiveFrames],
|
|
|
|
* [BowlingPhase] once [validFrameProgress] has climbed to
|
|
|
|
* continuing to report it through brief invalid streaks shorter
|
|
|
|
* [REQUIRED_CONSECUTIVE_FRAMES], continuing to report it through
|
|
|
|
* than [requiredInvalidFramesToExit], otherwise alongside a null phase.
|
|
|
|
* brief invalid streaks shorter than [REQUIRED_INVALID_FRAMES_TO_EXIT],
|
|
|
|
|
|
|
|
* otherwise alongside a null phase.
|
|
|
|
*/
|
|
|
|
*/
|
|
|
|
fun update(landmarks: Map<Int, SmoothedLandmark>, angles: PoseAngles): Result {
|
|
|
|
fun update(landmarks: Map<Int, SmoothedLandmark>, angles: PoseAngles): Result {
|
|
|
|
val metrics = Metrics(
|
|
|
|
val metrics = Metrics(
|
|
|
@@ -156,39 +197,54 @@ class PosePhaseDetector(
|
|
|
|
rightElbowAngleDegrees = angles.rightElbow
|
|
|
|
rightElbowAngleDegrees = angles.rightElbow
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// If the posture matches starting stance, target starting stance even if currently in another phase
|
|
|
|
// Identify the next phase we are looking for in the sequence.
|
|
|
|
val isStartingValid = isStartingStanceValid(metrics)
|
|
|
|
val targetPhase = when (currentPhase) {
|
|
|
|
val targetPhase = if ((isStartingValid && currentPhase != BowlingPhase.STARTING_STANCE)) {
|
|
|
|
null -> BowlingPhase.STARTING_STANCE
|
|
|
|
BowlingPhase.STARTING_STANCE
|
|
|
|
BowlingPhase.STARTING_STANCE -> BowlingPhase.APPROACH
|
|
|
|
} else {
|
|
|
|
BowlingPhase.APPROACH -> BowlingPhase.PUSHAWAY
|
|
|
|
when (currentPhase) {
|
|
|
|
BowlingPhase.PUSHAWAY -> BowlingPhase.SLIDE_AND_RELEASE
|
|
|
|
null -> BowlingPhase.STARTING_STANCE
|
|
|
|
BowlingPhase.SLIDE_AND_RELEASE -> BowlingPhase.FOLLOW_THROUGH
|
|
|
|
BowlingPhase.STARTING_STANCE -> BowlingPhase.APPROACH
|
|
|
|
else -> currentPhase
|
|
|
|
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
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 1. Check if the user is in the NEXT phase.
|
|
|
|
// 1. Check if the user is in the NEXT phase.
|
|
|
|
val isTargetValid = when (targetPhase) {
|
|
|
|
var isTargetValid = when (targetPhase) {
|
|
|
|
BowlingPhase.STARTING_STANCE -> isStartingValid
|
|
|
|
BowlingPhase.STARTING_STANCE -> isStartingStanceValid(metrics)
|
|
|
|
BowlingPhase.APPROACH -> isApproachValid(metrics)
|
|
|
|
BowlingPhase.APPROACH -> isApproachValid(metrics)
|
|
|
|
BowlingPhase.PUSHAWAY -> isPushawayValid(metrics)
|
|
|
|
BowlingPhase.PUSHAWAY -> isPushawayValid(metrics)
|
|
|
|
BowlingPhase.BACK_SWING -> isBackSwingValid(metrics)
|
|
|
|
BowlingPhase.SLIDE_AND_RELEASE -> isSlideReleaseValid(metrics, landmarks)
|
|
|
|
BowlingPhase.POWER_STEP -> isPowerStepValid(metrics)
|
|
|
|
BowlingPhase.FOLLOW_THROUGH -> isFollowThroughValid(metrics, landmarks)
|
|
|
|
BowlingPhase.SLIDE_AND_RELEASE -> isSlideAndReleaseValid(metrics)
|
|
|
|
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) {
|
|
|
|
if (isTargetValid) {
|
|
|
|
validFrameProgress = (validFrameProgress + 1).coerceAtMost(requiredConsecutiveFrames)
|
|
|
|
validFrameProgress = (validFrameProgress + 1).coerceAtMost(REQUIRED_CONSECUTIVE_FRAMES)
|
|
|
|
if (validFrameProgress >= requiredConsecutiveFrames) {
|
|
|
|
if (validFrameProgress >= REQUIRED_CONSECUTIVE_FRAMES) {
|
|
|
|
currentPhase = targetPhase
|
|
|
|
currentPhase = targetPhase
|
|
|
|
validFrameProgress = 0
|
|
|
|
validFrameProgress = 0
|
|
|
|
consecutiveInvalidFrames = 0
|
|
|
|
consecutiveInvalidFrames = 0
|
|
|
@@ -198,14 +254,18 @@ class PosePhaseDetector(
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. Check if the user has broken their CURRENT confirmed phase.
|
|
|
|
// 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) {
|
|
|
|
val isCurrentStillValid = when (currentPhase) {
|
|
|
|
BowlingPhase.STARTING_STANCE -> isStartingStanceValid(metrics)
|
|
|
|
BowlingPhase.STARTING_STANCE -> isStartingStanceValid(metrics)
|
|
|
|
BowlingPhase.APPROACH -> isApproachValid(metrics)
|
|
|
|
BowlingPhase.APPROACH -> isApproachValid(metrics)
|
|
|
|
BowlingPhase.PUSHAWAY -> isPushawayValid(metrics)
|
|
|
|
BowlingPhase.PUSHAWAY -> isPushawayValid(metrics)
|
|
|
|
BowlingPhase.BACK_SWING -> isBackSwingValid(metrics)
|
|
|
|
BowlingPhase.BACK_SWING -> false
|
|
|
|
BowlingPhase.POWER_STEP -> isPowerStepValid(metrics)
|
|
|
|
BowlingPhase.POWER_STEP -> false
|
|
|
|
BowlingPhase.SLIDE_AND_RELEASE -> isSlideAndReleaseValid(metrics)
|
|
|
|
BowlingPhase.SLIDE_AND_RELEASE -> isSlideReleaseValid(metrics, landmarks)
|
|
|
|
else -> true
|
|
|
|
BowlingPhase.FOLLOW_THROUGH -> isFollowThroughValid(metrics, landmarks)
|
|
|
|
|
|
|
|
else -> true // If null, only care about progress toward STARTING_STANCE.
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isCurrentStillValid || isTargetValid) {
|
|
|
|
if (isCurrentStillValid || isTargetValid) {
|
|
|
@@ -215,7 +275,7 @@ class PosePhaseDetector(
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. Handle resets: If we lose the current posture for too long, reset to null.
|
|
|
|
// 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
|
|
|
|
currentPhase = null
|
|
|
|
validFrameProgress = 0
|
|
|
|
validFrameProgress = 0
|
|
|
|
consecutiveInvalidFrames = 0
|
|
|
|
consecutiveInvalidFrames = 0
|
|
|
@@ -251,35 +311,35 @@ class PosePhaseDetector(
|
|
|
|
*/
|
|
|
|
*/
|
|
|
|
fun isStartingStanceValid(metrics: Metrics): Boolean {
|
|
|
|
fun isStartingStanceValid(metrics: Metrics): Boolean {
|
|
|
|
val torsoTilt = metrics.torsoTiltDegrees ?: return false
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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.
|
|
|
|
* @brief Checks whether this single frame's [Metrics] match the approach phase.
|
|
|
|
*
|
|
|
|
*
|
|
|
|
* Approach is characterized by:
|
|
|
|
* Approach is characterized by:
|
|
|
|
* - Torso Tilt: 5-20 degrees
|
|
|
|
* - Torso Tilt: 5-30 degrees
|
|
|
|
* - Knee Angle: 145-180 degrees
|
|
|
|
* - Knee Angle: 145-180 degrees
|
|
|
|
* - Elbow Angle: 60-130 degrees
|
|
|
|
* - Elbow Angle: 60-140 degrees
|
|
|
|
*
|
|
|
|
*
|
|
|
|
* @param metrics This frame's raw angle readings.
|
|
|
|
* @param metrics This frame's raw angle readings.
|
|
|
|
* @return true if torso tilt, knee angles, and elbow angles fall within range.
|
|
|
|
* @return true if torso tilt, knee angles, and elbow angles fall within range.
|
|
|
|
*/
|
|
|
|
*/
|
|
|
|
private fun isApproachValid(metrics: Metrics): Boolean {
|
|
|
|
private fun isApproachValid(metrics: Metrics): Boolean {
|
|
|
|
val torsoTilt = metrics.torsoTiltDegrees ?: return false
|
|
|
|
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)
|
|
|
|
val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees)
|
|
|
|
if (kneeAngles.isEmpty() || kneeAngles.any { it !in 145f..180f }) return false
|
|
|
|
if (kneeAngles.isEmpty() || kneeAngles.any { it !in 145f..180f }) return false
|
|
|
|
|
|
|
|
|
|
|
|
val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees)
|
|
|
|
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 }
|
|
|
|
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 &
|
|
|
|
* @brief Checks whether this single frame's [Metrics]/[landmarks] match
|
|
|
|
* release phase (finishing position, step 5).
|
|
|
|
* the slide & release phase (finishing position, step 5).
|
|
|
|
*
|
|
|
|
*
|
|
|
|
* Slide & release is characterized by:
|
|
|
|
* Slide & release is characterized by:
|
|
|
|
* - Torso Tilt: 15-45 degrees (the deepest forward lean of any phase --
|
|
|
|
* - 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
|
|
|
|
* sliding/front leg lowers the body through the release; requiring
|
|
|
|
* only one, not both, since we don't know which leg is forward)
|
|
|
|
* 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
|
|
|
|
* - Elbow Angle: at least one elbow extended to 150-180 degrees (the
|
|
|
|
* swing arm straightens through the release -- same release cue
|
|
|
|
* swing arm straightens through the release)
|
|
|
|
* [PoseStageAdvisor] already uses for its own final-step check)
|
|
|
|
* - 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.
|
|
|
|
* @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<Int, SmoothedLandmark>): Boolean {
|
|
|
|
val torsoTilt = metrics.torsoTiltDegrees ?: return false
|
|
|
|
val torsoTilt = metrics.torsoTiltDegrees ?: return false
|
|
|
|
if (torsoTilt !in 15f..45f) 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
|
|
|
|
if (kneeAngles.isEmpty() || kneeAngles.none { it in 90f..150f }) return false
|
|
|
|
|
|
|
|
|
|
|
|
val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees)
|
|
|
|
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<Int, SmoothedLandmark>): 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
|
|
|
|
* Only covers [STATIONARY_PHASES] -- the three phases a bowler actually
|
|
|
|
* holds still in long enough for frame-by-frame angle feedback to be
|
|
|
|
* holds still in long enough for frame-by-frame angle feedback to be
|
|
|
|
* meaningful. The remaining phases (approach, backswing, power step) are
|
|
|
|
* meaningful. The remaining phases (approach, the skipped back-swing/
|
|
|
|
* mid-motion by nature, so a per-frame "here's what's wrong" cue would
|
|
|
|
* power-step, follow-through) are mid-motion or too brief by nature, so
|
|
|
|
* either be stale by the time it's read or just describe normal
|
|
|
|
* a per-frame "here's what's wrong" cue would either be stale by the
|
|
|
|
* transitional movement as an error; those are left to [update]'s
|
|
|
|
* time it's read or just describe normal transitional movement as an
|
|
|
|
* existing pass/fail phase label instead.
|
|
|
|
* 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`
|
|
|
|
* Checks each phase's conditions in the same order as its `isXValid`
|
|
|
|
* counterpart and returns on the first one that fails, so the bowler
|
|
|
|
* 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)
|
|
|
|
val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees)
|
|
|
|
when {
|
|
|
|
when {
|
|
|
|
torsoTilt == null -> null
|
|
|
|
torsoTilt == null -> null
|
|
|
|
torsoTilt > torsoTiltMaxDegrees -> "Stand up straighter"
|
|
|
|
torsoTilt > 20f -> "Stand up straighter"
|
|
|
|
torsoTilt < torsoTiltMinDegrees -> "Relax your stance slightly"
|
|
|
|
torsoTilt < 1f -> "Relax your stance slightly"
|
|
|
|
kneeAngles.isEmpty() -> null
|
|
|
|
kneeAngles.isEmpty() -> null
|
|
|
|
kneeAngles.any { it < kneeAngleMinDegrees } -> "Straighten your legs"
|
|
|
|
kneeAngles.any { it < 150f } -> "Straighten your legs"
|
|
|
|
elbowAngles.isEmpty() -> null
|
|
|
|
elbowAngles.isEmpty() -> null
|
|
|
|
elbowAngles.any { it > elbowAngleMaxDegrees } -> "Bring the ball in closer to your body"
|
|
|
|
elbowAngles.any { it > 125f } -> "Bring the ball in closer to your body"
|
|
|
|
elbowAngles.any { it < elbowAngleMinDegrees } -> "Relax your arms a little"
|
|
|
|
elbowAngles.any { it < 70f } -> "Relax your arms a little"
|
|
|
|
else -> null
|
|
|
|
else -> null
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -414,59 +527,6 @@ class PosePhaseDetector(
|
|
|
|
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 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
|
|
|
|
* @brief Forward/backward torso lean from vertical, from the
|
|
|
|
* shoulder-midpoint-to-hip-midpoint vector.
|
|
|
|
* shoulder-midpoint-to-hip-midpoint vector.
|
|
|
|