UNPKG

bowling-analysis-system

Version:

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

568 lines (480 loc) 17.3 kB
/** * @module core/utils/TimeSeriesUtils * @description Centralized utilities for time series processing and analysis */ /** * Calculate the optimal time shift between two time series * @param {Array} series1 - First time series * @param {Array} series2 - Second time series * @param {Object} [options] - Configuration options * @param {number} [options.maxShiftPercentage=25] - Maximum shift as percentage of series length * @returns {number} Optimal time shift */ function calculateOptimalTimeShift(series1, series2, options = {}) { if (!series1 || !series2 || !Array.isArray(series1) || !Array.isArray(series2)) { return 0; } const { maxShiftPercentage = 25 } = options; // Find shift with minimum difference const maxShift = Math.min(series1.length, series2.length) * (maxShiftPercentage / 100); let bestShift = 0; let minDiff = Number.MAX_VALUE; for (let shift = -maxShift; shift <= maxShift; shift++) { let totalDiff = 0; let count = 0; for (let i = 0; i < series1.length; i++) { const j = i + shift; if (j >= 0 && j < series2.length && series1[i] !== null && series1[i] !== undefined && series2[j] !== null && series2[j] !== undefined) { totalDiff += Math.abs(series1[i] - series2[j]); count++; } } if (count > 0) { const avgDiff = totalDiff / count; if (avgDiff < minDiff) { minDiff = avgDiff; bestShift = shift; } } } return bestShift; } /** * Calculate amplitude scaling between two time series * @param {Array} series1 - First time series * @param {Array} series2 - Second time series * @returns {number} Amplitude scaling factor */ function calculateAmplitudeScaling(series1, series2) { if (!series1 || !series2 || !Array.isArray(series1) || !Array.isArray(series2)) { return 1.0; } // Filter out null/undefined values const filtered1 = series1.filter(v => v !== null && v !== undefined); const filtered2 = series2.filter(v => v !== null && v !== undefined); if (filtered1.length === 0 || filtered2.length === 0) { return 1.0; } // Calculate the ratio of ranges const range1 = Math.max(...filtered1) - Math.min(...filtered1); const range2 = Math.max(...filtered2) - Math.min(...filtered2); // Avoid division by zero if (range1 === 0) { return 1.0; } return range2 / range1; } /** * Apply a time shift to a time series * @param {Array} series - The time series to adjust * @param {number} shift - The time shift to apply * @param {Object} [options] - Configuration options * @param {boolean} [options.fillNulls=true] - Whether to fill nulls with interpolated values * @returns {Array} The shifted time series */ function applyTimeShift(series, shift, options = {}) { if (!series || !Array.isArray(series) || shift === 0) { return series; } const { fillNulls = true } = options; const result = new Array(series.length).fill(null); // Apply the shift for (let i = 0; i < series.length; i++) { const newIndex = i + shift; if (newIndex >= 0 && newIndex < series.length) { result[newIndex] = series[i]; } } // Fill in any nulls with interpolated values if requested if (fillNulls) { for (let i = 0; i < result.length; i++) { if (result[i] === null) { // Find closest non-null values on both sides let left = i - 1; let right = i + 1; while (left >= 0 && result[left] === null) { left--; } while (right < result.length && result[right] === null) { right++; } // Interpolate if we found values on both sides if (left >= 0 && right < result.length) { const leftValue = result[left]; const rightValue = result[right]; const ratio = (i - left) / (right - left); result[i] = leftValue + (rightValue - leftValue) * ratio; } // Use the value we found if only one side has a value else if (left >= 0) { result[i] = result[left]; } else if (right < result.length) { result[i] = result[right]; } } } } return result; } /** * Apply amplitude scaling to a time series * @param {Array} series - The time series to adjust * @param {number} scale - The scaling factor to apply * @returns {Array} The scaled time series */ function applyAmplitudeScaling(series, scale) { if (!series || !Array.isArray(series) || scale === 1.0) { return series; } // Apply scaling return series.map(value => { if (value === null || value === undefined) { return value; } return value * scale; }); } /** * Find peaks in a time series * @param {Array} series - The time series to analyze * @param {Object} [options] - Configuration options * @param {number} [options.windowSize=3] - Window size for peak detection * @param {number} [options.minProminence=0.1] - Minimum prominence for peak detection * @returns {Array} The detected peaks [{ index, value, prominence }] */ function findPeaks(series, options = {}) { if (!series || !Array.isArray(series) || series.length < 3) { return []; } const { windowSize = 3, minProminence = 0.1 } = options; // Filter out null/undefined values const cleanData = series.map((value, index) => ({ value: value === null || value === undefined ? NaN : value, index })).filter(item => !isNaN(item.value)); if (cleanData.length < 3) { return []; } const peaks = []; // Calculate data range for prominence threshold const values = cleanData.map(item => item.value); const dataMin = Math.min(...values); const dataMax = Math.max(...values); const dataRange = dataMax - dataMin; // Minimum prominence threshold const prominenceThreshold = dataRange * minProminence; for (let i = windowSize; i < cleanData.length - windowSize; i++) { const currentValue = cleanData[i].value; let isPeak = true; // Check if current point is higher than all points in window for (let j = i - windowSize; j <= i + windowSize; j++) { if (j !== i && cleanData[j].value >= currentValue) { isPeak = false; break; } } if (isPeak) { // Calculate prominence (height above highest saddle) let leftMin = currentValue; let rightMin = currentValue; // Find minimum to the left for (let j = i - 1; j >= 0; j--) { if (cleanData[j].value > cleanData[j+1].value) { // Found a rising edge, stop here leftMin = cleanData[j+1].value; break; } if (j === 0) { leftMin = cleanData[0].value; } } // Find minimum to the right for (let j = i + 1; j < cleanData.length; j++) { if (cleanData[j].value > cleanData[j-1].value) { // Found a rising edge, stop here rightMin = cleanData[j-1].value; break; } if (j === cleanData.length - 1) { rightMin = cleanData[cleanData.length - 1].value; } } // Prominence is height above highest saddle const saddleHeight = Math.max(leftMin, rightMin); const prominence = currentValue - saddleHeight; // Only add peaks with sufficient prominence if (prominence >= prominenceThreshold) { peaks.push({ index: cleanData[i].index, value: currentValue, prominence }); } } } return peaks; } /** * Find valleys in a time series * @param {Array} series - The time series to analyze * @param {Object} [options] - Configuration options * @param {number} [options.windowSize=3] - Window size for valley detection * @param {number} [options.minProminence=0.1] - Minimum prominence for valley detection * @returns {Array} The detected valleys [{ index, value, prominence }] */ function findValleys(series, options = {}) { if (!series || !Array.isArray(series) || series.length < 3) { return []; } const { windowSize = 3, minProminence = 0.1 } = options; // Filter out null/undefined values const cleanData = series.map((value, index) => ({ value: value === null || value === undefined ? NaN : value, index })).filter(item => !isNaN(item.value)); if (cleanData.length < 3) { return []; } const valleys = []; // Calculate data range for prominence threshold const values = cleanData.map(item => item.value); const dataMin = Math.min(...values); const dataMax = Math.max(...values); const dataRange = dataMax - dataMin; // Minimum prominence threshold const prominenceThreshold = dataRange * minProminence; for (let i = windowSize; i < cleanData.length - windowSize; i++) { const currentValue = cleanData[i].value; let isValley = true; // Check if current point is lower than all points in window for (let j = i - windowSize; j <= i + windowSize; j++) { if (j !== i && cleanData[j].value <= currentValue) { isValley = false; break; } } if (isValley) { // Calculate prominence (depth below lowest saddle) let leftMax = currentValue; let rightMax = currentValue; // Find maximum to the left for (let j = i - 1; j >= 0; j--) { if (cleanData[j].value < cleanData[j+1].value) { // Found a falling edge, stop here leftMax = cleanData[j+1].value; break; } if (j === 0) { leftMax = cleanData[0].value; } } // Find maximum to the right for (let j = i + 1; j < cleanData.length; j++) { if (cleanData[j].value < cleanData[j-1].value) { // Found a falling edge, stop here rightMax = cleanData[j-1].value; break; } if (j === cleanData.length - 1) { rightMax = cleanData[cleanData.length - 1].value; } } // Prominence is depth below lowest saddle const saddleHeight = Math.min(leftMax, rightMax); const prominence = saddleHeight - currentValue; // Only add valleys with sufficient prominence if (prominence >= prominenceThreshold) { valleys.push({ index: cleanData[i].index, value: currentValue, prominence }); } } } return valleys; } /** * Calculate time series adjustments between metrics and reference data * @param {Object} metrics - The calculated metrics * @param {Object} reference - The reference data * @returns {Object} Time series adjustments */ function calculateTimeSeriesAdjustments(metrics, reference) { const adjustments = {}; // Skip if metrics or reference is missing if (!metrics || !reference) { return adjustments; } // Process categories in metrics for (const category in metrics) { // Skip non-object or missing reference if (typeof metrics[category] !== 'object' || !reference[category]) { continue; } adjustments[category] = {}; // Process metrics in each category for (const metricName in metrics[category]) { if (!metrics[category][metricName] || !reference[category][metricName]) { continue; } const metricData = metrics[category][metricName]; const referenceData = reference[category][metricName]; // Skip if series is missing if (!metricData.series || !referenceData.series) { continue; } // Handle combined metrics with left/right properties if (metricData.left && metricData.right && metricData.left.series && metricData.right.series && referenceData.left && referenceData.right && referenceData.left.series && referenceData.right.series) { // Calculate adjustments for left side const leftTimeShift = calculateOptimalTimeShift( metricData.left.series, referenceData.left.series ); const leftScale = calculateAmplitudeScaling( metricData.left.series, referenceData.left.series ); // Calculate adjustments for right side const rightTimeShift = calculateOptimalTimeShift( metricData.right.series, referenceData.right.series ); const rightScale = calculateAmplitudeScaling( metricData.right.series, referenceData.right.series ); // Store adjustments adjustments[category][metricName] = { left: { timeShift: leftTimeShift, amplitudeScale: leftScale }, right: { timeShift: rightTimeShift, amplitudeScale: rightScale } }; } // Handle regular metrics else if (Array.isArray(metricData.series) && Array.isArray(referenceData.series)) { // Calculate time shift and scaling const timeShift = calculateOptimalTimeShift( metricData.series, referenceData.series ); const amplitudeScale = calculateAmplitudeScaling( metricData.series, referenceData.series ); // Store adjustments adjustments[category][metricName] = { timeShift, amplitudeScale }; } } } return adjustments; } /** * Apply time series adjustments to metrics * @param {Object} metrics - The metrics to adjust * @param {Object} adjustments - The time series adjustments to apply * @returns {Object} The adjusted metrics */ function applyTimeSeriesAdjustments(metrics, adjustments) { if (!metrics || !adjustments) { return metrics; } // Create a deep copy to avoid modifying the original const adjustedMetrics = JSON.parse(JSON.stringify(metrics)); // Process categories in adjustments for (const category in adjustments) { // Skip if category doesn't exist in metrics if (!adjustedMetrics[category]) { continue; } // Process metrics in each category for (const metricName in adjustments[category]) { if (!adjustedMetrics[category][metricName]) { continue; } const metricData = adjustedMetrics[category][metricName]; const adjustment = adjustments[category][metricName]; // Handle combined metrics with left/right properties if (metricData.left && metricData.right && metricData.left.series && metricData.right.series && adjustment.left && adjustment.right) { // Store original series metricData.originalSeries = { left: [...metricData.left.series], right: [...metricData.right.series] }; // Apply left side adjustments if (adjustment.left.timeShift) { metricData.left.series = applyTimeShift( metricData.left.series, adjustment.left.timeShift ); } if (adjustment.left.amplitudeScale) { metricData.left.series = applyAmplitudeScaling( metricData.left.series, adjustment.left.amplitudeScale ); } // Apply right side adjustments if (adjustment.right.timeShift) { metricData.right.series = applyTimeShift( metricData.right.series, adjustment.right.timeShift ); } if (adjustment.right.amplitudeScale) { metricData.right.series = applyAmplitudeScaling( metricData.right.series, adjustment.right.amplitudeScale ); } } // Handle regular metrics else if (Array.isArray(metricData.series)) { // Store original series metricData.originalSeries = [...metricData.series]; // Apply time shift if (adjustment.timeShift) { metricData.series = applyTimeShift( metricData.series, adjustment.timeShift ); } // Apply amplitude scaling if (adjustment.amplitudeScale) { metricData.series = applyAmplitudeScaling( metricData.series, adjustment.amplitudeScale ); } } } } return adjustedMetrics; } // Export all functions module.exports = { calculateOptimalTimeShift, calculateAmplitudeScaling, applyTimeShift, applyAmplitudeScaling, findPeaks, findValleys, calculateTimeSeriesAdjustments, applyTimeSeriesAdjustments };