export type Vec3 = { x: number; y: number; z: number; visibility?: number }; export const LM = { NOSE: 0, LEFT_SHOULDER: 11, RIGHT_SHOULDER: 12, LEFT_ELBOW: 13, RIGHT_ELBOW: 14, LEFT_WRIST: 15, RIGHT_WRIST: 16, LEFT_PINKY: 17, // NEW RIGHT_PINKY: 18, // NEW LEFT_INDEX: 19, RIGHT_INDEX: 20, LEFT_THUMB: 21, // NEW RIGHT_THUMB: 22, // NEW LEFT_HIP: 23, RIGHT_HIP: 24, LEFT_KNEE: 25, RIGHT_KNEE: 26, LEFT_ANKLE: 27, RIGHT_ANKLE: 28, } as const; function clamp(x: number, a: number, b: number) { return Math.max(a, Math.min(b, x)); } function sub(a: Vec3, b: Vec3) { return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z }; } function dot(a: Vec3, b: Vec3) { return a.x*b.x + a.y*b.y + a.z*b.z; } function norm(a: Vec3) { return Math.sqrt(dot(a,a)) + 1e-8; } export function angleDeg(A: Vec3, B: Vec3, C: Vec3): number { const BA = sub(A,B); const BC = sub(C,B); const cos = clamp(dot(BA,BC) / (norm(BA)*norm(BC)), -1, 1); return Math.acos(cos) * 180 / Math.PI; } function ema(prev: number | null, x: number, alpha: number): number { return prev === null ? x : alpha*x + (1-alpha)*prev; } function meanVisibility(lms: Vec3[], idxs: number[]): number { const v = idxs.map(i => (lms[i]?.visibility ?? 1.0)); const s = v.reduce((a,b)=>a+b, 0); return v.length ? s / v.length : 0; } export type PoseFeatures = { tMs: number; leftElbow: number; rightElbow: number; leftKnee: number; rightKnee: number; leftHip: number; rightHip: number; // normalized coords (0..1), y lebih besar = lebih bawah leftWristY: number; rightWristY: number; leftShoulderY: number; rightShoulderY: number; noseY: number; // NEW: Hand Orientation Data leftThumbY: number; leftPinkyY: number; rightThumbY: number; rightPinkyY: number; // NEW: X Coordinates for Width/Rotation Check leftThumbX: number; leftPinkyX: number; rightThumbX: number; rightPinkyX: number; visArms: number; visLegs: number; }; export function computeFeatures( normalized: Vec3[], // landmarks (image coords normalized) world: Vec3[], // worldLandmarks (meters) tMs: number ): PoseFeatures { const A = (i: number) => world[i]; const N = (i: number) => normalized[i]; const leftElbow = angleDeg(A(LM.LEFT_SHOULDER), A(LM.LEFT_ELBOW), A(LM.LEFT_WRIST)); const rightElbow = angleDeg(A(LM.RIGHT_SHOULDER), A(LM.RIGHT_ELBOW), A(LM.RIGHT_WRIST)); const leftKnee = angleDeg(A(LM.LEFT_HIP), A(LM.LEFT_KNEE), A(LM.LEFT_ANKLE)); const rightKnee = angleDeg(A(LM.RIGHT_HIP), A(LM.RIGHT_KNEE), A(LM.RIGHT_ANKLE)); const leftHip = angleDeg(A(LM.LEFT_SHOULDER), A(LM.LEFT_HIP), A(LM.LEFT_KNEE)); const rightHip = angleDeg(A(LM.RIGHT_SHOULDER), A(LM.RIGHT_HIP), A(LM.RIGHT_KNEE)); const armsIdx = [LM.LEFT_SHOULDER, LM.LEFT_ELBOW, LM.LEFT_WRIST, LM.RIGHT_SHOULDER, LM.RIGHT_ELBOW, LM.RIGHT_WRIST]; const legsIdx = [LM.LEFT_HIP, LM.LEFT_KNEE, LM.LEFT_ANKLE, LM.RIGHT_HIP, LM.RIGHT_KNEE, LM.RIGHT_ANKLE]; return { tMs, leftElbow, rightElbow, leftKnee, rightKnee, leftHip, rightHip, leftWristY: N(LM.LEFT_WRIST).y, rightWristY: N(LM.RIGHT_WRIST).y, leftShoulderY: N(LM.LEFT_SHOULDER).y, rightShoulderY: N(LM.RIGHT_SHOULDER).y, noseY: N(LM.NOSE).y, // Capture Thumb/Pinky Y for grip check leftThumbY: N(LM.LEFT_THUMB).y, leftPinkyY: N(LM.LEFT_PINKY).y, rightThumbY: N(LM.RIGHT_THUMB).y, rightPinkyY: N(LM.RIGHT_PINKY).y, // NEW: Capture X coordinates leftThumbX: N(LM.LEFT_THUMB).x, leftPinkyX: N(LM.LEFT_PINKY).x, rightThumbX: N(LM.RIGHT_THUMB).x, rightPinkyX: N(LM.RIGHT_PINKY).x, visArms: meanVisibility(world, armsIdx), visLegs: meanVisibility(world, legsIdx), }; } // ======================= // Robust FSM base // ======================= export class RepFSM { public state: "LOW" | "HIGH" = "LOW"; public reps = 0; private metricS: number | null = null; private metricPrev: number | null = null; private lastMotionT: number | null = null; private enteredHighT: number | null = null; private cycleStartT: number | null = null; private cycleMin: number | null = null; private cycleMax: number | null = null; constructor( public name: string, public minVis = 0.6, public emaAlpha = 0.25, public idleVelTh = 0.8, public idleMs = 900, public highHoldMs = 120, public minRepMs = 500, public maxRepMs = 12000, public minRomDeg = 60, ) {} visibilityOk(_f: PoseFeatures): boolean { return true; } metric(_f: PoseFeatures): number { throw new Error("metric not implemented"); } isLow(_m: number, _f: PoseFeatures): boolean { throw new Error("isLow not implemented"); } isHigh(_m: number, _f: PoseFeatures): boolean { throw new Error("isHigh not implemented"); } extraValid(_f: PoseFeatures): boolean { return true; } private updateRom(m: number) { this.cycleMin = this.cycleMin === null ? m : Math.min(this.cycleMin, m); this.cycleMax = this.cycleMax === null ? m : Math.max(this.cycleMax, m); } update(f: PoseFeatures): { delta: number; debug: any } { const t = f.tMs; if (!this.visibilityOk(f)) { return { delta: 0, debug: { name: this.name, state: this.state, note: "visibility_fail" } }; } const mRaw = this.metric(f); this.metricS = ema(this.metricS, mRaw, this.emaAlpha); if (this.metricPrev === null) { this.metricPrev = this.metricS; this.lastMotionT = t; return { delta: 0, debug: { name: this.name, state: this.state, m: this.metricS } }; } const vel = Math.abs(this.metricS - this.metricPrev); this.metricPrev = this.metricS; if (vel >= this.idleVelTh) this.lastMotionT = t; if (this.lastMotionT !== null && (t - this.lastMotionT) > this.idleMs) { // idle -> reset to safe state this.state = "LOW"; this.enteredHighT = null; this.cycleStartT = null; this.cycleMin = null; this.cycleMax = null; return { delta: 0, debug: { name: this.name, state: this.state, note: "idle" } }; } this.updateRom(this.metricS); if (this.state === "LOW") { if (this.isHigh(this.metricS, f) && this.extraValid(f)) { this.state = "HIGH"; this.enteredHighT = t; if (this.cycleStartT === null) this.cycleStartT = t; } return { delta: 0, debug: { name: this.name, state: this.state, m: this.metricS } }; } // state HIGH if (this.enteredHighT !== null && (t - this.enteredHighT) < this.highHoldMs) { return { delta: 0, debug: { name: this.name, state: this.state, m: this.metricS, note: "hold_high" } }; } if (this.isLow(this.metricS, f)) { const dur = this.cycleStartT ? (t - this.cycleStartT) : 0; const rom = (this.cycleMax !== null && this.cycleMin !== null) ? (this.cycleMax - this.cycleMin) : 0; const okDur = dur >= this.minRepMs && dur <= this.maxRepMs; const okRom = rom >= this.minRomDeg; let delta = 0; if (okDur && okRom) { this.reps += 1; delta = 1; } // reset this.state = "LOW"; this.enteredHighT = null; this.cycleStartT = null; this.cycleMin = null; this.cycleMax = null; return { delta, debug: { name: this.name, state: this.state, m: this.metricS, dur, rom, okDur, okRom } }; } return { delta: 0, debug: { name: this.name, state: this.state, m: this.metricS } }; } } // ======================= // Exercise counters // ======================= // --- Base Class for Curls --- class BaseCurlCounter extends RepFSM { constructor(public side: "left" | "right", name: string) { super(name, 0.6, 0.25, 0.8, 900, 120, 500, 12000, 70); } private highTh = 85; private lowTh = 140; visibilityOk(f: PoseFeatures) { return f.visArms >= this.minVis; } metric(f: PoseFeatures) { return this.side === "right" ? f.rightElbow : f.leftElbow; } isLow(m: number) { return m >= this.lowTh; } isHigh(m: number) { return m <= this.highTh; } } export class BicepCurlCounter extends BaseCurlCounter { constructor(side: "left" | "right" = "right") { super(side, "bicep_curl"); } // LOGIC: Simultaneous Lift // Valid ONLY if the OTHER arm is also active (Bent). extraValid(f: PoseFeatures) { const otherArmAngle = this.side === "right" ? f.leftElbow : f.rightElbow; // Check: Is the other arm bent? (< 120 degrees) // If the other arm is straight (> 120), then we are NOT doing simultaneous curls. if (otherArmAngle > 120) { return false; // Reject: Other arm is lazy/resting } return true; // Accept: Both arms are working } } export class HammerCurlCounter extends BaseCurlCounter { constructor(side: "left" | "right" = "right") { super(side, "hammer_curl"); } // LOGIC: Alternating Lift // Valid ONLY if the OTHER arm is resting (Straight). extraValid(f: PoseFeatures) { const otherArmAngle = this.side === "right" ? f.leftElbow : f.rightElbow; // Check: Is the other arm straight? (> 130 degrees) // If the other arm is bent (< 130), it means we are moving both (Simultaneous). if (otherArmAngle < 130) { return false; // Reject: Both arms are moving } return true; // Accept: Only this arm is moving } } export class OverheadPressCounter extends RepFSM { constructor() { // Using relaxed Min ROM (30) to ensuring counting super("overhead_press", 0.6, 0.25, 0.8, 900, 120, 500, 12000, 30); } visibilityOk(f: PoseFeatures) { return f.visArms >= this.minVis; } metric(f: PoseFeatures) { // Use MIN elbow angle to require at least one arm to lock out return Math.min(f.leftElbow, f.rightElbow); } // High State (Lockout) > 140 deg isHigh(m: number) { return m > 150; } // Low State (Start/Chin level) < 120 deg isLow(m: number) { return m < 140; } // Validation: Ensure hands are ABOVE shoulders extraValid(f: PoseFeatures) { const isHandsUpL = f.leftWristY < f.leftShoulderY; const isHandsUpR = f.rightWristY < f.rightShoulderY; return isHandsUpL && isHandsUpR; } } export class LateralRaiseCounter extends RepFSM { private highMargin = 0.05; private lowMargin = 0.12; constructor() { super("lateral_raise", 0.6, 0.25, 0.003, 900, 120, 500, 12000, 0); } visibilityOk(f: PoseFeatures) { return f.visArms >= this.minVis; } metric(f: PoseFeatures) { const dl = Math.abs(f.leftWristY - f.leftShoulderY); const dr = Math.abs(f.rightWristY - f.rightShoulderY); return 0.5*(dl+dr); } isHigh(m: number) { return m < this.highMargin; } isLow(m: number) { return m > this.lowMargin; } extraValid(f: PoseFeatures) { return f.leftElbow > 120 && f.rightElbow > 120; } } export class SquatCounter extends RepFSM { private topTh = 165; private bottomTh = 100; constructor() { super("squat", 0.6, 0.25, 0.8, 900, 120, 600, 12000, 40); } visibilityOk(f: PoseFeatures) { return f.visLegs >= this.minVis; } metric(f: PoseFeatures) { return Math.min(f.leftKnee, f.rightKnee); } isLow(m: number) { return m >= this.topTh; } // standing isHigh(m: number) { return m <= this.bottomTh; } // bottom extraValid(f: PoseFeatures) { return Math.min(f.leftHip, f.rightHip) < 140; } } export class DeadliftCounter extends RepFSM { private topHipTh = 165; private bottomHipTh = 120; constructor() { super("deadlift", 0.6, 0.25, 0.8, 900, 120, 600, 12000, 35); } visibilityOk(f: PoseFeatures) { return f.visLegs >= this.minVis && f.visArms >= 0.4; } metric(f: PoseFeatures) { return Math.min(f.leftHip, f.rightHip); } isLow(m: number) { return m >= this.topHipTh; } isHigh(m: number, f: PoseFeatures) { const knee = Math.min(f.leftKnee, f.rightKnee); return m <= this.bottomHipTh && knee > 110; } } export class LungeCounter extends RepFSM { private topKneeTh = 165; private bottomFrontTh = 105; private bottomBackTh = 130; constructor() { super("lunge", 0.6, 0.25, 0.8, 900, 120, 600, 12000, 25); } visibilityOk(f: PoseFeatures) { return f.visLegs >= this.minVis; } metric(f: PoseFeatures) { return Math.min(f.leftKnee, f.rightKnee); } isLow(_m: number, f: PoseFeatures) { return f.leftKnee > this.topKneeTh && f.rightKnee > this.topKneeTh; } isHigh(_m: number, f: PoseFeatures) { const front = Math.min(f.leftKnee, f.rightKnee); const back = Math.max(f.leftKnee, f.rightKnee); return front < this.bottomFrontTh && back < this.bottomBackTh; } }