bowling-analysis-system
Version:
A comprehensive system for analyzing bowling techniques using video processing and metrics calculation
568 lines (480 loc) • 17.3 kB
JavaScript
/**
* @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
};