349 lines
16 KiB
TypeScript
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));
|
|
}
|
|
}
|
|
|