UNPKG

bowling-analysis-system

Version:

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

631 lines (530 loc) 24.7 kB
/** * @module bowling_analysis/metrics/BiasCorrelationDetector * @description Event detection using bias correlation patterns */ const { deriveVelocityFromPosition, getMetricByPath } = require('./EventDetectionUtils'); const FootLandingDetector = require('./FootLandingDetector'); const fs = require('fs'); const path = require('path'); /** * Class for detecting events using bias correlation patterns */ class BiasCorrelationDetector { /** * Creates a new BiasCorrelationDetector * @param {Object} options - Configuration options */ constructor(options = {}) { this.options = { debug: false, ...options }; this.footLandingDetector = new FootLandingDetector({ debug: this.options.debug }); } /** * Debug logging * @private * @param {string} message - Message to log */ _debug(message, data) { if (this.options.debug) { if (data !== undefined) { console.log(`[BiasCorrelationDetector] ${message}`, JSON.stringify(data).substring(0, 200)); } else { console.log(`[BiasCorrelationDetector] ${message}`); } } } /** * Computes correlation for a pattern at a specific frame * @param {Array} metricData - Time series data for the metric * @param {number} frameIndex - Frame to check * @param {string} patternType - Type of pattern to look for (peak, valley, rising, falling) * @param {number} windowSize - Window size for pattern detection * @returns {number} Raw correlation score * @private */ _computePatternCorrelation(metricData, frameIndex, patternType = 'peak', windowSize = 11) { if (!metricData || !Array.isArray(metricData) || metricData.length === 0) { return 0; } // Ensure frameIndex is within bounds if (frameIndex < 0 || frameIndex >= metricData.length) { return 0; } // Get half window size (rounded down) const halfWindow = Math.floor(windowSize / 2); // Calculate start and end indices for the window const startIndex = Math.max(0, frameIndex - halfWindow); const endIndex = Math.min(metricData.length - 1, frameIndex + halfWindow); // Extract the window of data const windowData = metricData.slice(startIndex, endIndex + 1); // Skip if window is too small if (windowData.length < 3) { return 0; } // Filter null values const validData = windowData.filter(value => value !== null && value !== undefined); if (validData.length < 3) { return 0; } // Different correlation calculations based on pattern type switch (patternType.toLowerCase()) { case 'peak': // Check if the frame is a local maximum const isPeak = frameIndex > 0 && frameIndex < metricData.length - 1 && metricData[frameIndex] > metricData[frameIndex - 1] && metricData[frameIndex] > metricData[frameIndex + 1]; if (!isPeak) return 0; // Calculate peak prominence (raw value) const peakHeight = metricData[frameIndex]; const windowAvg = validData.reduce((sum, val) => sum + val, 0) / validData.length; // Return raw difference from average return peakHeight - windowAvg; case 'valley': // Check if the frame is a local minimum const isValley = frameIndex > 0 && frameIndex < metricData.length - 1 && metricData[frameIndex] < metricData[frameIndex - 1] && metricData[frameIndex] < metricData[frameIndex + 1]; if (!isValley) return 0; // Calculate valley prominence (raw value) const valleyDepth = metricData[frameIndex]; const valleyWindowAvg = validData.reduce((sum, val) => sum + val, 0) / validData.length; // Return raw difference from average return valleyWindowAvg - valleyDepth; case 'rising': // Check if there's a consistent rise around the frame if (frameIndex < 2 || frameIndex >= metricData.length - 2) return 0; const isRising = metricData[frameIndex - 2] < metricData[frameIndex - 1] && metricData[frameIndex - 1] < metricData[frameIndex] && metricData[frameIndex] < metricData[frameIndex + 1]; // Return raw value difference if rising return isRising ? (metricData[frameIndex + 1] - metricData[frameIndex - 2]) : 0; case 'falling': // Check if there's a consistent fall around the frame if (frameIndex < 2 || frameIndex >= metricData.length - 2) return 0; const isFalling = metricData[frameIndex - 2] > metricData[frameIndex - 1] && metricData[frameIndex - 1] > metricData[frameIndex] && metricData[frameIndex] > metricData[frameIndex + 1]; // Return raw value difference if falling return isFalling ? (metricData[frameIndex - 2] - metricData[frameIndex + 1]) : 0; default: // If pattern type not recognized, return zero return 0; } } /** * Gets the metric data from the metrics object based on path * @param {Object} metrics - Metrics data * @param {string} metricPath - Dot-notation path to the metric * @returns {Array|null} The metric data or null if not found * @private */ _getMetricData(metrics, metricPath) { if (!metrics || !metricPath) { return null; } const parts = metricPath.split('.'); let current = metrics; for (const part of parts) { if (current === null || current === undefined || typeof current !== 'object') { return null; } current = current[part]; } return Array.isArray(current) ? current : null; } /** * Detects events using bias correlation data * @param {Object} metrics - Metrics data * @param {Object} biasData - Bias correlation data * @returns {Object} Map of detected events with confidence scores */ async detectEventsUsingBias(metrics, biasData) { this._debug('Starting event detection using bias correlation'); // Check if we have necessary data if (!metrics || !biasData) { this._debug('Missing metrics or bias data for detection'); return {}; } // Output for detected events const detectedEvents = {}; // Check for moments data first (most reliable source) this._debug('Checking for moments data'); if (metrics.moments) { this._debug('Found moments data in metrics'); // Ball release if (metrics.moments.ball_release && Array.isArray(metrics.moments.ball_release) && metrics.moments.ball_release.length > 0) { detectedEvents.releasePoint = { frameIndex: metrics.moments.ball_release[0], confidence: 1.0, method: "moments_direct" }; this._debug(`Detected releasePoint at frame ${metrics.moments.ball_release[0]}`); } // Left foot plant (front foot) if (metrics.moments.left_foot_plant && Array.isArray(metrics.moments.left_foot_plant) && metrics.moments.left_foot_plant.length > 0) { const sortedFrames = [...metrics.moments.left_foot_plant].sort((a, b) => a - b); detectedEvents.frontFootLanding = { frameIndex: sortedFrames[0], confidence: 1.0, method: "moments_direct", side: "left" }; this._debug(`Detected frontFootLanding at frame ${sortedFrames[0]}`); } // Right foot plant (back foot) if (metrics.moments.right_foot_plant && Array.isArray(metrics.moments.right_foot_plant) && metrics.moments.right_foot_plant.length > 0) { const sortedFrames = [...metrics.moments.right_foot_plant].sort((a, b) => a - b); detectedEvents.backFootLanding = { frameIndex: sortedFrames[0], confidence: 1.0, method: "moments_direct", side: "right" }; this._debug(`Detected backFootLanding at frame ${sortedFrames[0]}`); } } // If we don't have moments, check timeSeries.moments as fallback else if (metrics.timeSeries && metrics.timeSeries.moments) { const momentsData = metrics.timeSeries.moments; this._debug('Found moments data in timeSeries'); // Apply the same logic as for direct moments data // Ball release if (momentsData.ball_release && Array.isArray(momentsData.ball_release) && momentsData.ball_release.length > 0) { detectedEvents.releasePoint = { frameIndex: momentsData.ball_release[0], confidence: 1.0, method: "moments_direct" }; this._debug(`Detected releasePoint at frame ${momentsData.ball_release[0]}`); } // Left foot plant (front foot) if (momentsData.left_foot_plant && Array.isArray(momentsData.left_foot_plant) && momentsData.left_foot_plant.length > 0) { const sortedFrames = [...momentsData.left_foot_plant].sort((a, b) => a - b); detectedEvents.frontFootLanding = { frameIndex: sortedFrames[0], confidence: 1.0, method: "moments_direct", side: "left" }; this._debug(`Detected frontFootLanding at frame ${sortedFrames[0]}`); } // Right foot plant (back foot) if (momentsData.right_foot_plant && Array.isArray(momentsData.right_foot_plant) && momentsData.right_foot_plant.length > 0) { const sortedFrames = [...momentsData.right_foot_plant].sort((a, b) => a - b); detectedEvents.backFootLanding = { frameIndex: sortedFrames[0], confidence: 1.0, method: "moments_direct", side: "right" }; this._debug(`Detected backFootLanding at frame ${sortedFrames[0]}`); } } // If we have events from moments data, no need to continue with bias correlation if (Object.keys(detectedEvents).length > 0) { this._debug('Found events from moments data, skipping bias correlation detection'); return detectedEvents; } // No moments data or couldn't find events from moments, try bias correlation this._debug('No events found from moments data, trying bias correlation detection'); // Skip bias correlation if we don't have the correlation patterns if (!biasData.correlationPatterns) { this._debug('No correlation patterns in bias data, cannot proceed with bias detection'); return detectedEvents; } // Get time series length const timeSeriesLength = metrics.metadata?.frameCount || metrics.metadata?.totalFrames || metrics.timeSeries?.position?.leftAnkleHeight?.length || metrics.timeSeries?.angles?.elbowFlexion?.length || 132; this._debug(`Using time series length: ${timeSeriesLength}`); // If we don't have velocity data, derive it let metricsWithVelocity = metrics; if (!metrics.timeSeries?.velocity) { this._debug('Adding velocity data to metrics'); metricsWithVelocity = deriveVelocityFromPosition(metrics); } // Try to detect release point first, as it's the most important event this._debug('Detecting release point using bias correlation'); // Map of event names in bias correlation patterns to standard event names const eventMappings = { 'ball_release': 'releasePoint', 'release_point': 'releasePoint', 'front_foot_landing': 'frontFootLanding', 'left_foot_plant': 'frontFootLanding', 'back_foot_landing': 'backFootLanding', 'right_foot_plant': 'backFootLanding' }; // Try each potential event type from bias data for (const biasEventType in biasData.correlationPatterns) { const standardEventName = eventMappings[biasEventType] || biasEventType; this._debug(`Trying to detect ${standardEventName} from bias event type ${biasEventType}`); // Skip if we already have this event from moments data if (detectedEvents[standardEventName]) { this._debug(`Already have ${standardEventName} from moments, skipping bias detection`); continue; } const result = this.detectEventUsingCorrelationPatterns( biasEventType, metricsWithVelocity, biasData, timeSeriesLength ); if (result.frame !== null) { detectedEvents[standardEventName] = { frameIndex: result.frame, confidence: result.confidence, method: "bias_correlation" }; // Add side information for foot landings if (standardEventName === 'frontFootLanding') { detectedEvents[standardEventName].side = 'left'; } else if (standardEventName === 'backFootLanding') { detectedEvents[standardEventName].side = 'right'; } this._debug(`Detected ${standardEventName} at frame ${result.frame} using bias correlation`); } else { this._debug(`Failed to detect ${standardEventName} using bias correlation`); } } // If we've detected a release point but no foot landings, try to derive them if (detectedEvents.releasePoint && (!detectedEvents.frontFootLanding || !detectedEvents.backFootLanding)) { this._debug('Detected release point but missing foot landings, trying to derive them'); const footLandings = this.footLandingDetector.findFootLandingsFromRelease( metricsWithVelocity, detectedEvents.releasePoint.frameIndex ); // Add any missing foot landings if (footLandings.frontFootLanding && !detectedEvents.frontFootLanding) { detectedEvents.frontFootLanding = footLandings.frontFootLanding; this._debug(`Derived frontFootLanding at frame ${footLandings.frontFootLanding.frameIndex}`); } if (footLandings.backFootLanding && !detectedEvents.backFootLanding) { detectedEvents.backFootLanding = footLandings.backFootLanding; this._debug(`Derived backFootLanding at frame ${footLandings.backFootLanding.frameIndex}`); } } // Log and return final events this._debug('Final detected events:', detectedEvents); return detectedEvents; } /** * Maps event type names to standard event names * @param {string} eventType - Raw event type name from bias data * @returns {string} Standardized event name * @private */ _mapEventTypeToStandard(eventType) { const mapping = { 'ball_release': 'releasePoint', 'release_point': 'releasePoint', 'releasePoint': 'releasePoint', 'left_foot_plant': 'frontFootLanding', 'right_foot_plant': 'backFootLanding', 'front_foot_landing': 'frontFootLanding', 'back_foot_landing': 'backFootLanding', 'frontFootLanding': 'frontFootLanding', 'backFootLanding': 'backFootLanding' }; return mapping[eventType] || eventType; } /** * Detect a specific event using correlation patterns * @param {string} eventName - Name of event to detect * @param {Object} metrics - Metrics data * @param {Object} biasData - Bias correlation data * @param {number} timeSeriesLength - Length of time series * @returns {Object} Object with frame and confidence */ detectEventUsingCorrelationPatterns(eventName, metrics, biasData, timeSeriesLength) { const result = { frame: null, confidence: 0 }; const correlationPatterns = biasData.correlationPatterns[eventName] || []; if (correlationPatterns.length === 0) { this._debug(`No correlation patterns found for ${eventName}`); return result; } this._debug(`Processing ${correlationPatterns.length} patterns for ${eventName}`); // Score each frame based on correlation patterns const frameScores = Array(timeSeriesLength).fill(0); const frameWeights = Array(timeSeriesLength).fill(0); // Check if we have moments data to use as a reference const hasMomentsData = metrics.moments && metrics.moments[eventName] && metrics.moments[eventName].length > 0; // Determine valid frame range based on pattern sequence let validFrameStart = 0; let validFrameEnd = timeSeriesLength - 1; // Apply sequence validation constraints if (this._validatePatternSequence([eventName])) { const validSequences = { 'backFootLanding': ['frontFootLanding', 'releasePoint'], 'frontFootLanding': ['releasePoint'], 'releasePoint': [] }; // Adjust frame range based on already detected events if (metrics.events) { const expectedNext = validSequences[eventName] || []; const previousEvents = Object.entries(metrics.events) .filter(([key]) => !expectedNext.includes(key)) .map(([_, event]) => event.frameIndex); if (previousEvents.length > 0) { validFrameStart = Math.max(validFrameStart, Math.max(...previousEvents)); } const nextEvents = Object.entries(metrics.events) .filter(([key]) => expectedNext.includes(key)) .map(([_, event]) => event.frameIndex); if (nextEvents.length > 0) { validFrameEnd = Math.min(validFrameEnd, Math.min(...nextEvents)); } } } let searchStart = validFrameStart; let searchEnd = validFrameEnd; // For releasePoint, restrict based on both moments data and biomechanical patterns if (eventName === 'releasePoint' || eventName === 'ball_release') { if (!hasMomentsData) { // Only restrict if we don't have moments data // For release point, search in the latter half of valid frames const halfwayPoint = Math.floor(validFrameStart + (validFrameEnd - validFrameStart) / 2); searchStart = halfwayPoint; } this._debug(`Restricting release point search to frames ${searchStart} - ${searchEnd}`); } // Process each correlation pattern for (const pattern of correlationPatterns) { // Extract the metric path and pattern information const { metric, correlation, weight, patternType } = pattern; const patternShift = pattern.patternShift || 0; const patternScale = pattern.patternScale || 1; // Apply pattern shift to search range if specified if (patternShift !== 0) { this._debug(`Applying pattern shift of ${patternShift} frames for ${metric}`); } // Get the time series data for this metric const metricData = getMetricByPath(metrics, metric); if (!metricData || !Array.isArray(metricData) || metricData.length === 0) { continue; } // Find min, max, average values for this metric const validValues = metricData.filter(v => v !== null && v !== undefined && !isNaN(v)); if (validValues.length === 0) { continue; } const mean = validValues.reduce((sum, val) => sum + val, 0) / validValues.length; const min = Math.min(...validValues); const max = Math.max(...validValues); // Score each frame based on pattern matching rather than absolute values let validFramesFound = 0; // Only process frames within the search range for (let i = searchStart; i <= searchEnd && i < metricData.length; i++) { // Apply pattern shift const shiftedIndex = i + patternShift; if (shiftedIndex < 0 || shiftedIndex >= metricData.length) { continue; } const value = metricData[shiftedIndex]; // Skip invalid values if (value === null || value === undefined || isNaN(value)) { continue; } validFramesFound++; // Default similarity score let similarityScore = 0; // Adjust score based on pattern type if specified if (patternType === 'peak') { // For peak pattern, use raw value compared to mean similarityScore = value - mean; } else if (patternType === 'valley') { // For valley pattern, use inverted raw value compared to mean similarityScore = mean - value; } else if (patternType === 'inflection') { // For inflection point, look for local extrema if (shiftedIndex > 0 && shiftedIndex < metricData.length - 1) { const prevValue = metricData[shiftedIndex - 1]; const nextValue = metricData[shiftedIndex + 1]; if (prevValue !== null && nextValue !== null) { // Calculate raw difference with neighbors const prevDiff = Math.abs(value - prevValue); const nextDiff = Math.abs(value - nextValue); similarityScore = prevDiff + nextDiff; } } } else { // Default behavior - use raw correlation value similarityScore = Math.abs(correlation); // For metrics with very specific patterns at release, use raw values if ((metric.includes('elbow') || metric.includes('shoulder')) && (eventName === 'releasePoint' || eventName === 'ball_release')) { // Use raw value directly without normalization similarityScore = Math.abs(correlation) * Math.abs(value); } } // Add to the total score for this frame frameScores[i] += similarityScore * Math.abs(weight); frameWeights[i] += Math.abs(weight); } } // Find frames with scores let framesWithScores = 0; let topFrames = []; for (let i = 0; i < frameScores.length; i++) { // Skip frames with no score or zero weight if (!frameScores[i] || !frameWeights[i]) continue; // Use raw score with weight as context const rawScore = frameScores[i]; const weight = frameWeights[i]; // Keep track of top frames based on scores topFrames.push({ frame: i, score: rawScore, weight }); } // Sort by score (high to low) topFrames.sort((a, b) => b.score - a.score); // Take top candidates for logging const topCandidates = topFrames.slice(0, 5); this._debug(`Found ${framesWithScores} frames with scores for ${eventName}`); if (topCandidates.length > 0) { this._debug('Top candidate frames:'); topCandidates.forEach(candidate => { this._debug(` Frame ${candidate.frame}: score ${candidate.score.toFixed(3)}`); }); // Best frame is the one with highest score result.frame = topFrames[0].frame; result.confidence = Math.min(1.0, topFrames[0].score); // If we have moments data for validation, check if our detection is close if (hasMomentsData) { const momentFrames = metrics.moments[eventName]; const closestMomentFrame = momentFrames.reduce((closest, current) => { return Math.abs(current - result.frame) < Math.abs(closest - result.frame) ? current : closest; }, momentFrames[0]); this._debug(`Closest moment frame for ${eventName}: ${closestMomentFrame}`); if (Math.abs(closestMomentFrame - result.frame) > 10) { this._debug(`Warning: Detected frame differs significantly from moment frame`); // Consider using the moment frame if detection is very different if (result.confidence < 0.8) { this._debug(`Using moment frame due to low confidence detection`); result.frame = closestMomentFrame; result.confidence = 0.9; // High confidence from moments data } } } } return result; } } module.exports = BiasCorrelationDetector;