|
3 | 3 | <head> |
4 | 4 | <meta charset="UTF-8"> |
5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
6 | | - <title>Lax Wall Ball Counter</title> |
| 6 | + <title>Lacrosse Wall Ball Tracker - Arm Movement</title> |
| 7 | + <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;700&display=swap" rel="stylesheet"> |
7 | 8 | <style> |
8 | | - @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap'); |
9 | | - |
10 | 9 | body { |
11 | | - font-family: 'Roboto', sans-serif; |
| 10 | + font-family: 'Roboto Mono', monospace; |
| 11 | + background-color: #121212; |
| 12 | + color: #e0e0e0; |
12 | 13 | display: flex; |
13 | 14 | flex-direction: column; |
14 | 15 | align-items: center; |
15 | 16 | justify-content: center; |
16 | 17 | height: 100vh; |
17 | 18 | margin: 0; |
18 | | - background: #1a1a1a; |
19 | | - color: #fff; |
| 19 | + overflow: hidden; |
20 | 20 | } |
| 21 | + |
21 | 22 | #container { |
22 | 23 | position: relative; |
23 | 24 | width: 90vw; |
24 | | - max-width: 640px; |
25 | | - border-radius: 16px; |
| 25 | + max-width: 800px; |
| 26 | + aspect-ratio: 16 / 9; |
| 27 | + border-radius: 15px; |
26 | 28 | overflow: hidden; |
27 | | - box-shadow: 0 8px 32px rgba(0,0,0,0.3); |
| 29 | + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); |
| 30 | + background: #000; |
28 | 31 | } |
29 | | - canvas { |
30 | | - display: block; |
| 32 | + |
| 33 | + video, canvas { |
| 34 | + position: absolute; |
| 35 | + top: 0; |
| 36 | + left: 0; |
31 | 37 | width: 100%; |
32 | | - height: auto; |
| 38 | + height: 100%; |
| 39 | + object-fit: cover; |
| 40 | + transform: scaleX(-1); |
33 | 41 | } |
34 | | - .overlay-ui { |
| 42 | + |
| 43 | + #ui-container { |
35 | 44 | position: absolute; |
36 | | - top: 20px; |
37 | | - left: 20px; |
38 | | - right: 20px; |
| 45 | + top: 0; |
| 46 | + left: 0; |
| 47 | + width: 100%; |
| 48 | + height: 100%; |
39 | 49 | display: flex; |
| 50 | + flex-direction: column; |
40 | 51 | justify-content: space-between; |
41 | | - align-items: flex-start; |
| 52 | + align-items: center; |
| 53 | + padding: 20px; |
| 54 | + box-sizing: border-box; |
| 55 | + } |
| 56 | + |
| 57 | + #counter-container { |
| 58 | + background: rgba(0, 0, 0, 0.6); |
| 59 | + backdrop-filter: blur(10px); |
| 60 | + padding: 10px 25px; |
| 61 | + border-radius: 50px; |
| 62 | + border: 1px solid rgba(255, 255, 255, 0.1); |
| 63 | + text-align: center; |
| 64 | + } |
| 65 | + |
| 66 | + #counter-container h1 { |
| 67 | + margin: 0; |
| 68 | + font-size: 1.2em; |
| 69 | + color: #ff416c; |
| 70 | + text-transform: uppercase; |
| 71 | + letter-spacing: 2px; |
42 | 72 | } |
| 73 | + |
43 | 74 | #counter { |
44 | 75 | font-size: 3em; |
45 | 76 | font-weight: 700; |
46 | | - padding: 10px 20px; |
47 | | - background: rgba(0, 0, 0, 0.3); |
48 | | - border-radius: 12px; |
49 | | - backdrop-filter: blur(10px); |
50 | | - -webkit-backdrop-filter: blur(10px); |
51 | | - border: 1px solid rgba(255, 255, 255, 0.1); |
52 | | - text-shadow: 0 2px 4px rgba(0,0,0,0.5); |
| 77 | + color: #fff; |
| 78 | + margin: 0; |
53 | 79 | } |
54 | | - #instructions { |
55 | | - font-size: 1em; |
56 | | - padding: 10px 15px; |
57 | | - background: rgba(0, 0, 0, 0.3); |
58 | | - border-radius: 12px; |
| 80 | + |
| 81 | + #controls { |
| 82 | + background: rgba(0, 0, 0, 0.6); |
59 | 83 | backdrop-filter: blur(10px); |
60 | | - -webkit-backdrop-filter: blur(10px); |
| 84 | + padding: 20px; |
| 85 | + border-radius: 15px; |
61 | 86 | border: 1px solid rgba(255, 255, 255, 0.1); |
62 | | - max-width: 200px; |
63 | | - } |
64 | | - #controls { |
65 | | - position: absolute; |
66 | | - bottom: 20px; |
67 | | - left: 50%; |
68 | | - transform: translateX(-50%); |
69 | | - display: flex; |
70 | | - gap: 10px; |
| 87 | + text-align: center; |
| 88 | + max-width: 300px; |
71 | 89 | } |
72 | | - .control-button { |
| 90 | + |
| 91 | + #startButton { |
| 92 | + background: linear-gradient(to right, #ff416c, #ff4b2b); |
| 93 | + color: white; |
| 94 | + border: none; |
73 | 95 | padding: 15px 30px; |
74 | 96 | font-size: 1.2em; |
75 | | - font-weight: 700; |
| 97 | + font-family: 'Roboto Mono', monospace; |
| 98 | + border-radius: 10px; |
76 | 99 | cursor: pointer; |
77 | | - border: none; |
78 | | - border-radius: 12px; |
79 | | - color: #fff; |
80 | | - background: linear-gradient(45deg, #ff416c, #ff4b2b); |
81 | | - box-shadow: 0 4px 15px rgba(0,0,0,0.2); |
82 | | - transition: all 0.3s ease; |
| 100 | + transition: transform 0.2s, box-shadow 0.2s; |
| 101 | + } |
| 102 | + |
| 103 | + #startButton:disabled { |
| 104 | + background: #555; |
| 105 | + cursor: not-allowed; |
83 | 106 | } |
84 | | - .control-button:hover { |
85 | | - transform: translateY(-2px); |
86 | | - box-shadow: 0 6px 20px rgba(0,0,0,0.3); |
| 107 | + |
| 108 | + #startButton:not(:disabled):hover { |
| 109 | + transform: translateY(-3px); |
| 110 | + box-shadow: 0 5px 15px rgba(255, 65, 108, 0.4); |
87 | 111 | } |
| 112 | + |
88 | 113 | #loader { |
89 | 114 | position: absolute; |
90 | 115 | top: 50%; |
|
96 | 121 | width: 60px; |
97 | 122 | height: 60px; |
98 | 123 | animation: spin 1s linear infinite; |
99 | | - display: none; /* Initially hidden */ |
| 124 | + z-index: 10; |
100 | 125 | } |
| 126 | + |
101 | 127 | @keyframes spin { |
102 | 128 | 0% { transform: translate(-50%, -50%) rotate(0deg); } |
103 | 129 | 100% { transform: translate(-50%, -50%) rotate(360deg); } |
104 | 130 | } |
| 131 | + |
| 132 | + #instructions p { |
| 133 | + margin: 5px 0; |
| 134 | + } |
105 | 135 | </style> |
106 | | - <!-- TensorFlow.js --> |
107 | | - <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@3.11.0/dist/tf.min.js"></script> |
108 | | - <!-- COCO-SSD model --> |
109 | | - <script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/coco-ssd@2.2.2/dist/coco-ssd.min.js"></script> |
110 | 136 | </head> |
111 | 137 | <body> |
| 138 | + |
112 | 139 | <div id="container"> |
113 | | - <video id="video" width="640" height="480" autoplay muted playsinline style="display:none;"></video> |
| 140 | + <video id="video" width="640" height="480" autoplay muted playsinline></video> |
114 | 141 | <canvas id="canvas" width="640" height="480"></canvas> |
115 | 142 | <div id="loader"></div> |
116 | | - <div class="overlay-ui"> |
117 | | - <div id="counter">0</div> |
118 | | - <div id="instructions"> |
119 | | - <p><strong>Wall Ball Tracker</strong></p> |
120 | | - <p>Aim your camera at the wall. The app will count reps when the ball hits the designated wall zone.</p> |
| 143 | + <div id="ui-container"> |
| 144 | + <div id="counter-container"> |
| 145 | + <h1>Reps</h1> |
| 146 | + <p id="counter">0</p> |
| 147 | + </div> |
| 148 | + <div id="controls"> |
| 149 | + <div id="instructions"></div> |
| 150 | + <button id="startButton" disabled>Loading Model...</button> |
121 | 151 | </div> |
122 | | - </div> |
123 | | - <div id="controls"> |
124 | | - <button id="startButton" class="control-button">Start</button> |
125 | 152 | </div> |
126 | 153 | </div> |
127 | 154 |
|
| 155 | + <!-- TensorFlow.js Core --> |
| 156 | + <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core@3.11.0/dist/tf-core.min.js"></script> |
| 157 | + <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-converter@3.11.0/dist/tf-converter.min.js"></script> |
| 158 | + <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-webgl@3.11.0/dist/tf-backend-webgl.min.js"></script> |
| 159 | + |
| 160 | + <!-- Pose Detection model --> |
| 161 | + <script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/pose-detection@2.0.0/dist/pose-detection.min.js"></script> |
| 162 | + |
128 | 163 | <script> |
129 | 164 | const video = document.getElementById('video'); |
130 | 165 | const canvas = document.getElementById('canvas'); |
|
133 | 168 | const counterElement = document.getElementById('counter'); |
134 | 169 | const loader = document.getElementById('loader'); |
135 | 170 | const controls = document.getElementById('controls'); |
| 171 | + const instructions = document.getElementById('instructions'); |
136 | 172 |
|
137 | | - let model = null; |
| 173 | + let detector = null; |
138 | 174 | let repCount = 0; |
139 | | - let ballState = { x: 0, y: 0, detected: false, lastDetectionTime: 0 }; |
140 | | - let lastWallHitTime = 0; |
| 175 | + let armState = 'down'; // 'down' or 'up' |
141 | 176 | let isDetecting = false; |
142 | 177 |
|
143 | 178 | // --- Main Setup --- |
144 | 179 | async function setup() { |
145 | 180 | loader.style.display = 'block'; |
146 | | - await cocoSsd.load().then(loadedModel => { |
147 | | - model = loadedModel; |
148 | | - console.log('Object detection model loaded.'); |
149 | | - loader.style.display = 'none'; |
150 | | - startButton.disabled = false; |
151 | | - startButton.textContent = 'Start Camera'; |
152 | | - }); |
| 181 | + instructions.innerHTML = '<p><strong>Loading Model...</strong></p><p>Please wait, this may take a moment.</p>'; |
| 182 | + const detectorConfig = {modelType: poseDetection.movenet.modelType.SINGLEPOSE_LIGHTNING}; |
| 183 | + detector = await poseDetection.createDetector(poseDetection.SupportedModels.MoveNet, detectorConfig); |
| 184 | + console.log('Pose detection model loaded.'); |
| 185 | + loader.style.display = 'none'; |
| 186 | + startButton.disabled = false; |
| 187 | + startButton.textContent = 'Start Camera'; |
| 188 | + instructions.innerHTML = '<p><strong>Arm Movement Tracker</strong></p><p>Press Start and position yourself in the frame. Reps are counted when you raise and lower your arm.</p>'; |
153 | 189 |
|
154 | 190 | startButton.addEventListener('click', async () => { |
155 | 191 | if (!isDetecting) { |
|
167 | 203 |
|
168 | 204 | async function setupCamera() { |
169 | 205 | const stream = await navigator.mediaDevices.getUserMedia({ |
170 | | - video: { facingMode: 'environment' }, // Use 'user' for front camera |
| 206 | + video: { facingMode: 'user' }, // Use 'environment' for back camera |
171 | 207 | audio: false |
172 | 208 | }); |
173 | 209 | video.srcObject = stream; |
|
179 | 215 | } |
180 | 216 |
|
181 | 217 | async function detectFrame() { |
182 | | - if (!model) { |
| 218 | + if (!detector) { |
183 | 219 | requestAnimationFrame(detectFrame); |
184 | 220 | return; |
185 | 221 | } |
186 | 222 |
|
187 | | - const predictions = await model.detect(video); |
188 | | - |
| 223 | + const poses = await detector.estimatePoses(video); |
189 | 224 | ctx.drawImage(video, 0, 0, canvas.width, canvas.height); |
190 | 225 |
|
191 | | - const ball = predictions.find(p => p.class === 'sports ball'); |
192 | | - |
193 | | - if (ball) { |
194 | | - // Draw bounding box |
195 | | - ctx.strokeStyle = 'red'; |
196 | | - ctx.lineWidth = 2; |
197 | | - ctx.strokeRect(ball.bbox[0], ball.bbox[1], ball.bbox[2], ball.bbox[3]); |
198 | | - ctx.fillStyle = 'red'; |
199 | | - ctx.fillText(`${ball.class} (${Math.round(ball.score * 100)}%)`, ball.bbox[0], ball.bbox[1] > 10 ? ball.bbox[1] - 5 : 10); |
200 | | - |
201 | | - const ballCenterX = ball.bbox[0] + ball.bbox[2] / 2; |
202 | | - const ballCenterY = ball.bbox[1] + ball.bbox[3] / 2; |
203 | | - |
204 | | - updateBallState(ballCenterX, ballCenterY); |
205 | | - checkForRep(); |
206 | | - } else { |
207 | | - ballState.detected = false; |
| 226 | + if (poses && poses.length > 0) { |
| 227 | + const keypoints = poses[0].keypoints; |
| 228 | + drawPose(keypoints); |
| 229 | + checkForRep(keypoints); |
208 | 230 | } |
209 | 231 |
|
210 | 232 | requestAnimationFrame(detectFrame); |
211 | 233 | } |
212 | 234 |
|
213 | | - function updateBallState(x, y) { |
214 | | - ballState.x = x; |
215 | | - ballState.y = y; |
216 | | - ballState.detected = true; |
217 | | - ballState.lastDetectionTime = Date.now(); |
| 235 | + function drawPose(keypoints) { |
| 236 | + // Draw keypoints |
| 237 | + for (const keypoint of keypoints) { |
| 238 | + if (keypoint.score > 0.5) { |
| 239 | + ctx.fillStyle = '#ff416c'; |
| 240 | + ctx.beginPath(); |
| 241 | + ctx.arc(keypoint.x, keypoint.y, 5, 0, 2 * Math.PI); |
| 242 | + ctx.fill(); |
| 243 | + } |
| 244 | + } |
| 245 | + // Draw skeleton |
| 246 | + const adjacentPairs = poseDetection.util.getAdjacentPairs(poseDetection.SupportedModels.MoveNet); |
| 247 | + ctx.strokeStyle = '#fff'; |
| 248 | + ctx.lineWidth = 2; |
| 249 | + for (const [i, j] of adjacentPairs) { |
| 250 | + const kp1 = keypoints[i]; |
| 251 | + const kp2 = keypoints[j]; |
| 252 | + if (kp1.score > 0.5 && kp2.score > 0.5) { |
| 253 | + ctx.beginPath(); |
| 254 | + ctx.moveTo(kp1.x, kp1.y); |
| 255 | + ctx.lineTo(kp2.x, kp2.y); |
| 256 | + ctx.stroke(); |
| 257 | + } |
| 258 | + } |
218 | 259 | } |
219 | 260 |
|
220 | | - function checkForRep() { |
221 | | - const now = Date.now(); |
222 | | - // Define a "wall zone" as the far side of the screen (e.g., right 20%) |
223 | | - const wallZoneStart = canvas.width * 0.8; |
| 261 | + function checkForRep(keypoints) { |
| 262 | + const rightShoulder = keypoints.find(k => k.name === 'right_shoulder'); |
| 263 | + const rightWrist = keypoints.find(k => k.name === 'right_wrist'); |
224 | 264 |
|
225 | | - // Cooldown period to prevent multiple counts for one hit |
226 | | - const cooldown = 1000; // 1 second |
| 265 | + if (rightShoulder && rightWrist && rightShoulder.score > 0.5 && rightWrist.score > 0.5) { |
| 266 | + // Simple bicep curl logic: check if wrist is above shoulder |
| 267 | + if (armState === 'down' && rightWrist.y < rightShoulder.y) { |
| 268 | + armState = 'up'; |
| 269 | + } |
227 | 270 |
|
228 | | - if (ballState.x > wallZoneStart && now - lastWallHitTime > cooldown) { |
229 | | - repCount++; |
230 | | - counterElement.textContent = repCount; |
231 | | - lastWallHitTime = now; |
232 | | - console.log(`Rep counted! Total: ${repCount}`); |
| 271 | + if (armState === 'up' && rightWrist.y > rightShoulder.y) { |
| 272 | + repCount++; |
| 273 | + counterElement.textContent = repCount; |
| 274 | + armState = 'down'; |
| 275 | + console.log(`Rep counted! Total: ${repCount}`); |
| 276 | + } |
233 | 277 | } |
234 | 278 | } |
235 | 279 |
|
236 | | - // --- Suggestions for improving accuracy --- |
237 | | - /* |
238 | | - 1. Custom Model: Train a model specifically on lacrosse balls for better detection. |
239 | | - 2. Pose Detection: Use a model like MoveNet or PoseNet to track the player's body. |
240 | | - A 'catch' could be registered when the ball's position is near the player's hands. |
241 | | - 3. Trajectory Analysis: Instead of simple zones, track the ball's path over several frames. |
242 | | - A rep could be a sequence of: throw -> hit (sudden change in direction) -> catch. |
243 | | - This is more complex but much more robust. |
244 | | - 4. User-defined Zones: Allow the user to draw a rectangle on the screen to define the 'wall' area. |
245 | | - This makes the app adaptable to different environments. |
246 | | - 5. Kalman Filter: Use a Kalman filter to smooth the ball's detected position and predict its location |
247 | | - if the model temporarily loses track of it. This helps bridge gaps in detection. |
248 | | - */ |
249 | | - |
250 | 280 | setup(); |
251 | 281 | </script> |
252 | | - |
253 | 282 | </body> |
254 | 283 | </html> |
0 commit comments