UNPKG

bowling-analysis-system

Version:

A comprehensive system for analyzing bowling techniques using video processing and metrics calculation

487 lines (404 loc) 23.9 kB
/** * @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 };