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