UNPKG

bowling-analysis-system

Version:

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

657 lines (546 loc) 22.2 kB
/** * @module core/utils/CorrelationUtils * @description Centralized utilities for correlation calculations and analysis */ const { findPeaks, findValleys } = require('./TimeSeriesUtils'); /** * Calculate Pearson correlation coefficient between two signals * @param {Array} signal1 - First signal array * @param {Array} signal2 - Second signal array * @returns {number} Correlation coefficient (-1 to 1) or 0 if calculation fails */ function calculateCorrelationCoefficient(signal1, signal2) { // Filter out null values const filteredPairs = []; for (let i = 0; i < Math.min(signal1.length, signal2.length); i++) { if (signal1[i] !== null && signal1[i] !== undefined && signal2[i] !== null && signal2[i] !== undefined) { filteredPairs.push([signal1[i], signal2[i]]); } } if (filteredPairs.length < 3) return 0; // Need at least a few points // Calculate means const mean1 = filteredPairs.reduce((sum, pair) => sum + pair[0], 0) / filteredPairs.length; const mean2 = filteredPairs.reduce((sum, pair) => sum + pair[1], 0) / filteredPairs.length; // Calculate covariance and variances let covariance = 0; let variance1 = 0; let variance2 = 0; for (const [val1, val2] of filteredPairs) { const diff1 = val1 - mean1; const diff2 = val2 - mean2; covariance += diff1 * diff2; variance1 += diff1 * diff1; variance2 += diff2 * diff2; } // Avoid division by zero if (variance1 === 0 || variance2 === 0) return 0; // Calculate Pearson correlation const correlation = covariance / (Math.sqrt(variance1) * Math.sqrt(variance2)); return correlation; } /** * Calculate correlation score between a time series and event frames * @param {Array} timeSeries - Time series data * @param {Array} eventFrames - Array of event frame indices * @returns {Object} Correlation information including score and details */ function calculateEventCorrelation(timeSeries, eventFrames) { // Skip if insufficient data if (!timeSeries || timeSeries.length === 0 || !eventFrames || eventFrames.length === 0) { return { score: 0, details: { reason: 'Insufficient data' } }; } // Create an array of zeros with spikes at event locations const eventSignal = Array(timeSeries.length).fill(0); eventFrames.forEach(frame => { if (frame >= 0 && frame < eventSignal.length) { eventSignal[frame] = 1; } }); // Calculate correlation between the time series and event signal const score = calculateCorrelationCoefficient(timeSeries, eventSignal); // Get additional details about the correlation const details = { peakCorrelation: false, valleyCorrelation: false, eventValues: eventFrames.map(frame => { if (frame >= 0 && frame < timeSeries.length) { return timeSeries[frame]; } return null; }).filter(v => v !== null) }; // Check if events tend to occur at peaks or valleys if (details.eventValues.length > 0) { const nonEventValues = timeSeries.filter((v, i) => !eventFrames.includes(i) && v !== null); if (nonEventValues.length > 0) { const avgEventValue = details.eventValues.reduce((a, b) => a + b, 0) / details.eventValues.length; const avgNonEventValue = nonEventValues.reduce((a, b) => a + b, 0) / nonEventValues.length; details.peakCorrelation = avgEventValue > avgNonEventValue; details.valleyCorrelation = avgEventValue < avgNonEventValue; } } return { score, details }; } /** * Calculate correlations between metrics and reference moments * @param {Object} metrics - Metrics data with timeSeries property * @param {Object} referenceData - Reference data with moments property * @returns {Object} Calculated correlations */ function calculateMetricEventCorrelations(metrics, referenceData) { // Initialize correlations object const correlations = {}; // Ensure we have valid data if (!metrics || !metrics.timeSeries || !referenceData || !referenceData.moments) { return { error: 'Missing required data for correlation calculation' }; } // Process each metric category Object.keys(metrics.timeSeries).forEach(category => { correlations[category] = {}; // Process each metric in the category Object.keys(metrics.timeSeries[category]).forEach(metricName => { correlations[category][metricName] = {}; // Get time series data for this metric const metricTimeSeries = metrics.timeSeries[category][metricName]; // Process each event type Object.keys(referenceData.moments).forEach(eventType => { // Get reference frames for this event const eventFrames = referenceData.moments[eventType]; if (Array.isArray(eventFrames) && eventFrames.length > 0) { // Calculate correlation between metric and event frames correlations[category][metricName][eventType] = calculateEventCorrelation(metricTimeSeries, eventFrames); } }); }); }); return correlations; } /** * Calculate inter-metric correlations at a specific frame * @param {Object} metrics - Metrics object with timeSeries property * @param {number} frameIndex - Frame index to calculate correlations for * @returns {Object} Inter-metric correlations */ function calculateInterMetricCorrelations(metrics, frameIndex) { if (!metrics || !metrics.timeSeries) { return { error: 'Missing metrics data' }; } // Validate frame index const maxFrames = getMaxFrameCount(metrics.timeSeries); if (frameIndex < 0 || frameIndex >= maxFrames) { return { error: 'Invalid frame index' }; } const relationships = { frame: frameIndex, metrics: [], correlations: {} }; // Extract all available metrics at this frame const allMetrics = []; for (const category in metrics.timeSeries) { for (const metricName in metrics.timeSeries[category]) { const timeSeries = metrics.timeSeries[category][metricName]; if (Array.isArray(timeSeries) && frameIndex < timeSeries.length && timeSeries[frameIndex] !== null && timeSeries[frameIndex] !== undefined) { const metric = { id: `${category}.${metricName}`, category, name: metricName, value: timeSeries[frameIndex] }; allMetrics.push(metric); relationships.metrics.push(metric); } } } // Calculate correlations between each pair of metrics for (let i = 0; i < allMetrics.length; i++) { const metric1 = allMetrics[i]; if (!relationships.correlations[metric1.id]) { relationships.correlations[metric1.id] = {}; } for (let j = i + 1; j < allMetrics.length; j++) { const metric2 = allMetrics[j]; // Simple ratio correlation const ratio = metric1.value !== 0 ? metric2.value / metric1.value : 0; const difference = metric2.value - metric1.value; relationships.correlations[metric1.id][metric2.id] = { ratio, difference }; // Add the reverse correlation too if (!relationships.correlations[metric2.id]) { relationships.correlations[metric2.id] = {}; } relationships.correlations[metric2.id][metric1.id] = { ratio: metric2.value !== 0 ? metric1.value / metric2.value : 0, difference: -difference }; } } return relationships; } /** * Get the maximum frame count from time series data * @param {Object} timeSeriesData - Time series data object * @returns {number} Maximum frame count */ function getMaxFrameCount(timeSeriesData) { let maxFrames = 0; for (const category in timeSeriesData) { for (const metricName in timeSeriesData[category]) { const timeSeries = timeSeriesData[category][metricName]; if (Array.isArray(timeSeries) && timeSeries.length > maxFrames) { maxFrames = timeSeries.length; } } } return maxFrames; } /** * Calculate timing correlations between metrics and reference data * @param {Object} metrics - Metrics object with timing property * @param {Object} reference - Reference data with timing property * @returns {Object} Timing correlation factors */ function calculateTimingCorrelations(metrics, reference) { if (!metrics || !metrics.timing || !reference || !reference.timing) { return {}; } const correlations = { overallScore: 0, factors: {} }; // Process each timing metric for (const timingMetric in metrics.timing) { if (reference.timing[timingMetric] && metrics.timing[timingMetric] && typeof metrics.timing[timingMetric].value === 'number' && typeof reference.timing[timingMetric].value === 'number') { const metricValue = metrics.timing[timingMetric].value; const referenceValue = reference.timing[timingMetric].value; // Calculate simple ratio and difference const ratio = referenceValue !== 0 ? metricValue / referenceValue : 0; const difference = metricValue - referenceValue; const percentDiff = referenceValue !== 0 ? (difference / referenceValue) * 100 : 0; correlations.factors[timingMetric] = { metric: metricValue, reference: referenceValue, ratio, difference, percentDiff }; } } // Calculate overall score based on average match if (Object.keys(correlations.factors).length > 0) { let totalMatch = 0; let count = 0; for (const metric in correlations.factors) { // Calculate match score (1 = perfect match, 0 = complete mismatch) const percentDiff = Math.abs(correlations.factors[metric].percentDiff); const matchScore = Math.max(0, 1 - (percentDiff / 100)); correlations.factors[metric].matchScore = matchScore; totalMatch += matchScore; count++; } correlations.overallScore = count > 0 ? totalMatch / count : 0; } return correlations; } /** * Predict a frame using correlation factors * @param {Object} metrics - Phase one metrics data * @param {Object} correlations - Correlation factors for the event * @param {string} eventName - Name of the event * @param {Function} [debugLogger] - Optional debug logging function * @returns {number|null} Predicted frame index or null if prediction fails */ function predictFrameUsingCorrelations(metrics, correlations, eventName, debugLogger) { const debug = debugLogger || (() => {}); debug(`Predicting ${eventName} using correlation factors`); // Safety check for correlations if (!correlations || Object.keys(correlations).length === 0) { debug('No correlation data available for this event'); return null; } // Get time series data const timeSeriesData = metrics.timeSeries || {}; if (!timeSeriesData || Object.keys(timeSeriesData).length === 0) { debug('No time series data available for correlation-based prediction'); return null; } // Find frame count let frameCount = 0; for (const category in timeSeriesData) { for (const metricName in timeSeriesData[category]) { const data = timeSeriesData[category][metricName]; if (Array.isArray(data) && data.length > 0) { frameCount = data.length; break; } } if (frameCount > 0) break; } if (frameCount === 0) { debug('Cannot determine frame count for correlation-based prediction'); return null; } // Calculate scores for each frame const scores = new Array(frameCount).fill(0); let totalPredictiveScore = 0; let metricsUsed = 0; // For each metric with correlations for (const category in correlations) { // Skip if category doesn't exist in time series data if (!timeSeriesData[category]) { continue; } for (const metricName in correlations[category]) { // Skip if metric doesn't exist in correlations or time series if (!correlations[category][metricName] || !correlations[category][metricName][eventName] || !timeSeriesData[category][metricName]) { continue; } const correlation = correlations[category][metricName][eventName]; // Skip if this metric doesn't have a predictive score if (!correlation || !correlation.eventPredictiveScore || correlation.eventPredictiveScore <= 0) { continue; } const metricData = timeSeriesData[category][metricName]; // Skip if metric data is not an array or is empty if (!Array.isArray(metricData) || metricData.length === 0) { continue; } // Weight of this metric in prediction const weight = correlation.eventPredictiveScore; totalPredictiveScore += weight; metricsUsed++; // Check pattern type const pattern = correlation.window ? correlation.window.pattern : null; // For each frame, calculate contribution to score for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) { if (frameIndex >= metricData.length || metricData[frameIndex] === null) continue; let frameScore = 0; // Check for pattern match if (pattern && frameIndex > 1 && frameIndex < frameCount - 2 && frameIndex < metricData.length - 2) { // Check for peak pattern if (pattern === 'peak') { if (frameIndex - 1 < metricData.length && frameIndex + 1 < metricData.length && metricData[frameIndex - 1] !== null && metricData[frameIndex + 1] !== null) { const isPeak = metricData[frameIndex - 1] < metricData[frameIndex] && metricData[frameIndex] > metricData[frameIndex + 1]; if (isPeak) { frameScore += 1.0; } } } // Check for valley pattern else if (pattern === 'valley') { if (frameIndex - 1 < metricData.length && frameIndex + 1 < metricData.length && metricData[frameIndex - 1] !== null && metricData[frameIndex + 1] !== null) { const isValley = metricData[frameIndex - 1] > metricData[frameIndex] && metricData[frameIndex] < metricData[frameIndex + 1]; if (isValley) { frameScore += 1.0; } } } // Check for inflection pattern else if (pattern === 'inflection') { if (frameIndex - 1 < metricData.length && frameIndex + 1 < metricData.length && metricData[frameIndex - 1] !== null && metricData[frameIndex + 1] !== null) { const derivative1 = metricData[frameIndex] - metricData[frameIndex - 1]; const derivative2 = metricData[frameIndex + 1] - metricData[frameIndex]; // Sign change in derivative indicates inflection if ((derivative1 * derivative2) <= 0) { frameScore += 0.8; } } } } // Check for value match if (correlation.valueAtMainFrame !== null) { // Calculate similarity to the expected value at frame const valueDifference = Math.abs(metricData[frameIndex] - correlation.valueAtMainFrame); // Make sure value range is valid if (correlation.valueRange && correlation.valueRange.max !== null && correlation.valueRange.min !== null) { const expectedRange = correlation.valueRange.max - correlation.valueRange.min; if (expectedRange > 0) { // Higher score for values closer to the expected value const similarityScore = 1.0 - Math.min(1.0, valueDifference / expectedRange); frameScore += similarityScore * 0.5; } } } // Check for derivative match if (correlation.derivativeAtFrame !== null && frameIndex > 0 && frameIndex < frameCount - 1 && frameIndex - 1 < metricData.length && frameIndex + 1 < metricData.length && metricData[frameIndex - 1] !== null && metricData[frameIndex + 1] !== null) { const derivative = (metricData[frameIndex + 1] - metricData[frameIndex - 1]) / 2; const derivativeDifference = Math.abs(derivative - correlation.derivativeAtFrame); // Higher score for derivatives closer to the expected derivative const derivativeSimilarity = Math.exp(-derivativeDifference * 5); frameScore += derivativeSimilarity * 0.5; } // Add weighted score to the frame scores[frameIndex] += frameScore * weight; } } } // If no metrics were used, return null if (metricsUsed === 0) { debug(`No usable metrics found for ${eventName}, falling back to heuristic-based detection`); return null; } // Apply a sliding window to favor clusters if (totalPredictiveScore > 0) { // Use raw scores without normalization // Apply sliding window const windowSize = 2; const windowedScores = new Array(scores.length).fill(0); for (let i = 0; i < scores.length; i++) { let windowSum = scores[i]; let windowCount = 1; // Look at frames before for (let j = 1; j <= windowSize; j++) { if (i - j >= 0) { windowSum += scores[i - j] * (1 - j * 0.3); windowCount++; } } // Look at frames after for (let j = 1; j <= windowSize; j++) { if (i + j < scores.length) { windowSum += scores[i + j] * (1 - j * 0.3); windowCount++; } } windowedScores[i] = windowSum / windowCount; } // Find the frame with highest score let maxScore = -1; let maxIndex = null; for (let i = 0; i < windowedScores.length; i++) { if (windowedScores[i] > maxScore) { maxScore = windowedScores[i]; maxIndex = i; } } if (maxIndex !== null && maxScore > 0.2) { debug(`Correlation-based prediction for ${eventName}: frame ${maxIndex} (score: ${maxScore.toFixed(3)})`); return maxIndex; } else if (maxIndex !== null) { debug(`Weak correlation-based prediction for ${eventName}: frame ${maxIndex} (score: ${maxScore.toFixed(3)})`); debug(`Score too low, falling back to heuristic-based detection`); } } else { debug(`No predictive metrics found for ${eventName}, falling back to heuristic-based detection`); } return null; } /** * Calculate correlations between metrics and adjust them * @param {Object} metrics - Metrics data * @param {Object} reference - Reference data * @returns {Object} Correlation adjustments that can be applied */ function calculateCorrelationAdjustments(metrics, reference) { if (!metrics || !reference) { return {}; } const adjustments = { scaling: {}, offsets: {}, relationships: {} }; // Calculate scaling factors between metrics and reference for (const category in metrics) { if (typeof metrics[category] !== 'object' || !reference[category]) { continue; } adjustments.scaling[category] = {}; adjustments.offsets[category] = {}; for (const metricName in metrics[category]) { if (reference[category][metricName] && metrics[category][metricName] && typeof metrics[category][metricName].value === 'number' && typeof reference[category][metricName].value === 'number') { const metricValue = metrics[category][metricName].value; const referenceValue = reference[category][metricName].value; // Calculate scaling factor (avoid division by zero) if (metricValue !== 0) { adjustments.scaling[category][metricName] = referenceValue / metricValue; } else { adjustments.scaling[category][metricName] = 1; } // Calculate offset adjustments.offsets[category][metricName] = referenceValue - metricValue; } } } // Calculate inter-metric relationships if (metrics.timeSeries) { adjustments.relationships = calculateInterMetricRelationships(metrics.timeSeries); } return adjustments; } /** * Calculate relationships between different metrics in time series data * @param {Object} timeSeriesData - Time series data * @returns {Object} Metric relationships */ function calculateInterMetricRelationships(timeSeriesData) { const relationships = {}; // Get list of all metrics const allMetrics = []; for (const category in timeSeriesData) { for (const metricName in timeSeriesData[category]) { if (Array.isArray(timeSeriesData[category][metricName])) { allMetrics.push({ id: `${category}.${metricName}`, category, name: metricName, data: timeSeriesData[category][metricName] }); } } } // Calculate correlations between all pairs of metrics for (let i = 0; i < allMetrics.length; i++) { const metric1 = allMetrics[i]; relationships[metric1.id] = {}; for (let j = i + 1; j < allMetrics.length; j++) { const metric2 = allMetrics[j]; // Calculate correlation between time series const correlation = calculateCorrelationCoefficient(metric1.data, metric2.data); if (!isNaN(correlation)) { relationships[metric1.id][metric2.id] = { correlation }; // Add reverse relationship too if (!relationships[metric2.id]) { relationships[metric2.id] = {}; } relationships[metric2.id][metric1.id] = { correlation }; } } } return relationships; } module.exports = { calculateCorrelationCoefficient, calculateEventCorrelation, calculateMetricEventCorrelations, calculateInterMetricCorrelations, getMaxFrameCount, calculateTimingCorrelations, predictFrameUsingCorrelations, calculateCorrelationAdjustments, calculateInterMetricRelationships };