Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fee57bd44 | ||
|
|
d7abc9fb4b | ||
|
|
96d24827b9 | ||
|
|
118d5ae93c | ||
|
|
bf090b2526 | ||
|
|
1465b7984a | ||
|
|
95a64de109 | ||
|
|
439ce873a6 | ||
|
|
c52a133a0d | ||
|
|
bd9254d19c | ||
|
|
ac3eb98b9c | ||
|
|
3c682c1b30 | ||
|
|
f30bf103be | ||
|
|
c461bcc433 | ||
|
|
c6501d00f2 | ||
|
|
b907747755 | ||
|
|
18fc5596ef |
@@ -0,0 +1,37 @@
|
||||
# Git LFS tracking for BowlEye
|
||||
# Binary/large-asset types are stored via LFS and lockable, since binaries
|
||||
# can't be merged. Run `git lfs lock <file>` before editing one of these.
|
||||
# See docs/ and the "So you got your VM" onboarding deck for context.
|
||||
|
||||
# ML models (pose detection, custom-trained)
|
||||
*.tflite filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.onnx filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.pt filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.pb filter=lfs diff=lfs merge=lfs -text lockable
|
||||
|
||||
# Video / audio (bowling capture footage, test clips)
|
||||
*.mp4 filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.mov filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.wav filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.mp3 filter=lfs diff=lfs merge=lfs -text lockable
|
||||
|
||||
# Images
|
||||
*.png filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.jpg filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.jpeg filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.webp filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.psd filter=lfs diff=lfs merge=lfs -text lockable
|
||||
|
||||
# Android build / signing artifacts
|
||||
*.apk filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.aab filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.jar filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.aar filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.so filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.keystore filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.jks filter=lfs diff=lfs merge=lfs -text lockable
|
||||
|
||||
# Misc large/binary
|
||||
*.zip filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.fbx filter=lfs diff=lfs merge=lfs -text lockable
|
||||
*.blend filter=lfs diff=lfs merge=lfs -text lockable
|
||||
Vendored
+66
@@ -0,0 +1,66 @@
|
||||
// CI pipeline for BowlEye (PinPoint). Builds the Debug APK and runs unit
|
||||
// tests on every push. Runs on the team19 VM's Jenkins install, using the
|
||||
// Android SDK at /opt/android-sdk (see docs/ for the VM setup this depends on).
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
options {
|
||||
timeout(time: 45, unit: 'MINUTES')
|
||||
disableConcurrentBuilds()
|
||||
buildDiscarder(logRotator(numToKeepStr: '20'))
|
||||
}
|
||||
|
||||
environment {
|
||||
ANDROID_HOME = '/opt/android-sdk'
|
||||
ANDROID_SDK_ROOT = '/opt/android-sdk'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
stage('Prepare SDK pointer') {
|
||||
steps {
|
||||
// AGP will also pick up ANDROID_HOME from the environment, but
|
||||
// writing local.properties explicitly keeps this working even
|
||||
// if the job ever runs on a different agent/environment setup.
|
||||
sh 'echo "sdk.dir=/opt/android-sdk" > local.properties'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build Debug APK') {
|
||||
steps {
|
||||
sh 'chmod +x ./gradlew'
|
||||
sh './gradlew assembleDebug --no-daemon --stacktrace'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Unit Tests') {
|
||||
steps {
|
||||
sh './gradlew testDebugUnitTest --no-daemon --stacktrace'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Deploy website') {
|
||||
// Only runs when a push actually touches website/, so unrelated
|
||||
// app commits don't trigger a redeploy. Just a plain file copy --
|
||||
// nginx serves the file straight off disk, no reload needed.
|
||||
when {
|
||||
changeset 'website/**'
|
||||
}
|
||||
steps {
|
||||
sh 'cp website/index.html /var/www/pinpoint-site/index.html'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
junit allowEmptyResults: true, testResults: '**/build/test-results/**/*.xml'
|
||||
archiveArtifacts artifacts: 'app/build/outputs/apk/**/*.apk', allowEmptyArchive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,13 +89,17 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
// class for FeedbackUI
|
||||
private lateinit var feedbackUI: FeedbackUI
|
||||
|
||||
// The team's 5-step terminology, in order -- deliberately not one entry
|
||||
// per BowlingPhase, since some phases span two of these (see
|
||||
// PosePhaseDetector.phaseForStep).
|
||||
private val stepLabels = listOf(
|
||||
R.string.pose_phase_starting_stance,
|
||||
R.string.pose_phase_approach,
|
||||
R.string.step_term_half_step,
|
||||
R.string.step_term_preparation_step,
|
||||
R.string.pose_phase_pushaway,
|
||||
R.string.pose_phase_back_swing,
|
||||
R.string.pose_phase_power_step,
|
||||
R.string.pose_phase_slide_and_release,
|
||||
R.string.step_term_slide,
|
||||
R.string.step_term_finishing_position,
|
||||
)
|
||||
private var currentPhaseToggleIndex = 0
|
||||
|
||||
@@ -137,6 +141,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
stepCounterUi = StepCounterUiController(
|
||||
cardStepCounter = binding.cardStepCounter,
|
||||
textStepCountBig = binding.textStepCountBig,
|
||||
textStepCounterLabel = binding.textStepCounterLabel,
|
||||
)
|
||||
feedbackUI = FeedbackUI(binding.root)
|
||||
audioFeedbackSettings = AudioFeedbackSettings(applicationContext)
|
||||
@@ -259,7 +264,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
}
|
||||
launch {
|
||||
viewModel.stepEvents.collect { events ->
|
||||
stepCounterUi.renderStepCount(events.size)
|
||||
stepCounterUi.renderStepCount(events.size, events.lastOrNull()?.poseConfirmed ?: true)
|
||||
if (events.isNotEmpty()) {
|
||||
Log.d(TAG, "Step ${events.size}: ${events.last()}")
|
||||
}
|
||||
@@ -445,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))
|
||||
|
||||
@@ -173,6 +173,7 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
|
||||
angles = angles,
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
isStartingPosition = isStartingStance,
|
||||
currentPhase = phaseResult.phase,
|
||||
)
|
||||
val currentStepCount = stepEvents.value.size
|
||||
_poseStageFeedback.value = PoseStageAdvisor.feedback(
|
||||
|
||||
@@ -399,14 +399,17 @@ class CameraXController(
|
||||
}
|
||||
|
||||
private fun drawPhaseBadgeOverlay(canvas: Canvas, phase: BowlingPhase, scale: Float) {
|
||||
val label = when (phase) {
|
||||
BowlingPhase.STARTING_STANCE -> "Starting Stance"
|
||||
BowlingPhase.APPROACH -> "Approach"
|
||||
BowlingPhase.PUSHAWAY -> "Pushaway"
|
||||
BowlingPhase.BACK_SWING -> "Backswing"
|
||||
BowlingPhase.POWER_STEP -> "Power Step"
|
||||
BowlingPhase.SLIDE_AND_RELEASE -> "Slide & Release"
|
||||
}
|
||||
val label = appContext.getString(
|
||||
when (phase) {
|
||||
BowlingPhase.STARTING_STANCE -> R.string.pose_phase_starting_stance
|
||||
BowlingPhase.APPROACH -> R.string.pose_phase_approach
|
||||
BowlingPhase.PUSHAWAY -> R.string.pose_phase_pushaway
|
||||
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) {
|
||||
BowlingPhase.STARTING_STANCE -> R.color.Starting_stance_ready
|
||||
BowlingPhase.APPROACH -> R.color.Approach_ready
|
||||
@@ -414,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)
|
||||
|
||||
@@ -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<Int, SmoothedLandmark>, angles: PoseAngles): Result {
|
||||
val metrics = Metrics(
|
||||
@@ -156,39 +197,58 @@ 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. The
|
||||
// only BowlingPhase values not already matched above are
|
||||
// BACK_SWING/POWER_STEP (never currentPhase -- see this class's
|
||||
// doc) and FOLLOW_THROUGH, so reaching else always means
|
||||
// FOLLOW_THROUGH: the final phase, nothing to advance to.
|
||||
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 -> BowlingPhase.FOLLOW_THROUGH
|
||||
}
|
||||
|
||||
// 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 +258,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 +279,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 +315,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 +370,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 +381,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<Int, SmoothedLandmark>): Boolean {
|
||||
val torsoTilt = metrics.torsoTiltDegrees ?: return false
|
||||
if (torsoTilt !in 15f..45f) return false
|
||||
|
||||
@@ -339,7 +399,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<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 +462,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 +488,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,51 +531,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 its corresponding [BowlingPhase].
|
||||
*
|
||||
* step 0 -> STARTING_STANCE
|
||||
* step 1 -> APPROACH
|
||||
* step 2 -> PUSHAWAY
|
||||
* step 3 -> BACK_SWING
|
||||
* step 4 -> POWER_STEP
|
||||
* step 5 -> SLIDE_AND_RELEASE
|
||||
*/
|
||||
fun phaseForStep(stepCount: Int): BowlingPhase = when {
|
||||
stepCount <= 0 -> BowlingPhase.STARTING_STANCE
|
||||
stepCount == 1 -> BowlingPhase.APPROACH
|
||||
stepCount == 2 -> BowlingPhase.PUSHAWAY
|
||||
stepCount == 3 -> BowlingPhase.BACK_SWING
|
||||
stepCount == 4 -> BowlingPhase.POWER_STEP
|
||||
else -> BowlingPhase.SLIDE_AND_RELEASE
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Maps a [BowlingPhase] to its corresponding 5-step approach step count.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
fun stepForPhase(phase: BowlingPhase): Int = when (phase) {
|
||||
BowlingPhase.STARTING_STANCE -> 0
|
||||
BowlingPhase.APPROACH -> 1
|
||||
BowlingPhase.PUSHAWAY -> 2
|
||||
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.
|
||||
|
||||
@@ -31,19 +31,24 @@ package com.example.jnicpp.bowling
|
||||
*/
|
||||
object PoseStageAdvisor {
|
||||
|
||||
// Step-2 cue: ball still close to the body just after push-away, so the
|
||||
// swing-arm shoulder angle (elbow-shoulder-hip) should still be small.
|
||||
private const val PUSH_AWAY_MAX_SHOULDER_DEG = 30f
|
||||
// Step names follow the team's 5-step terminology (see
|
||||
// PosePhaseDetector.phaseForStep): 1/2 step, preparation step, push away
|
||||
// & backswing, power step, slide -> finishing position.
|
||||
|
||||
// Step-3 cue: arm swinging down and back past the body.
|
||||
private const val DOWNSWING_MIN_SHOULDER_DEG = 25f
|
||||
private const val DOWNSWING_MAX_SHOULDER_DEG = 75f
|
||||
// Preparation-step cue: the ball is still held close to the body before
|
||||
// the push away, so the swing-arm shoulder angle (elbow-shoulder-hip)
|
||||
// should still be small.
|
||||
private const val PREPARATION_MAX_SHOULDER_DEG = 30f
|
||||
|
||||
// Step-4 cue: arm swinging well back behind the body.
|
||||
private const val BACKSWING_MIN_SHOULDER_DEG = 60f
|
||||
// Push away & backswing cue: ball pushed out and swinging past the body.
|
||||
private const val PUSH_AWAY_MIN_SHOULDER_DEG = 25f
|
||||
private const val PUSH_AWAY_MAX_SHOULDER_DEG = 75f
|
||||
|
||||
// Final-position cues: front knee bent to lower the slide, swing arm
|
||||
// relatively straight through the release.
|
||||
// Power-step cue: ball at the peak of the backswing, well behind the body.
|
||||
private const val POWER_STEP_MIN_SHOULDER_DEG = 60f
|
||||
|
||||
// Slide / finishing-position cues: front knee bent to lower the slide,
|
||||
// swing arm relatively straight through the release.
|
||||
private const val RELEASE_MAX_KNEE_DEG = 140f
|
||||
private const val RELEASE_MIN_ELBOW_DEG = 150f
|
||||
|
||||
@@ -61,25 +66,35 @@ object PoseStageAdvisor {
|
||||
val frontKnee = smallerOf(angles.leftKnee, angles.rightKnee)
|
||||
|
||||
return when {
|
||||
(stepNumber == null || stepNumber <= 1) -> "Starting position - stay relaxed"
|
||||
(stepNumber == null || stepNumber <= 0) -> "Starting position - stay relaxed"
|
||||
|
||||
stepNumber == 1 -> "½ step - small step, weight shifting forward"
|
||||
|
||||
stepNumber == 2 -> swingShoulder?.let {
|
||||
if (it <= PUSH_AWAY_MAX_SHOULDER_DEG) "Good push-away" else "Push the ball out first"
|
||||
if (it <= PREPARATION_MAX_SHOULDER_DEG) {
|
||||
"Good preparation step"
|
||||
} else {
|
||||
"Keep the ball close until the push away"
|
||||
}
|
||||
}
|
||||
|
||||
stepNumber == 3 -> swingShoulder?.let {
|
||||
if (it in DOWNSWING_MIN_SHOULDER_DEG..DOWNSWING_MAX_SHOULDER_DEG) {
|
||||
"Good downswing"
|
||||
if (it in PUSH_AWAY_MIN_SHOULDER_DEG..PUSH_AWAY_MAX_SHOULDER_DEG) {
|
||||
"Good push away & backswing"
|
||||
} else {
|
||||
"Let the arm swing naturally"
|
||||
"Push the ball up and let it swing"
|
||||
}
|
||||
}
|
||||
|
||||
stepNumber == 4 -> swingShoulder?.let {
|
||||
if (it >= BACKSWING_MIN_SHOULDER_DEG) "Good backswing" else "Swing the arm further back"
|
||||
if (it >= POWER_STEP_MIN_SHOULDER_DEG) {
|
||||
"Good power step - ball at the top"
|
||||
} else {
|
||||
"Let the ball swing higher on the power step"
|
||||
}
|
||||
}
|
||||
|
||||
else -> { // final step (5+)
|
||||
else -> { // slide & finishing position (5+)
|
||||
val kneeGood = frontKnee != null && frontKnee <= RELEASE_MAX_KNEE_DEG
|
||||
val armGood = swingElbow != null && swingElbow >= RELEASE_MIN_ELBOW_DEG
|
||||
when {
|
||||
|
||||
@@ -6,16 +6,22 @@ package com.example.jnicpp.bowling
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.example.jnicpp.R
|
||||
|
||||
/**
|
||||
* @brief Owns rendering for [BowlingCameraActivity]'s step-counter card.
|
||||
*
|
||||
* @param cardStepCounter The step-counter card container view.
|
||||
* @param textStepCountBig The large step-count number TextView.
|
||||
* @param textStepCounterLabel The small "STEPS" label below the count,
|
||||
* repurposed to flag when the latest step wasn't corroborated by
|
||||
* [PosePhaseDetector] -- see [renderStepCount]'s `latestPoseConfirmed`.
|
||||
*/
|
||||
class StepCounterUiController(
|
||||
private val cardStepCounter: View,
|
||||
private val textStepCountBig: TextView,
|
||||
private val textStepCounterLabel: TextView,
|
||||
) {
|
||||
// Last step count rendered, so pulse() in renderStepCount only plays
|
||||
// when a new step actually pushed the count up.
|
||||
@@ -36,14 +42,34 @@ class StepCounterUiController(
|
||||
|
||||
/**
|
||||
* @brief Renders the current step count, pulsing the card if a new step was just confirmed.
|
||||
*
|
||||
* Never withholds or delays a step because of [latestPoseConfirmed] --
|
||||
* the ankle-peak count is always trusted (see [StepEvent.poseConfirmed]'s
|
||||
* doc for why); this only swaps the small label below the number to flag
|
||||
* a disagreement for whoever's testing/tuning detection to notice.
|
||||
*
|
||||
* @param stepCount Total steps counted so far in the current attempt.
|
||||
* @param latestPoseConfirmed Whether the most recently counted step (if
|
||||
* any) was corroborated by [PosePhaseDetector] at the time it was
|
||||
* counted; ignored when [stepCount] is 0.
|
||||
*/
|
||||
fun renderStepCount(stepCount: Int) {
|
||||
fun renderStepCount(stepCount: Int, latestPoseConfirmed: Boolean = true) {
|
||||
textStepCountBig.text = stepCount.toString()
|
||||
if (stepCount > lastRenderedStepCount) {
|
||||
pulse()
|
||||
}
|
||||
lastRenderedStepCount = stepCount
|
||||
|
||||
val flagged = stepCount > 0 && !latestPoseConfirmed
|
||||
textStepCounterLabel.text = textStepCounterLabel.context.getString(
|
||||
if (flagged) R.string.step_counter_label_unconfirmed else R.string.step_counter_label
|
||||
)
|
||||
textStepCounterLabel.setTextColor(
|
||||
ContextCompat.getColor(
|
||||
textStepCounterLabel.context,
|
||||
if (flagged) R.color.recording_red else R.color.step_counter_accent,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** @brief Briefly scales the step counter up and back down, drawing the eye to a newly confirmed step. */
|
||||
|
||||
@@ -57,12 +57,17 @@ class StepCountingSession {
|
||||
* @param angles Joint angles computed for this same frame.
|
||||
* @param timestampMs Wall-clock time this frame was analyzed, in milliseconds.
|
||||
* @param isStartingPosition Whether the bowler is currently in the starting position.
|
||||
* @param currentPhase [PosePhaseDetector]'s currently-classified delivery
|
||||
* phase for this same frame, or null if none currently validates
|
||||
* -- used only to set each new [StepEvent.poseConfirmed] below,
|
||||
* never to gate step counting itself (see that field's doc).
|
||||
*/
|
||||
fun onFrame(
|
||||
landmarks: Map<Int, SmoothedLandmark>,
|
||||
angles: PoseAngles,
|
||||
timestampMs: Long,
|
||||
isStartingPosition: Boolean = false,
|
||||
currentPhase: BowlingPhase? = null,
|
||||
) {
|
||||
val smoothedAnkleHip = ankleHipSmoother.smooth(landmarks)
|
||||
val frame = buildPoseFrame(
|
||||
@@ -78,7 +83,15 @@ class StepCountingSession {
|
||||
_stepEvents.value = emptyList()
|
||||
}
|
||||
if (result.newSteps.isNotEmpty()) {
|
||||
_stepEvents.value += result.newSteps
|
||||
// Compared phase-to-phase (via each phase's starting step) rather
|
||||
// than against the raw step number, since one phase can span
|
||||
// several steps -- e.g. APPROACH covers steps 1-2, so a pose still
|
||||
// in APPROACH is correct when step 2 lands.
|
||||
val posePhaseStart = PosePhaseDetector.stepForPhase(currentPhase ?: BowlingPhase.STARTING_STANCE)
|
||||
_stepEvents.value += result.newSteps.map { step ->
|
||||
val expectedPhaseStart = PosePhaseDetector.stepForPhase(PosePhaseDetector.phaseForStep(step.stepIndex))
|
||||
step.copy(poseConfirmed = posePhaseStart >= expectedPhaseStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,21 @@ enum class Foot { LEFT, RIGHT }
|
||||
* @param timestampMs Time this foot-plant was detected, in milliseconds.
|
||||
* @param foot Which foot planted.
|
||||
* @param stepIndex 1-based position of this step in the overall approach sequence.
|
||||
* @param poseConfirmed Whether [PosePhaseDetector]'s independently-classified
|
||||
* posture had already reached the delivery phase this step index
|
||||
* expects (see [PosePhaseDetector.phaseForStep]) at the moment this
|
||||
* step was counted -- see [StepCountingSession.onFrame]. The ankle-peak
|
||||
* count is trusted either way (this never blocks a step from being
|
||||
* counted); false just flags that the two signals disagreed, e.g. a
|
||||
* step 1 landing before pose ever confirmed the bowler had actually
|
||||
* left the starting stance. Always true from [StepDetector.detect]'s
|
||||
* batch pass, which has no phase information to compare against.
|
||||
*/
|
||||
data class StepEvent(
|
||||
val timestampMs: Long,
|
||||
val foot: Foot,
|
||||
val stepIndex: Int,
|
||||
val poseConfirmed: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_step_counter_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/step_counter_label"
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_step_counter_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/step_counter_label"
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
<color name="Back_swing_ready">#CC9C27B0</color> <!-- purple -->
|
||||
<color name="Power_step_ready">#CCFF9800</color> <!-- orange/amber -->
|
||||
<color name="Slide_and_release_ready">#CCE91E63</color> <!-- pink/red -->
|
||||
<color name="Follow_through_ready">#CC00BCD4</color> <!-- cyan -->
|
||||
<color name="final_position_highlight">#FFFFD600</color>
|
||||
</resources>
|
||||
@@ -15,6 +15,7 @@
|
||||
<string name="recording_timer_placeholder">00:00</string>
|
||||
<string name="step_count_big_placeholder">0</string>
|
||||
<string name="step_counter_label">STEPS</string>
|
||||
<string name="step_counter_label_unconfirmed">STEPS · UNCONFIRMED BY POSE</string>
|
||||
<string name="reset_counter">Reset Steps</string>
|
||||
<string name="reset_hint_idle">✋ Raise a hand, hold 5s to reset</string>
|
||||
<string name="reset_hint_holding">Keep holding… %1$d%%</string>
|
||||
@@ -50,12 +51,17 @@
|
||||
<string name="editor_save">Save</string>
|
||||
<string name="editor_saved_toast">Settings saved</string>
|
||||
<string name="editor_invalid_value_toast">Enter a valid number for every field</string>
|
||||
<string name="pose_phase_starting_stance">Starting Stance</string>
|
||||
<string name="pose_phase_approach">Approach</string>
|
||||
<string name="pose_phase_pushaway">Pushaway</string>
|
||||
<string name="pose_phase_back_swing">Backswing</string>
|
||||
<string name="pose_phase_starting_stance">Starting Position</string>
|
||||
<string name="pose_phase_approach">½ Step / Preparation Step</string>
|
||||
<string name="pose_phase_pushaway">Push Away & Backswing</string>
|
||||
<string name="pose_phase_back_swing">@string/pose_phase_pushaway</string>
|
||||
<string name="pose_phase_power_step">Power Step</string>
|
||||
<string name="pose_phase_slide_and_release">Slide & Release</string>
|
||||
<string name="pose_phase_waiting">Waiting for stance…</string>
|
||||
<string name="pose_phase_slide_and_release">Slide & Finishing Position</string>
|
||||
<string name="pose_phase_follow_through">Follow Through</string>
|
||||
<string name="pose_phase_waiting">Waiting for starting position…</string>
|
||||
<string name="step_term_half_step">½ Step</string>
|
||||
<string name="step_term_preparation_step">Preparation Step</string>
|
||||
<string name="step_term_slide">Slide</string>
|
||||
<string name="step_term_finishing_position">Finishing Position</string>
|
||||
<string name="pose_metrics_format">Torso: %1$s · Knee L: %2$s R: %3$s\nElbow L: %4$s R: %5$s</string>
|
||||
</resources>
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>PinPoint — Bowling Form Coaching</title>
|
||||
<meta name="description" content="PinPoint (BowlEye) is an Android app that uses on-device pose detection to help bowlers correct their approach and delivery form.">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f7f5f2;
|
||||
--surface: #ffffff;
|
||||
--ink: #1c1b1f;
|
||||
--ink-soft: #55524f;
|
||||
--accent: #c4392b;
|
||||
--accent-ink: #ffffff;
|
||||
--line: #e6e2db;
|
||||
--pin-cream: #fbfaf7;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #16151a;
|
||||
--surface: #1f1e24;
|
||||
--ink: #f3f1ee;
|
||||
--ink-soft: #b8b4ad;
|
||||
--accent: #e0574a;
|
||||
--accent-ink: #16151a;
|
||||
--line: #302e35;
|
||||
--pin-cream: #232228;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #16151a;
|
||||
--surface: #1f1e24;
|
||||
--ink: #f3f1ee;
|
||||
--ink-soft: #b8b4ad;
|
||||
--accent: #e0574a;
|
||||
--accent-ink: #16151a;
|
||||
--line: #302e35;
|
||||
--pin-cream: #232228;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.wrap { max-width: 1040px; margin: 0 auto; padding-inline: 20px; }
|
||||
|
||||
header.site {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-block: 18px;
|
||||
}
|
||||
header.site .wrap { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.15rem; letter-spacing: -0.01em; }
|
||||
.brand .dot { width: 11px; height: 11px; border-radius: 50%; background: var(--accent); flex: none; }
|
||||
nav.site a { color: var(--ink-soft); text-decoration: none; margin-left: 22px; font-size: 0.95rem; }
|
||||
nav.site a:hover { color: var(--ink); }
|
||||
|
||||
.hero { padding-block: 72px 56px; }
|
||||
.hero .kicker { color: var(--accent); font-weight: 700; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 14px; }
|
||||
.hero h1 { font-size: clamp(2.1rem, 5vw, 3.2rem); line-height: 1.08; margin: 0 0 18px; letter-spacing: -0.02em; }
|
||||
.hero p.lead { color: var(--ink-soft); font-size: 1.15rem; max-width: 640px; margin: 0 0 28px; }
|
||||
.cta-row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.btn { display: inline-block; padding: 12px 22px; border-radius: 8px; font-weight: 600; font-size: 0.95rem; text-decoration: none; border: 1px solid transparent; }
|
||||
.btn.primary { background: var(--accent); color: var(--accent-ink); }
|
||||
.btn.ghost { border-color: var(--line); color: var(--ink); }
|
||||
|
||||
section { padding-block: 56px; border-top: 1px solid var(--line); }
|
||||
section h2 { font-size: 1.7rem; margin: 0 0 8px; letter-spacing: -0.01em; }
|
||||
section p.section-lead { color: var(--ink-soft); margin: 0 0 32px; max-width: 620px; }
|
||||
|
||||
.scope-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 28px; margin-bottom: 8px; }
|
||||
@media (max-width: 640px) { .scope-grid { grid-template-columns: 1fr; } }
|
||||
.scope-grid h3 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-soft); margin: 0 0 12px; }
|
||||
.scope-grid ul { margin: 0; padding-left: 1.1em; }
|
||||
.scope-grid li { margin-bottom: 6px; }
|
||||
|
||||
.feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 20px; }
|
||||
.feature-card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 22px; }
|
||||
.feature-card .icon { width: 34px; height: 34px; border-radius: 8px; background: var(--pin-cream); border: 1px solid var(--line); display: flex; align-items: center; justify-content: center; margin-bottom: 14px; font-size: 1.1rem; }
|
||||
.feature-card h3 { font-size: 1.02rem; margin: 0 0 8px; }
|
||||
.feature-card p { color: var(--ink-soft); font-size: 0.93rem; margin: 0; }
|
||||
|
||||
.team-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 16px; }
|
||||
.member { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px; }
|
||||
.member .name { font-weight: 600; margin-bottom: 2px; }
|
||||
.member .role { color: var(--accent); font-size: 0.85rem; font-weight: 600; }
|
||||
|
||||
.status-row { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 20px; }
|
||||
.status-pill { display: flex; align-items: center; gap: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 999px; padding: 8px 16px; font-size: 0.88rem; text-decoration: none; color: var(--ink); }
|
||||
.status-pill .led { width: 8px; height: 8px; border-radius: 50%; background: #3aa65a; flex: none; }
|
||||
|
||||
footer.site { border-top: 1px solid var(--line); padding-block: 28px; color: var(--ink-soft); font-size: 0.85rem; }
|
||||
footer.site .wrap { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
|
||||
footer.site a { color: var(--ink-soft); }
|
||||
|
||||
code.inline { background: var(--pin-cream); border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; font-size: 0.85em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="site">
|
||||
<div class="wrap">
|
||||
<div class="brand"><span class="dot"></span> PinPoint</div>
|
||||
<nav class="site">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#scope">Scope</a>
|
||||
<a href="#team">Team</a>
|
||||
<a href="#status">Status</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<div class="wrap">
|
||||
<div class="kicker">DigiPen FullStack · Team 19</div>
|
||||
<h1>Fix your bowling form before your next frame.</h1>
|
||||
<p class="lead">
|
||||
PinPoint (working codename <code class="inline">BowlEye</code>) is an Android app that watches your
|
||||
approach through your phone's camera, tracks your body in real time with on-device pose detection,
|
||||
and shows you exactly where your steps, timing, and joint angles go wrong — no coach required.
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="btn primary" href="#features">See what it does</a>
|
||||
<a class="btn ghost" href="http://51.79.242.163:5000/unplayable01/PinPoint" target="_blank" rel="noopener">View source</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="problem">
|
||||
<div class="wrap">
|
||||
<h2>The problem</h2>
|
||||
<p class="section-lead">
|
||||
Most bowlers improve by trial and error, or by paying for a coach's eye. PinPoint gives anyone who can
|
||||
bowl a way to see their own mistakes — where their feet land, how their hips and ankles move at release,
|
||||
and how that compares to correct form — using nothing but a phone propped up at the lane.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="features">
|
||||
<div class="wrap">
|
||||
<h2>What it does</h2>
|
||||
<p class="section-lead">Built around live pose tracking during a real approach, not a recorded lesson.</p>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<div class="icon">📹</div>
|
||||
<h3>Live camera capture</h3>
|
||||
<p>Front or back camera, portrait or landscape, switchable mid-session via CameraX.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">🦴</div>
|
||||
<h3>Real-time pose overlay</h3>
|
||||
<p>A skeleton drawn live over the camera preview from on-device ML Kit landmark detection.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">👣</div>
|
||||
<h3>Step & phase detection</h3>
|
||||
<p>Counts steps through the approach and segments it into stance, approach, and release phases.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">📐</div>
|
||||
<h3>Joint angle analysis</h3>
|
||||
<p>Computes ankle and hip angles at the key moments that matter for a clean release.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">🎯</div>
|
||||
<h3>Mistake & correction feedback</h3>
|
||||
<p>Surfaces what went wrong and how to fix it — with the ideal form shown for comparison.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">🔊</div>
|
||||
<h3>Spoken instructions</h3>
|
||||
<p>Hear correction cues, not just read them, so you can keep your eyes on the lane.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="scope">
|
||||
<div class="wrap">
|
||||
<h2>Who it's for, and today's scope</h2>
|
||||
<p class="section-lead">
|
||||
Anyone who can bowl. This version focuses on getting the core coaching loop right before broadening it.
|
||||
</p>
|
||||
<div class="scope-grid">
|
||||
<div>
|
||||
<h3>In scope</h3>
|
||||
<ul>
|
||||
<li>Five-step bowling approaches</li>
|
||||
<li>Right-handed bowlers</li>
|
||||
<li>Starting position and finishing position correction</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Not yet</h3>
|
||||
<ul>
|
||||
<li>Left-handed / non-five-step approaches</li>
|
||||
<li>Cloud sync across devices</li>
|
||||
<li>Automated scoring against a reference model</li>
|
||||
<li>iOS support</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="team">
|
||||
<div class="wrap">
|
||||
<h2>Team</h2>
|
||||
<p class="section-lead">DigiPen Institute of Technology — FullStack, Team 19.</p>
|
||||
<div class="team-grid">
|
||||
<div class="member"><div class="name">Lu Yong Wei</div><div class="role">Product Owner</div></div>
|
||||
<div class="member"><div class="name">Gabriel Low</div><div class="role">Programmer / Version Control</div></div>
|
||||
<div class="member"><div class="name">Harine S/O Sumen</div><div class="role">Tech Lead</div></div>
|
||||
<div class="member"><div class="name">Liu Jingwen</div><div class="role">Programmer / Serialization</div></div>
|
||||
<div class="member"><div class="name">Auvik Kumar Biswas</div><div class="role">Programmer / Feedback</div></div>
|
||||
<div class="member"><div class="name">Chan Qi Ying</div><div class="role">Programmer / UI Lead</div></div>
|
||||
<div class="member"><div class="name">Low Yu Sheng Javier</div><div class="role">Product Manager</div></div>
|
||||
<div class="member"><div class="name">Khalil Belabadia</div><div class="role">Programmer / Audio Lead</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="status">
|
||||
<div class="wrap">
|
||||
<h2>Project infrastructure</h2>
|
||||
<p class="section-lead">Self-hosted on the team's own VM, per the course's FullStack infrastructure track.</p>
|
||||
<div class="status-row">
|
||||
<a class="status-pill" href="http://51.79.242.163:5000/unplayable01/PinPoint" target="_blank" rel="noopener"><span class="led"></span> Gitea repository</a>
|
||||
<a class="status-pill" href="http://51.79.242.163:5001/job/PinPoint-CI/" target="_blank" rel="noopener"><span class="led"></span> Jenkins CI</a>
|
||||
<a class="status-pill" href="https://github.com/DefiantWanderer/PinPoint" target="_blank" rel="noopener"><span class="led"></span> GitHub mirror</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="site">
|
||||
<div class="wrap">
|
||||
<div>PinPoint / BowlEye — DigiPen FullStack, Team 19.</div>
|
||||
<div><a href="http://51.79.242.163:5000/unplayable01/PinPoint" target="_blank" rel="noopener">Source</a></div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user