UNPKG

bowling-analysis-system

Version:

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

560 lines (472 loc) 21 kB
/** * @module bowling_analysis/metrics/PhaseTwoProcessor * @description Processor for Phase Two metrics (events and bias) */ const { EventEmitter } = require('events'); const { defaultLogger } = require('../../utils/logger'); const BiasCalculator = require('../processors/BiasCalculator'); const path = require('path'); const fs = require('fs'); /** * @class PhaseTwoProcessor * @description Processes Phase Two metrics (events and bias) * @extends EventEmitter */ class PhaseTwoProcessor extends EventEmitter { /** * Create a new PhaseTwoProcessor * @param {Object} options - Configuration options * @param {Logger} [options.logger] - Custom logger * @param {boolean} [options.debug=false] - Enable debug logging * @param {string} [options.biasPath] - Path to bias data file */ constructor(options = {}) { super(); this.options = { debug: false, biasPath: null, generateBiasIfMissing: true, validateEvents: true, includeTags: true, includeTimeSeries: true, ...options }; // Initialize logger this.logger = options.logger || defaultLogger.child('PhaseTwoProcessor'); // Set debug level if enabled if (this.options.debug) { this.logger.setLevel('debug'); } // Path to bias file this.biasPath = this.options.biasPath; // Initialize the bias calculator this.biasCalculator = new BiasCalculator({ debug: this.options.debug, biasFilePath: this.biasPath || path.join(process.cwd(), 'bias.json'), logger: this.logger.child('BiasCalculator') }); this.logger.info('Initialized'); } /** * Process Phase Two metrics * @param {Object} phaseOneData - Phase One data * @param {Object} options - Processing options * @returns {Promise<Object>} Phase Two metrics */ async process(phaseOneData, options = {}) { try { const mergedOptions = { ...this.options, ...options }; this.logger.debug('Starting Phase Two processing'); this.emit('start', mergedOptions); // Initialize result with empty event data const result = { events: {}, eventMetrics: {}, timeSeries: {}, metadata: { processedAt: new Date().toISOString() } }; // Verify minimum required data if (!phaseOneData || !phaseOneData.metrics || !phaseOneData.timeSeries) { throw new Error('Invalid Phase One data for Phase Two processing'); } // Process the bias and event detection try { // Fix bias file path handling - don't use mergedOptions directly // Instead use a properly resolved path const biasFilePath = mergedOptions.biasPath || this.biasPath || path.join(process.cwd(), 'bias.json'); // Execute the bias calculator with Phase One metrics const biasResult = await this.biasCalculator.execute({ metrics: { metrics: phaseOneData.metrics, timeSeries: phaseOneData.timeSeries } }, { saveBias: mergedOptions.generateBiasIfMissing, biasFilePath: biasFilePath }); // Store bias data in result if (biasResult.bias) { result.bias = biasResult.bias; this.logger.debug('Applied bias data'); } // Store detected events if (biasResult.events && Object.keys(biasResult.events).length > 0) { result.events = biasResult.events; // Count events const eventCount = Object.keys(biasResult.events).length; this.logger.debug(`Detected ${eventCount} events`); // If we have simulated moments, include them in the result if (biasResult.simulatedMoments) { result.simulatedMoments = biasResult.simulatedMoments; this.logger.debug(`Generated simulated moments for ${Object.keys(biasResult.simulatedMoments).length} event types`); } // If we have events, calculate event metrics if (eventCount > 0) { const eventMetrics = await this._calculateEventMetrics(phaseOneData, biasResult.events, mergedOptions); if (eventMetrics) { result.eventMetrics = eventMetrics; this.logger.debug(`Calculated ${Object.keys(eventMetrics).length} event metrics`); } // Generate time series data based on events const timeSeriesData = this._generateTimeSeriesData(phaseOneData, biasResult.events, mergedOptions); if (timeSeriesData) { result.timeSeries = timeSeriesData; this.logger.debug(`Generated ${Object.keys(timeSeriesData).length} event-based time series`); } // Add event sequence validation metadata result.metadata.eventSequenceValid = this._validateEventSequence(biasResult.events).valid; } } else { this.logger.warn('No events detected in Phase Two'); } } catch (error) { this.logger.error(`Error in bias calculation and event detection: ${error.message}`); // Continue processing but mark as error result.metadata.biasError = error.message; result.metadata.eventsDetected = false; } this.logger.debug('Phase Two processing completed'); this.emit('complete', result); return result; } catch (error) { this.logger.error(`Phase Two processing failed: ${error.message}`); this.emit('error', error); throw error; } } /** * Calculate metrics based on detected events * @param {Object} phaseOneData - Phase One data * @param {Object} events - Detected events * @param {Object} options - Processing options * @returns {Promise<Object>} Event metrics * @private */ async _calculateEventMetrics(phaseOneData, events, options) { try { this.logger.debug('Calculating event metrics'); const { metrics, timeSeries } = phaseOneData; const eventMetrics = {}; // Find frames for critical events, considering both old and new naming schemes const releaseFrame = events.releasePoint?.frame || events.ball_release?.frame; const frontFootFrame = events.frontFootLanding?.frame || events.left_foot_plant?.frame; const backFootFrame = events.backFootLanding?.frame || events.right_foot_plant?.frame; // If we don't have the main critical events, return minimal metrics if (!releaseFrame && !frontFootFrame && !backFootFrame) { this.logger.warn('No critical events found, skipping event metrics calculation'); return { isValid: false }; } // Calculate timing metrics based on events if (options.includeTags) { eventMetrics.timing = this._calculateTimingMetrics(events); this.logger.debug(`Calculated ${Object.keys(eventMetrics.timing || {}).length} timing metrics`); } // Calculate release metrics if we have release frame if (releaseFrame !== undefined) { const releaseMetrics = this._calculateReleaseMetrics(releaseFrame, metrics, timeSeries); Object.assign(eventMetrics, releaseMetrics); this.logger.debug(`Calculated metrics for release frame ${releaseFrame}`); } // Calculate approach metrics if we have foot plant frames if (frontFootFrame !== undefined && backFootFrame !== undefined) { const approachMetrics = this._calculateApproachMetrics(frontFootFrame, backFootFrame, metrics, timeSeries); Object.assign(eventMetrics, approachMetrics); this.logger.debug(`Calculated metrics between foot plants (${backFootFrame}-${frontFootFrame})`); } return eventMetrics; } catch (error) { this.logger.error(`Error calculating event metrics: ${error.message}`); return { isValid: false, error: error.message }; } } /** * Calculate timing metrics based on events * @param {Object} events - Detected events * @returns {Object} Timing metrics * @private */ _calculateTimingMetrics(events) { // Find frames for critical events, considering both old and new naming schemes const releaseFrame = events.releasePoint?.frame || events.ball_release?.frame; const frontFootFrame = events.frontFootLanding?.frame || events.left_foot_plant?.frame; const backFootFrame = events.backFootLanding?.frame || events.right_foot_plant?.frame; // Default timing metrics const timingMetrics = { approachTime: 0, releaseTime: 0, approachDuration: 0 }; // Calculate approach time if we have both foot landing frames if (frontFootFrame !== undefined && backFootFrame !== undefined) { // Time between back foot landing and front foot landing timingMetrics.approachTime = Math.abs(frontFootFrame - backFootFrame) / 30; // Assuming 30 fps timingMetrics.approachDuration = timingMetrics.approachTime; } // Calculate release time if we have release and front foot frames if (releaseFrame !== undefined && frontFootFrame !== undefined) { timingMetrics.releaseTime = Math.abs(releaseFrame - frontFootFrame) / 30; // Assuming 30 fps } return timingMetrics; } /** * Calculate release metrics at the release frame * @param {number} releaseFrame - Release frame index * @param {Object} metrics - Phase One metrics * @param {Object} timeSeries - Time series data * @returns {Object} Release metrics * @private */ _calculateReleaseMetrics(releaseFrame, metrics, timeSeries) { const releaseMetrics = { confidence: 0, armSpeed: 0, wristPosition: 0, releaseAngle: 0 }; // Try to extract actual metrics if time series data exists if (timeSeries.power && timeSeries.power.armVelocities) { const armVelocities = timeSeries.power.armVelocities; if (Array.isArray(armVelocities) && armVelocities[releaseFrame] !== undefined) { releaseMetrics.armSpeed = armVelocities[releaseFrame]; // Increase confidence if we have actual data releaseMetrics.confidence = 0.7; } } // Extract release angle if angle time series exists if (timeSeries.angles && timeSeries.angles.armAngles) { const armAngles = timeSeries.angles.armAngles; if (Array.isArray(armAngles) && armAngles[releaseFrame] !== undefined) { releaseMetrics.releaseAngle = armAngles[releaseFrame]; // Increase confidence if we have actual data releaseMetrics.confidence = Math.max(releaseMetrics.confidence, 0.7); } } // Extract wrist position if available if (timeSeries.position && timeSeries.position.wristPositions) { const wristPos = timeSeries.position.wristPositions; if (Array.isArray(wristPos) && wristPos[releaseFrame] !== undefined) { releaseMetrics.wristPosition = wristPos[releaseFrame]; // Increase confidence if we have actual data releaseMetrics.confidence = Math.max(releaseMetrics.confidence, 0.7); } } return releaseMetrics; } /** * Calculate approach metrics based on foot landing frames * @param {number} frontFootFrame - Front foot landing frame * @param {number} backFootFrame - Back foot landing frame * @param {Object} metrics - Phase One metrics * @param {Object} timeSeries - Time series data * @returns {Object} Approach metrics * @private */ _calculateApproachMetrics(frontFootFrame, backFootFrame, metrics, timeSeries) { const approachMetrics = { confidence: 0, speed: 0, balance: 0, consistency: 0 }; // Calculate approach speed if we have position time series if (frontFootFrame !== undefined && backFootFrame !== undefined) { if (timeSeries.position && timeSeries.position.hipPosition) { const hipPositions = timeSeries.position.hipPosition; if (Array.isArray(hipPositions)) { // Get positions at both frames const frontPosition = hipPositions[frontFootFrame]; const backPosition = hipPositions[backFootFrame]; if (frontPosition && backPosition) { // Calculate distance and time to get speed const distance = Math.abs(frontPosition - backPosition); const time = Math.abs(frontFootFrame - backFootFrame) / 30; // Assuming 30 fps approachMetrics.speed = distance / time; approachMetrics.confidence = 0.7; } } } // Calculate balance if we have balance metrics if (timeSeries.balance && timeSeries.balance.stabilityIndex) { const stability = timeSeries.balance.stabilityIndex; if (Array.isArray(stability)) { // Average stability between foot plants let sum = 0; let count = 0; for (let i = backFootFrame; i <= frontFootFrame; i++) { if (stability[i] !== undefined && stability[i] !== null) { sum += stability[i]; count++; } } if (count > 0) { approachMetrics.balance = sum / count; approachMetrics.confidence = Math.max(approachMetrics.confidence, 0.7); } } } // Calculate consistency if we have multiple relevant metrics if (timeSeries.consistency && timeSeries.consistency.motionConsistency) { const consistencyData = timeSeries.consistency.motionConsistency; if (Array.isArray(consistencyData)) { // Get consistency in approach phase const phaseConsistency = consistencyData.slice(backFootFrame, frontFootFrame + 1) .filter(v => v !== undefined && v !== null); if (phaseConsistency.length > 0) { approachMetrics.consistency = phaseConsistency.reduce((a, b) => a + b, 0) / phaseConsistency.length; approachMetrics.confidence = Math.max(approachMetrics.confidence, 0.7); } } } } return approachMetrics; } /** * Generate event-based time series data * @param {Object} phaseOneData - Phase One data * @param {Object} events - Detected events * @param {Object} options - Processing options * @returns {Object} Time series data * @private */ _generateTimeSeriesData(phaseOneData, events, options) { try { this.logger.debug('Generating time series data'); const result = {}; // If no time series data from phase one, return empty if (!phaseOneData.timeSeries || !phaseOneData.timeSeries.frameIndex) { return result; } // Get the time series length const timeSeriesLength = phaseOneData.timeSeries.frameIndex.length; // Create events category result.events = {}; // Extract key events const { releasePoint, frontFootLanding, backFootLanding, ball_release, left_foot_plant, right_foot_plant } = events; // Use either legacy or new event names const releaseFrame = (releasePoint ? releasePoint.frame : null) || (ball_release ? ball_release.frame : null); const frontFootFrame = (frontFootLanding ? frontFootLanding.frame : null) || (left_foot_plant ? left_foot_plant.frame : null); const backFootFrame = (backFootLanding ? backFootLanding.frame : null) || (right_foot_plant ? right_foot_plant.frame : null); // Generate eventPhase - shows which phase of the bowling action each frame belongs to if (backFootFrame !== null && frontFootFrame !== null && releaseFrame !== null) { const eventPhase = Array(timeSeriesLength).fill(null); for (let i = 0; i < timeSeriesLength; i++) { if (i < backFootFrame) { eventPhase[i] = 0; // Run-up } else if (i < frontFootFrame) { eventPhase[i] = 1; // Stride } else if (i < releaseFrame) { eventPhase[i] = 2; // Delivery } else { eventPhase[i] = 3; // Follow-through } } result.events.eventPhase = eventPhase; } // Generate releaseProximity - how close each frame is to release point if (releaseFrame !== null) { const releaseProximity = Array(timeSeriesLength).fill(null); for (let i = 0; i < timeSeriesLength; i++) { releaseProximity[i] = Math.max(0, 1 - Math.abs(i - releaseFrame) / 30); } result.events.releaseProximity = releaseProximity; } // Generate frontFootProximity - how close each frame is to front foot landing if (frontFootFrame !== null) { const frontFootProximity = Array(timeSeriesLength).fill(null); for (let i = 0; i < timeSeriesLength; i++) { frontFootProximity[i] = Math.max(0, 1 - Math.abs(i - frontFootFrame) / 30); } result.events.frontFootProximity = frontFootProximity; } // Generate backFootProximity - how close each frame is to back foot landing if (backFootFrame !== null) { const backFootProximity = Array(timeSeriesLength).fill(null); for (let i = 0; i < timeSeriesLength; i++) { backFootProximity[i] = Math.max(0, 1 - Math.abs(i - backFootFrame) / 30); } result.events.backFootProximity = backFootProximity; } return result; } catch (error) { this.logger.error(`Error generating time series data: ${error.message}`); return {}; } } /** * Validate event sequence * @param {Object} events - Detected events * @returns {Object} Validation result * @private */ _validateEventSequence(events) { try { const result = { valid: true, message: 'Event sequence is valid', issues: [] }; // Extract key events const { releasePoint, frontFootLanding, backFootLanding, ball_release, left_foot_plant, right_foot_plant } = events; // Use either legacy or new event names const releaseFrame = (releasePoint ? releasePoint.frame : null) || (ball_release ? ball_release.frame : null); const frontFootFrame = (frontFootLanding ? frontFootLanding.frame : null) || (left_foot_plant ? left_foot_plant.frame : null); const backFootFrame = (backFootLanding ? backFootLanding.frame : null) || (right_foot_plant ? right_foot_plant.frame : null); // Check if we have all required events const missingEvents = []; if (releaseFrame === null) missingEvents.push('ball_release'); if (frontFootFrame === null) missingEvents.push('front_foot_landing'); if (backFootFrame === null) missingEvents.push('back_foot_landing'); if (missingEvents.length > 0) { result.valid = false; result.message = `Missing required events: ${missingEvents.join(', ')}`; result.issues.push({ type: 'missing_events', events: missingEvents }); return result; } // Check temporal sequence // Sequence should be: back foot -> front foot -> release // Check if back foot is before front foot if (backFootFrame >= frontFootFrame) { result.valid = false; result.issues.push({ type: 'sequence_error', message: 'Back foot landing should be before front foot landing', expected: 'backFoot < frontFoot', actual: `${backFootFrame} >= ${frontFootFrame}` }); } // Check if front foot is before release if (frontFootFrame >= releaseFrame) { result.valid = false; result.issues.push({ type: 'sequence_error', message: 'Front foot landing should be before ball release', expected: 'frontFoot < release', actual: `${frontFootFrame} >= ${releaseFrame}` }); } // Update message if there are issues if (!result.valid) { result.message = `Event sequence validation failed with ${result.issues.length} issues`; } return result; } catch (error) { this.logger.error(`Error validating event sequence: ${error.message}`); return { valid: false, message: `Error validating event sequence: ${error.message}`, issues: [{ type: 'error', message: error.message }] }; } } } module.exports = { PhaseTwoProcessor };