21 Commits
Author SHA1 Message Date
unplayable 0724c6c9d7 Merge pull request #2 from DefiantWanderer/site/project-website
Site/project website
2026-09-18 00:36:14 +08:00
harineandClaude Sonnet 5 2fee57bd44 Auto-deploy website on push to master
Adds a Jenkinsfile stage that copies website/index.html to the live
nginx docroot whenever a change touches website/, so teammates can
edit the site (via Gitea web UI or a normal PR) without needing VM
access -- merge to master and it's live within one poll cycle (~5 min).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 00:33:44 +08:00
harineandClaude Sonnet 5 d7abc9fb4b Add project website (Goal 3: required deliverable)
Static one-page site covering the problem statement, features, scope,
team roster, and infra links, drawn from docs/proposal.md and
docs/design/product-design.md. Deployed via nginx on the team19 VM
at http://51.79.242.163:5002.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 18:42:00 +08:00
harine 96d24827b9 Merge branch 'master' into Harine 2026-09-17 17:53:08 +08:00
unplayable 118d5ae93c Merge pull request #1 from DefiantWanderer/ci/jenkins-pipeline
Add Jenkinsfile for CI build pipeline
2026-09-17 12:28:44 +08:00
harineandClaude Sonnet 5 bf090b2526 Add Jenkinsfile for CI build pipeline
Builds the Debug APK and runs unit tests on push, using the Android
SDK/NDK toolchain installed on the team19 VM (Goal 2 of the course's
VM onboarding). Runs on Jenkins at http://51.79.242.163:5001.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 11:21:58 +08:00
harine 1465b7984a Update PosePhaseDetector.kt 2026-09-16 22:54:42 +08:00
harine 95a64de109 Merge remote-tracking branch 'origin/jingwen' into Harine 2026-09-16 22:54:25 +08:00
harine 439ce873a6 Update PosePhaseDetector.kt 2026-09-16 22:32:18 +08:00
harine c52a133a0d Merge branch 'master' into Harine 2026-09-16 22:23:41 +08:00
harine bd9254d19c Add .gitattributes for Git LFS binary tracking
Tracks ML models, video/audio, images, and Android build artifacts
via LFS with file locking, per the team's VM/source-control setup.
2026-09-16 22:17:33 +08:00
Khalil Belabadia e89188a34e Merge branch 'master' into auwk2 2026-09-16 20:23:11 +08:00
DefiantWanderer 39eac8e70b Update proposal.md 2026-09-16 17:40:58 +08:00
jingwen121 3c682c1b30 adjusted order 2026-09-14 21:38:10 +08:00
midnight-masala bb6698a028 updated live feedback
correct the user live
- Starting position
- Finishing position
- Second step
2026-09-14 02:01:00 +08:00
midnight-masala 3227d08e2d Fix wrong Gradle 9.6.0 distribution checksum
distributionSha256Sum was a typo/copy-paste error from whoever bumped
the Gradle version on master, not the real checksum for gradle-9.6.0-bin.zip.
Verified the correct value directly against Gradle's own published
checksum at downloads.gradle.org, and it matches what a fresh download
of the distribution actually hashes to. The wrong value was blocking
gradlew from building at all.
2026-09-14 01:24:43 +08:00
jingwen121 f30bf103be Added slide and follow through 2026-09-13 00:33:32 +08:00
jingwen121 c461bcc433 Merge branch 'master' into jingwen 2026-09-12 21:43:28 +08:00
jingwen121 c6501d00f2 Squashed commit of the following:
commit f0b9b2af67
Merge: 373d65a bbaa7bb
Author: Gabriel Low <73009315+gabriellow1111@users.noreply.github.com>
Date:   Sat Sep 12 18:17:04 2026 +0800

    Merge branch 'merge-testing'

commit 373d65a9d5
Merge: 28fc652 dd0e6fd
Author: harine <harinesumen@gmail.com>
Date:   Sat Sep 12 17:34:27 2026 +0800

    Merge branch 'master' into Harine

commit 28fc652097
Author: harine <harinesumen@gmail.com>
Date:   Sat Sep 12 17:34:07 2026 +0800

    new stuff added yes

commit bbaa7bb04f
Merge: 1c6ced2 610bfd7
Author: Gabriel Low <73009315+gabriellow1111@users.noreply.github.com>
Date:   Fri Sep 11 11:18:35 2026 +0800

    Merge branch 'Gabriel' into merge-testing

