@@ -11,8 +11,27 @@ export class GestureDetector { |
| 11 | 11 | this.onHandsDetected = null; |
| 12 | 12 | |
| 13 | 13 | // Detection parameters |
| 14 | | - this.minHandSize = 5; // Very small |
| 15 | | - this.maxHandY = 1.0; // No vertical restriction |
| 14 | + this.minHandSize = 30; // Minimum hand size in pixels |
| 15 | + this.maxHandSize = 150; // Maximum hand size (hand-sized) |
| 16 | + this.idealHandRatio = 1.2; // Expected width/height ratio |
| 17 | + |
| 18 | + // Tracking state |
| 19 | + this.yellowHistory = []; |
| 20 | + this.greenHistory = []; |
| 21 | + this.maxHistory = 5; |
| 22 | + |
| 23 | + // Adaptive thresholds |
| 24 | + this.yellowThreshold = { h: [0.12, 0.20], s: 0.3, v: 0.4 }; |
| 25 | + this.greenThreshold = { h: [0.25, 0.40], s: 0.3, v: 0.3 }; |
| 26 | + |
| 27 | + // Smoothing |
| 28 | + this.lastYellow = null; |
| 29 | + this.lastGreen = null; |
| 30 | + this.smoothingFactor = 0.3; |
| 31 | + |
| 32 | + // Motion detection |
| 33 | + this.previousFrame = null; |
| 34 | + this.motionThreshold = 10; |
| 16 | 35 | } |
| 17 | 36 | |
| 18 | 37 | async start() { |
@@ -34,7 +53,6 @@ export class GestureDetector { |
| 34 | 53 | |
| 35 | 54 | this.video.srcObject = stream; |
| 36 | 55 | |
| 37 | | - // Wait for video to be ready |
| 38 | 56 | await new Promise((resolve) => { |
| 39 | 57 | this.video.onloadedmetadata = resolve; |
| 40 | 58 | }); |
@@ -63,20 +81,20 @@ export class GestureDetector { |
| 63 | 81 | |
| 64 | 82 | // Get image data and detect hands |
| 65 | 83 | const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height); |
| 66 | | - const detection = this.detectHands(imageData); |
| 84 | + const detection = this.detectHandsSmart(imageData); |
| 67 | 85 | |
| 68 | 86 | // Draw detection boxes |
| 69 | | - if (detection.redHand) { |
| 70 | | - this.drawBox(detection.redHand, '#000000'); // Black outline for white objects |
| 87 | + if (detection.yellowHand) { |
| 88 | + this.drawBox(detection.yellowHand, '#ffff00', 'Effect Select'); |
| 71 | 89 | } |
| 72 | 90 | |
| 73 | | - if (detection.yellowHand) { |
| 74 | | - this.drawBox(detection.yellowHand, '#40e0d0'); // Turquoise for teal |
| 91 | + if (detection.greenHand) { |
| 92 | + this.drawBox(detection.greenHand, '#00ff00', 'Parameter'); |
| 75 | 93 | } |
| 76 | 94 | |
| 77 | 95 | // Call callback if registered |
| 78 | 96 | if (this.onHandsDetected) { |
| 79 | | - this.onHandsDetected(detection.redHand, detection.yellowHand); |
| 97 | + this.onHandsDetected(detection.yellowHand, detection.greenHand); |
| 80 | 98 | } |
| 81 | 99 | } |
| 82 | 100 | |
@@ -86,107 +104,277 @@ export class GestureDetector { |
| 86 | 104 | process(); |
| 87 | 105 | } |
| 88 | 106 | |
| 89 | | - drawBox(box, color) { |
| 90 | | - this.ctx.strokeStyle = color; |
| 91 | | - this.ctx.lineWidth = 2; |
| 92 | | - this.ctx.strokeRect(box.x, box.y, box.width, box.height); |
| 93 | | - |
| 94 | | - // Draw center point |
| 95 | | - const centerX = box.x + box.width / 2; |
| 96 | | - const centerY = box.y + box.height / 2; |
| 97 | | - this.ctx.fillStyle = color; |
| 98 | | - this.ctx.beginPath(); |
| 99 | | - this.ctx.arc(centerX, centerY, 4, 0, 2 * Math.PI); |
| 100 | | - this.ctx.fill(); |
| 101 | | - } |
| 102 | | - |
| 103 | | - detectHands(imageData) { |
| 107 | + detectHandsSmart(imageData) { |
| 104 | 108 | const data = imageData.data; |
| 105 | 109 | const width = imageData.width; |
| 106 | 110 | const height = imageData.height; |
| 107 | 111 | |
| 108 | | - // Pixel arrays for each color |
| 109 | | - const whitePixels = []; |
| 110 | | - const tealPixels = []; |
| 112 | + // Detect motion areas first (if we have a previous frame) |
| 113 | + const motionMask = this.previousFrame ? |
| 114 | + this.detectMotion(data, this.previousFrame, width, height) : null; |
| 115 | + |
| 116 | + // Store current frame for next iteration |
| 117 | + this.previousFrame = new Uint8ClampedArray(data); |
| 111 | 118 | |
| 112 | | - // Sample center pixel for debugging |
| 113 | | - const centerX = Math.floor(width / 2); |
| 114 | | - const centerY = Math.floor(height / 2); |
| 115 | | - const centerI = (centerY * width + centerX) * 4; |
| 116 | | - const centerR = data[centerI]; |
| 117 | | - const centerG = data[centerI + 1]; |
| 118 | | - const centerB = data[centerI + 2]; |
| 119 | + // Color detection with motion priority |
| 120 | + const yellowCandidates = []; |
| 121 | + const greenCandidates = []; |
| 119 | 122 | |
| 120 | | - // Process every 2nd pixel for better performance |
| 123 | + // Adaptive sampling - more samples in motion areas |
| 121 | 124 | for (let y = 0; y < height; y += 2) { |
| 122 | 125 | for (let x = 0; x < width; x += 2) { |
| 123 | 126 | const i = (y * width + x) * 4; |
| 124 | | - const r = data[i]; |
| 125 | | - const g = data[i + 1]; |
| 126 | | - const b = data[i + 2]; |
| 127 | 127 | |
| 128 | | - // Convert to HSV for better color discrimination |
| 129 | | - const hsv = this.rgbToHsv(r/255, g/255, b/255); |
| 128 | + // Skip if no motion in this area (when motion mask exists) |
| 129 | + if (motionMask && !motionMask[y * width + x]) { |
| 130 | + continue; |
| 131 | + } |
| 130 | 132 | |
| 131 | | - // BRIGHT WHITE detection |
| 132 | | - // White should have very low saturation and high brightness |
| 133 | | - const isWhite = hsv.s < 0.15 && // Very low saturation (near grayscale) |
| 134 | | - hsv.v > 0.75 && // High brightness |
| 135 | | - r > 190 && g > 190 && b > 190 && // All channels high |
| 136 | | - Math.abs(r - g) < 20 && // Channels close together |
| 137 | | - Math.abs(g - b) < 20 && // Neutral color |
| 138 | | - Math.abs(r - b) < 20; // No color cast |
| 133 | + const r = data[i] / 255; |
| 134 | + const g = data[i + 1] / 255; |
| 135 | + const b = data[i + 2] / 255; |
| 139 | 136 | |
| 140 | | - // Alternative: Very bright neutral colors |
| 141 | | - const isBrightNeutral = (r + g + b) > 650 && // Total brightness |
| 142 | | - Math.max(r, g, b) - Math.min(r, g, b) < 25; // Low variance |
| 137 | + const hsv = this.rgbToHsv(r, g, b); |
| 143 | 138 | |
| 144 | | - if (isWhite || isBrightNeutral) { |
| 145 | | - whitePixels.push({ x, y }); |
| 139 | + // YELLOW detection (bright, saturated yellow) |
| 140 | + if (hsv.h >= this.yellowThreshold.h[0] && |
| 141 | + hsv.h <= this.yellowThreshold.h[1] && |
| 142 | + hsv.s > this.yellowThreshold.s && |
| 143 | + hsv.v > this.yellowThreshold.v && |
| 144 | + r > 0.6 && g > 0.6 && b < 0.4) { |
| 145 | + yellowCandidates.push({ x, y, confidence: hsv.s * hsv.v }); |
| 146 | 146 | } |
| 147 | 147 | |
| 148 | | - // TEAL detection (keeping exactly as is - it works!) |
| 149 | | - const isTeal = (hsv.h > 0.45 && hsv.h < 0.55) && // Cyan range |
| 150 | | - hsv.s > 0.3 && // Some saturation |
| 151 | | - hsv.v > 0.3 && // Not too dark |
| 152 | | - (g > r * 1.2 || b > r * 1.2); // Green or blue dominant |
| 148 | + // GREEN detection (bright, saturated green) |
| 149 | + if (hsv.h >= this.greenThreshold.h[0] && |
| 150 | + hsv.h <= this.greenThreshold.h[1] && |
| 151 | + hsv.s > this.greenThreshold.s && |
| 152 | + hsv.v > this.greenThreshold.v && |
| 153 | + g > r * 1.3 && g > b * 1.2) { |
| 154 | + greenCandidates.push({ x, y, confidence: hsv.s * hsv.v }); |
| 155 | + } |
| 156 | + } |
| 157 | + } |
| 158 | + |
| 159 | + // Cluster and filter candidates |
| 160 | + const yellowClusters = this.clusterPoints(yellowCandidates); |
| 161 | + const greenClusters = this.clusterPoints(greenCandidates); |
| 162 | + |
| 163 | + // Find best hand-sized clusters |
| 164 | + const yellowHand = this.findBestHandCluster(yellowClusters, 'yellow'); |
| 165 | + const greenHand = this.findBestHandCluster(greenClusters, 'green'); |
| 166 | + |
| 167 | + // Apply temporal smoothing |
| 168 | + const smoothedYellow = this.smoothDetection(yellowHand, this.lastYellow); |
| 169 | + const smoothedGreen = this.smoothDetection(greenHand, this.lastGreen); |
| 170 | + |
| 171 | + this.lastYellow = smoothedYellow; |
| 172 | + this.lastGreen = smoothedGreen; |
| 173 | + |
| 174 | + return { |
| 175 | + yellowHand: smoothedYellow, |
| 176 | + greenHand: smoothedGreen |
| 177 | + }; |
| 178 | + } |
| 179 | + |
| 180 | + detectMotion(currentData, previousData, width, height) { |
| 181 | + const motionMask = new Uint8Array(width * height); |
| 182 | + |
| 183 | + for (let y = 0; y < height; y += 4) { |
| 184 | + for (let x = 0; x < width; x += 4) { |
| 185 | + const i = (y * width + x) * 4; |
| 186 | + |
| 187 | + const diffR = Math.abs(currentData[i] - previousData[i]); |
| 188 | + const diffG = Math.abs(currentData[i + 1] - previousData[i + 1]); |
| 189 | + const diffB = Math.abs(currentData[i + 2] - previousData[i + 2]); |
| 190 | + |
| 191 | + const totalDiff = diffR + diffG + diffB; |
| 192 | + |
| 193 | + if (totalDiff > this.motionThreshold) { |
| 194 | + // Mark 4x4 area as motion |
| 195 | + for (let dy = 0; dy < 4; dy++) { |
| 196 | + for (let dx = 0; dx < 4; dx++) { |
| 197 | + if (y + dy < height && x + dx < width) { |
| 198 | + motionMask[(y + dy) * width + (x + dx)] = 1; |
| 199 | + } |
| 200 | + } |
| 201 | + } |
| 202 | + } |
| 203 | + } |
| 204 | + } |
| 205 | + |
| 206 | + return motionMask; |
| 207 | + } |
| 208 | + |
| 209 | + clusterPoints(points) { |
| 210 | + if (points.length < 20) return []; |
| 211 | + |
| 212 | + const clusters = []; |
| 213 | + const visited = new Set(); |
| 214 | + |
| 215 | + // DBSCAN-like clustering |
| 216 | + const epsilon = 30; // Maximum distance between points in a cluster |
| 217 | + const minPoints = 20; // Minimum points to form a cluster |
| 218 | + |
| 219 | + for (let i = 0; i < points.length; i++) { |
| 220 | + if (visited.has(i)) continue; |
| 221 | + |
| 222 | + const neighbors = []; |
| 223 | + const cluster = []; |
| 224 | + |
| 225 | + // Find all neighbors |
| 226 | + for (let j = 0; j < points.length; j++) { |
| 227 | + const dist = Math.sqrt( |
| 228 | + Math.pow(points[i].x - points[j].x, 2) + |
| 229 | + Math.pow(points[i].y - points[j].y, 2) |
| 230 | + ); |
| 231 | + |
| 232 | + if (dist < epsilon) { |
| 233 | + neighbors.push(j); |
| 234 | + } |
| 235 | + } |
| 236 | + |
| 237 | + if (neighbors.length >= minPoints) { |
| 238 | + // Start a new cluster |
| 239 | + for (const idx of neighbors) { |
| 240 | + if (!visited.has(idx)) { |
| 241 | + visited.add(idx); |
| 242 | + cluster.push(points[idx]); |
| 243 | + } |
| 244 | + } |
| 153 | 245 | |
| 154 | | - if (isTeal) { |
| 155 | | - tealPixels.push({ x, y }); |
| 246 | + if (cluster.length > 0) { |
| 247 | + clusters.push(cluster); |
| 156 | 248 | } |
| 157 | 249 | } |
| 158 | 250 | } |
| 159 | 251 | |
| 160 | | - // Debug: Draw center crosshair and color info |
| 161 | | - this.ctx.strokeStyle = '#000000'; |
| 162 | | - this.ctx.lineWidth = 2; |
| 163 | | - this.ctx.strokeRect(centerX - 10, centerY - 10, 20, 20); |
| 164 | | - this.ctx.strokeStyle = '#ffffff'; |
| 165 | | - this.ctx.lineWidth = 1; |
| 166 | | - this.ctx.strokeRect(centerX - 9, centerY - 9, 18, 18); |
| 167 | | - |
| 168 | | - // Show color at center with better contrast |
| 169 | | - this.ctx.fillStyle = '#000000'; |
| 170 | | - this.ctx.fillRect(10, 10, 200, 30); |
| 171 | | - this.ctx.fillStyle = '#ffffff'; |
| 172 | | - this.ctx.font = '12px monospace'; |
| 173 | | - this.ctx.fillText(`Center RGB: ${centerR},${centerG},${centerB}`, 15, 30); |
| 174 | | - |
| 175 | | - // Find bounding boxes |
| 176 | | - const whiteHand = this.getBoundingBox(whitePixels, height); |
| 177 | | - const tealHand = this.getBoundingBox(tealPixels, height); |
| 178 | | - |
| 179 | | - // Debug logging |
| 180 | | - if (whitePixels.length > 50 || tealPixels.length > 50) { |
| 181 | | - console.log('White pixels:', whitePixels.length, 'Teal pixels:', tealPixels.length); |
| 252 | + return clusters; |
| 253 | + } |
| 254 | + |
| 255 | + findBestHandCluster(clusters, color) { |
| 256 | + if (clusters.length === 0) return null; |
| 257 | + |
| 258 | + let bestCluster = null; |
| 259 | + let bestScore = -1; |
| 260 | + |
| 261 | + for (const cluster of clusters) { |
| 262 | + // Calculate bounding box |
| 263 | + const xs = cluster.map(p => p.x); |
| 264 | + const ys = cluster.map(p => p.y); |
| 265 | + |
| 266 | + const box = { |
| 267 | + x: Math.min(...xs), |
| 268 | + y: Math.min(...ys), |
| 269 | + width: Math.max(...xs) - Math.min(...xs), |
| 270 | + height: Math.max(...ys) - Math.min(...ys) |
| 271 | + }; |
| 272 | + |
| 273 | + // Skip if too small or too large |
| 274 | + if (box.width < this.minHandSize || box.height < this.minHandSize || |
| 275 | + box.width > this.maxHandSize || box.height > this.maxHandSize) { |
| 276 | + continue; |
| 277 | + } |
| 278 | + |
| 279 | + // Calculate quality score |
| 280 | + const sizeScore = 1 - Math.abs(box.width * box.height - 5000) / 10000; // Ideal area ~5000px |
| 281 | + const ratioScore = 1 - Math.abs(box.width / box.height - this.idealHandRatio) / 2; |
| 282 | + const densityScore = cluster.length / (box.width * box.height) * 100; |
| 283 | + const confidenceScore = cluster.reduce((sum, p) => sum + p.confidence, 0) / cluster.length; |
| 284 | + |
| 285 | + const totalScore = sizeScore * 0.3 + ratioScore * 0.2 + densityScore * 0.2 + confidenceScore * 0.3; |
| 286 | + |
| 287 | + if (totalScore > bestScore) { |
| 288 | + bestScore = totalScore; |
| 289 | + bestCluster = box; |
| 290 | + bestCluster.confidence = confidenceScore; |
| 291 | + bestCluster.color = color; |
| 292 | + } |
| 293 | + } |
| 294 | + |
| 295 | + // Update history for this color |
| 296 | + if (bestCluster) { |
| 297 | + const history = color === 'yellow' ? this.yellowHistory : this.greenHistory; |
| 298 | + history.push(bestCluster); |
| 299 | + if (history.length > this.maxHistory) { |
| 300 | + history.shift(); |
| 301 | + } |
| 182 | 302 | } |
| 183 | 303 | |
| 184 | | - return { |
| 185 | | - redHand: whiteHand, |
| 186 | | - yellowHand: tealHand |
| 304 | + return bestCluster; |
| 305 | + } |
| 306 | + |
| 307 | + smoothDetection(current, previous) { |
| 308 | + if (!current) return null; |
| 309 | + if (!previous) return current; |
| 310 | + |
| 311 | + // Smooth position and size |
| 312 | + return { |
| 313 | + x: previous.x + (current.x - previous.x) * this.smoothingFactor, |
| 314 | + y: previous.y + (current.y - previous.y) * this.smoothingFactor, |
| 315 | + width: previous.width + (current.width - previous.width) * this.smoothingFactor, |
| 316 | + height: previous.height + (current.height - previous.height) * this.smoothingFactor, |
| 317 | + confidence: current.confidence, |
| 318 | + color: current.color |
| 187 | 319 | }; |
| 188 | 320 | } |
| 189 | 321 | |
| 322 | + drawBox(box, color, label) { |
| 323 | + // Draw bounding box |
| 324 | + this.ctx.strokeStyle = color; |
| 325 | + this.ctx.lineWidth = 3; |
| 326 | + this.ctx.strokeRect(box.x, box.y, box.width, box.height); |
| 327 | + |
| 328 | + // Draw corners for style |
| 329 | + const cornerLength = 15; |
| 330 | + this.ctx.lineWidth = 4; |
| 331 | + |
| 332 | + // Top-left |
| 333 | + this.ctx.beginPath(); |
| 334 | + this.ctx.moveTo(box.x, box.y + cornerLength); |
| 335 | + this.ctx.lineTo(box.x, box.y); |
| 336 | + this.ctx.lineTo(box.x + cornerLength, box.y); |
| 337 | + this.ctx.stroke(); |
| 338 | + |
| 339 | + // Top-right |
| 340 | + this.ctx.beginPath(); |
| 341 | + this.ctx.moveTo(box.x + box.width - cornerLength, box.y); |
| 342 | + this.ctx.lineTo(box.x + box.width, box.y); |
| 343 | + this.ctx.lineTo(box.x + box.width, box.y + cornerLength); |
| 344 | + this.ctx.stroke(); |
| 345 | + |
| 346 | + // Bottom-left |
| 347 | + this.ctx.beginPath(); |
| 348 | + this.ctx.moveTo(box.x, box.y + box.height - cornerLength); |
| 349 | + this.ctx.lineTo(box.x, box.y + box.height); |
| 350 | + this.ctx.lineTo(box.x + cornerLength, box.y + box.height); |
| 351 | + this.ctx.stroke(); |
| 352 | + |
| 353 | + // Bottom-right |
| 354 | + this.ctx.beginPath(); |
| 355 | + this.ctx.moveTo(box.x + box.width - cornerLength, box.y + box.height); |
| 356 | + this.ctx.lineTo(box.x + box.width, box.y + box.height); |
| 357 | + this.ctx.lineTo(box.x + box.width, box.y + box.height - cornerLength); |
| 358 | + this.ctx.stroke(); |
| 359 | + |
| 360 | + // Draw label with confidence |
| 361 | + if (label) { |
| 362 | + this.ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; |
| 363 | + this.ctx.fillRect(box.x, box.y - 25, 120, 22); |
| 364 | + this.ctx.fillStyle = color; |
| 365 | + this.ctx.font = 'bold 14px monospace'; |
| 366 | + this.ctx.fillText(`${label} ${Math.round(box.confidence * 100)}%`, box.x + 5, box.y - 8); |
| 367 | + } |
| 368 | + |
| 369 | + // Draw center point |
| 370 | + const centerX = box.x + box.width / 2; |
| 371 | + const centerY = box.y + box.height / 2; |
| 372 | + this.ctx.fillStyle = color; |
| 373 | + this.ctx.beginPath(); |
| 374 | + this.ctx.arc(centerX, centerY, 4, 0, 2 * Math.PI); |
| 375 | + this.ctx.fill(); |
| 376 | + } |
| 377 | + |
| 190 | 378 | rgbToHsv(r, g, b) { |
| 191 | 379 | const max = Math.max(r, g, b); |
| 192 | 380 | const min = Math.min(r, g, b); |
@@ -208,31 +396,4 @@ export class GestureDetector { |
| 208 | 396 | |
| 209 | 397 | return { h, s, v }; |
| 210 | 398 | } |
| 211 | | - |
| 212 | | - getBoundingBox(pixels, frameHeight) { |
| 213 | | - // Higher threshold to avoid noise |
| 214 | | - if (pixels.length < 100) return null; |
| 215 | | - |
| 216 | | - const xs = pixels.map(p => p.x); |
| 217 | | - const ys = pixels.map(p => p.y); |
| 218 | | - |
| 219 | | - const box = { |
| 220 | | - x: Math.min(...xs), |
| 221 | | - y: Math.min(...ys), |
| 222 | | - width: Math.max(...xs) - Math.min(...xs), |
| 223 | | - height: Math.max(...ys) - Math.min(...ys) |
| 224 | | - }; |
| 225 | | - |
| 226 | | - // Reasonable size requirements |
| 227 | | - if (box.width < 20 || box.height < 20) { |
| 228 | | - return null; |
| 229 | | - } |
| 230 | | - |
| 231 | | - // Reject if too large (probably background) |
| 232 | | - if (box.width > frameHeight * 0.5 || box.height > frameHeight * 0.5) { |
| 233 | | - return null; |
| 234 | | - } |
| 235 | | - |
| 236 | | - return box; |
| 237 | | - } |
| 238 | 399 | } |