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:
parent
087294881d
commit
aa930fec58
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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! ❌';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue