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