UNPKG

bowling-analysis-system

Version:

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

452 lines (391 loc) 13.6 kB
/** * @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 };