bowling-analysis-system
Version:
A comprehensive system for analyzing bowling techniques using video processing and metrics calculation
452 lines (391 loc) • 13.6 kB
JavaScript
/**
* @fileoverview Time series utilities for bowling metrics processing.
* Provides functions for normalization, extraction, visualization, and statistics
* for bowling metrics time series data.
*/
/**
* Standardizes time series data with left/right pairs into a consistent format
* @param {Object} timeSeries - The time series data containing left/right metrics
* @param {string} path - Dot-notation path to the specific metric to format
* @returns {Object} - Standardized time series with left/right properties
*/
export function standardizeTimeSeriesFormat(timeSeries, path) {
if (!timeSeries) {
throw new Error('Time series data is required');
}
// Get the specific time series data using the path
const data = extractByPath(timeSeries, path);
if (!data) {
throw new Error(`No data found at path: ${path}`);
}
// Check if already in left/right format
if (Array.isArray(data) && data.length > 0 &&
data[0].hasOwnProperty('left') &&
data[0].hasOwnProperty('right')) {
return data;
}
// Handle separate left/right arrays
const leftPath = path.replace(/\.[^.]+$/, '.left$&');
const rightPath = path.replace(/\.[^.]+$/, '.right$&');
const leftData = extractByPath(timeSeries, leftPath);
const rightData = extractByPath(timeSeries, rightPath);
if (!leftData || !rightData) {
throw new Error(`Could not find both left and right data for ${path}`);
}
if (leftData.length !== rightData.length) {
throw new Error('Left and right time series arrays must have the same length');
}
// Combine into standard format
return leftData.map((leftValue, index) => {
const rightValue = rightData[index];
return { left: leftValue, right: rightValue };
});
}
/**
* Extracts data from a time series object using dot notation
* @param {Object} data - The data object to extract from
* @param {string} path - Dot notation path (e.g., 'angles.armAngles')
* @returns {*} - The extracted data
*/
export function extractByPath(data, path) {
if (!data) return undefined;
if (!path) return data;
const keys = path.split('.');
let result = data;
for (const key of keys) {
if (result === undefined || result === null) return undefined;
result = result[key];
}
return result;
}
/**
* Extracts time series data between two events
* @param {Object} metricsData - The complete metrics data object
* @param {string} path - The path to the time series within the timeSeries object
* @param {string} startEvent - The name of the starting event
* @param {string} endEvent - The name of the ending event
* @returns {Array} The extracted time series segment
*/
export function extractBetweenEvents(metricsData, path, startEvent, endEvent) {
if (!metricsData.events[startEvent]) {
throw new Error(`Start event "${startEvent}" does not exist`);
}
if (!metricsData.events[endEvent]) {
throw new Error(`End event "${endEvent}" does not exist`);
}
// Handle path correctly - add 'timeSeries.' prefix if not already included
const fullPath = path.startsWith('timeSeries.') ? path : `timeSeries.${path}`;
const timeSeries = extractByPath(metricsData, fullPath);
if (!timeSeries) {
throw new Error(`Time series at path "${fullPath}" does not exist`);
}
const startIdx = metricsData.events[startEvent].frameIndex;
const endIdx = metricsData.events[endEvent].frameIndex;
if (startIdx > endIdx) {
throw new Error(`Start event (frame ${startIdx}) occurs after end event (frame ${endIdx})`);
}
return timeSeries.slice(startIdx, endIdx + 1);
}
/**
* Calculates statistics for a time series segment
* @param {Array} timeSeriesData - Array of time series data
* @param {boolean} hasLeftRight - Whether the data has left/right format
* @returns {Object} - Statistics for the time series
*/
export function calculateStatistics(timeSeriesData, hasLeftRight = false) {
if (!Array.isArray(timeSeriesData) || timeSeriesData.length === 0) {
throw new Error('Invalid time series data');
}
// Handle left/right format
if (hasLeftRight || (timeSeriesData[0] &&
typeof timeSeriesData[0] === 'object' &&
'left' in timeSeriesData[0] &&
'right' in timeSeriesData[0])) {
// Extract separate arrays for left, right and compute stats
const leftValues = timeSeriesData.map(item => item.left);
const rightValues = timeSeriesData.map(item => item.right);
return {
left: computeStats(leftValues),
right: computeStats(rightValues)
};
}
// Handle simple array
return computeStats(timeSeriesData);
}
/**
* Computes basic statistics for an array of numbers
* @param {Array} values - Array of numeric values
* @returns {Object} - Statistics object with min, max, mean, median, stdDev
*/
function computeStats(values) {
const sortedValues = [...values].sort((a, b) => a - b);
const min = sortedValues[0];
const max = sortedValues[sortedValues.length - 1];
const sum = sortedValues.reduce((acc, val) => acc + val, 0);
const mean = sum / sortedValues.length;
const midIndex = Math.floor(sortedValues.length / 2);
const median = sortedValues.length % 2 === 0
? (sortedValues[midIndex - 1] + sortedValues[midIndex]) / 2
: sortedValues[midIndex];
const squaredDiffs = sortedValues.map(value => Math.pow(value - mean, 2));
const variance = squaredDiffs.reduce((acc, val) => acc + val, 0) / sortedValues.length;
const stdDev = Math.sqrt(variance);
return {
min,
max,
mean,
median,
stdDev,
range: max - min
};
}
/**
* Creates a configuration object for visualizing time series data
* @param {Array} timeSeriesData - The time series data to visualize
* @param {Object} options - Visualization options
* @param {Object} events - Optional events to mark on the visualization
* @returns {Object} - Chart configuration object
*/
export function createVisualizationConfig(timeSeriesData, options = {}, events = {}) {
const {
title = 'Time Series Visualization',
xLabel = 'Frame',
yLabel = 'Value',
hasLeftRight = false,
colorLeft = 'rgba(54, 162, 235, 0.7)',
colorRight = 'rgba(255, 99, 132, 0.7)'
} = options;
// Generate labels (x-axis values)
const labels = Array.from({ length: timeSeriesData.length }, (_, i) => i);
let datasets = [];
// Handle left/right format
if (hasLeftRight || (timeSeriesData[0] &&
typeof timeSeriesData[0] === 'object' &&
'left' in timeSeriesData[0] &&
'right' in timeSeriesData[0])) {
datasets = [
{
label: 'Left',
data: timeSeriesData.map(item => item.left),
borderColor: colorLeft,
backgroundColor: colorLeft.replace('0.7', '0.1'),
borderWidth: 2,
tension: 0.4
},
{
label: 'Right',
data: timeSeriesData.map(item => item.right),
borderColor: colorRight,
backgroundColor: colorRight.replace('0.7', '0.1'),
borderWidth: 2,
tension: 0.4
}
];
} else {
// Simple array format
datasets = [
{
label: 'Value',
data: timeSeriesData,
borderColor: colorLeft,
backgroundColor: colorLeft.replace('0.7', '0.1'),
borderWidth: 2,
tension: 0.4
}
];
}
// Convert events to annotation format
const annotations = {};
Object.entries(events).forEach(([eventName, eventData], index) => {
if (eventData && eventData.frameIndex !== undefined) {
annotations[eventName] = {
type: 'line',
xMin: eventData.frameIndex,
xMax: eventData.frameIndex,
borderColor: `hsl(${index * 45 % 360}, 80%, 60%)`,
borderWidth: 2,
label: {
content: eventName,
enabled: true,
position: 'top'
}
};
}
});
return {
type: 'line',
data: {
labels,
datasets
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: title
},
annotation: {
annotations
}
},
scales: {
x: {
title: {
display: true,
text: xLabel
}
},
y: {
title: {
display: true,
text: yLabel
}
}
}
}
};
}
/**
* Converter for preparing time series data for chart display
* @param {Array} data - Time series data
* @param {Object} options - Conversion options
* @returns {Object} - Data formatted for chart display
*/
export function timeSeriesConverter(data, options = {}) {
const {
labelPrefix = '',
colorLeft = 'rgba(54, 162, 235, 0.7)',
colorRight = 'rgba(255, 99, 132, 0.7)'
} = options;
// If data is already in the right format, return it
if (data.labels && data.datasets) {
return data;
}
// Handle array of left/right objects
if (Array.isArray(data) && data.length > 0 &&
typeof data[0] === 'object' &&
'left' in data[0] &&
'right' in data[0]) {
return {
labels: Array.from({ length: data.length }, (_, i) => `${labelPrefix}${i}`),
datasets: [
{
label: 'Left',
data: data.map(item => item.left),
borderColor: colorLeft,
backgroundColor: colorLeft.replace('0.7', '0.1'),
borderWidth: 2
},
{
label: 'Right',
data: data.map(item => item.right),
borderColor: colorRight,
backgroundColor: colorRight.replace('0.7', '0.1'),
borderWidth: 2
}
]
};
}
// Handle simple array of values
if (Array.isArray(data) && typeof data[0] !== 'object') {
return {
labels: Array.from({ length: data.length }, (_, i) => `${labelPrefix}${i}`),
datasets: [
{
label: 'Value',
data: data,
borderColor: colorLeft,
backgroundColor: colorLeft.replace('0.7', '0.1'),
borderWidth: 2
}
]
};
}
throw new Error('Unsupported data format for conversion');
}
/**
* Validates the structure of time series data for a specified path
* @param {Object} data - The complete metrics data
* @param {string} path - Path to the time series to validate
* @returns {Object} - Validation result with isValid and message
*/
export function validateTimeSeriesStructure(data, path) {
const timeSeries = extractByPath(data, path);
if (!timeSeries) {
return { isValid: false, message: `Time series not found at path: ${path}` };
}
if (!Array.isArray(timeSeries)) {
return { isValid: false, message: `Data at path ${path} is not an array` };
}
if (timeSeries.length === 0) {
return { isValid: false, message: `Time series at path ${path} is empty` };
}
// Check if data is in left/right/average format
if (typeof timeSeries[0] === 'object' &&
'left' in timeSeries[0] &&
'right' in timeSeries[0]) {
// Check if all elements have the same structure
const isConsistent = timeSeries.every(item =>
typeof item === 'object' &&
'left' in item &&
'right' in item &&
(typeof item.left === 'number' || item.left === null || typeof item.left === 'object') &&
(typeof item.right === 'number' || item.right === null || typeof item.right === 'object')
);
if (!isConsistent) {
return {
isValid: false,
message: `Inconsistent data structure in time series at path ${path}`
};
}
return { isValid: true, message: 'Valid left/right structure' };
}
// For simple arrays, check if all elements are numbers
if (typeof timeSeries[0] !== 'object') {
const allNumbers = timeSeries.every(item => typeof item === 'number');
if (!allNumbers) {
return {
isValid: false,
message: `Time series at path ${path} contains non-numeric values`
};
}
return { isValid: true, message: 'Valid numeric array' };
}
return { isValid: false, message: `Unknown data structure at path ${path}` };
}
/**
* Identifies all available time series metrics in the data
* @param {Object} data - The metrics data structure
* @param {string} basePath - Starting path for search
* @returns {Array} - List of all time series paths available
*/
export function discoverTimeSeriesMetrics(data, basePath = 'timeSeries') {
const metrics = [];
function traverse(obj, path) {
if (!obj || typeof obj !== 'object') return;
if (Array.isArray(obj) && obj.length > 0) {
metrics.push(path);
return;
}
for (const key in obj) {
traverse(obj[key], path ? `${path}.${key}` : key);
}
}
const startObj = extractByPath(data, basePath);
traverse(startObj, basePath);
return metrics;
}
/**
* Module exports
*/
export default {
standardizeTimeSeriesFormat,
extractByPath,
extractBetweenEvents,
calculateStatistics,
createVisualizationConfig,
timeSeriesConverter,
validateTimeSeriesStructure,
discoverTimeSeriesMetrics
};