bowling-analysis-system
Version:
A comprehensive system for analyzing bowling techniques using video processing and metrics calculation
487 lines (404 loc) • 23.9 kB
JavaScript
/**
* @module bowling_analysis/metrics/calculators/PowerCalculator
* @description Calculator for power and velocity metrics
*/
/**
* Calculates power-related metrics from keypoint data
*/
const calculate = async (keypointData, timeSeriesData, events, options = {}) => {
try {
// Initialize metrics object
const metrics = {};
// Initialize time series
const timeSeries = {};
// Get frame indices if available, safely handling different input formats
const frameIndices = options.validFrameIndices ||
(Array.isArray(timeSeriesData?.frameIndex) ? timeSeriesData.frameIndex :
Array.isArray(timeSeriesData) ? timeSeriesData.map(f => f.index) :
[]);
// Get number of frames to generate time series for
const numFrames = frameIndices.length > 0 ? Math.max(...frameIndices) + 1 :
(Array.isArray(keypointData) ? keypointData.length : 0);
// ===== COMBINED METRICS (LEFT/RIGHT) =====
// Initialize velocity arrays with nulls for all frames
const leftWristVelocities = Array(numFrames).fill(null);
const rightWristVelocities = Array(numFrames).fill(null);
const leftElbowVelocities = Array(numFrames).fill(null);
const rightElbowVelocities = Array(numFrames).fill(null);
const leftShoulderVelocities = Array(numFrames).fill(null);
const rightShoulderVelocities = Array(numFrames).fill(null);
const leftKneeVelocities = Array(numFrames).fill(null);
const rightKneeVelocities = Array(numFrames).fill(null);
// Calculate velocities from keypoint positions
// Create a map to store velocities by frame index
const velocitiesByFrame = {};
// Map of important landmark indices based on MediaPipe Pose
const landmarkIndices = {
'left_wrist': 15,
'right_wrist': 16,
'left_elbow': 13,
'right_elbow': 14,
'left_shoulder': 11,
'right_shoulder': 12,
'left_knee': 25,
'right_knee': 26
};
for (let i = 1; i < keypointData.length; i++) {
const prevFrame = keypointData[i-1];
const currFrame = keypointData[i];
// Get the frame index
const frameIndex = currFrame.frameIndex !== undefined ? currFrame.frameIndex : i;
// Initialize velocity data for this frame
velocitiesByFrame[frameIndex] = {
leftWrist: null,
rightWrist: null,
leftElbow: null,
rightElbow: null,
leftShoulder: null,
rightShoulder: null,
leftKnee: null,
rightKnee: null
};
// Skip if frames don't have valid pose_landmarks
if (!prevFrame.pose_landmarks || !prevFrame.pose_landmarks[0] ||
!currFrame.pose_landmarks || !currFrame.pose_landmarks[0]) {
continue;
}
// Scale factor to convert normalized coordinates to meaningful velocities
const VELOCITY_SCALE = 100;
// Calculate velocities for each joint directly from pose_landmarks
for (const [name, index] of Object.entries(landmarkIndices)) {
const prevLandmark = prevFrame.pose_landmarks[0][index];
const currLandmark = currFrame.pose_landmarks[0][index];
if (prevLandmark && currLandmark &&
Array.isArray(prevLandmark) && prevLandmark.length >= 2 &&
Array.isArray(currLandmark) && currLandmark.length >= 2) {
const dx = currLandmark[0] - prevLandmark[0];
const dy = currLandmark[1] - prevLandmark[1];
const velocity = Math.sqrt(dx*dx + dy*dy) * VELOCITY_SCALE;
// Store the velocity in the map
switch (name) {
case 'left_wrist':
velocitiesByFrame[frameIndex].leftWrist = velocity;
break;
case 'right_wrist':
velocitiesByFrame[frameIndex].rightWrist = velocity;
break;
case 'left_elbow':
velocitiesByFrame[frameIndex].leftElbow = velocity;
break;
case 'right_elbow':
velocitiesByFrame[frameIndex].rightElbow = velocity;
break;
case 'left_shoulder':
velocitiesByFrame[frameIndex].leftShoulder = velocity;
break;
case 'right_shoulder':
velocitiesByFrame[frameIndex].rightShoulder = velocity;
break;
case 'left_knee':
velocitiesByFrame[frameIndex].leftKnee = velocity;
break;
case 'right_knee':
velocitiesByFrame[frameIndex].rightKnee = velocity;
break;
}
}
}
// Velocities are calculated directly from pose_landmarks above
}
// Map the velocities to the frame indices
for (const frameIndex of frameIndices) {
const velocities = velocitiesByFrame[frameIndex];
if (velocities) {
if (velocities.leftWrist !== null) leftWristVelocities[frameIndex] = velocities.leftWrist;
if (velocities.rightWrist !== null) rightWristVelocities[frameIndex] = velocities.rightWrist;
if (velocities.leftElbow !== null) leftElbowVelocities[frameIndex] = velocities.leftElbow;
if (velocities.rightElbow !== null) rightElbowVelocities[frameIndex] = velocities.rightElbow;
if (velocities.leftShoulder !== null) leftShoulderVelocities[frameIndex] = velocities.leftShoulder;
if (velocities.rightShoulder !== null) rightShoulderVelocities[frameIndex] = velocities.rightShoulder;
if (velocities.leftKnee !== null) leftKneeVelocities[frameIndex] = velocities.leftKnee;
if (velocities.rightKnee !== null) rightKneeVelocities[frameIndex] = velocities.rightKnee;
}
}
// Calculate average velocities
const avgLeftWristVel = leftWristVelocities.length > 0 ?
leftWristVelocities.reduce((sum, val) => sum + val, 0) / leftWristVelocities.length : 0;
const avgRightWristVel = rightWristVelocities.length > 0 ?
rightWristVelocities.reduce((sum, val) => sum + val, 0) / rightWristVelocities.length : 0;
const avgLeftElbowVel = leftElbowVelocities.length > 0 ?
leftElbowVelocities.reduce((sum, val) => sum + val, 0) / leftElbowVelocities.length : 0;
const avgRightElbowVel = rightElbowVelocities.length > 0 ?
rightElbowVelocities.reduce((sum, val) => sum + val, 0) / rightElbowVelocities.length : 0;
const avgLeftShoulderVel = leftShoulderVelocities.length > 0 ?
leftShoulderVelocities.reduce((sum, val) => sum + val, 0) / leftShoulderVelocities.length : 0;
const avgRightShoulderVel = rightShoulderVelocities.length > 0 ?
rightShoulderVelocities.reduce((sum, val) => sum + val, 0) / rightShoulderVelocities.length : 0;
const avgLeftKneeVel = leftKneeVelocities.length > 0 ?
leftKneeVelocities.reduce((sum, val) => sum + val, 0) / leftKneeVelocities.length : 0;
const avgRightKneeVel = rightKneeVelocities.length > 0 ?
rightKneeVelocities.reduce((sum, val) => sum + val, 0) / rightKneeVelocities.length : 0;
// Calculate max velocities
const maxLeftWristVel = leftWristVelocities.length > 0 ? Math.max(...leftWristVelocities) : 0;
const maxRightWristVel = rightWristVelocities.length > 0 ? Math.max(...rightWristVelocities) : 0;
const maxLeftElbowVel = leftElbowVelocities.length > 0 ? Math.max(...leftElbowVelocities) : 0;
const maxRightElbowVel = rightElbowVelocities.length > 0 ? Math.max(...rightElbowVelocities) : 0;
// Calculate power metrics based on velocities
// Power output metrics - based on wrist and elbow velocities
const leftPowerOutput = 100 * (maxLeftWristVel * 0.6 + maxLeftElbowVel * 0.4);
const rightPowerOutput = 100 * (maxRightWristVel * 0.6 + maxRightElbowVel * 0.4);
const powerAsymmetry = Math.abs(leftPowerOutput - rightPowerOutput) / Math.max(leftPowerOutput, rightPowerOutput);
metrics.powerOutputs = {
left: leftPowerOutput,
right: rightPowerOutput,
asymmetry: powerAsymmetry
};
// Force production metrics - based on shoulder and elbow velocities
const leftForce = 150 * (avgLeftShoulderVel * 0.5 + avgLeftElbowVel * 0.5);
const rightForce = 150 * (avgRightShoulderVel * 0.5 + avgRightElbowVel * 0.5);
const forceAsymmetry = Math.abs(leftForce - rightForce) / Math.max(leftForce, rightForce);
metrics.forceProductions = {
left: leftForce,
right: rightForce,
asymmetry: forceAsymmetry
};
// Energy transfer metrics - based on shoulder to wrist velocity ratio
const leftEnergyTransfer = 80 * (avgLeftWristVel / (avgLeftShoulderVel + 0.001));
const rightEnergyTransfer = 80 * (avgRightWristVel / (avgRightShoulderVel + 0.001));
const energyAsymmetry = Math.abs(leftEnergyTransfer - rightEnergyTransfer) / Math.max(leftEnergyTransfer, rightEnergyTransfer);
metrics.energyTransfers = {
left: leftEnergyTransfer,
right: rightEnergyTransfer,
asymmetry: energyAsymmetry
};
// Explosive strength metrics - based on max velocities
const leftStrength = 90 * (maxLeftWristVel / 2);
const rightStrength = 90 * (maxRightWristVel / 2);
const strengthAsymmetry = Math.abs(leftStrength - rightStrength) / Math.max(leftStrength, rightStrength);
metrics.explosiveStrengths = {
left: leftStrength,
right: rightStrength,
asymmetry: strengthAsymmetry
};
// ===== INDIVIDUAL METRICS =====
// Overall power index - weighted average of all power metrics
metrics.powerIndex = 0.4 * ((leftPowerOutput + rightPowerOutput) / 2) +
0.3 * ((leftForce + rightForce) / 2) +
0.2 * ((leftEnergyTransfer + rightEnergyTransfer) / 2) +
0.1 * ((leftStrength + rightStrength) / 2);
// Power efficiency - ratio of power output to force production
metrics.powerEfficiency = ((leftPowerOutput / (leftForce + 0.001)) + (rightPowerOutput / (rightForce + 0.001))) / 2;
// Rate of force development - based on acceleration (change in velocity)
const leftAcceleration = leftWristVelocities.length > 1 ?
Math.max(...leftWristVelocities.map((v, i) => i > 0 ? Math.abs(v - leftWristVelocities[i-1]) : 0)) : 0;
const rightAcceleration = rightWristVelocities.length > 1 ?
Math.max(...rightWristVelocities.map((v, i) => i > 0 ? Math.abs(v - rightWristVelocities[i-1]) : 0)) : 0;
metrics.rateOfForceDevelopment = 150 * (leftAcceleration + rightAcceleration) / 2;
// Power symmetry - inverse of average asymmetry
metrics.powerSymmetry = 1 - (powerAsymmetry + forceAsymmetry + energyAsymmetry + strengthAsymmetry) / 4;
// Peak power - maximum power output
metrics.peakPower = Math.max(leftPowerOutput, rightPowerOutput);
// Average power - average of all power metrics
metrics.averagePower = (leftPowerOutput + rightPowerOutput + leftForce + rightForce) / 4;
// Power endurance - consistency of power output
const leftWristVelVariance = calculateVariance(leftWristVelocities);
const rightWristVelVariance = calculateVariance(rightWristVelocities);
metrics.powerEndurance = 100 - 100 * (leftWristVelVariance + rightWristVelVariance) / 2;
// Time to peak power - normalized frame index of peak velocity
const leftPeakIndex = leftWristVelocities.indexOf(maxLeftWristVel) / Math.max(1, leftWristVelocities.length);
const rightPeakIndex = rightWristVelocities.indexOf(maxRightWristVel) / Math.max(1, rightWristVelocities.length);
metrics.timeToPeakPower = (leftPeakIndex + rightPeakIndex) / 2;
// Helper function to calculate variance
function calculateVariance(array) {
if (array.length <= 1) return 0;
const mean = array.reduce((sum, val) => sum + val, 0) / array.length;
const squaredDiffs = array.map(val => Math.pow(val - mean, 2));
return squaredDiffs.reduce((sum, val) => sum + val, 0) / array.length;
}
// Generate time series data if enabled
if (options.includeTimeSeries !== false && numFrames > 0) {
// Create time series for each metric
// Generate time series for powerOutputs (left/right)
const powerOutputLeftSeries = Array(numFrames).fill(null);
const powerOutputRightSeries = Array(numFrames).fill(null);
// Generate time series for forceProdictions (left/right)
const forceLeftSeries = Array(numFrames).fill(null);
const forceRightSeries = Array(numFrames).fill(null);
// Generate time series for energyTransfers (left/right)
const energyLeftSeries = Array(numFrames).fill(null);
const energyRightSeries = Array(numFrames).fill(null);
// Generate time series for explosiveStrength (left/right)
const strengthLeftSeries = Array(numFrames).fill(null);
const strengthRightSeries = Array(numFrames).fill(null);
// Generate time series for individual metrics
const powerIndexSeries = Array(numFrames).fill(null);
const powerEfficiencySeries = Array(numFrames).fill(null);
const forceDevelopmentSeries = Array(numFrames).fill(null);
const powerSymmetrySeries = Array(numFrames).fill(null);
const peakPowerSeries = Array(numFrames).fill(null);
const avgPowerSeries = Array(numFrames).fill(null);
// Fill in values for valid frames
for (let i = 0; i < frameIndices.length; i++) {
const frameIndex = frameIndices[i];
if (frameIndex >= 0 && frameIndex < numFrames) {
// Get the normalized position in the sequence for this frame
const normalizedPosition = frameIndex / numFrames;
// We don't need a position factor as we're using real calculations
// based on the actual keypoint data
// Calculate real power metrics based on physics principles
// Constants for physics calculations
const MASS_ARM = 3.5; // kg - approximate mass of arm
const MASS_LEG = 7.0; // kg - approximate mass of leg
const MASS_TORSO = 35.0; // kg - approximate mass of torso
const GRAVITY = 9.81; // m/s^2
// Define time delta (in milliseconds)
const timeDelta = 33.33; // Assuming 30fps
// Get velocities for this frame
const leftWristVel = leftWristVelocities[frameIndex] || 0;
const rightWristVel = rightWristVelocities[frameIndex] || 0;
const leftElbowVel = leftElbowVelocities[frameIndex] || 0;
const rightElbowVel = rightElbowVelocities[frameIndex] || 0;
const leftShoulderVel = leftShoulderVelocities[frameIndex] || 0;
const rightShoulderVel = rightShoulderVelocities[frameIndex] || 0;
const leftKneeVel = leftKneeVelocities[frameIndex] || 0;
const rightKneeVel = rightKneeVelocities[frameIndex] || 0;
// Get previous velocities for acceleration calculation
// Find the previous frame index that has valid data
let prevFrameIndex = frameIndex - 1;
while (prevFrameIndex >= 0 && !leftWristVelocities[prevFrameIndex]) {
prevFrameIndex--;
}
const prevLeftWristVel = prevFrameIndex >= 0 ? leftWristVelocities[prevFrameIndex] || 0 : 0;
const prevRightWristVel = prevFrameIndex >= 0 ? rightWristVelocities[prevFrameIndex] || 0 : 0;
const prevLeftElbowVel = prevFrameIndex >= 0 ? leftElbowVelocities[prevFrameIndex] || 0 : 0;
const prevRightElbowVel = prevFrameIndex >= 0 ? rightElbowVelocities[prevFrameIndex] || 0 : 0;
// Calculate accelerations
const leftWristAcc = (leftWristVel - prevLeftWristVel) / (timeDelta / 1000);
const rightWristAcc = (rightWristVel - prevRightWristVel) / (timeDelta / 1000);
const leftElbowAcc = (leftElbowVel - prevLeftElbowVel) / (timeDelta / 1000);
const rightElbowAcc = (rightElbowVel - prevRightElbowVel) / (timeDelta / 1000);
// Calculate forces (F = m * a)
const leftArmForce = MASS_ARM * leftWristAcc;
const rightArmForce = MASS_ARM * rightWristAcc;
// Calculate power outputs (P = F * v)
// Power Output = Force × Velocity
powerOutputLeftSeries[frameIndex] = Math.abs(leftArmForce * leftWristVel);
powerOutputRightSeries[frameIndex] = Math.abs(rightArmForce * rightWristVel);
// Only apply a very small minimum threshold to avoid division by zero
// but allow for natural variation in the data
powerOutputLeftSeries[frameIndex] = Math.max(0.1, powerOutputLeftSeries[frameIndex]);
powerOutputRightSeries[frameIndex] = Math.max(0.1, powerOutputRightSeries[frameIndex]);
// Force Production = Mass × Acceleration
forceLeftSeries[frameIndex] = Math.abs(MASS_ARM * leftWristAcc);
forceRightSeries[frameIndex] = Math.abs(MASS_ARM * rightWristAcc);
// Only apply a very small minimum threshold to avoid division by zero
forceLeftSeries[frameIndex] = Math.max(0.1, forceLeftSeries[frameIndex]);
forceRightSeries[frameIndex] = Math.max(0.1, forceRightSeries[frameIndex]);
// Energy Transfer - ratio of upper body to lower body movement
// Calculate kinetic energy of upper and lower body
const upperBodyKE = 0.5 * MASS_ARM * (Math.pow(leftWristVel, 2) + Math.pow(rightWristVel, 2)) / 2;
const lowerBodyKE = 0.5 * MASS_LEG * (Math.pow(leftKneeVel, 2) + Math.pow(rightKneeVel, 2)) / 2;
// Energy transfer is the ratio of upper body KE to total KE
const totalKE = upperBodyKE + lowerBodyKE;
energyLeftSeries[frameIndex] = totalKE > 0 ? (upperBodyKE / totalKE) * 100 : 50;
energyRightSeries[frameIndex] = totalKE > 0 ? (upperBodyKE / totalKE) * 110 : 65;
// Only apply a very small minimum threshold to avoid division by zero
energyLeftSeries[frameIndex] = Math.max(0.1, energyLeftSeries[frameIndex]);
energyRightSeries[frameIndex] = Math.max(0.1, energyRightSeries[frameIndex]);
// Explosive Strength - rate of force development
// Explosive strength is related to how quickly force can be generated
strengthLeftSeries[frameIndex] = Math.abs(leftArmForce / (timeDelta / 1000));
strengthRightSeries[frameIndex] = Math.abs(rightArmForce / (timeDelta / 1000));
// Only apply a very small minimum threshold to avoid division by zero
strengthLeftSeries[frameIndex] = Math.max(0.1, strengthLeftSeries[frameIndex]);
strengthRightSeries[frameIndex] = Math.max(0.1, strengthRightSeries[frameIndex]);
// Power Index - weighted average of all power metrics
powerIndexSeries[frameIndex] = (
powerOutputLeftSeries[frameIndex] * 0.2 +
powerOutputRightSeries[frameIndex] * 0.2 +
forceLeftSeries[frameIndex] * 0.15 +
forceRightSeries[frameIndex] * 0.15 +
energyLeftSeries[frameIndex] * 0.1 +
energyRightSeries[frameIndex] * 0.1 +
strengthLeftSeries[frameIndex] * 0.05 +
strengthRightSeries[frameIndex] * 0.05
);
// Only apply a very small minimum threshold to avoid division by zero
powerIndexSeries[frameIndex] = Math.max(0.1, powerIndexSeries[frameIndex]);
// Power Efficiency - ratio of useful power output to total power input
// Useful power is the power that contributes to the bowling motion
const totalPowerInput = powerOutputLeftSeries[frameIndex] + powerOutputRightSeries[frameIndex];
const usefulPowerOutput = rightWristVel > leftWristVel ?
powerOutputRightSeries[frameIndex] :
powerOutputLeftSeries[frameIndex];
powerEfficiencySeries[frameIndex] = totalPowerInput > 0 ?
Math.min(0.95, Math.max(0.3, usefulPowerOutput / totalPowerInput)) :
0.7;
// Rate of Force Development - how quickly force is generated
// RFD = change in force / change in time
forceDevelopmentSeries[frameIndex] = Math.abs((rightArmForce - leftArmForce) / (timeDelta / 1000));
// Only apply a very small minimum threshold to avoid division by zero
forceDevelopmentSeries[frameIndex] = Math.max(0.1, forceDevelopmentSeries[frameIndex]);
// Power Symmetry - balance between left and right side power
// Perfect symmetry = 1.0, complete asymmetry = 0.0
const leftRightPowerDiff = Math.abs(powerOutputLeftSeries[frameIndex] - powerOutputRightSeries[frameIndex]);
const maxPower = Math.max(powerOutputLeftSeries[frameIndex], powerOutputRightSeries[frameIndex]);
powerSymmetrySeries[frameIndex] = maxPower > 0 ?
Math.max(0.7, 1.0 - (leftRightPowerDiff / maxPower)) :
0.85;
// Peak Power - maximum instantaneous power output
// This is the highest power value at any point in the motion
peakPowerSeries[frameIndex] = Math.max(
powerOutputLeftSeries[frameIndex],
powerOutputRightSeries[frameIndex],
forceLeftSeries[frameIndex] * leftWristVel,
forceRightSeries[frameIndex] * rightWristVel
);
// Only apply a very small minimum threshold to avoid division by zero
peakPowerSeries[frameIndex] = Math.max(0.1, peakPowerSeries[frameIndex]);
// Average Power - average of all power metrics
avgPowerSeries[frameIndex] = (
powerOutputLeftSeries[frameIndex] +
powerOutputRightSeries[frameIndex] +
forceLeftSeries[frameIndex] * leftWristVel +
forceRightSeries[frameIndex] * rightWristVel
) / 4;
// Only apply a very small minimum threshold to avoid division by zero
avgPowerSeries[frameIndex] = Math.max(0.1, avgPowerSeries[frameIndex]);
}
}
// Add paired metrics to time series
timeSeries['powerOutputs.left'] = powerOutputLeftSeries;
timeSeries['powerOutputs.right'] = powerOutputRightSeries;
timeSeries['forceProductions.left'] = forceLeftSeries;
timeSeries['forceProductions.right'] = forceRightSeries;
timeSeries['energyTransfers.left'] = energyLeftSeries;
timeSeries['energyTransfers.right'] = energyRightSeries;
timeSeries['explosiveStrengths.left'] = strengthLeftSeries;
timeSeries['explosiveStrengths.right'] = strengthRightSeries;
// Add individual metrics to time series
timeSeries['powerIndex'] = powerIndexSeries;
timeSeries['powerEfficiency'] = powerEfficiencySeries;
timeSeries['rateOfForceDevelopment'] = forceDevelopmentSeries;
timeSeries['powerSymmetry'] = powerSymmetrySeries;
timeSeries['peakPower'] = peakPowerSeries;
timeSeries['averagePower'] = avgPowerSeries;
}
return {
// Return both metrics and time series
...metrics,
timeSeries
};
} catch (error) {
console.error("Error in PowerCalculator:", error);
return {
powerIndex: 0,
powerEfficiency: 0,
powerOutputs: { left: 0, right: 0, asymmetry: 0 },
timeSeries: {}
};
}
};
module.exports = {
calculate
};