UNPKG

bowling-analysis-system

Version:

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

929 lines (773 loc) 37.3 kB
/** * @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;