commit 1c6ced2dce
Author: Gabriel Low <73009315+gabriellow1111@users.noreply.github.com>
Date:   Fri Sep 11 11:12:30 2026 +0800

    this good

commit dd0e6fdc39
Author: DefiantWanderer <yongwei1349@gmail.com>
Date:   Fri Sep 11 10:32:34 2026 +0800

    Revert "fixing compatibility issues"

    This reverts commit 564e7d83f8.

commit 564e7d83f8
Author: DefiantWanderer <yongwei1349@gmail.com>
Date:   Fri Sep 11 10:28:40 2026 +0800

    fixing compatibility issues

commit 610bfd7991
Author: Gabriel Low <73009315+gabriellow1111@users.noreply.github.com>
Date:   Fri Sep 11 10:13:25 2026 +0800

    Merged Khalil

commit cfe76da1d3
Merge: 584818b 9fcdc13
Author: Gabriel Low <73009315+gabriellow1111@users.noreply.github.com>
Date:   Fri Sep 11 10:03:51 2026 +0800

    Merge branch 'Khalil' into Gabriel

commit 584818b95a
Author: Gabriel Low <73009315+gabriellow1111@users.noreply.github.com>
Date:   Thu Sep 10 12:47:06 2026 +0800

    added back phase toggle button

commit 17fad430c1
Author: Gabriel Low <73009315+gabriellow1111@users.noreply.github.com>
Date:   Thu Sep 10 12:09:46 2026 +0800

    Cleaned up merge UI, added reset step button

commit ac4366d04d
Author: Gabriel Low <73009315+gabriellow1111@users.noreply.github.com>
Date:   Thu Sep 10 00:31:47 2026 +0800

    Added some UI to recording

commit d601efcf43
Author: Gabriel Low <73009315+gabriellow1111@users.noreply.github.com>
Date:   Thu Sep 10 00:13:06 2026 +0800

    Corrected 5 step approach phases & added placeholders for future logic

commit 7cf217488a
Author: Gabriel Low <73009315+gabriellow1111@users.noreply.github.com>
Date:   Wed Sep 9 22:48:06 2026 +0800

    Cleared warnings

commit 981071ef74
Author: Gabriel Low <73009315+gabriellow1111@users.noreply.github.com>
Date:   Wed Sep 9 20:09:18 2026 +0800

    Fix Merge, cleaned up UI

commit f30bbe2a34
Merge: 2f45115 7518594
Author: Gabriel Low <73009315+gabriellow1111@users.noreply.github.com>
Date:   Tue Sep 8 14:11:21 2026 +0800

    Merge branch 'au-au' into Gabriel

commit 9fcdc13699
Author: Khalil Belabadia <belabakhalil@gmail.com>
Date:   Mon Sep 7 22:16:00 2026 +0800

    Audio feedback update

    Added the following files :

    - AudioCue.kt
    - AudioFeedbackEngine
    - AudioFeedbackSettings

    Edited files :

    - BowlingCameraActivity (added the triggerAudiofeedback function)

commit 7518594440
Merge: e7a9c2b 7af128d
Author: midnight-masala <2401021@sit.singaporetech.edu.sg>
Date:   Mon Sep 7 21:26:18 2026 +0800

    Merge remote-tracking branch 'origin/au-au' into au-au

    # Conflicts:
    #	app/src/main/java/com/example/jnicpp/bowling/BowlingCameraActivity.kt
    #	app/src/main/res/layout-land/activity_bowling_camera.xml
    #	app/src/main/res/layout/activity_bowling_camera.xml

commit e7a9c2b140
Author: midnight-masala <2401021@sit.singaporetech.edu.sg>
Date:   Mon Sep 7 21:22:11 2026 +0800

    Add live per-step form feedback based on joint angles

    Extends pose angle tracking with knee bend (hip-knee-ankle), then uses
    it alongside the existing elbow/shoulder angles in a new
    PoseStageAdvisor to give a short live cue for whichever step of the
    approach is in progress: push-away on step 2, downswing on step 3,
    backswing on step 4, and knee-bend/arm-extension on the final step.
    Thresholds are starting defaults, not measured coaching data, and are
    expected to be retuned against real approach footage.

    Also fixes a layout bug found while testing this live: the new
    feedback text was chained via ConstraintLayout's toBottomOf to the
    step banner above it, so whenever that banner was hidden (GONE) the
    feedback text rendered at its collapsed zero-height position instead
    of staying put, landing on top of the recording indicator. Both now
    sit in a plain vertical LinearLayout, which collapses GONE children
    correctly.

    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

