UNPKG

bowling-analysis-system

Version:

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

1,183 lines (998 loc) 42.2 kB
/** * @module core/metrics/phase1 * @description Calculate biomechanical metrics from keypoint data */ const angleCalculations = require('../../bowling_analysis/metrics/calculations/AngleCalculations'); const velocityCalculations = require('../../bowling_analysis/metrics/calculations/VelocityCalculations'); const positionCalculations = require('../../bowling_analysis/metrics/calculations/PositionCalculations'); const accelerationCalculations = require('../../bowling_analysis/metrics/calculations/AccelerationCalculations'); const balanceCalculations = require('../../bowling_analysis/metrics/calculations/BalanceCalculations'); const powerCalculations = require('../../bowling_analysis/metrics/calculations/PowerCalculations'); const { PHASE_ONE_METRICS } = require('../constants/MetricsDependencyMap'); // Use our custom MetricsUtilities instead of the original one const metricsUtilities = require('./MetricsUtilities'); /** * Extracts landmarks from a frame object, handling different formats * @param {Object} frame - The frame object containing pose landmarks * @returns {Array|null} - Array of landmarks or null if not found */ function getLandmarksFromFrame(frame) { if (!frame) { return null; } // Handle the case where frame is directly an array of landmarks if (Array.isArray(frame)) { return frame; } // Check if pose_landmarks exists if (!frame.pose_landmarks) { return null; } // Handle empty pose_landmarks if (Array.isArray(frame.pose_landmarks) && frame.pose_landmarks.length === 0) { return null; } // Handle triple-nested array structure: frame.pose_landmarks is [[landmark1, landmark2, ...]] if (Array.isArray(frame.pose_landmarks) && frame.pose_landmarks.length === 1 && Array.isArray(frame.pose_landmarks[0])) { return frame.pose_landmarks[0]; } // Handle double-nested array structure: frame.pose_landmarks is [landmark1, landmark2, ...] if (Array.isArray(frame.pose_landmarks) && Array.isArray(frame.pose_landmarks[0])) { return frame.pose_landmarks; } // If we get here, the landmarks are in an unexpected format return null; } /** * Helper function to calculate combined left/right metric * @param {any} leftValue - Left side metric value * @param {any} rightValue - Right side metric value * @returns {Object|null} Combined metric object with left and right properties */ function _calculateCombinedMetric(leftValue, rightValue) { if (leftValue === null && rightValue === null) { return null; } // Handle case where one value is null if (leftValue === null) { return { left: null, right: rightValue }; } if (rightValue === null) { return { left: leftValue, right: null }; } // Both values exist return { left: leftValue, right: rightValue }; } /** * Identifies whether the time series metric should be combined based on its name * @param {string} metricName - Metric name to check * @returns {boolean} True if this should be a combined metric */ function _shouldBeCombinedMetric(metricName) { // Check if the metric is already in combined format (ends with 's') if (metricName.endsWith('s') && !metricName.endsWith('mass') && !metricName.endsWith('stress')) { // Check if it's a false positive (metrics that naturally end with 's') const falsePositives = ['mass', 'stress', 'progress', 'radius', 'status', 'axis', 'focus']; for (const suffix of falsePositives) { if (metricName.endsWith(suffix)) { return false; } } return true; } // Check if the metric is likely to have left/right components const bodyPartMetrics = [ 'shoulder', 'elbow', 'wrist', 'hip', 'knee', 'ankle', 'foot', 'arm', 'leg', 'hand', 'posturalSway', 'centerOfPressure', 'rotation', 'flexion', 'extension', 'abduction', 'adduction', 'power', 'velocity', 'acceleration', 'balance', 'position', 'length', 'width', 'angle', 'joint', 'muscle', 'pressure', 'force', 'stability', 'finger', 'toe', 'thumb', 'forearm', 'upperbody', 'lowerbody', 'thigh', 'calf', 'torque' ]; for (const part of bodyPartMetrics) { if (metricName.toLowerCase().includes(part.toLowerCase())) { // Skip metrics that specifically shouldn't be combined despite containing body part names const nonCombinableMetrics = [ 'centerOfMass', 'totalBody', 'bodyHeight', 'medialLateral', 'anteriorPosterior', 'vertical', 'medianVelocity' ]; if (nonCombinableMetrics.includes(metricName)) { return false; } return true; } } return false; } /** * Standardizes time series data to ensure all metrics with left/right components * use the combined format { left, right } * @param {Object} timeSeriesData - The time series data to standardize * @returns {Object} Standardized time series data */ function _standardizeTimeSeriesFormat(timeSeriesData) { if (!timeSeriesData) { return {}; } const standardizedData = {}; // Process each category Object.entries(timeSeriesData).forEach(([category, metrics]) => { if (typeof metrics !== 'object') { return; } standardizedData[category] = {}; // Keep track of metrics that have been combined const combinedMetrics = new Set(); // Process each metric in the category Object.entries(metrics).forEach(([metricName, values]) => { // Skip if the values are not an array if (!Array.isArray(values)) { return; } // Check if this is a "left" metric that should be combined if (metricName.startsWith('left')) { const baseName = metricName.substring(4); // Remove 'left' const rightName = `right${baseName}`; // Use lowercase first character for the combined name and add 's' const combinedName = baseName.charAt(0).toLowerCase() + baseName.slice(1) + 's'; // If right metric exists, combine them if (metrics[rightName] && Array.isArray(metrics[rightName])) { standardizedData[category][combinedName] = values.map((leftValue, index) => { const rightValue = index < metrics[rightName].length ? metrics[rightName][index] : null; return _calculateCombinedMetric(leftValue, rightValue); }); // Mark as combined so we don't process the right metric later combinedMetrics.add(rightName); combinedMetrics.add(metricName); } else { // No right metric found, still create combined format standardizedData[category][combinedName] = values.map(leftValue => _calculateCombinedMetric(leftValue, null) ); combinedMetrics.add(metricName); } } // Check if this is a "right" metric that should be combined (if not already processed with left) else if (metricName.startsWith('right') && !combinedMetrics.has(metricName)) { const baseName = metricName.substring(5); // Remove 'right' const combinedName = baseName.charAt(0).toLowerCase() + baseName.slice(1) + 's'; // If this right metric wasn't already processed with a left one standardizedData[category][combinedName] = values.map(rightValue => _calculateCombinedMetric(null, rightValue) ); combinedMetrics.add(metricName); } // Check if this is a metric that should be combined but isn't yet in combined format else if (_shouldBeCombinedMetric(metricName) && !combinedMetrics.has(metricName) && !metricName.endsWith('s')) { // Convert to combined format with pluralized name const combinedName = metricName + 's'; standardizedData[category][combinedName] = values.map(value => { if (value === null) { return null; } // If already in combined format, use as is if (value && typeof value === 'object' && 'left' in value) { return value; } // Otherwise, create a new combined format with same value for both sides return { left: value, right: value }; }); combinedMetrics.add(metricName); } // Any other metric (not a left/right body part), keep as is else if (!combinedMetrics.has(metricName)) { standardizedData[category][metricName] = values; } }); }); return standardizedData; } /** * Wrapper for angle calculations */ const wrappedAngleCalculations = { calculateAngles: function(frame) { const landmarks = frame.keypoints; const angles = {}; try { // Format landmarks for angle calculations const formattedLandmarks = landmarks.map(landmark => [ landmark.x, landmark.y, landmark.z, landmark.confidence || 1.0 ]); // Calculate shoulder angles (left and right) const leftShoulderAngle = angleCalculations.calculateLeftShoulderRotation(formattedLandmarks); const rightShoulderAngle = angleCalculations.calculateRightShoulderRotation(formattedLandmarks); angles.shoulderRotations = _calculateCombinedMetric(leftShoulderAngle, rightShoulderAngle); // Calculate elbow angles (left and right) const leftElbowAngle = angleCalculations.calculateLeftElbowFlexion(formattedLandmarks); const rightElbowAngle = angleCalculations.calculateRightElbowFlexion(formattedLandmarks); angles.elbowFlexions = _calculateCombinedMetric(leftElbowAngle, rightElbowAngle); // Calculate hip angles (left and right) const leftHipAngle = angleCalculations.calculateLeftHipRotation(formattedLandmarks); const rightHipAngle = angleCalculations.calculateRightHipRotation(formattedLandmarks); angles.hipRotations = _calculateCombinedMetric(leftHipAngle, rightHipAngle); // Calculate knee angles (left and right) const leftKneeAngle = angleCalculations.calculateLeftKneeFlexion(formattedLandmarks); const rightKneeAngle = angleCalculations.calculateRightKneeFlexion(formattedLandmarks); angles.kneeFlexions = _calculateCombinedMetric(leftKneeAngle, rightKneeAngle); // Calculate ankle angles (left and right) const leftAnkleAngle = angleCalculations.calculateLeftAnkleFlexion(formattedLandmarks); const rightAnkleAngle = angleCalculations.calculateRightAnkleFlexion(formattedLandmarks); angles.ankleFlexions = _calculateCombinedMetric(leftAnkleAngle, rightAnkleAngle); // Add other essential angle metrics angles.trunkLean = angleCalculations.calculateTrunkLean(formattedLandmarks); } catch (error) { console.warn(`Error calculating angle metrics: ${error.message}`); } return angles; } }; /** * Wrapper for position calculations */ const wrappedPositionCalculations = { calculatePositions: function(frame) { const landmarks = frame.keypoints; const positions = {}; try { // Format landmarks for position calculations const formattedLandmarks = landmarks.map(landmark => [ landmark.x, landmark.y, landmark.z, landmark.confidence || 1.0 ]); // Calculate approach position (where the bowler is relative to the lane) positions.approachPosition = positionCalculations.calculateApproachPosition(formattedLandmarks); // Calculate balance position positions.balancePosition = positionCalculations.calculateBalancePosition(formattedLandmarks); // Calculate head position positions.headPosition = positionCalculations.calculateHeadPosition(formattedLandmarks); // Calculate torso length positions.torsoLength = positionCalculations.calculateTorsoLength(formattedLandmarks); // Calculate stance width positions.stanceWidth = positionCalculations.calculateStanceWidth(formattedLandmarks); // Calculate other required position metrics const leftShoulderPos = positionCalculations.calculateLeftShoulderPosition(formattedLandmarks); const rightShoulderPos = positionCalculations.calculateRightShoulderPosition(formattedLandmarks); positions.shoulderPosition = _calculateCombinedMetric(leftShoulderPos, rightShoulderPos); const leftHipPos = positionCalculations.calculateLeftHipPosition(formattedLandmarks); const rightHipPos = positionCalculations.calculateRightHipPosition(formattedLandmarks); positions.hipPosition = _calculateCombinedMetric(leftHipPos, rightHipPos); const leftKneePos = positionCalculations.calculateLeftKneePosition(formattedLandmarks); const rightKneePos = positionCalculations.calculateRightKneePosition(formattedLandmarks); positions.kneePosition = _calculateCombinedMetric(leftKneePos, rightKneePos); const leftFootPos = positionCalculations.calculateLeftFootPosition(formattedLandmarks); const rightFootPos = positionCalculations.calculateRightFootPosition(formattedLandmarks); positions.footPosition = _calculateCombinedMetric(leftFootPos, rightFootPos); } catch (error) { console.warn(`Error calculating position metrics: ${error.message}`); } return positions; } }; /** * Wrapper for velocity calculations */ const wrappedVelocityCalculations = { calculateVelocities: function(currentFrame, previousFrame) { const currentLandmarks = currentFrame.keypoints; const previousLandmarks = previousFrame.keypoints; const timeDelta = (currentFrame.timestamp - previousFrame.timestamp) / 1000 || 0.033; // Default to 30fps const velocities = {}; // Calculate all velocity metrics for (const metricName in velocityCalculations) { if (typeof velocityCalculations[metricName] === 'function' && metricName.startsWith('calculate')) { try { // Convert landmarks to the format expected by the velocity calculations const formattedCurrentLandmarks = currentLandmarks.map(landmark => [ landmark.x, landmark.y, landmark.z, landmark.confidence || 1.0 ]); const formattedPreviousLandmarks = previousLandmarks.map(landmark => [ landmark.x, landmark.y, landmark.z, landmark.confidence || 1.0 ]); const value = velocityCalculations[metricName](formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta); const simpleName = metricName.replace('calculate', '').charAt(0).toLowerCase() + metricName.replace('calculate', '').slice(1); velocities[simpleName] = value; } catch (error) { console.warn(`Error calculating velocity metric ${metricName}: ${error.message}`); } } } return velocities; } }; /** * Wrapper for acceleration calculations */ const wrappedAccelerationCalculations = { calculateAccelerations: function(currentFrame, previousFrame, previousPreviousFrame) { const currentLandmarks = currentFrame.keypoints; const previousLandmarks = previousFrame.keypoints; const previousPreviousLandmarks = previousPreviousFrame.keypoints; const timeDelta = (currentFrame.timestamp - previousFrame.timestamp) / 1000 || 0.033; // Default to 30fps const accelerations = {}; // Calculate all acceleration metrics for (const metricName in accelerationCalculations) { if (typeof accelerationCalculations[metricName] === 'function' && metricName.startsWith('calculate')) { try { // Convert landmarks to the format expected by the acceleration calculations const formattedCurrentLandmarks = currentLandmarks.map(landmark => [ landmark.x, landmark.y, landmark.z, landmark.confidence || 1.0 ]); const formattedPreviousLandmarks = previousLandmarks.map(landmark => [ landmark.x, landmark.y, landmark.z, landmark.confidence || 1.0 ]); const formattedPreviousPreviousLandmarks = previousPreviousLandmarks.map(landmark => [ landmark.x, landmark.y, landmark.z, landmark.confidence || 1.0 ]); const value = accelerationCalculations[metricName]( formattedCurrentLandmarks, formattedPreviousLandmarks, formattedPreviousPreviousLandmarks, timeDelta ); const simpleName = metricName.replace('calculate', '').charAt(0).toLowerCase() + metricName.replace('calculate', '').slice(1); accelerations[simpleName] = value; } catch (error) { console.warn(`Error calculating acceleration metric ${metricName}: ${error.message}`); } } } return accelerations; } }; /** * Wrapper for balance calculations */ const wrappedBalanceCalculations = { calculateBalance: function(currentFrame, previousFrame = null) { const landmarks = currentFrame.keypoints; const balance = {}; try { // Format landmarks for balance calculations const formattedLandmarks = landmarks.map(landmark => [ landmark.x, landmark.y, landmark.z, landmark.confidence || 1.0 ]); // Format previous landmarks if available let formattedPreviousLandmarks = null; if (previousFrame && previousFrame.keypoints) { formattedPreviousLandmarks = previousFrame.keypoints.map(landmark => [ landmark.x, landmark.y, landmark.z, landmark.confidence || 1.0 ]); } // Calculate stability index (balance quality) balance.stabilityIndex = balanceCalculations.calculateStabilityIndex(formattedLandmarks); // Calculate dynamic stability (balance during movement) if (formattedPreviousLandmarks) { balance.dynamicStability = balanceCalculations.calculateDynamicStability( formattedLandmarks, formattedPreviousLandmarks ); } // Calculate lateral balance (side-to-side) balance.lateralBalance = balanceCalculations.calculateLateralBalance(formattedLandmarks); // Calculate anterior balance (front-to-back) balance.anteriorBalance = balanceCalculations.calculateAnteriorBalance(formattedLandmarks); // Calculate release balance (stability at ball release) balance.releaseBalance = balanceCalculations.calculateReleaseBalance(formattedLandmarks); // Calculate approach balance (stability during approach) balance.approachBalance = balanceCalculations.calculateApproachBalance(formattedLandmarks); // Calculate center of pressure metrics const leftCenterOfPressure = balanceCalculations.calculateLeftCenterOfPressure(formattedLandmarks); const rightCenterOfPressure = balanceCalculations.calculateRightCenterOfPressure(formattedLandmarks); balance.centerOfPressure = _calculateCombinedMetric(leftCenterOfPressure, rightCenterOfPressure); // Calculate postural sway metrics const leftPosturalSway = balanceCalculations.calculateLeftPosturalSway(formattedLandmarks); const rightPosturalSway = balanceCalculations.calculateRightPosturalSway(formattedLandmarks); balance.posturalSway = _calculateCombinedMetric(leftPosturalSway, rightPosturalSway); } catch (error) { console.warn(`Error calculating balance metrics: ${error.message}`); } return balance; } }; /** * Wrapper for power calculations */ const wrappedPowerCalculations = { calculatePower: function(currentFrame, previousFrame) { const currentLandmarks = currentFrame.keypoints; const previousLandmarks = previousFrame.keypoints; const timeDelta = (currentFrame.timestamp - previousFrame.timestamp) / 1000 || 0.033; // Default to 30fps const power = {}; try { // Format landmarks for power calculations const formattedCurrentLandmarks = currentLandmarks.map(landmark => [ landmark.x, landmark.y, landmark.z, landmark.confidence || 1.0 ]); const formattedPreviousLandmarks = previousLandmarks.map(landmark => [ landmark.x, landmark.y, landmark.z, landmark.confidence || 1.0 ]); // Calculate joint-specific power metrics const leftWristPower = powerCalculations.calculateLeftWristPower( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); const rightWristPower = powerCalculations.calculateRightWristPower( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); power.wristPower = _calculateCombinedMetric(leftWristPower, rightWristPower); const leftElbowPower = powerCalculations.calculateLeftElbowPower( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); const rightElbowPower = powerCalculations.calculateRightElbowPower( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); power.elbowPower = _calculateCombinedMetric(leftElbowPower, rightElbowPower); const leftShoulderPower = powerCalculations.calculateLeftShoulderPower( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); const rightShoulderPower = powerCalculations.calculateRightShoulderPower( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); power.shoulderPower = _calculateCombinedMetric(leftShoulderPower, rightShoulderPower); const leftHipPower = powerCalculations.calculateLeftHipPower( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); const rightHipPower = powerCalculations.calculateRightHipPower( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); power.hipPower = _calculateCombinedMetric(leftHipPower, rightHipPower); const leftKneePower = powerCalculations.calculateLeftKneePower( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); const rightKneePower = powerCalculations.calculateRightKneePower( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); power.kneePower = _calculateCombinedMetric(leftKneePower, rightKneePower); const leftAnklePower = powerCalculations.calculateLeftAnklePower( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); const rightAnklePower = powerCalculations.calculateRightAnklePower( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); power.anklePower = _calculateCombinedMetric(leftAnklePower, rightAnklePower); // Calculate whole body power metrics power.totalBodyPower = powerCalculations.calculateTotalBodyPower( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); power.powerGeneration = powerCalculations.calculatePowerGeneration( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); power.energyTransfer = powerCalculations.calculateEnergyTransfer( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); power.kineticEnergy = powerCalculations.calculateKineticEnergy( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); power.potentialEnergy = powerCalculations.calculatePotentialEnergy( formattedCurrentLandmarks ); power.totalEnergy = powerCalculations.calculateTotalEnergy( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); power.workDone = powerCalculations.calculateWorkDone( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); power.powerEfficiency = powerCalculations.calculatePowerEfficiency( formattedCurrentLandmarks, formattedPreviousLandmarks, timeDelta ); } catch (error) { console.warn(`Error calculating power metrics: ${error.message}`); } return power; } }; /** * Calculate biomechanical metrics from keypoint data * @param {Array} keypointData - Array of frames with pose landmarks * @param {Object} options - Processing options * @returns {Object} Metrics object including time series and summary metrics */ function calculateBiomechanicalMetrics(keypointData, options = {}) { const debug = options.debug || false; const includeTimeSeries = options.includeTimeSeries !== false; if (debug) { console.log(`Processing ${keypointData.length} frames`); } // Prepare result structure const result = { timeSeries: { angles: {}, velocity: {}, position: {}, acceleration: {}, balance: {}, power: {} }, summary: { angles: {}, velocity: {}, position: {}, acceleration: {}, balance: {}, power: {} } }; // Skip processing if no data if (!keypointData || keypointData.length === 0) { console.warn('No keypoint data provided'); return result; } // Initializing time series arrays for each metric const timeSeriesData = { angles: {}, velocity: {}, position: {}, acceleration: {}, balance: {}, power: {} }; // Process each frame let validFrames = 0; let invalidFrames = 0; // Initialize frame for the prev of the first frame let prevFrame = null; // Process each frame keypointData.forEach((frame, frameIndex) => { try { // Get landmarks from frame const landmarks = getLandmarksFromFrame(frame); // Skip if no landmarks if (!landmarks || landmarks.length === 0) { invalidFrames++; return; } // Process frame with each metric category try { // Calculate all metrics for this frame const frameMetrics = { angles: wrappedAngleCalculations.calculateAngles(frame), position: wrappedPositionCalculations.calculatePositions(frame) }; // Calculate velocity metrics if we have a previous frame if (prevFrame) { frameMetrics.velocity = wrappedVelocityCalculations.calculateVelocities(frame, prevFrame); frameMetrics.balance = wrappedBalanceCalculations.calculateBalance(frame, prevFrame); frameMetrics.power = wrappedPowerCalculations.calculatePower(frame, prevFrame); } // Add to time series data if (includeTimeSeries) { // Add each metric to its category time series for (const [category, metrics] of Object.entries(frameMetrics)) { for (const [metricName, value] of Object.entries(metrics)) { // Initialize array if not exists if (!timeSeriesData[category][metricName]) { timeSeriesData[category][metricName] = []; } // Add value to time series timeSeriesData[category][metricName].push(value); } } } // Mark this as a valid frame validFrames++; } catch (error) { console.error(`Error processing frame ${frameIndex}:`, error); invalidFrames++; } } catch (error) { console.error(`Error extracting landmarks from frame ${frameIndex}:`, error); invalidFrames++; } // Update previous frame prevFrame = frame; }); if (debug) { console.log(`Processed ${validFrames} valid frames, ${invalidFrames} invalid frames`); } // Calculate summary statistics for each time series for (const [category, metrics] of Object.entries(timeSeriesData)) { for (const [metricName, values] of Object.entries(metrics)) { // Include time series data in result if (includeTimeSeries) { // Ensure the category and metric exist in timeSeries if (!result.timeSeries[category]) { result.timeSeries[category] = {}; } result.timeSeries[category][metricName] = values; } // Calculate summary statistics if (!result.summary[category]) { result.summary[category] = {}; } // Filter out null values const nonNullValues = values.filter(value => value !== null && value !== undefined); if (nonNullValues.length > 0) { const stats = { min: Math.min(...nonNullValues), max: Math.max(...nonNullValues), mean: calculateAverage(nonNullValues), count: nonNullValues.length }; result.summary[category][metricName] = stats; } else { result.summary[category][metricName] = null; } } } // Add normalization for time series data before returning the metrics if (result.timeSeries) { // Bypass standardization but keep left/right formatting // result.timeSeries = _standardizeTimeSeriesFormat(result.timeSeries); } return result; } /** * Validate the metrics data to identify potential quality issues * @param {Object} timeSeriesData - Time series data to validate * @returns {Object} Validation results with issues detected */ function validateMetrics(timeSeriesData) { const validation = { missingMetrics: [], zeroValueMetrics: [], lowVarianceMetrics: [] }; Object.keys(timeSeriesData).forEach(category => { if (category === 'metadata' || category === 'validation') return; Object.keys(timeSeriesData[category]).forEach(metricName => { const values = timeSeriesData[category][metricName]; // Handle non-array values (like single numbers or objects) if (!Array.isArray(values)) { // If it's a number and it's 0, add to zeroValueMetrics if (typeof values === 'number' && values === 0) { validation.zeroValueMetrics.push(`${category}.${metricName}`); } // If it's null, undefined, or NaN, add to missingMetrics else if (values === null || values === undefined || (typeof values === 'number' && isNaN(values))) { validation.missingMetrics.push(`${category}.${metricName}`); } return; } // Skip if values is an empty array if (values.length === 0) { validation.missingMetrics.push(`${category}.${metricName}`); return; } // Check for metrics with all nulls const nonNullValues = values.filter(value => value !== null); if (nonNullValues.length === 0) { validation.missingMetrics.push(`${category}.${metricName}`); return; } // Check for combined left/right metrics (arrays of objects with left/right properties) const firstNonNull = nonNullValues[0]; if (typeof firstNonNull === 'object' && (firstNonNull.hasOwnProperty('left') || firstNonNull.hasOwnProperty('right'))) { // Check left side values const nonNullLeftValues = nonNullValues .map(value => value.left) .filter(value => value !== null && value !== undefined && !isNaN(value)); if (nonNullLeftValues.length === 0) { validation.missingMetrics.push(`${category}.${metricName}.left`); } else { const nonZeroLeftValues = nonNullLeftValues.filter(value => value !== 0); if (nonZeroLeftValues.length === 0) { validation.zeroValueMetrics.push(`${category}.${metricName}.left`); } else { const uniqueLeftValues = new Set(nonZeroLeftValues); if (uniqueLeftValues.size === 1) { validation.lowVarianceMetrics.push(`${category}.${metricName}.left`); } } } // Check right side values const nonNullRightValues = nonNullValues .map(value => value.right) .filter(value => value !== null && value !== undefined && !isNaN(value)); if (nonNullRightValues.length === 0) { validation.missingMetrics.push(`${category}.${metricName}.right`); } else { const nonZeroRightValues = nonNullRightValues.filter(value => value !== 0); if (nonZeroRightValues.length === 0) { validation.zeroValueMetrics.push(`${category}.${metricName}.right`); } else { const uniqueRightValues = new Set(nonZeroRightValues); if (uniqueRightValues.size === 1) { validation.lowVarianceMetrics.push(`${category}.${metricName}.right`); } } } return; } // For regular numeric arrays if (typeof firstNonNull === 'number') { const nonZeroValues = nonNullValues.filter(value => value !== 0); if (nonZeroValues.length === 0) { validation.zeroValueMetrics.push(`${category}.${metricName}`); return; } // Check for low variance (all values are the same) const uniqueValues = new Set(nonZeroValues); if (uniqueValues.size === 1) { validation.lowVarianceMetrics.push(`${category}.${metricName}`); } } }); }); return validation; } /** * Calculate average of an array of values * @param {Array} values - Array of numeric values * @returns {number} Average value */ function calculateAverage(values) { if (values.length === 0) return null; return values.reduce((sum, val) => sum + val, 0) / values.length; } /** * Calculate variance of an array of values * @param {Array} values - Array of numeric values * @param {number} mean - Mean of the values (optional) * @returns {number} Variance */ function calculateVariance(values, mean = null) { if (values.length === 0) return null; const avg = mean !== null ? mean : calculateAverage(values); return values.reduce((sum, val) => sum + Math.pow(val - avg, 2), 0) / values.length; } /** * Calculate summary metrics from time series data * @param {Object} timeSeriesData - Time series data for each metric category * @returns {Object} Summary metrics */ function calculateSummaryMetrics(timeSeriesData) { const summaryMetrics = { angles: {}, velocity: {}, position: {}, acceleration: {}, balance: {}, power: {} }; // Process each category for (const category in timeSeriesData) { // Skip metadata if (category === 'metadata') continue; // Process each metric in the category for (const metricName in timeSeriesData[category]) { const metricData = timeSeriesData[category][metricName]; // Skip if no data if (!metricData || metricData.length === 0) { summaryMetrics[category][metricName] = 0; continue; } // For complex position metrics like footPosition that return objects, use the last valid value if (category === 'position' && typeof metricData[metricData.length - 1] === 'object' && metricData[metricData.length - 1] !== null) { // Find the last valid value let lastValidValue = null; for (let i = metricData.length - 1; i >= 0; i--) { if (metricData[i] !== null && metricData[i] !== undefined) { lastValidValue = metricData[i]; break; } } summaryMetrics[category][metricName] = lastValidValue || { x: 0, y: 0, z: 0 }; continue; } // For scalar position metrics like torsoLength and stanceWidth, calculate full statistics if (category === 'position' && (metricName === 'torsoLength' || metricName === 'stanceWidth')) { // Filter out null, undefined, and NaN values const validValues = metricData.filter(value => value !== null && value !== undefined && !isNaN(value) ); if (validValues.length === 0) { summaryMetrics[category][metricName] = { value: 0, unit: 'raw', confidence: 0 }; continue; } // Calculate comprehensive statistics const sum = validValues.reduce((acc, val) => acc + val, 0); const mean = sum / validValues.length; const min = Math.min(...validValues); const max = Math.max(...validValues); // Calculate standard deviation const squaredDiffs = validValues.map(val => Math.pow(val - mean, 2)); const avgSquaredDiff = squaredDiffs.reduce((acc, val) => acc + val, 0) / validValues.length; const stdDev = Math.sqrt(avgSquaredDiff); // Calculate data quality/confidence const dataQuality = validValues.length / metricData.length; const confidence = Math.min(0.95, 0.5 + (dataQuality * 0.5)); // Store as object with statistics summaryMetrics[category][metricName] = { value: mean, min: min, max: max, stdDev: stdDev, unit: 'raw', confidence: confidence, // Adjust confidence based on data quality quality: { validPoints: validValues.length, totalPoints: metricData.length, validPercentage: (dataQuality * 100).toFixed(1) + '%' } }; continue; } // For other metrics, calculate average, min, max const validValues = metricData.filter(value => value !== null && value !== undefined && !isNaN(value) ); if (validValues.length === 0) { summaryMetrics[category][metricName] = 0; continue; } // Calculate statistics const sum = validValues.reduce((acc, val) => acc + val, 0); const average = sum / validValues.length; const min = Math.min(...validValues); const max = Math.max(...validValues); // Store as object with statistics summaryMetrics[category][metricName] = { average, min, max, count: validValues.length }; } } return summaryMetrics; } /** * Validate time series metrics * @param {Object} timeSeriesData - Time series data for each metric category * @returns {Object} Validation results */ function validateTimeSeriesMetrics(timeSeriesData) { const missingMetrics = []; const zeroValueMetrics = []; const lowVarianceMetrics = []; // Process each category for (const category in timeSeriesData) { // Skip metadata if (category === 'metadata') continue; // Process each metric in the category for (const metricName in timeSeriesData[category]) { const metricData = timeSeriesData[category][metricName]; // Check if metric is missing if (!metricData || metricData.length === 0) { missingMetrics.push(`${category}.${metricName}`); continue; } // Skip position metrics for zero value check if (category === 'position') continue; // Check if all values are zero const validValues = metricData.filter(value => value !== null && value !== undefined && !isNaN(value) ); if (validValues.length === 0) { missingMetrics.push(`${category}.${metricName}`); continue; } // Check if all values are zero const allZero = validValues.every(value => value === 0); if (allZero) { zeroValueMetrics.push(`${category}.${metricName}`); continue; } // Check for left/right combined metrics if (typeof validValues[0] === 'object' && (validValues[0].hasOwnProperty('left') || validValues[0].hasOwnProperty('right'))) { // Check if all left values are zero const allLeftZero = validValues.every(value => value.left === 0 || value.left === null || value.left === undefined || isNaN(value.left) ); // Check if all right values are zero const allRightZero = validValues.every(value => value.right === 0 || value.right === null || value.right === undefined || isNaN(value.right) ); if (allLeftZero) { zeroValueMetrics.push(`${category}.${metricName}.left`); } if (allRightZero) { zeroValueMetrics.push(`${category}.${metricName}.right`); } continue; } // Check for low variance if (validValues.length > 5) { const sum = validValues.reduce((acc, val) => acc + val, 0); const mean = sum / validValues.length; // Calculate variance const squaredDiffs = validValues.map(value => Math.pow(value - mean, 2)); const variance = squaredDiffs.reduce((acc, val) => acc + val, 0) / validValues.length; // Check if variance is very low if (variance < 0.0001 && mean !== 0) { lowVarianceMetrics.push(`${category}.${metricName}`); } } } } return { missingMetrics, zeroValueMetrics, lowVarianceMetrics }; } /** * Calculate phase one metrics * @param {Array} frames - Array of frames with pose landmarks * @param {Object} options - Processing options * @returns {Object} Phase one metrics result */ function calculatePhaseOneMetrics(frames, options = {}) { // Call our existing biomechanical metrics function return calculateBiomechanicalMetrics(frames, { minConfidence: options.confidenceThreshold || 0.5, includeTimeSeries: options.includeTimeSeries !== false, ...options }); } module.exports = { calculateBiomechanicalMetrics, calculatePhaseOneMetrics, calculateSummaryMetrics, validateMetrics, validateTimeSeriesMetrics, calculateVariance };