bowling-analysis-system
Version:
A comprehensive system for analyzing bowling techniques using video processing and metrics calculation
929 lines (773 loc) • 37.3 kB
JavaScript
/**
* @module bowling_analysis/metrics/FootLandingDetector
* @description Detects foot landing events in bowling by working backward from the release point
*/
const coreUtils = require('../../core/utils');
const { getBiasConfig } = require('../../core/utils/BiasConfig');
const { deriveVelocityFromPosition } = require('./EventDetectionUtils');
/**
* Default configuration for foot landing detection
* @type {Object}
*/
const DEFAULT_CONFIG = {
// Velocity threshold for considering foot on ground
velocityThreshold: 0.15,
// Position change threshold for stable position
stablePositionThreshold: 0.05,
// Position change threshold for unstable position
unstablePositionThreshold: 0.2,
// Minimum section length to consider valid (frames)
minSectionLength: 3,
// Typical offset from release point to front foot landing (frames)
frontFootOffset: 10,
// Typical offset from front foot landing to back foot landing (frames)
backFootOffset: 10,
// Maximum distance for considering a valley as a foot landing (frames)
maxValleyDistance: 10,
// Confidence decay per frame of distance from expected position
confidenceDecayPerFrame: 0.02,
// Base confidence for estimated foot landings
baseEstimateConfidence: 0.5,
// Base confidence for detected foot landings
baseDetectConfidence: 0.7
};
/**
* Class for detecting foot landing events in bowling
*/
class FootLandingDetector {
/**
* Creates a new FootLandingDetector
* @param {Object} options - Configuration options
* @param {boolean} [options.debug=false] - Whether to log debug messages
* @param {Object} [options.config=null] - Custom configuration overrides
*/
constructor(options = {}) {
this.debug = options.debug || false;
// Start with default config, will update with dynamic values later
this.config = { ...DEFAULT_CONFIG, ...(options.config || {}) };
// Flag to track if thresholds have been calculated dynamically
this.thresholdsCalculated = false;
this._debug('FootLandingDetector initialized');
}
/**
* Detect foot landings based on metrics and release frame
* @param {Object} metrics - Time series metrics data
* @param {number} releaseFrame - Frame index of the release point
* @returns {Object} Detected foot landings with confidence levels
*/
detectFootLandings(metrics, releaseFrame) {
if (!metrics || !metrics.timeSeries) {
this._debug('Invalid metrics data');
return {};
}
if (typeof releaseFrame !== 'number' || releaseFrame < 0) {
this._debug('Invalid release frame');
return {};
}
this._debug(`Detecting foot landings relative to release frame ${releaseFrame}`);
// Calculate dynamic thresholds based on the data
if (!this.thresholdsCalculated) {
this._calculateDynamicThresholds(metrics);
}
// Initialize events object
const events = {};
// Track backward from release frame to find foot landings
// First, look for clear foot-on-ground sections
const footOnGroundSections = this._findFootOnGroundSections(metrics, releaseFrame);
if (footOnGroundSections.frontFoot && footOnGroundSections.frontFoot.length > 0) {
// Sort sections by confidence (descending)
footOnGroundSections.frontFoot.sort((a, b) => b.confidence - a.confidence);
// Use the highest confidence section
const bestSection = footOnGroundSections.frontFoot[0];
events.frontFootLanding = bestSection.startFrame;
events.frontFootConfidence = bestSection.confidence;
this._debug(`Detected front foot landing at frame ${events.frontFootLanding} with confidence ${events.frontFootConfidence}`);
}
if (footOnGroundSections.backFoot && footOnGroundSections.backFoot.length > 0) {
// Sort sections by confidence (descending)
footOnGroundSections.backFoot.sort((a, b) => b.confidence - a.confidence);
// Use the highest confidence section
const bestSection = footOnGroundSections.backFoot[0];
events.backFootLanding = bestSection.startFrame;
events.backFootConfidence = bestSection.confidence;
this._debug(`Detected back foot landing at frame ${events.backFootLanding} with confidence ${events.backFootConfidence}`);
}
// If we couldn't find clear sections, use estimates based on release point
if (!events.frontFootLanding) {
const frontFootEstimate = this._estimateFrontFootLanding(metrics, releaseFrame);
if (frontFootEstimate) {
events.frontFootLanding = frontFootEstimate.frameIndex;
events.frontFootConfidence = frontFootEstimate.confidence;
this._debug(`Estimated front foot landing at frame ${events.frontFootLanding} with confidence ${events.frontFootConfidence}`);
}
}
if (!events.backFootLanding) {
const backFootEstimate = this._estimateBackFootLanding(metrics, releaseFrame, events.frontFootLanding);
if (backFootEstimate) {
events.backFootLanding = backFootEstimate.frameIndex;
events.backFootConfidence = backFootEstimate.confidence;
this._debug(`Estimated back foot landing at frame ${events.backFootLanding} with confidence ${events.backFootConfidence}`);
}
}
// Ensure logical ordering of foot landings
if (events.frontFootLanding && events.backFootLanding) {
if (events.backFootLanding >= events.frontFootLanding) {
// Back foot should land before front foot
this._debug('WARNING: Back foot landing detected after front foot landing, adjusting');
// Adjust back foot landing to be before front foot
const adjustedBackFootFrame = Math.max(0, events.frontFootLanding - this.config.backFootOffset);
events.backFootLanding = adjustedBackFootFrame;
events.backFootConfidence = Math.min(events.backFootConfidence, 0.5);
}
}
// Ensure foot landings are before release
if (events.frontFootLanding && events.frontFootLanding >= releaseFrame) {
this._debug('WARNING: Front foot landing detected after release point, adjusting');
events.frontFootLanding = Math.max(0, releaseFrame - this.config.frontFootOffset);
events.frontFootConfidence = Math.min(events.frontFootConfidence, 0.5);
}
if (events.backFootLanding && events.backFootLanding >= releaseFrame) {
this._debug('WARNING: Back foot landing detected after release point, adjusting');
events.backFootLanding = Math.max(0, releaseFrame - (this.config.frontFootOffset + this.config.backFootOffset));
events.backFootConfidence = Math.min(events.backFootConfidence, 0.5);
}
return events;
}
/**
* Find sections where feet are on the ground
* @private
* @param {Object} metrics - Time series metrics data
* @param {number} releaseFrame - Frame index of the release point
* @returns {Object} Sections where feet are on the ground
*/
_findFootOnGroundSections(metrics, releaseFrame) {
const sections = {
frontFoot: [],
backFoot: []
};
// Get velocity data for feet
const frontFootVelocity = this._getMetricData(metrics, 'velocity', 'frontFoot');
const backFootVelocity = this._getMetricData(metrics, 'velocity', 'backFoot');
// Get position data for feet
const frontFootPosition = this._getMetricData(metrics, 'position', 'frontFoot');
const backFootPosition = this._getMetricData(metrics, 'position', 'backFoot');
// If we don't have velocity data, try to use knee or ankle velocity
const frontKneeVelocity = frontFootVelocity ? null : this._getMetricData(metrics, 'velocity', 'frontKnee');
const backKneeVelocity = backFootVelocity ? null : this._getMetricData(metrics, 'velocity', 'backKnee');
// Process front foot
if (frontFootVelocity || frontKneeVelocity) {
const velocityData = frontFootVelocity || frontKneeVelocity;
const positionData = frontFootPosition;
// Find sections where velocity is near zero (foot on ground)
const frontFootSections = this._findLowVelocitySections(
velocityData,
positionData,
0,
releaseFrame
);
sections.frontFoot = frontFootSections;
}
// Process back foot
if (backFootVelocity || backKneeVelocity) {
const velocityData = backFootVelocity || backKneeVelocity;
const positionData = backFootPosition;
// Find sections where velocity is near zero (foot on ground)
const backFootSections = this._findLowVelocitySections(
velocityData,
positionData,
0,
releaseFrame
);
sections.backFoot = backFootSections;
}
return sections;
}
/**
* Find sections with low velocity (indicating foot on ground)
* @private
* @param {Array} velocityData - Velocity time series data
* @param {Array} positionData - Position time series data (optional)
* @param {number} startFrame - Start frame for search
* @param {number} endFrame - End frame for search
* @returns {Array} Sections with low velocity
*/
_findLowVelocitySections(velocityData, positionData, startFrame, endFrame) {
if (!velocityData || velocityData.length === 0) {
return [];
}
const sections = [];
let inSection = false;
let sectionStart = 0;
let sectionEnd = 0;
let sectionConfidence = 0;
// Search backward from end frame to start frame
for (let i = Math.min(endFrame, velocityData.length - 1); i >= startFrame; i--) {
const velocity = Math.abs(velocityData[i] || 0);
if (velocity <= this.config.velocityThreshold) {
// Low velocity - potential foot on ground
if (!inSection) {
// Start a new section
inSection = true;
sectionStart = i;
sectionEnd = i;
sectionConfidence = 0.5;
} else {
// Continue the section
sectionEnd = i;
// Increase confidence as section gets longer
sectionConfidence = Math.min(0.9, sectionConfidence + 0.02);
}
// Check position stability if available
if (positionData && positionData[i] !== undefined && positionData[i-1] !== undefined) {
const positionChange = Math.abs(positionData[i] - positionData[i-1]);
if (positionChange < this.config.stablePositionThreshold) {
// Stable position increases confidence
sectionConfidence = Math.min(0.95, sectionConfidence + 0.01);
} else if (positionChange > this.config.unstablePositionThreshold) {
// Unstable position decreases confidence
sectionConfidence = Math.max(0.3, sectionConfidence - 0.05);
}
}
} else {
// Higher velocity - foot likely not on ground
if (inSection) {
// End the current section
if (sectionEnd - sectionStart >= this.config.minSectionLength - 1) {
// Only add sections that are at least minSectionLength frames long
sections.push({
startFrame: sectionStart,
endFrame: sectionEnd,
length: sectionEnd - sectionStart + 1,
confidence: sectionConfidence
});
}
inSection = false;
}
}
}
// Check if we ended while still in a section
if (inSection && sectionEnd - sectionStart >= this.config.minSectionLength - 1) {
sections.push({
startFrame: sectionStart,
endFrame: sectionEnd,
length: sectionEnd - sectionStart + 1,
confidence: sectionConfidence
});
}
return sections;
}
/**
* Estimate front foot landing based on release frame
* @private
* @param {Object} metrics - Time series metrics data
* @param {number} releaseFrame - Frame index of the release point
* @returns {Object|null} Estimated front foot landing
*/
_estimateFrontFootLanding(metrics, releaseFrame) {
// Typical front foot landing is frontFootOffset frames before release
const estimatedFrame = Math.max(0, releaseFrame - this.config.frontFootOffset);
// Try to refine the estimate using velocity patterns
const frontFootVelocity = this._getMetricData(metrics, 'velocity', 'frontFoot');
const frontKneeVelocity = this._getMetricData(metrics, 'velocity', 'frontKnee');
const velocityData = frontFootVelocity || frontKneeVelocity;
if (velocityData) {
// Look for velocity valleys (where velocity approaches zero)
const patterns = coreUtils.findPatterns(velocityData.slice(0, releaseFrame), {
type: 'velocity' // Use the standardized velocity configuration
});
if (patterns.valleys && patterns.valleys.length > 0) {
// Find the valley closest to the estimated frame
let closestValley = null;
let minDistance = Infinity;
for (const valley of patterns.valleys) {
const distance = Math.abs(valley.index - estimatedFrame);
if (distance < minDistance) {
minDistance = distance;
closestValley = valley;
}
}
if (closestValley && minDistance < this.config.maxValleyDistance) {
// Use the valley as the refined estimate
return {
frameIndex: closestValley.index,
confidence: this.config.baseDetectConfidence - (minDistance * this.config.confidenceDecayPerFrame)
};
}
}
}
// Fall back to the typical estimate
return {
frameIndex: estimatedFrame,
confidence: this.config.baseEstimateConfidence
};
}
/**
* Estimate back foot landing based on release frame and front foot landing
* @private
* @param {Object} metrics - Time series metrics data
* @param {number} releaseFrame - Frame index of the release point
* @param {number} frontFootFrame - Frame index of the front foot landing (if available)
* @returns {Object|null} Estimated back foot landing
*/
_estimateBackFootLanding(metrics, releaseFrame, frontFootFrame) {
let estimatedFrame;
if (frontFootFrame) {
// Typical back foot landing is backFootOffset frames before front foot
estimatedFrame = Math.max(0, frontFootFrame - this.config.backFootOffset);
} else {
// If no front foot landing, estimate from release point
estimatedFrame = Math.max(0, releaseFrame - (this.config.frontFootOffset + this.config.backFootOffset));
}
// Try to refine the estimate using velocity patterns
const backFootVelocity = this._getMetricData(metrics, 'velocity', 'backFoot');
const backKneeVelocity = this._getMetricData(metrics, 'velocity', 'backKnee');
const velocityData = backFootVelocity || backKneeVelocity;
if (velocityData) {
// Look for velocity valleys (where velocity approaches zero)
const patterns = coreUtils.findPatterns(velocityData.slice(0, frontFootFrame || releaseFrame), {
type: 'velocity' // Use the standardized velocity configuration
});
if (patterns.valleys && patterns.valleys.length > 0) {
// Find the valley closest to the estimated frame
let closestValley = null;
let minDistance = Infinity;
for (const valley of patterns.valleys) {
const distance = Math.abs(valley.index - estimatedFrame);
if (distance < minDistance) {
minDistance = distance;
closestValley = valley;
}
}
if (closestValley && minDistance < this.config.maxValleyDistance) {
// Use the valley as the refined estimate
return {
frameIndex: closestValley.index,
confidence: this.config.baseDetectConfidence - (minDistance * this.config.confidenceDecayPerFrame)
};
}
}
}
// Fall back to the typical estimate
return {
frameIndex: estimatedFrame,
confidence: this.config.baseEstimateConfidence
};
}
/**
* Get metric data from metrics object
* @private
* @param {Object} metrics - Metrics data
* @param {string} category - Metric category
* @param {string} metric - Metric name
* @returns {Array|null} Metric data or null if not found
*/
_getMetricData(metrics, category, metric) {
if (!metrics || !metrics.timeSeries || !metrics.timeSeries[category]) {
return null;
}
return metrics.timeSeries[category][metric] || null;
}
/**
* Log debug message if debug is enabled
* @private
* @param {string} message - Debug message
*/
_debug(message) {
if (this.debug) {
console.log(`[FootLandingDetector] ${message}`);
}
}
/**
* Calculate dynamic thresholds based on the provided metrics data
* @param {Object} metrics - Time series metrics data
* @private
*/
_calculateDynamicThresholds(metrics) {
this._debug('Calculating dynamic thresholds from metrics data');
// Get foot velocity and position data
const leftFootVelocity = this._getMetricData(metrics, 'velocity', 'leftFoot');
const rightFootVelocity = this._getMetricData(metrics, 'velocity', 'rightFoot');
const leftFootPosition = this._getMetricData(metrics, 'position', 'leftFoot');
const rightFootPosition = this._getMetricData(metrics, 'position', 'rightFoot');
// Calculate velocity threshold
this.config.velocityThreshold = this._calculateVelocityThreshold([leftFootVelocity, rightFootVelocity]);
// Calculate position thresholds
const positionThresholds = this._calculatePositionThresholds([leftFootPosition, rightFootPosition]);
this.config.stablePositionThreshold = positionThresholds.stable;
this.config.unstablePositionThreshold = positionThresholds.unstable;
// Calculate section length based on frame rate
const totalFrames = metrics.metadata?.frameCount || 0;
if (totalFrames > 0) {
// Minimum section should scale with total frames, but not be less than 3
this.config.minSectionLength = Math.max(3, Math.floor(totalFrames * 0.02));
}
// Calculate offsets based on total frames
if (totalFrames > 0) {
this.config.frontFootOffset = Math.floor(totalFrames * 0.07); // ~7% of total frames
this.config.backFootOffset = Math.floor(totalFrames * 0.1); // ~10% of total frames
this.config.maxValleyDistance = Math.floor(totalFrames * 0.08); // ~8% of total frames
}
this.thresholdsCalculated = true;
this._debug(`Dynamic thresholds calculated:
velocityThreshold: ${this.config.velocityThreshold.toFixed(3)}
stablePositionThreshold: ${this.config.stablePositionThreshold.toFixed(3)}
unstablePositionThreshold: ${this.config.unstablePositionThreshold.toFixed(3)}
minSectionLength: ${this.config.minSectionLength}
frontFootOffset: ${this.config.frontFootOffset}
backFootOffset: ${this.config.backFootOffset}
maxValleyDistance: ${this.config.maxValleyDistance}`);
}
/**
* Calculate velocity threshold from foot velocity data
* @param {Array<Array<number>>} velocityDataSets - Array of velocity data arrays
* @returns {number} Calculated velocity threshold
* @private
*/
_calculateVelocityThreshold(velocityDataSets) {
let allVelocities = [];
// Combine all valid velocity values
for (const dataSet of velocityDataSets) {
if (Array.isArray(dataSet)) {
const validValues = dataSet.filter(v => v !== null && v !== undefined);
allVelocities = allVelocities.concat(validValues);
}
}
if (allVelocities.length === 0) {
return DEFAULT_CONFIG.velocityThreshold;
}
// Calculate statistics
allVelocities.sort((a, b) => a - b);
// Use percentile approach to find "nearly stopped" velocity
const index = Math.floor(allVelocities.length * 0.15); // 15th percentile
const threshold = Math.max(0.05, allVelocities[index] || DEFAULT_CONFIG.velocityThreshold);
return threshold;
}
/**
* Calculate position thresholds from foot position data
* @param {Array<Array<number>>} positionDataSets - Array of position data arrays
* @returns {Object} Object with stable and unstable thresholds
* @private
*/
_calculatePositionThresholds(positionDataSets) {
let allChanges = [];
// Calculate changes between consecutive positions
for (const dataSet of positionDataSets) {
if (Array.isArray(dataSet) && dataSet.length > 1) {
for (let i = 1; i < dataSet.length; i++) {
if (dataSet[i] !== null && dataSet[i-1] !== null) {
const change = Math.abs(dataSet[i] - dataSet[i-1]);
allChanges.push(change);
}
}
}
}
if (allChanges.length === 0) {
return {
stable: DEFAULT_CONFIG.stablePositionThreshold,
unstable: DEFAULT_CONFIG.unstablePositionThreshold
};
}
// Sort changes for percentile calculations
allChanges.sort((a, b) => a - b);
// Use percentiles to determine thresholds
const stableIndex = Math.floor(allChanges.length * 0.2); // 20th percentile
const unstableIndex = Math.floor(allChanges.length * 0.6); // 60th percentile
return {
stable: Math.max(0.01, allChanges[stableIndex] || DEFAULT_CONFIG.stablePositionThreshold),
unstable: Math.max(0.05, allChanges[unstableIndex] || DEFAULT_CONFIG.unstablePositionThreshold)
};
}
/**
* Detect foot plants using enhanced detection methods
* @param {Object} metrics - Time series metrics data
* @param {number} releaseFrame - Frame index of the release point
* @param {Array<number>} [thresholds] - Optional array of thresholds to try
* @returns {Object} Object with leftFootPlants and rightFootPlants arrays
*/
detectFootPlantsEnhanced(metrics, releaseFrame, thresholds) {
// Set default thresholds if not provided
if (!thresholds || !Array.isArray(thresholds) || thresholds.length === 0) {
thresholds = [0.05, 0.1, 0.15, 0.2];
}
// Initialize results
const result = {
leftFootPlants: [],
rightFootPlants: []
};
try {
// Get ankle/foot height and velocity data
const leftAnkleHeight = metrics.timeSeries?.position?.leftAnkleHeight ||
metrics.timeSeries?.position?.leftFootHeight || [];
const rightAnkleHeight = metrics.timeSeries?.position?.rightAnkleHeight ||
metrics.timeSeries?.position?.rightFootHeight || [];
const leftAnkleVelocity = metrics.timeSeries?.velocity?.leftAnkleVelocity ||
metrics.timeSeries?.velocity?.leftFootVelocity || [];
const rightAnkleVelocity = metrics.timeSeries?.velocity?.rightAnkleVelocity ||
metrics.timeSeries?.velocity?.rightFootVelocity || [];
// Also check for moments data
const leftFootPlantMoments = metrics.moments?.left_foot_plant || [];
const rightFootPlantMoments = metrics.moments?.right_foot_plant || [];
// Helper function to detect foot plants based on height and velocity
const detectPlantsForFoot = (heightData, velocityData, plantMoments, side) => {
const plants = [];
// First include any moments data
plantMoments.forEach(frame => {
// Find a range of frames for this plant (look for adjacent planted frames)
let startFrame = frame;
let endFrame = frame;
// Look backwards for contiguous planted frames
while (startFrame > 0 && plantMoments.includes(startFrame - 1)) {
startFrame--;
}
// Look forwards for contiguous planted frames
while (endFrame < metrics.metadata?.frameCount - 1 && plantMoments.includes(endFrame + 1)) {
endFrame++;
}
plants.push({
frameIndex: startFrame,
confidence: 1.0,
duration: endFrame - startFrame + 1,
side: side
});
});
// Then try detection based on height and velocity
if (heightData.length > 0 && velocityData.length > 0) {
// Find minimum and maximum height for normalization
const validHeights = heightData.filter(h => h !== null && h !== undefined && !isNaN(h));
const minHeight = Math.min(...validHeights);
const maxHeight = Math.max(...validHeights);
const heightRange = maxHeight - minHeight;
// For each threshold, find segments where height is low and velocity is low
for (const threshold of thresholds) {
let inPlant = false;
let plantStart = 0;
let plantEnd = 0;
for (let i = 0; i < heightData.length; i++) {
if (i >= velocityData.length) break;
// Check if both data points are valid
if (heightData[i] === null || heightData[i] === undefined ||
velocityData[i] === null || velocityData[i] === undefined) {
continue;
}
// Use relative height position without normalizing to 0-1
// Check if height is in the lower 30% of the range
const isLowHeight = heightRange > 0 ?
(heightData[i] - minHeight) < (0.3 * heightRange) : true;
// Check if foot appears planted (low and stable)
const isPlanted = isLowHeight && Math.abs(velocityData[i]) < threshold;
if (isPlanted && !inPlant) {
// Start of a plant
inPlant = true;
plantStart = i;
} else if (!isPlanted && inPlant) {
// End of a plant
inPlant = false;
plantEnd = i - 1;
// If plant is long enough, add it
const duration = plantEnd - plantStart + 1;
if (duration >= 3) { // Minimum 3 frames for a valid plant
// Calculate confidence based on duration
const confidence = Math.min(1.0, 0.7 + (duration / 30) * 0.3);
plants.push({
frameIndex: plantStart,
confidence: confidence,
duration: duration,
side: side
});
}
}
}
// Handle case where we end while in a plant
if (inPlant) {
plantEnd = heightData.length - 1;
const duration = plantEnd - plantStart + 1;
if (duration >= 3) {
const confidence = Math.min(1.0, 0.7 + (duration / 30) * 0.3);
plants.push({
frameIndex: plantStart,
confidence: confidence,
duration: duration,
side: side
});
}
}
}
}
// Sort plants by frame index (descending) so most recent come first
return plants.sort((a, b) => b.frameIndex - a.frameIndex);
};
// Detect plants for both feet
result.leftFootPlants = detectPlantsForFoot(leftAnkleHeight, leftAnkleVelocity, leftFootPlantMoments, 'left');
result.rightFootPlants = detectPlantsForFoot(rightAnkleHeight, rightAnkleVelocity, rightFootPlantMoments, 'right');
this._debugLog(`Enhanced detection found ${result.leftFootPlants.length} left foot plants and ${result.rightFootPlants.length} right foot plants`);
} catch (error) {
console.error('Error in detectFootPlantsEnhanced:', error);
}
return result;
}
/**
* Find foot landings from a known release point
* @param {Object} metrics - Metrics data including time series positions
* @param {number} releaseFrame - The frame index of the ball release
* @returns {Object} Object with detected front and back foot landings
*/
findFootLandingsFromRelease(metrics, releaseFrame) {
this._debugLog(`Finding foot landings based on release frame ${releaseFrame}`);
// Initialize results
const results = {};
// Check metrics data
if (!metrics || !metrics.timeSeries || !metrics.timeSeries.position) {
this._debugLog('Invalid metrics data for foot landing detection');
return results;
}
// Check if we have velocity data (derive if missing)
if (!metrics.timeSeries.velocity && metrics.timeSeries.position) {
this._debugLog('No velocity data found, deriving from position');
metrics = deriveVelocityFromPosition(metrics);
}
// Check for moments data first (prioritize moments-based detection)
let momentsData = metrics.moments || metrics.timeSeries?.moments;
if (momentsData) {
this._debugLog('Found moments data for enhanced detection');
// Front foot (typically left 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);
// Find the closest left foot plant before release
const validFrames = sortedFrames.filter(frame => frame < releaseFrame);
if (validFrames.length > 0) {
// Use the most recent left foot plant before release
const frameIndex = validFrames.reduce((latest, current) =>
current > latest ? current : latest, validFrames[0]);
results.frontFootLanding = {
frameIndex: frameIndex,
confidence: 1,
method: 'enhanced_detection',
side: 'left'
};
this._debugLog(`Detected front foot landing at frame ${frameIndex} from moments data`);
}
}
// Back foot (typically right 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);
// Find the closest right foot plant before release
const validFrames = sortedFrames.filter(frame => frame < releaseFrame);
if (validFrames.length > 0) {
// Use the most recent right foot plant before release
const frameIndex = validFrames.reduce((latest, current) =>
current > latest ? current : latest, validFrames[0]);
results.backFootLanding = {
frameIndex: frameIndex,
confidence: 1,
method: 'enhanced_detection',
side: 'right'
};
this._debugLog(`Detected back foot landing at frame ${frameIndex} from moments data`);
}
}
// If we found both landings from moments, return them
if (results.frontFootLanding && results.backFootLanding) {
this._debugLog('Successfully detected both foot landings from moments data');
return results;
}
}
// Fall back to biomechanical detection if moments-based detection didn't find both landings
this._debugLog('Using biomechanical detection for missing foot landings');
// Use enhanced biomechanical detection
const biomechanicalResults = this.detectFootPlantsEnhanced(metrics, releaseFrame);
// Front foot landing (if not already detected)
if (!results.frontFootLanding && biomechanicalResults.leftFootPlants && biomechanicalResults.leftFootPlants.length > 0) {
// Find the most relevant left foot plant
const relevantPlants = biomechanicalResults.leftFootPlants
.filter(plant => plant.frameIndex < releaseFrame)
.sort((a, b) => b.frameIndex - a.frameIndex); // Sort descending by frame
if (relevantPlants.length > 0) {
const bestPlant = relevantPlants[0]; // Most recent before release
results.frontFootLanding = {
frameIndex: bestPlant.frameIndex,
confidence: bestPlant.confidence,
method: 'biomechanical',
side: 'left'
};
this._debugLog(`Detected front foot landing at frame ${bestPlant.frameIndex} with biomechanical detection`);
}
}
// Back foot landing (if not already detected)
if (!results.backFootLanding && biomechanicalResults.rightFootPlants && biomechanicalResults.rightFootPlants.length > 0) {
// Find the most relevant right foot plant
const relevantPlants = biomechanicalResults.rightFootPlants
.filter(plant => plant.frameIndex < releaseFrame)
.sort((a, b) => b.frameIndex - a.frameIndex); // Sort descending by frame
if (relevantPlants.length > 0) {
const bestPlant = relevantPlants[0]; // Most recent before release
results.backFootLanding = {
frameIndex: bestPlant.frameIndex,
confidence: bestPlant.confidence,
method: 'biomechanical',
side: 'right'
};
this._debugLog(`Detected back foot landing at frame ${bestPlant.frameIndex} with biomechanical detection`);
}
}
// If still missing foot landings, use estimation
if (!results.frontFootLanding && results.backFootLanding) {
// Estimate front foot landing based on back foot
const backFootFrame = results.backFootLanding.frameIndex;
const estimatedFrontFootFrame = Math.min(releaseFrame - 5, backFootFrame + 15);
results.frontFootLanding = {
frameIndex: estimatedFrontFootFrame,
confidence: 0.5,
method: 'estimated',
side: 'left'
};
this._debugLog(`Estimated front foot landing at frame ${estimatedFrontFootFrame} based on back foot`);
} else if (!results.backFootLanding && results.frontFootLanding) {
// Estimate back foot landing based on front foot
const frontFootFrame = results.frontFootLanding.frameIndex;
const estimatedBackFootFrame = Math.max(0, frontFootFrame - 15);
results.backFootLanding = {
frameIndex: estimatedBackFootFrame,
confidence: 0.5,
method: 'estimated',
side: 'right'
};
this._debugLog(`Estimated back foot landing at frame ${estimatedBackFootFrame} based on front foot`);
} else if (!results.frontFootLanding && !results.backFootLanding) {
// If no foot landings detected, estimate both based on release frame
// Front foot typically occurs ~10-15 frames before release
const estimatedFrontFootFrame = Math.max(0, releaseFrame - 12);
results.frontFootLanding = {
frameIndex: estimatedFrontFootFrame,
confidence: 0.4,
method: 'estimated',
side: 'left'
};
// Back foot typically occurs ~10-15 frames before front foot
const estimatedBackFootFrame = Math.max(0, estimatedFrontFootFrame - 12);
results.backFootLanding = {
frameIndex: estimatedBackFootFrame,
confidence: 0.4,
method: 'estimated',
side: 'right'
};
this._debugLog(`Estimated foot landings: front=${estimatedFrontFootFrame}, back=${estimatedBackFootFrame}`);
}
this._debugLog('Final foot landing detection results:', results);
return results;
}
/**
* Logs debug information if debug mode is enabled
* @param {string} message - Debug message
* @param {*} data - Optional data to log
* @private
*/
_debugLog(message, data) {
if (this.debug) {
console.log(`[FootLandingDetector] ${message}`);
if (data !== undefined) {
console.log(typeof data === 'object' ? JSON.stringify(data, null, 2) : data);
}
}
}
}
module.exports = FootLandingDetector;