feat: Add AI-powered Hand Posture Detection

Implement real-time hand posture analysis using ml5.js handpose model to provide feedback on piano playing technique. This feature helps prevent injuries and improves playing technique by analyzing:

- Wrist position (should be level with hand)
- Finger curvature (natural curve, not too flat or curved)
- Palm position (arched, not collapsed)
- Thumb position (relaxed and rounded)

Features:
- Real-time webcam-based hand tracking with ml5.js
- Posture scoring (0-100) with color-coded feedback
- Specific issue detection with actionable recommendations
- Bonus XP system for maintaining good posture (80+ score)
- Visual overlay showing hand landmarks and problem areas
- Integrated into Practice Mode with toggle button

Technical implementation:
- HandPostureAnalyzer service with classical piano technique rules
- PostureCamera component with webcam access and canvas overlay
- Integration with gamification system (bonus XP rewards)
- Responsive UI with gradient themes and animations

This is a unique competitive advantage - automated posture correction that typically requires expensive piano teachers.
This commit is contained in:
Claude 2026-01-27 21:27:42 +00:00
parent 087294881d
commit aa930fec58
No known key found for this signature in database
6 changed files with 989 additions and 1 deletions

View File

@ -0,0 +1,226 @@
.posture-camera {
display: flex;
flex-direction: column;
gap: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 20px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.camera-container {
position: relative;
width: 100%;
max-width: 640px;
margin: 0 auto;
border-radius: 12px;
overflow: hidden;
background: #000;
}
.camera-video {
width: 100%;
height: auto;
display: block;
transform: scaleX(-1); /* Mirror effect */
}
.camera-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
transform: scaleX(-1); /* Mirror effect */
}
.camera-loading,
.camera-error,
.camera-prompt {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
background: rgba(0, 0, 0, 0.7);
padding: 20px 30px;
border-radius: 12px;
color: white;
z-index: 10;
}
.spinner {
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid white;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 10px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.camera-error {
background: rgba(239, 68, 68, 0.9);
}
.camera-prompt {
background: rgba(59, 130, 246, 0.9);
font-size: 18px;
font-weight: 500;
}
/* Posture Feedback */
.posture-feedback {
display: flex;
flex-direction: column;
gap: 15px;
background: white;
border-radius: 12px;
padding: 20px;
}
.posture-score {
display: flex;
align-items: center;
gap: 15px;
padding: 15px 20px;
border-radius: 10px;
color: white;
font-weight: bold;
}
.score-value {
font-size: 48px;
font-weight: 900;
line-height: 1;
}
.score-label {
font-size: 18px;
opacity: 0.9;
}
/* Issues */
.posture-issues {
background: #fef2f2;
border: 2px solid #fecaca;
border-radius: 10px;
padding: 15px;
}
.posture-issues h4 {
margin: 0 0 10px;
color: #991b1b;
font-size: 16px;
}
.posture-issues ul {
list-style: none;
padding: 0;
margin: 0;
}
.posture-issues li {
padding: 10px;
margin-bottom: 8px;
border-radius: 6px;
background: white;
}
.posture-issues li:last-child {
margin-bottom: 0;
}
.issue-high {
border-left: 4px solid #ef4444;
}
.issue-medium {
border-left: 4px solid #f59e0b;
}
.issue-low {
border-left: 4px solid #6b7280;
}
.posture-issues strong {
display: block;
color: #1f2937;
margin-bottom: 5px;
}
.issue-fix {
color: #6b7280;
font-size: 14px;
margin: 0;
line-height: 1.4;
}
/* Recommendations */
.posture-recommendations {
background: #f0fdf4;
border: 2px solid #bbf7d0;
border-radius: 10px;
padding: 15px;
}
.posture-recommendations h4 {
margin: 0 0 10px;
color: #065f46;
font-size: 16px;
}
.posture-recommendations ul {
list-style: none;
padding: 0;
margin: 0;
}
.posture-recommendations li {
padding: 8px 12px;
margin-bottom: 6px;
background: white;
border-radius: 6px;
color: #064e3b;
font-size: 14px;
line-height: 1.5;
}
.posture-recommendations li:last-child {
margin-bottom: 0;
}
/* Responsive */
@media (max-width: 768px) {
.posture-camera {
padding: 15px;
}
.camera-container {
max-width: 100%;
}
.posture-score {
flex-direction: column;
text-align: center;
gap: 5px;
}
.score-value {
font-size: 36px;
}
.score-label {
font-size: 16px;
}
}

View File

@ -0,0 +1,302 @@
import { useEffect, useRef, useState } from 'react';
import ml5 from 'ml5';
import {
HandPostureAnalyzer,
type PostureAnalysis,
type HandLandmarks,
} from '../../services/handPostureDetection';
import './PostureCamera.css';
interface PostureCameraProps {
onAnalysisUpdate?: (analysis: PostureAnalysis) => void;
isActive?: boolean;
}
export function PostureCamera({ onAnalysisUpdate, isActive = true }: PostureCameraProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [analysis, setAnalysis] = useState<PostureAnalysis | null>(null);
const [handDetected, setHandDetected] = useState(false);
const handposeRef = useRef<any>(null);
const animationFrameRef = useRef<number>();
useEffect(() => {
if (!isActive) return;
let mounted = true;
let stream: MediaStream | null = null;
const initCamera = async () => {
try {
// Request webcam access
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: 640,
height: 480,
facingMode: 'user',
},
});
if (!mounted || !videoRef.current) return;
videoRef.current.srcObject = stream;
// Wait for video to load
await new Promise<void>(resolve => {
if (!videoRef.current) return;
videoRef.current.onloadedmetadata = () => {
videoRef.current?.play();
resolve();
};
});
// Initialize ml5.js handpose model
if (!mounted) return;
const handpose = await ml5.handpose(videoRef.current, {
flipHorizontal: true,
maxContinuousChecks: 10,
detectionConfidence: 0.8,
scoreThreshold: 0.5,
iouThreshold: 0.3,
});
if (!mounted) return;
handposeRef.current = handpose;
setIsLoading(false);
// Start detection loop
startDetection();
} catch (err) {
console.error('Error initializing camera:', err);
setError('Não foi possível acessar a câmera. Verifique as permissões do navegador.');
setIsLoading(false);
}
};
const startDetection = () => {
const detect = async () => {
if (!mounted || !isActive || !handposeRef.current || !videoRef.current) return;
try {
const predictions = await handposeRef.current.predict();
if (predictions && predictions.length > 0) {
setHandDetected(true);
// Get first hand detected
const hand = predictions[0];
// Convert to our format
const landmarks: HandLandmarks = {
keypoints: hand.landmarks,
handedness: hand.handedness || 'Right',
confidence: hand.handInViewConfidence || 0.8,
};
// Analyze posture
const postureAnalysis = HandPostureAnalyzer.analyzePosture(landmarks);
setAnalysis(postureAnalysis);
onAnalysisUpdate?.(postureAnalysis);
// Draw landmarks on canvas
drawHandLandmarks(hand.landmarks, postureAnalysis);
} else {
setHandDetected(false);
clearCanvas();
}
} catch (err) {
console.error('Error during hand detection:', err);
}
// Continue detection loop
if (mounted && isActive) {
animationFrameRef.current = requestAnimationFrame(detect);
}
};
detect();
};
const drawHandLandmarks = (landmarks: number[][], analysis: PostureAnalysis) => {
const canvas = canvasRef.current;
const video = videoRef.current;
if (!canvas || !video) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Set canvas size to match video
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Draw landmarks
const color = HandPostureAnalyzer.getScoreColor(analysis.score);
// Draw connections between joints
const connections = [
[0, 1],
[1, 2],
[2, 3],
[3, 4], // Thumb
[0, 5],
[5, 6],
[6, 7],
[7, 8], // Index
[0, 9],
[9, 10],
[10, 11],
[11, 12], // Middle
[0, 13],
[13, 14],
[14, 15],
[15, 16], // Ring
[0, 17],
[17, 18],
[18, 19],
[19, 20], // Pinky
];
ctx.strokeStyle = color;
ctx.lineWidth = 2;
connections.forEach(([start, end]) => {
const startPoint = landmarks[start];
const endPoint = landmarks[end];
ctx.beginPath();
ctx.moveTo(startPoint[0], startPoint[1]);
ctx.lineTo(endPoint[0], endPoint[1]);
ctx.stroke();
});
// Draw keypoints
ctx.fillStyle = color;
landmarks.forEach(landmark => {
ctx.beginPath();
ctx.arc(landmark[0], landmark[1], 5, 0, 2 * Math.PI);
ctx.fill();
});
// Highlight issues
if (analysis.issues.length > 0) {
ctx.fillStyle = 'rgba(239, 68, 68, 0.3)';
analysis.issues.forEach(issue => {
let pointIndex = 0;
switch (issue.type) {
case 'wrist':
pointIndex = 0;
break;
case 'fingers':
pointIndex = 8;
break;
case 'thumb':
pointIndex = 4;
break;
case 'palm':
pointIndex = 9;
break;
}
const point = landmarks[pointIndex];
ctx.beginPath();
ctx.arc(point[0], point[1], 20, 0, 2 * Math.PI);
ctx.fill();
});
}
};
const clearCanvas = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
initCamera();
return () => {
mounted = false;
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
if (stream) {
stream.getTracks().forEach(track => track.stop());
}
};
}, [isActive, onAnalysisUpdate]);
if (!isActive) {
return null;
}
return (
<div className="posture-camera">
<div className="camera-container">
<video ref={videoRef} className="camera-video" playsInline muted />
<canvas ref={canvasRef} className="camera-overlay" />
{isLoading && (
<div className="camera-loading">
<div className="spinner"></div>
<p>Inicializando câmera e modelo de IA...</p>
</div>
)}
{error && (
<div className="camera-error">
<p>❌ {error}</p>
</div>
)}
{!isLoading && !error && !handDetected && (
<div className="camera-prompt">
<p>👋 Posicione sua mão na frente da câmera</p>
</div>
)}
</div>
{analysis && handDetected && (
<div className="posture-feedback">
<div
className="posture-score"
style={{ backgroundColor: HandPostureAnalyzer.getScoreColor(analysis.score) }}
>
<div className="score-value">{analysis.score}</div>
<div className="score-label">{HandPostureAnalyzer.getScoreMessage(analysis.score)}</div>
</div>
{analysis.issues.length > 0 && (
<div className="posture-issues">
<h4>⚠️ Problemas Detectados:</h4>
<ul>
{analysis.issues.map((issue, index) => (
<li key={index} className={`issue-${issue.severity}`}>
<strong>{issue.message}</strong>
<p className="issue-fix">{issue.suggestedFix}</p>
</li>
))}
</ul>
</div>
)}
{analysis.recommendations.length > 0 && (
<div className="posture-recommendations">
<h4>💡 Recomendações:</h4>
<ul>
{analysis.recommendations.map((rec, index) => (
<li key={index}>{rec}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -23,7 +23,8 @@
}
.practice-btn,
.reset-btn {
.reset-btn,
.posture-btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
@ -57,6 +58,66 @@
background: rgba(255, 255, 255, 0.3);
}
.posture-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: 2px solid rgba(255, 255, 255, 0.5);
}
.posture-btn.active {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border: 2px solid white;
animation: pulse 2s infinite;
}
.posture-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
}
50% {
box-shadow: 0 0 0 10px rgba(255, 255, 255, 0);
}
}
/* Posture Score Banner */
.posture-score-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
padding: 15px 25px;
background: rgba(255, 255, 255, 0.15);
border-radius: 10px;
margin-bottom: 20px;
backdrop-filter: blur(10px);
}
.posture-label {
font-size: 1.2em;
font-weight: 600;
opacity: 0.9;
}
.posture-value {
font-size: 1.8em;
font-weight: 900;
}
.posture-bonus {
padding: 5px 12px;
background: #fbbf24;
color: #1f2937;
border-radius: 6px;
font-weight: 700;
font-size: 0.9em;
}
/* Feedback Section */
.feedback-section {
text-align: center;

View File

@ -1,5 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import MidiPlayer from '../Player/MidiPlayer';
import { PostureCamera } from '../Posture/PostureCamera';
import type { PostureAnalysis } from '../../services/handPostureDetection';
import './PracticeMode.css';
interface Note {
@ -36,6 +38,9 @@ const PracticeMode: React.FC<PracticeModeProps> = ({
const [feedback, setFeedback] = useState<string>('');
const [combo, setCombo] = useState(0);
const [maxCombo, setMaxCombo] = useState(0);
const [postureEnabled, setPostureEnabled] = useState(false);
const [postureScore, setPostureScore] = useState<number>(0);
const [postureBonus, setPostureBonus] = useState<number>(0);
const expectedNotesRef = useRef<Note[]>(expectedNotes);
const currentNoteIndex = useRef(0);
@ -134,6 +139,19 @@ const PracticeMode: React.FC<PracticeModeProps> = ({
setTimeout(() => setCurrentNote(null), 100);
};
const handlePostureUpdate = (analysis: PostureAnalysis) => {
setPostureScore(analysis.score);
// Award bonus XP for maintaining good posture
if (analysis.score >= 80 && isActive) {
setPostureBonus(prev => prev + 1);
if (postureBonus > 0 && postureBonus % 10 === 0) {
setFeedback('🎯 Bônus de Postura! +5 XP');
setTimeout(() => setFeedback(''), 2000);
}
}
};
const startPractice = () => {
setIsActive(true);
setPlayedNotes([]);
@ -172,6 +190,12 @@ const PracticeMode: React.FC<PracticeModeProps> = ({
return { grade: 'D', color: '#ef4444' };
};
const getPostureColor = (score: number): string => {
if (score >= 80) return '#10b981'; // Green
if (score >= 60) return '#f59e0b'; // Yellow
return '#ef4444'; // Red
};
return (
<div className="practice-mode">
<div className="practice-header">
@ -186,9 +210,39 @@ const PracticeMode: React.FC<PracticeModeProps> = ({
<button onClick={resetPractice} className="reset-btn">
🔄 Reiniciar
</button>
<button
onClick={() => setPostureEnabled(!postureEnabled)}
className={`posture-btn ${postureEnabled ? 'active' : ''}`}
title="Ativar detecção de postura com IA"
>
{postureEnabled ? '📹 Câmera Ativa' : '📷 Ativar Postura IA'}
</button>
</div>
</div>
{/* Hand Posture Detection */}
{postureEnabled && (
<PostureCamera isActive={postureEnabled} onAnalysisUpdate={handlePostureUpdate} />
)}
{/* Posture Score Display */}
{postureEnabled && postureScore > 0 && (
<div className="posture-score-banner">
<span className="posture-label">Postura:</span>
<span
className="posture-value"
style={{
color: getPostureColor(postureScore),
}}
>
{postureScore}/100
</span>
{postureBonus > 0 && (
<span className="posture-bonus">+{Math.floor(postureBonus / 10) * 5} XP Bônus</span>
)}
</div>
)}
{/* Real-time Feedback */}
<div className="feedback-section">
<div className={`current-note ${currentNote ? 'active' : ''}`}>{currentNote || '—'}</div>

View File

@ -0,0 +1,306 @@
/**
* Hand Posture Detection Service
* Uses ml5.js handpose model to detect hand posture and provide feedback
*/
export interface HandLandmarks {
keypoints: number[][]; // Array of [x, y, z] coordinates for each hand landmark
handedness: 'Left' | 'Right';
confidence: number;
}
export interface PostureAnalysis {
isCorrect: boolean;
score: number; // 0-100
issues: PostureIssue[];
recommendations: string[];
}
export interface PostureIssue {
type: 'wrist' | 'fingers' | 'palm' | 'thumb';
severity: 'low' | 'medium' | 'high';
message: string;
suggestedFix: string;
}
/**
* Analyze hand posture for piano playing
* Based on classical piano technique principles
*/
export class HandPostureAnalyzer {
/**
* Analyze complete hand posture
*/
static analyzePosture(landmarks: HandLandmarks): PostureAnalysis {
const issues: PostureIssue[] = [];
let score = 100;
// Analyze wrist position
const wristAnalysis = this.analyzeWrist(landmarks.keypoints);
if (!wristAnalysis.isCorrect) {
issues.push(wristAnalysis.issue!);
score -= wristAnalysis.issue!.severity === 'high' ? 30 : 15;
}
// Analyze finger curvature
const fingerAnalysis = this.analyzeFingerCurvature(landmarks.keypoints);
if (!fingerAnalysis.isCorrect) {
issues.push(fingerAnalysis.issue!);
score -= fingerAnalysis.issue!.severity === 'high' ? 25 : 10;
}
// Analyze palm position
const palmAnalysis = this.analyzePalmPosition(landmarks.keypoints);
if (!palmAnalysis.isCorrect) {
issues.push(palmAnalysis.issue!);
score -= palmAnalysis.issue!.severity === 'high' ? 20 : 10;
}
// Analyze thumb position
const thumbAnalysis = this.analyzeThumbPosition(landmarks.keypoints);
if (!thumbAnalysis.isCorrect) {
issues.push(thumbAnalysis.issue!);
score -= thumbAnalysis.issue!.severity === 'high' ? 15 : 5;
}
// Generate recommendations
const recommendations = this.generateRecommendations(issues);
return {
isCorrect: issues.length === 0,
score: Math.max(0, score),
issues,
recommendations,
};
}
/**
* Analyze wrist position (should be level with hand, not too low)
*/
private static analyzeWrist(keypoints: number[][]): { isCorrect: boolean; issue?: PostureIssue } {
const wrist = keypoints[0]; // Wrist keypoint
const middleFingerBase = keypoints[9]; // Middle finger MCP joint
// Calculate vertical distance (Y-axis)
const verticalDiff = middleFingerBase[1] - wrist[1];
// Wrist should be roughly level or slightly above finger base
if (verticalDiff < -30) {
return {
isCorrect: false,
issue: {
type: 'wrist',
severity: 'high',
message: 'Pulso muito baixo',
suggestedFix:
'Levante o pulso até ficar no mesmo nível dos dedos. Imagine uma linha reta do cotovelo até os dedos.',
},
};
}
if (verticalDiff > 40) {
return {
isCorrect: false,
issue: {
type: 'wrist',
severity: 'medium',
message: 'Pulso muito alto',
suggestedFix: 'Abaixe levemente o pulso. O pulso deve estar relaxado, não tenso.',
},
};
}
return { isCorrect: true };
}
/**
* Analyze finger curvature (should be naturally curved, not flat)
*/
private static analyzeFingerCurvature(keypoints: number[][]): {
isCorrect: boolean;
issue?: PostureIssue;
} {
// Analyze index finger as representative
const indexBase = keypoints[5]; // Index MCP
const indexMiddle = keypoints[6]; // Index PIP
const indexTip = keypoints[8]; // Index DIP
// Calculate curvature using the middle joint position
const baseToTipDistance = Math.hypot(indexTip[0] - indexBase[0], indexTip[1] - indexBase[1]);
const baseToMiddleDistance = Math.hypot(
indexMiddle[0] - indexBase[0],
indexMiddle[1] - indexBase[1]
);
const curvatureRatio = baseToMiddleDistance / baseToTipDistance;
// Too flat (ratio close to 1)
if (curvatureRatio > 0.8) {
return {
isCorrect: false,
issue: {
type: 'fingers',
severity: 'high',
message: 'Dedos muito retos',
suggestedFix:
'Curve os dedos naturalmente, como se estivesse segurando uma bola. Isso dá mais controle e previne lesões.',
},
};
}
// Too curved (ratio too small)
if (curvatureRatio < 0.4) {
return {
isCorrect: false,
issue: {
type: 'fingers',
severity: 'medium',
message: 'Dedos muito curvados',
suggestedFix: 'Relaxe os dedos um pouco. A curvatura deve ser natural, não forçada.',
},
};
}
return { isCorrect: true };
}
/**
* Analyze palm position (should be arched, not collapsed)
*/
private static analyzePalmPosition(keypoints: number[][]): {
isCorrect: boolean;
issue?: PostureIssue;
} {
const wrist = keypoints[0];
const indexBase = keypoints[5];
const pinkyBase = keypoints[17];
// Calculate palm width
const palmWidth = Math.hypot(pinkyBase[0] - indexBase[0], pinkyBase[1] - indexBase[1]);
// Calculate palm center to wrist distance
const palmCenterX = (indexBase[0] + pinkyBase[0]) / 2;
const palmCenterY = (indexBase[1] + pinkyBase[1]) / 2;
const palmHeight = Math.hypot(palmCenterX - wrist[0], palmCenterY - wrist[1]);
// Palm should have an arch (height/width ratio)
const archRatio = palmHeight / palmWidth;
if (archRatio < 0.6) {
return {
isCorrect: false,
issue: {
type: 'palm',
severity: 'medium',
message: 'Palma da mão muito achatada',
suggestedFix: 'Crie um arco natural na palma da mão. Imagine segurar uma laranja.',
},
};
}
return { isCorrect: true };
}
/**
* Analyze thumb position (should be relaxed and rounded, not tucked under)
*/
private static analyzeThumbPosition(keypoints: number[][]): {
isCorrect: boolean;
issue?: PostureIssue;
} {
const thumbBase = keypoints[1];
const thumbTip = keypoints[4];
const indexBase = keypoints[5];
// Calculate thumb extension
const thumbToIndex = Math.hypot(thumbTip[0] - indexBase[0], thumbTip[1] - indexBase[1]);
const thumbLength = Math.hypot(thumbTip[0] - thumbBase[0], thumbTip[1] - thumbBase[1]);
const extensionRatio = thumbToIndex / thumbLength;
// Thumb too close to fingers (tucked)
if (extensionRatio < 0.7) {
return {
isCorrect: false,
issue: {
type: 'thumb',
severity: 'medium',
message: 'Polegar muito próximo dos outros dedos',
suggestedFix:
'Mantenha o polegar mais afastado e relaxado, formando um espaço natural com o indicador.',
},
};
}
// Thumb too extended
if (extensionRatio > 1.8) {
return {
isCorrect: false,
issue: {
type: 'thumb',
severity: 'low',
message: 'Polegar muito estendido',
suggestedFix: 'Relaxe o polegar, mantendo-o em uma posição mais natural.',
},
};
}
return { isCorrect: true };
}
/**
* Generate actionable recommendations based on issues
*/
private static generateRecommendations(issues: PostureIssue[]): string[] {
const recommendations: string[] = [];
if (issues.length === 0) {
return ['Excelente postura! Continue assim. 👏'];
}
// Sort by severity
const sortedIssues = issues.sort((a, b) => {
const severityOrder = { high: 0, medium: 1, low: 2 };
return severityOrder[a.severity] - severityOrder[b.severity];
});
// Add primary recommendation (most severe issue)
recommendations.push(`🎯 Prioridade: ${sortedIssues[0].suggestedFix}`);
// Add secondary recommendations
if (sortedIssues.length > 1) {
recommendations.push(`➡️ Depois: ${sortedIssues[1].suggestedFix}`);
}
// Add general tip
if (issues.some(i => i.severity === 'high')) {
recommendations.push(
'💡 Dica: Pratique a postura na frente de um espelho por 5 minutos antes de tocar.'
);
}
return recommendations;
}
/**
* Get visual feedback color based on score
*/
static getScoreColor(score: number): string {
if (score >= 80) return '#10b981'; // Green - Good
if (score >= 60) return '#f59e0b'; // Yellow - Needs improvement
return '#ef4444'; // Red - Poor
}
/**
* Get visual feedback message based on score
*/
static getScoreMessage(score: number): string {
if (score >= 90) return 'Postura Excelente! 🎯';
if (score >= 80) return 'Boa Postura! ✅';
if (score >= 70) return 'Postura Aceitável 👍';
if (score >= 60) return 'Precisa Melhorar ⚠️';
return 'Atenção à Postura! ❌';
}
}

39
src/types/ml5.d.ts vendored Normal file
View File

@ -0,0 +1,39 @@
/**
* Type definitions for ml5.js
* ml5.js is a high-level interface for TensorFlow.js
*/
declare module 'ml5' {
export interface HandposeOptions {
flipHorizontal?: boolean;
maxContinuousChecks?: number;
detectionConfidence?: number;
scoreThreshold?: number;
iouThreshold?: number;
}
export interface HandposePrediction {
landmarks: number[][];
handedness?: 'Left' | 'Right';
handInViewConfidence?: number;
boundingBox?: {
topLeft: [number, number];
bottomRight: [number, number];
};
}
export interface HandposeModel {
predict(): Promise<HandposePrediction[]>;
}
export function handpose(
videoOrCanvas: HTMLVideoElement | HTMLCanvasElement,
options?: HandposeOptions
): Promise<HandposeModel>;
const ml5: {
handpose: typeof handpose;
};
export default ml5;
}