Spaces:
Paused
Paused
| /** | |
| * Visualization Module | |
| * Handles canvas rendering, skeleton overlay, and visual effects | |
| */ | |
| export class Visualizer { | |
| constructor() { | |
| this.canvas = null; | |
| this.ctx = null; | |
| this.animationFrame = null; | |
| this.isPlaying = false; | |
| // Visualization settings | |
| this.settings = { | |
| showSkeleton: true, | |
| showKeypoints: true, | |
| showTrails: false, | |
| lineThickness: 2, | |
| pointRadius: 4, | |
| trailLength: 10 | |
| }; | |
| // Color scheme | |
| this.colors = { | |
| highConfidence: '#10b981', // Green | |
| medConfidence: '#f59e0b', // Orange | |
| lowConfidence: '#ef4444', // Red | |
| connection: '#6366f1' // Blue | |
| }; | |
| // Trail history | |
| this.trailHistory = []; | |
| this.maxTrailLength = 30; | |
| // Skeleton connections (MediaPipe Pose landmark indices) | |
| this.connections = [ | |
| // Face | |
| [0, 1], [1, 2], [2, 3], [3, 7], | |
| [0, 4], [4, 5], [5, 6], [6, 8], | |
| // Torso | |
| [9, 10], [11, 12], [11, 23], [12, 24], [23, 24], | |
| // Arms | |
| [11, 13], [13, 15], [15, 17], [15, 19], [15, 21], | |
| [12, 14], [14, 16], [16, 18], [16, 20], [16, 22], | |
| // Legs | |
| [23, 25], [25, 27], [27, 29], [27, 31], | |
| [24, 26], [26, 28], [28, 30], [28, 32] | |
| ]; | |
| } | |
| /** | |
| * Initialize canvas overlay | |
| */ | |
| init(videoId, canvasId = null) { | |
| const video = document.getElementById(videoId); | |
| if (!video) { | |
| console.error('Video element not found'); | |
| return false; | |
| } | |
| // Create or get canvas | |
| if (canvasId) { | |
| this.canvas = document.getElementById(canvasId); | |
| } else { | |
| this.canvas = this.createOverlayCanvas(video); | |
| } | |
| if (!this.canvas) { | |
| console.error('Canvas element not found or could not be created'); | |
| return false; | |
| } | |
| this.ctx = this.canvas.getContext('2d'); | |
| // Match canvas size to video | |
| this.resizeCanvas(video); | |
| // Handle video resize | |
| video.addEventListener('loadedmetadata', () => { | |
| this.resizeCanvas(video); | |
| }); | |
| window.addEventListener('resize', () => { | |
| this.resizeCanvas(video); | |
| }); | |
| return true; | |
| } | |
| /** | |
| * Create overlay canvas above video | |
| */ | |
| createOverlayCanvas(video) { | |
| const canvas = document.createElement('canvas'); | |
| canvas.id = 'overlay-canvas'; | |
| canvas.style.position = 'absolute'; | |
| canvas.style.top = '0'; | |
| canvas.style.left = '0'; | |
| canvas.style.pointerEvents = 'none'; | |
| canvas.style.zIndex = '10'; | |
| // Insert canvas after video | |
| video.parentNode.style.position = 'relative'; | |
| video.parentNode.appendChild(canvas); | |
| return canvas; | |
| } | |
| /** | |
| * Resize canvas to match video | |
| */ | |
| resizeCanvas(video) { | |
| if (!this.canvas) return; | |
| this.canvas.width = video.videoWidth || video.clientWidth; | |
| this.canvas.height = video.videoHeight || video.clientHeight; | |
| this.canvas.style.width = video.clientWidth + 'px'; | |
| this.canvas.style.height = video.clientHeight + 'px'; | |
| } | |
| /** | |
| * Draw skeleton from pose landmarks | |
| */ | |
| drawSkeleton(landmarks, confidence = 0.5) { | |
| if (!this.ctx || !landmarks || landmarks.length === 0) return; | |
| this.clear(); | |
| const width = this.canvas.width; | |
| const height = this.canvas.height; | |
| // Draw connections | |
| if (this.settings.showSkeleton) { | |
| this.ctx.lineWidth = this.settings.lineThickness; | |
| this.connections.forEach(([startIdx, endIdx]) => { | |
| if (startIdx < landmarks.length && endIdx < landmarks.length) { | |
| const start = landmarks[startIdx]; | |
| const end = landmarks[endIdx]; | |
| // Check visibility | |
| if (start[2] > confidence && end[2] > confidence) { | |
| const x1 = start[0] * width; | |
| const y1 = start[1] * height; | |
| const x2 = end[0] * width; | |
| const y2 = end[1] * height; | |
| // Color based on average confidence | |
| const avgConf = (start[2] + end[2]) / 2; | |
| this.ctx.strokeStyle = this.getConfidenceColor(avgConf); | |
| // Draw line | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(x1, y1); | |
| this.ctx.lineTo(x2, y2); | |
| this.ctx.stroke(); | |
| } | |
| } | |
| }); | |
| } | |
| // Draw keypoints | |
| if (this.settings.showKeypoints) { | |
| landmarks.forEach((landmark, idx) => { | |
| if (landmark[2] > confidence) { | |
| const x = landmark[0] * width; | |
| const y = landmark[1] * height; | |
| const conf = landmark[2]; | |
| // Draw point | |
| this.ctx.fillStyle = this.getConfidenceColor(conf); | |
| this.ctx.beginPath(); | |
| this.ctx.arc(x, y, this.settings.pointRadius, 0, 2 * Math.PI); | |
| this.ctx.fill(); | |
| // Draw landmark index (for debugging) | |
| // this.ctx.fillStyle = 'white'; | |
| // this.ctx.font = '10px Arial'; | |
| // this.ctx.fillText(idx, x + 5, y - 5); | |
| } | |
| }); | |
| } | |
| // Draw trails if enabled | |
| if (this.settings.showTrails) { | |
| this.drawTrails(); | |
| } | |
| } | |
| /** | |
| * Get color based on confidence score | |
| */ | |
| getConfidenceColor(confidence) { | |
| if (confidence >= 0.8) { | |
| return this.colors.highConfidence; | |
| } else if (confidence >= 0.5) { | |
| return this.colors.medConfidence; | |
| } else { | |
| return this.colors.lowConfidence; | |
| } | |
| } | |
| /** | |
| * Add pose to trail history | |
| */ | |
| addToTrail(landmarks) { | |
| if (!landmarks) return; | |
| this.trailHistory.push(landmarks); | |
| // Keep trail length limited | |
| if (this.trailHistory.length > this.maxTrailLength) { | |
| this.trailHistory.shift(); | |
| } | |
| } | |
| /** | |
| * Draw movement trails | |
| */ | |
| drawTrails() { | |
| if (this.trailHistory.length < 2) return; | |
| const width = this.canvas.width; | |
| const height = this.canvas.height; | |
| // Draw trails for key points (e.g., wrists and ankles) | |
| const trailPoints = [15, 16, 27, 28]; // Left/right wrists and ankles | |
| trailPoints.forEach(pointIdx => { | |
| this.ctx.strokeStyle = this.colors.connection; | |
| this.ctx.lineWidth = 1; | |
| this.ctx.globalAlpha = 0.5; | |
| this.ctx.beginPath(); | |
| let firstPoint = true; | |
| this.trailHistory.forEach((landmarks, idx) => { | |
| if (pointIdx < landmarks.length) { | |
| const point = landmarks[pointIdx]; | |
| if (point[2] > 0.5) { // Visibility threshold | |
| const x = point[0] * width; | |
| const y = point[1] * height; | |
| if (firstPoint) { | |
| this.ctx.moveTo(x, y); | |
| firstPoint = false; | |
| } else { | |
| this.ctx.lineTo(x, y); | |
| } | |
| } | |
| } | |
| }); | |
| this.ctx.stroke(); | |
| this.ctx.globalAlpha = 1.0; | |
| }); | |
| } | |
| /** | |
| * Clear canvas | |
| */ | |
| clear() { | |
| if (!this.ctx) return; | |
| this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); | |
| } | |
| /** | |
| * Draw text overlay | |
| */ | |
| drawText(text, x, y, options = {}) { | |
| if (!this.ctx) return; | |
| const { | |
| font = '16px Arial', | |
| color = '#ffffff', | |
| background = 'rgba(0, 0, 0, 0.7)', | |
| padding = 8 | |
| } = options; | |
| this.ctx.font = font; | |
| const metrics = this.ctx.measureText(text); | |
| const textWidth = metrics.width; | |
| const textHeight = 20; // Approximate height | |
| // Draw background | |
| if (background) { | |
| this.ctx.fillStyle = background; | |
| this.ctx.fillRect( | |
| x - padding, | |
| y - textHeight - padding, | |
| textWidth + padding * 2, | |
| textHeight + padding * 2 | |
| ); | |
| } | |
| // Draw text | |
| this.ctx.fillStyle = color; | |
| this.ctx.fillText(text, x, y); | |
| } | |
| /** | |
| * Draw info box with stats | |
| */ | |
| drawInfoBox(info, position = 'top-left') { | |
| if (!this.ctx || !info) return; | |
| const padding = 10; | |
| const lineHeight = 20; | |
| const lines = Object.entries(info).map(([key, value]) => `${key}: ${value}`); | |
| // Calculate box dimensions | |
| this.ctx.font = '14px Arial'; | |
| const maxWidth = Math.max(...lines.map(line => this.ctx.measureText(line).width)); | |
| const boxWidth = maxWidth + padding * 2; | |
| const boxHeight = lines.length * lineHeight + padding * 2; | |
| // Determine position | |
| let x, y; | |
| switch (position) { | |
| case 'top-left': | |
| x = padding; | |
| y = padding; | |
| break; | |
| case 'top-right': | |
| x = this.canvas.width - boxWidth - padding; | |
| y = padding; | |
| break; | |
| case 'bottom-left': | |
| x = padding; | |
| y = this.canvas.height - boxHeight - padding; | |
| break; | |
| case 'bottom-right': | |
| x = this.canvas.width - boxWidth - padding; | |
| y = this.canvas.height - boxHeight - padding; | |
| break; | |
| default: | |
| x = padding; | |
| y = padding; | |
| } | |
| // Draw background | |
| this.ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; | |
| this.ctx.fillRect(x, y, boxWidth, boxHeight); | |
| // Draw border | |
| this.ctx.strokeStyle = '#6366f1'; | |
| this.ctx.lineWidth = 2; | |
| this.ctx.strokeRect(x, y, boxWidth, boxHeight); | |
| // Draw text | |
| this.ctx.fillStyle = '#ffffff'; | |
| this.ctx.font = '14px Arial'; | |
| lines.forEach((line, idx) => { | |
| this.ctx.fillText(line, x + padding, y + padding + (idx + 1) * lineHeight); | |
| }); | |
| } | |
| /** | |
| * Draw FPS counter | |
| */ | |
| drawFPS(fps) { | |
| this.drawText(`FPS: ${fps.toFixed(1)}`, 10, 30, { | |
| font: '16px monospace', | |
| color: '#10b981' | |
| }); | |
| } | |
| /** | |
| * Toggle skeleton visibility | |
| */ | |
| toggleSkeleton() { | |
| this.settings.showSkeleton = !this.settings.showSkeleton; | |
| return this.settings.showSkeleton; | |
| } | |
| /** | |
| * Toggle keypoints visibility | |
| */ | |
| toggleKeypoints() { | |
| this.settings.showKeypoints = !this.settings.showKeypoints; | |
| return this.settings.showKeypoints; | |
| } | |
| /** | |
| * Toggle trails | |
| */ | |
| toggleTrails() { | |
| this.settings.showTrails = !this.settings.showTrails; | |
| if (!this.settings.showTrails) { | |
| this.trailHistory = []; | |
| } | |
| return this.settings.showTrails; | |
| } | |
| /** | |
| * Update settings | |
| */ | |
| updateSettings(newSettings) { | |
| this.settings = { ...this.settings, ...newSettings }; | |
| } | |
| /** | |
| * Destroy visualizer | |
| */ | |
| destroy() { | |
| if (this.animationFrame) { | |
| cancelAnimationFrame(this.animationFrame); | |
| } | |
| if (this.canvas && this.canvas.parentNode) { | |
| this.canvas.parentNode.removeChild(this.canvas); | |
| } | |
| this.ctx = null; | |
| this.canvas = null; | |
| } | |
| } | |
| // Create global instance | |
| export const visualizer = new Visualizer(); |