commit 7af128d575
Author: midnight-masala <2401021@sit.singaporetech.edu.sg>
Date:   Mon Sep 7 20:58:03 2026 +0800

    steps

    identify the steps

commit 2bb7c6ca7b
Author: midnight-masala <2401021@sit.singaporetech.edu.sg>
Date:   Mon Sep 7 20:58:03 2026 +0800

    Add live final-position feedback and fix step detection accuracy

    Shows a live banner as each step of the approach is counted, ending in
    "FINAL POSITION - RELEASE!" once the 5th footfall lands. Also fixes
    LiveStepDetector's peak-prominence check, which compared a candidate
    footfall only to its immediate neighboring frame and silently dropped
    real steps that landed across several closely-spaced frames; it now
    tracks the true rise/fall trough on each side instead. Switches
    PoseAnalyzer to ML Kit's faster base pose model (the accurate model was
    starving the peak detector of frames on unaccelerated hardware), and
    makes LiveStepDetector's stillness-based reset toggleable, currently
    off for easier testing against recorded reference clips.

    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:43:10 +08:00
jingwen121 b907747755 Merge branch 'master' into jingwen 2026-09-08 11:53:55 +08:00
jingwen121 18fc5596ef fixed version 2026-09-08 11:53:31 +08:00
12 changed files with 733 additions and 143 deletions
+37
View File
@@ -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
View File
@@ -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
}
}
}
@@ -312,10 +312,17 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
// Combined with poseEnabled (rather than posePhase alone) so
// the label can tell "pose off" (hidden) apart from "pose on
// but not yet in the target posture" (amber prompt) -- both
// cases otherwise report a null phase.
// cases otherwise report a null phase. poseCorrection rides
// along the same collector (rather than its own, like
// poseMetrics below) since it directly changes what
// renderPosePhase puts in the label -- see that function.
launch {
combine(viewModel.poseEnabled, viewModel.posePhase) { enabled, phase -> enabled to phase }
.collect { (enabled, phase) -> renderPosePhase(enabled, phase) }
combine(
viewModel.poseEnabled,
viewModel.posePhase,
viewModel.poseCorrection,
) { enabled, phase, correction -> Triple(enabled, phase, correction) }
.collect { (enabled, phase, correction) -> renderPosePhase(enabled, phase, correction) }
}
// Raw angle readout backing the label above -- its own
// collector since it's driven by a separate StateFlow
@@ -400,15 +407,24 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
*
* @param poseEnabled Whether pose detection is currently on at all.
* @param phase The bowler's current delivery phase, or null if none currently validates.
* @param correction A specific "here's what to fix" instruction from
* [PosePhaseDetector] when [phase] is null and the bowler is
* being measured against one of the stationary phases (starting
* stance, pushaway, slide & release) -- shown in place of the
* generic "waiting" message when present, so the bowler knows
* exactly what to adjust instead of just that they're not there yet.
*/
private fun renderPosePhase(poseEnabled: Boolean, phase: BowlingPhase?) {
private fun renderPosePhase(poseEnabled: Boolean, phase: BowlingPhase?, correction: String?) {
if (!poseEnabled) {
binding.textPoseFeedback.visibility = View.GONE
return
}
binding.textPoseFeedback.visibility = View.VISIBLE
// Every other BowlingPhase falls back to the "waiting" message too --
// see PosePhaseDetector's class doc, only STARTING_STANCE is detected today.
// Every confirmed phase gets its own label/color below; an
// unconfirmed one falls back to a specific correction when
// PosePhaseDetector has one (see its class doc), otherwise the
// generic "waiting" message -- e.g. mid-approach, where per-frame
// correction isn't meaningful.
when (phase) {
BowlingPhase.STARTING_STANCE -> {
binding.textPoseFeedback.text = getString(R.string.pose_phase_starting_stance)
@@ -434,8 +450,12 @@ 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 = getString(R.string.pose_phase_waiting)
binding.textPoseFeedback.text = correction ?: getString(R.string.pose_phase_waiting)
binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.Starting_stance_waiting))
}
}
@@ -88,6 +88,13 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
//This frame's torso/knee/elbow angle readings, or null if pose detection is off.
val poseMetrics: StateFlow<PosePhaseDetector.Metrics?> = _poseMetrics.asStateFlow()
// Specific "here's what to fix" instruction while holding one of the
// stationary phases (starting stance, pushaway, slide & release) -- see
// PosePhaseDetector.correctionFor's doc for why only those three.
private val _poseCorrection = MutableStateFlow<String?>(null)
/** @brief Live corrective instruction for the current stationary phase, or null if nothing to correct. */
val poseCorrection: StateFlow<String?> = _poseCorrection.asStateFlow()
/** @brief Steps detected so far in the current attempt, since the last reset. */
val stepEvents: StateFlow<List<StepEvent>> get() = stepCountingSession.stepEvents
@@ -132,6 +139,7 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
posePhaseDetector.reset()
_posePhase.value = null
_poseMetrics.value = null
_poseCorrection.value = null
}
}
@@ -153,6 +161,7 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
val phaseResult = posePhaseDetector.update(landmarks, angles)
_posePhase.value = phaseResult.phase
_poseMetrics.value = phaseResult.metrics
_poseCorrection.value = phaseResult.correction
// Check if the current pose matches the Starting Stance (pure posture query)
val isStartingStance = (phaseResult.phase == BowlingPhase.STARTING_STANCE)
@@ -407,6 +407,7 @@ class CameraXController(
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) {
@@ -416,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
@@ -124,20 +151,42 @@ class PosePhaseDetector(
* @brief One [update] call's outcome: the classified phase plus the raw angles it was based on.
* @param phase See [update]'s return doc.
* @param metrics This frame's raw angle readings, for display regardless of whether [phase] validated.
* @param correction A specific corrective instruction (e.g. "Straighten
* your legs") when the bowler is being measured against one of
* [STATIONARY_PHASES] but doesn't currently match it, or null
* when there's nothing to correct -- either because the current
* posture already validates, the target phase is one of the
* moving phases this detector doesn't give live corrections for
* (see [correctionFor]'s doc), or a needed angle wasn't
* confidently read this frame.
*/
data class Result(val phase: BowlingPhase?, val metrics: Metrics)
data class Result(val phase: BowlingPhase?, val metrics: Metrics, val correction: String? = null)
/**
* @brief Feeds one frame's landmarks/angles into the detector.
*
* 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(
@@ -148,36 +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) {
// 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.BACK_SWING
BowlingPhase.BACK_SWING -> BowlingPhase.POWER_STEP
BowlingPhase.POWER_STEP -> BowlingPhase.SLIDE_AND_RELEASE
else -> currentPhase
}
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
@@ -187,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) {
@@ -204,13 +279,19 @@ 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
}
return Result(currentPhase, metrics)
val correction = if (!isTargetValid && targetPhase in STATIONARY_PHASES) {
correctionFor(targetPhase, metrics)
} else {
null
}
return Result(currentPhase, metrics, correction)
}
/** @brief Clears all detection state. Call at the start of a new session/attempt. */
@@ -234,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 }
}
/**
@@ -289,60 +370,167 @@ 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 Placeholder validation for Slide & Release phase (Step 5). */
@Suppress("UNUSED_PARAMETER")
private fun isSlideAndReleaseValid(metrics: Metrics): Boolean = true
companion object {
/**
* @brief Maps a 5-step approach step count (0..5) to the [BowlingPhase]
* the bowler should be in once that step has landed.
* @brief Checks whether this single frame's [Metrics]/[landmarks] match
* the slide & release phase (finishing position, step 5).
*
* 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.
* Slide & release is characterized by:
* - Torso Tilt: 15-45 degrees (the deepest forward lean of any phase --
* the bowler is bent into the slide)
* - Knee Angle: at least one knee bent to 90-150 degrees (the
* sliding/front leg lowers the body through the release; requiring
* only one, not both, since we don't know which leg is forward)
* - Elbow Angle: at least one elbow extended to 150-180 degrees (the
* swing arm straightens through the release)
* - 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.
*
* 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)
* @param metrics This frame's raw angle readings.
* @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.
*/
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
private fun isSlideReleaseValid(metrics: Metrics, landmarks: Map<Int, SmoothedLandmark>): Boolean {
val torsoTilt = metrics.torsoTiltDegrees ?: return false
if (torsoTilt !in 15f..45f) return false
val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees)
if (kneeAngles.isEmpty() || kneeAngles.none { it in 90f..150f }) return false
val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees)
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 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.
* @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.
*/
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
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
}
}
/**
* @brief Produces a specific corrective instruction for why [metrics]
* doesn't currently match [targetPhase].
*
* Only covers [STATIONARY_PHASES] -- the three phases a bowler actually
* holds still in long enough for frame-by-frame angle feedback to be
* meaningful. The remaining phases (approach, 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
* gets one actionable instruction at a time rather than a list.
*
* @param targetPhase Which stationary phase to check [metrics] against. Must be one of [STATIONARY_PHASES].
* @param metrics This frame's raw angle readings.
* @return A short corrective instruction, or null if [metrics] already
* validates for [targetPhase] (nothing to correct) or a needed
* angle wasn't confidently read this frame (nothing useful to say yet).
*/
private fun correctionFor(targetPhase: BowlingPhase, metrics: Metrics): String? = when (targetPhase) {
BowlingPhase.STARTING_STANCE -> {
val torsoTilt = metrics.torsoTiltDegrees
val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees)
val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees)
when {
torsoTilt == null -> null
torsoTilt > 20f -> "Stand up straighter"
torsoTilt < 1f -> "Relax your stance slightly"
kneeAngles.isEmpty() -> null
kneeAngles.any { it < 150f } -> "Straighten your legs"
elbowAngles.isEmpty() -> null
elbowAngles.any { it > 125f } -> "Bring the ball in closer to your body"
elbowAngles.any { it < 70f } -> "Relax your arms a little"
else -> null
}
}
BowlingPhase.PUSHAWAY -> {
val torsoTilt = metrics.torsoTiltDegrees
val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees)
val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees)
when {
torsoTilt == null -> null
torsoTilt < 5f -> "Lean forward slightly as you push away"
torsoTilt > 25f -> "Don't lean too far forward yet"
kneeAngles.isEmpty() -> null
kneeAngles.any { it < 145f } -> "Keep your legs mostly straight here"
elbowAngles.isEmpty() -> null
elbowAngles.none { it in 130f..180f } -> "Push the ball further out"
else -> null
}
}
BowlingPhase.SLIDE_AND_RELEASE -> {
val torsoTilt = metrics.torsoTiltDegrees
val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees)
val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees)
when {
torsoTilt == null -> null
torsoTilt < 15f -> "Bend forward more into the slide"
torsoTilt > 45f -> "Don't lean in too far"
kneeAngles.isEmpty() -> null
kneeAngles.none { it in 90f..150f } -> "Bend your sliding knee more"
elbowAngles.isEmpty() -> null
elbowAngles.none { it in 150f..180f } -> "Extend your swing arm fully"
else -> null
}
}
else -> null
}
/**
* @brief Forward/backward torso lean from vertical, from the
* shoulder-midpoint-to-hip-midpoint vector.
+1
View File
@@ -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>
+1
View File
@@ -57,6 +57,7 @@
<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 &amp; 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>
+19
View File
@@ -5,11 +5,30 @@
## Suggested sections to include
- Problem statement / motivation
Provide an app for users to improve their bowling by learning hwo to get into the correct positions.
- Target users
Anyone who is able to bowl
- Proposed solution and key features
Able to see their mistakes
Able to see instructions on how to correct their mistakes
Able to hear instructions on how to correct their mistakes
Able to see the ideal form
- Scope (in-scope vs. out-of-scope for this project)
Five step bowling only
Right handed bowling only
Starting position correction and finishing position only
- Success criteria
User is able to get into the correct position with help from the app
- Team members and roles
Lu Yong Wei (Product Owner)
Gabriel Low (Programmer/ Version control manager)
Harine S/O Sumen (Tech Lead)
Liu Jingwen (Programmer/ Serialization manager)
Auvik Kumar Biswas (Programmer/ Feedback manager)
Chan Qi Ying (Programmer/ UI lead)
Low Yu Sheng Javier (Product Manager)
Khalil Belabadia (Programmer/ Audio Lead)
## Working summary (placeholder, derived from the current codebase — replace with the real proposal)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -1,7 +1,7 @@
#Fri Apr 10 12:25:49 SGT 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06
distributionSha256Sum=bbaeb2fef8710818cf0e261201dab964c572f92b942812df0c3620d62a529a01
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
+247
View File
@@ -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 &amp; 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 &amp; 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>