152 lines
5.8 KiB
TypeScript
152 lines
5.8 KiB
TypeScript
|
|
import { XGBoostPredictor } from './XGBoostPredictor';
|
|
import { Landmark, EXERCISE_CONFIGS } from './ExerciseRules';
|
|
import { RehabCore } from './RehabCore';
|
|
import { calculateAngle } from './MathUtils';
|
|
|
|
// Label Encoder mapping from python: Classes: ['berdiri' 'duduk' 'jatuh']
|
|
const LABELS = ['Standing', 'Sitting', 'Fall Detected'];
|
|
|
|
export class HARCore {
|
|
private predictor: XGBoostPredictor;
|
|
private rehab: RehabCore;
|
|
private currentExercise: string | null = null;
|
|
|
|
constructor() {
|
|
this.predictor = new XGBoostPredictor();
|
|
this.rehab = new RehabCore();
|
|
}
|
|
|
|
// Set the active exercise to track (from menu)
|
|
public setExercise(name: string) {
|
|
// Map UI name to internal config name if needed
|
|
// For now assume direct match e.g. "Bicep Curl" -> "bicep_curls"
|
|
// Simple normalizer
|
|
const lowerName = name.toLowerCase();
|
|
|
|
// Find matching key in EXERCISE_CONFIGS
|
|
// EXERCISE_CONFIGS keys are like 'bicep_curl', 'squat'
|
|
// UI names might be "Bicep Curl", "Squats"
|
|
const key = Object.keys(EXERCISE_CONFIGS).find(k =>
|
|
lowerName.includes(k.replace('_', ' ')) ||
|
|
k.replace('_', ' ').includes(lowerName.split(' ')[0])
|
|
);
|
|
|
|
this.currentExercise = key || null;
|
|
}
|
|
|
|
public resetParams() {
|
|
this.rehab.reset();
|
|
// this.currentExercise = null; // Don't nullify exercise, just counters
|
|
}
|
|
|
|
public async process(landmarks: Landmark[], worldLandmarks: Landmark[] = []) {
|
|
if (!landmarks || landmarks.length === 0) return null;
|
|
|
|
// 1. Activity Recognition (HAR) - XGBoost
|
|
const features = this.extractFeatures(landmarks);
|
|
const probs = this.predictor.predict(features);
|
|
const maxIdx = probs.indexOf(Math.max(...probs));
|
|
const status = LABELS[maxIdx];
|
|
const confidence = probs[maxIdx];
|
|
|
|
// 2. Exercise Counting (Rehab) - Heuristic
|
|
let reps = 0;
|
|
let feedback = "";
|
|
let debug = {};
|
|
|
|
if (this.currentExercise) {
|
|
const result = this.rehab.process(this.currentExercise, landmarks, worldLandmarks);
|
|
if (result) {
|
|
// Combine left/right reps for total or max?
|
|
// Usually we want total completed reps.
|
|
reps = this.rehab.getReps(this.currentExercise);
|
|
|
|
// Construct feedback
|
|
const stateL = result.left.stage;
|
|
const stateR = result.right.stage;
|
|
feedback = `L: ${stateL || '-'} | R: ${stateR || '-'}`;
|
|
if (result.feedback && result.feedback.length > 0) {
|
|
feedback += ` | ${result.feedback}`; // Add generic feedback
|
|
}
|
|
|
|
debug = {
|
|
angles: { l: result.left.angle, r: result.right.angle },
|
|
scores: result.scores
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
status,
|
|
confidence,
|
|
exercise: this.currentExercise,
|
|
reps,
|
|
feedback,
|
|
debug
|
|
};
|
|
}
|
|
|
|
private extractFeatures(landmarks: Landmark[]): number[] {
|
|
// 1. Flatten Raw Keypoints (33 * 4 = 132 features)
|
|
const raw: number[] = [];
|
|
landmarks.forEach(lm => {
|
|
raw.push(lm.x, lm.y, lm.z, lm.visibility || 0);
|
|
});
|
|
|
|
// 2. Derived Features
|
|
// Helper to get landmark
|
|
const getLm = (idx: number) => landmarks[idx];
|
|
|
|
// Helper to flatten {x,y} to point for calculateAngle
|
|
const pt = (lm: Landmark) => ({x: lm.x, y: lm.y});
|
|
const calcAng = (a: Landmark, b: Landmark, c: Landmark) => calculateAngle(pt(a), pt(b), pt(c));
|
|
|
|
const derived: number[] = [];
|
|
|
|
// Angles
|
|
// 0: Left Elbow (11-13-15)
|
|
derived.push(calcAng(getLm(11), getLm(13), getLm(15)));
|
|
// 1: Right Elbow (12-14-16)
|
|
derived.push(calcAng(getLm(12), getLm(14), getLm(16)));
|
|
// 2: Left Hip (11-23-25)
|
|
derived.push(calcAng(getLm(11), getLm(23), getLm(25)));
|
|
// 3: Right Hip (12-24-26)
|
|
derived.push(calcAng(getLm(12), getLm(24), getLm(26)));
|
|
// 4: Left Knee (23-25-27)
|
|
derived.push(calcAng(getLm(23), getLm(25), getLm(27)));
|
|
// 5: Right Knee (24-26-28)
|
|
derived.push(calcAng(getLm(24), getLm(26), getLm(28)));
|
|
|
|
// Distances & Ratios
|
|
const dist = (a: Landmark, b: Landmark) => Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
|
|
|
|
const shoulderWidth = dist(getLm(11), getLm(12));
|
|
const hipWidth = dist(getLm(23), getLm(24));
|
|
|
|
const midShoulder = { x: (getLm(11).x + getLm(12).x)/2, y: (getLm(11).y + getLm(12).y)/2 };
|
|
const midHip = { x: (getLm(23).x + getLm(24).x)/2, y: (getLm(23).y + getLm(24).y)/2 };
|
|
const torsoHeight = dist(midShoulder as Landmark, midHip as Landmark);
|
|
|
|
const eps = 1e-6;
|
|
// 6: Shoulder Width Ratio
|
|
derived.push(shoulderWidth / (torsoHeight + eps));
|
|
// 7: Hip Width Ratio
|
|
derived.push(hipWidth / (torsoHeight + eps));
|
|
|
|
// 8: Torso Vertical Alignment (Cosine Similarity with [0, -1])
|
|
// Vector from Hip to Shoulder (Upwards)
|
|
const torsoVec = { x: midShoulder.x - midHip.x, y: midShoulder.y - midHip.y };
|
|
const verticalVec = { x: 0, y: -1 }; // Up in image coordinates (y is down)?
|
|
// Python: vertical_vector = np.array([0, -1])
|
|
// In python/opencv y is down. So [0, -1] is UP vector.
|
|
|
|
const dot = (torsoVec.x * verticalVec.x) + (torsoVec.y * verticalVec.y);
|
|
const norm = Math.sqrt(torsoVec.x * torsoVec.x + torsoVec.y * torsoVec.y);
|
|
|
|
derived.push(norm > 0 ? dot / norm : 0);
|
|
|
|
return [...raw, ...derived];
|
|
}
|
|
}
|