STRAPS_LOCALHOST/lib/pose/RehabCore.ts

349 lines
16 KiB
TypeScript

import { Landmark, EXERCISE_CONFIGS } from './ExerciseRules';
import {
computeFeatures, RepFSM, Vec3, PoseFeatures,
BicepCurlCounter, HammerCurlCounter, OverheadPressCounter,
LateralRaiseCounter, SquatCounter, DeadliftCounter, LungeCounter
} from './RehabFSM';
import { calculateRangeDeviation, computeMAE } from './MathUtils';
const normalizeExerciseName = (input: string): string => {
if (!input) return '';
const clean = input.toLowerCase().trim().replace(/\s+/g, '_'); // "Overhead Press" -> "overhead_press"
// Map common variations to internal keys
if (clean.includes('bicep')) return 'bicep_curl';
if (clean.includes('hammer')) return 'hammer_curl';
if (clean.includes('overhead') || clean.includes('shoulder_press')) return 'shoulder_press';
if (clean.includes('lateral')) return 'lateral_raises';
if (clean.includes('squat')) return 'squat';
if (clean.includes('deadlift')) return 'deadlift';
if (clean.includes('lunge')) return 'lunges';
return clean; // Fallback
};
// Map UI names to Counter Classes
const COUNTER_MAP: { [key: string]: () => RepFSM[] } = {
'bicep_curl': () => [new BicepCurlCounter('left'), new BicepCurlCounter('right')],
'hammer_curl': () => [new HammerCurlCounter('left'), new HammerCurlCounter('right')],
'shoulder_press': () => [new OverheadPressCounter()], // Bilateral logic inside? No, it's single counter based on avg/both
'lateral_raises': () => [new LateralRaiseCounter()],
'squat': () => [new SquatCounter()],
'deadlift': () => [new DeadliftCounter()],
'lunges': () => [new LungeCounter()] // Bilateral or unified? FSM seems unified (min of both knees)
};
export class RehabCore {
private counters: { [key: string]: RepFSM[] } = {};
private worldLandmarksCache: Vec3[] = []; // If we had world landmarks, for now we might approximate or expect them passed
// NEW: Threshold for warning (degrees)
private readonly DEVIATION_THRESHOLD = 15.0;
constructor() {
// Initialize all counters? Or lazy load?
// Let's lazy load or init on reset.
}
public reset() {
this.counters = {};
console.log("RehabCore: Counters reset");
}
// --- UPDATED: Comprehensive 6-Way Wrong Exercise Detection ---
private validateExerciseType(
configKey: string,
features: PoseFeatures
): string | null {
// Feature Extraction
const minKneeAngle = Math.min(features.leftKnee, features.rightKnee);
const isLegsBent = minKneeAngle < 130;
const minElbowAngle = Math.min(features.leftElbow, features.rightElbow);
const isElbowsBent = minElbowAngle < 110;
const isArmsStraight = minElbowAngle > 140;
const isHandsOverhead = (features.leftWristY < features.noseY) || (features.rightWristY < features.noseY);
const isHandsLow = (features.leftWristY > features.leftShoulderY) && (features.rightWristY > features.rightShoulderY);
const diffElbow = Math.abs(features.leftElbow - features.rightElbow);
const isAlternating = diffElbow > 40;
const isSimultaneous = diffElbow < 20;
if (configKey === 'bicep_curl') {
if (isLegsBent) return "Detected: Squat/Lunge. Stand straight for Curls.";
if (isHandsOverhead) return "Detected: Overhead Press. Keep elbows down.";
if (isArmsStraight && !isHandsLow) return "Detected: Lateral Raise. Bend your elbows.";
if (isAlternating) return "Detected: Hammer Curl (Alternating). Move both arms together.";
return null;
}
if (configKey === 'hammer_curl') {
if (isLegsBent) return "Detected: Squat/Lunge. Stand straight.";
if (isHandsOverhead) return "Detected: Overhead Press. Keep elbows down.";
if (isArmsStraight && !isHandsLow) return "Detected: Lateral Raise. Bend your elbows.";
if (isSimultaneous && isElbowsBent) return "Detected: Bicep Curl (Simultaneous). Alternate arms.";
return null;
}
if (configKey === 'shoulder_press') {
if (isLegsBent) return "Detected: Squat/Lunge. Focus on upper body.";
if (isHandsLow && isElbowsBent) return "Detected: Bicep Curl. Push weight UP, not curl.";
if (isArmsStraight && !isHandsOverhead) return "Detected: Lateral Raise/Deadlift. Press overhead.";
if (isAlternating) return "Detected: Alternating Press. Push both arms together.";
return null;
}
if (configKey === 'lateral_raises') {
if (isLegsBent) return "Detected: Squat/Lunge. Stand straight.";
if (isHandsOverhead) return "Detected: Overhead Press. Stop at shoulder height.";
if (isElbowsBent) return "Detected: Bicep/Hammer Curl. Keep arms straight (T-pose).";
return null;
}
if (configKey === 'squat') {
if (!isLegsBent && isElbowsBent) return "Detected: Bicep/Hammer Curl. Bend your knees!";
if (!isLegsBent && isHandsOverhead) return "Detected: Overhead Press. Focus on legs.";
const diffKnee = Math.abs(features.leftKnee - features.rightKnee);
if (isLegsBent && diffKnee > 30) return "Detected: Lunge. Keep knees symmetrical for Squat.";
return null;
}
if (configKey === 'lunges') {
if (!isLegsBent && isElbowsBent) return "Detected: Curl. Focus on legs.";
if (!isLegsBent && isHandsOverhead) return "Detected: Press. Focus on legs.";
const diffKnee = Math.abs(features.leftKnee - features.rightKnee);
if (isLegsBent && diffKnee < 15) return "Detected: Squat. Step one foot back for Lunge.";
return null;
}
if (configKey === 'deadlift') {
const isHipsBent = features.leftHip < 140 || features.rightHip < 140;
if (!isHipsBent && isElbowsBent) return "Detected: Curl. Keep arms straight/locked.";
if (!isHipsBent && isHandsOverhead) return "Detected: Press. Keep bar low.";
if (!isHipsBent && isLegsBent) return "Detected: Squat (Too much knee). Hinge at hips more.";
return null;
}
return null;
}
private calculateDeviation(
configKey: string,
features: any,
fsmState: "LOW" | "HIGH"
): { mae: number; isDeviating: boolean; details: string[] } {
const config = EXERCISE_CONFIGS[configKey];
if (!config || !config.dynamic_angles) {
return { mae: 0, isDeviating: false, details: [] };
}
let targetSuffix = '';
if (config.phase_type === 'start_down') {
targetSuffix = (fsmState === 'HIGH') ? '_up' : '_down';
} else {
targetSuffix = (fsmState === 'HIGH') ? '_down' : '_up';
}
const errors: number[] = [];
const details: string[] = [];
Object.keys(config.dynamic_angles).forEach(key => {
if (key.endsWith(targetSuffix)) {
const prefix = key.replace(targetSuffix, '');
let val = 0;
// --- FIX: Specific Handling for Overhead Press & Curls (Dual Arm Checks) ---
if ((configKey === 'bicep_curl' || configKey === 'hammer_curl' || configKey === 'shoulder_press') && prefix === 'elbow') {
const errL = calculateRangeDeviation(features.leftElbow, config.dynamic_angles[key]);
const errR = calculateRangeDeviation(features.rightElbow, config.dynamic_angles[key]);
errors.push(errL, errR);
if(errL > 0) details.push(`L_${key} dev ${errL.toFixed(0)}`);
if(errR > 0) details.push(`R_${key} dev ${errR.toFixed(0)}`);
return;
}
// Standard Averaging Logic (As requested)
if (prefix.includes('elbow')) val = (features.leftElbow + features.rightElbow) / 2;
else if (prefix.includes('knee')) val = (features.leftKnee + features.rightKnee) / 2;
else if (prefix.includes('hip')) val = (features.leftHip + features.rightHip) / 2;
else if (prefix.includes('shoulder')) val = (features.leftShoulderY * 180);
if(val > 0) {
const err = calculateRangeDeviation(val, config.dynamic_angles[key]);
errors.push(err);
if(err > 0) details.push(`${key} dev ${err.toFixed(0)}`);
}
}
});
const mae = computeMAE(errors);
return {
mae,
isDeviating: mae > this.DEVIATION_THRESHOLD,
details
};
}
public process(exerciseName: string, landmarks: Landmark[], worldLandmarks: Landmark[] = [], frameTime: number = 0) {
// // Normalize exercise name
// const KEY_MAP: {[key:string]: string} = {
// 'bicep_curls': 'bicep_curl',
// 'shoulder_press': 'shoulder_press',
// 'hammer_curls': 'hammer_curl',
// 'lateral_raises': 'lateral_raises',
// 'squats': 'squat',
// 'deadlifts': 'deadlift',
// 'lunges': 'lunges'
// };
const configKey = normalizeExerciseName(exerciseName);
// Init counters if not exists
if (!this.counters[configKey]) {
const factory = COUNTER_MAP[configKey];
if (factory) {
console.log(`RehabCore: Initialized counter for ${configKey}`);
this.counters[configKey] = factory();
} else {
console.warn(`RehabCore: No factory found for exercise "${configKey}" (Raw: ${exerciseName})`);
return null; // Unknown exercise
}
}
const activeCounters = this.counters[configKey];
if (!activeCounters) return null;
// Data Conversion
// We usually need World Landmarks for accurate angles (meters).
// MediaPipe Pose returns:
// 1. poseLandmarks (normalized x,y,z)
// 2. poseWorldLandmarks (meters x,y,z)
//
// The current `landmarks` input in Straps usually comes from `poseLandmarks` (normalized).
// The new algorithm expects `normalized` AND `world`.
// If we only have normalized, we can pass normalized as world, but angles might be skewed by perspective.
// HOWEVER, `angleDeg` uses `sub` and `dot`. If z is normalized (0..1 scale relative to image width), it's roughly ok for basic 2D-ish angles.
// Ideally we update `HARCore` to pass world landmarks too.
// For now, I will use `landmarks` for BOTH, assuming the user is aware or `z` is roughly scaled.
// Actually `HARCore` sees `Landmark` interface which has x,y,z.
const vecLandmarks: Vec3[] = landmarks.map(l => ({ x: l.x, y: l.y, z: l.z || 0, visibility: l.visibility }));
const vecWorld: Vec3[] = (worldLandmarks && worldLandmarks.length > 0)
? worldLandmarks.map(l => ({ x: l.x, y: l.y, z: l.z || 0, visibility: l.visibility }))
: vecLandmarks; // Fallback
// Compute Features
const features = computeFeatures(vecLandmarks, vecWorld, frameTime || Date.now());
// Update Counters
const results = activeCounters.map(c => c.update(features));
// Determine dominant state (Use the first counter as primary reference)
const mainCounter = this.counters[configKey]?.[0];
const fsmState = mainCounter ? mainCounter.state : "LOW";
// Calculate Deviation
const deviationAnalysis = this.calculateDeviation(exerciseName, features, fsmState);
const wrongExerciseWarning = this.validateExerciseType(configKey, features);
// Format Output for HARCore
// Old format: { left: { stage, reps, angle }, right: { stage, reps, angle }, feedback, scores }
// Determine Left/Right results
// If we have 2 counters, usually [0]=Left, [1]=Right (based on my factory above)
// Wait, BicepCurlCounter('left') is first?
// Let's look at factory:
// 'bicep_curl': () => [new BicepCurlCounter('left'), new BicepCurlCounter('right')],
let leftRes = { stage: 'REST', reps: 0, angle: 0 };
let rightRes = { stage: 'REST', reps: 0, angle: 0 };
let feedback = "";
if (configKey === 'bicep_curl' || configKey === 'hammer_curl') {
const lCounter = activeCounters[0];
const rCounter = activeCounters[1];
leftRes = {
stage: lCounter.state === 'HIGH' ? 'UP' : 'DOWN',
reps: lCounter.reps,
angle: features.leftElbow
};
rightRes = {
stage: rCounter.state === 'HIGH' ? 'UP' : 'DOWN',
reps: rCounter.reps,
angle: features.rightElbow
};
} else {
// Unified counters (Squat, Press, etc)
// We apply result to "Both" or just map to nice UI
const main = activeCounters[0];
const stage = main.state === 'HIGH' ? 'UP' : 'DOWN';
const reps = main.reps;
leftRes = { stage, reps, angle: 0 }; // Angle 0 for now as main metric might be diff
rightRes = { stage, reps, angle: 0 };
// Populate specific angles for UI if needed
if (configKey === 'squat') { leftRes.angle = features.leftKnee; rightRes.angle = features.rightKnee; }
if (configKey === 'shoulder_press') { leftRes.angle = features.leftElbow; rightRes.angle = features.rightElbow; } // Approx
}
if (wrongExerciseWarning) {
feedback = `⚠️ ${wrongExerciseWarning}`;
}
// Append Deviation info to feedback
else if (deviationAnalysis.isDeviating) {
const detailText = deviationAnalysis.details.join(", ");
feedback += ` | Fix Form: ${detailText}`;
}
// Accumulate feedback? FSM has `debug`
// results.forEach(r => if(r.debug.note) feedback += r.debug.note + " ");
return {
left: leftRes,
right: rightRes,
feedback: feedback.trim(),
scores: {deviation_mae: deviationAnalysis.mae} // No scores in new FSM yet
};
}
public getReps(exName: string) {
// // Normalized key
// const KEY_MAP: {[key:string]: string} = {
// 'bicep_curls': 'bicep_curl',
// 'shoulder_press': 'shoulder_press',
// 'hammer_curls': 'hammer_curl',
// 'lateral_raises': 'lateral_raises',
// 'squats': 'squat',
// 'deadlifts': 'deadlift',
// 'lunges': 'lunges'
// };
const configKey = normalizeExerciseName(exName);
const counters = this.counters[configKey];
if (!counters || counters.length === 0) return 0;
if (configKey === 'hammer_curl') {
return Math.min(...counters.map(c => c.reps));
}
// If multiple counters (bilateral), usually we return the SUM or MAX or MIN?
// Old logic was: wait for both to complete -> increment total.
// New FSM logic tracks reps independently.
// For Curls, it likely makes sense to show Total Reps (L+R) or Max?
// Usually "1 Rep" means both arms if simultaneous, or 1 each.
// For now, let's return the AVG or MAX.
// If unilateral exercise mode?
// Straps usually assumes bilateral simultaneous.
// If I do 10 left and 10 right = 20 total? or 10 sets?
// Let's return MAX for now (assuming users try to keep sync).
// Actually, if I do alternate curls, I want sum?
// Let's stick to MAX for synchronized exercises.
return Math.max(...counters.map(c => c.reps));
}
}