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
JavaScript
/**
* @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
};