diff --git a/main.py b/main.py index fcae666..753683f 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ from utils.pose_detector import PoseDetector from utils.punch_counter import PunchCounter from utils.ui_manager import UIManager +from utils.combo_detector import ComboDetector # Added from utils.data_manager import DataManager from utils.calibration import Calibrator @@ -24,6 +25,17 @@ def __init__(self): self.data_manager = DataManager() self.calibrator = Calibrator() + # Combo Detection related attributes + self.combo_detector = ComboDetector() # Uses default combos + self.current_detected_combo = None + self.last_combo_detected_time = 0 + self.combo_display_duration = 3.0 # seconds + self.combo_stats = { + 'attempts': 0, + 'successes': 0, + 'detected_combos': {} + } + # Application state self.is_running = False self.is_calibrating = False @@ -49,6 +61,11 @@ def start_session(self): """Start a new punching session""" self.session_start_time = datetime.now() self.punch_counter.reset_counter() + # Reset combo stats for the new session + self.current_detected_combo = None + self.last_combo_detected_time = 0 + self.combo_stats = {'attempts': 0, 'successes': 0, 'detected_combos': {}} + print("Combo stats reset for new session.") self.data_manager.create_new_session() print(f"New session started at {self.session_start_time}") @@ -60,7 +77,8 @@ def end_session(self): 'duration': session_duration, 'total_punches': self.punch_counter.total_count, 'punch_types': self.punch_counter.get_punch_types_count(), - 'punches_per_minute': self.punch_counter.total_count / (session_duration / 60) if session_duration > 0 else 0 + 'punches_per_minute': self.punch_counter.total_count / (session_duration / 60) if session_duration > 0 else 0, + 'combo_stats': self.combo_stats } self.data_manager.save_session_data(session_data) print(f"Session ended. {self.punch_counter.total_count} punches recorded over {session_duration:.1f} seconds.") @@ -83,7 +101,11 @@ def process_frame(self, frame): self.punch_counter.get_punch_types_count(), self.session_start_time, self.punch_counter.velocity_threshold, - paused=True + paused=True, + current_detected_combo=self.current_detected_combo, + last_combo_detected_time=self.last_combo_detected_time, + combo_display_duration=self.combo_display_duration, + combo_stats=self.combo_stats ) if self.is_calibrating: @@ -96,6 +118,24 @@ def process_frame(self, frame): else: # Normal processing - detect punches punches_detected = self.punch_counter.detect_punches(poses) + + # Combo Detection Logic + if len(self.punch_counter.punch_event_history) > 0: + detected_combo_name = self.combo_detector.detect_combo(self.punch_counter.punch_event_history) + + if detected_combo_name: + if self.current_detected_combo != detected_combo_name or \ + (time.time() - self.last_combo_detected_time > self.combo_display_duration): + + print(f"COMBO DETECTED: {detected_combo_name}") + self.current_detected_combo = detected_combo_name + self.last_combo_detected_time = time.time() + + self.combo_stats['successes'] += 1 + self.combo_stats['detected_combos'][detected_combo_name] = \ + self.combo_stats['detected_combos'].get(detected_combo_name, 0) + 1 + + self.punch_counter.punch_event_history.clear() # Add visual feedback if punches are detected if punches_detected: @@ -111,7 +151,11 @@ def process_frame(self, frame): self.punch_counter.get_punch_types_count(), self.session_start_time, self.punch_counter.velocity_threshold, - paused=False + paused=False, + current_detected_combo=self.current_detected_combo, + last_combo_detected_time=self.last_combo_detected_time, + combo_display_duration=self.combo_display_duration, + combo_stats=self.combo_stats ) # Show debug visualization if enabled diff --git a/tests/test_combo_detector.py b/tests/test_combo_detector.py new file mode 100644 index 0000000..d6aeb73 --- /dev/null +++ b/tests/test_combo_detector.py @@ -0,0 +1,139 @@ +import unittest +import time +from collections import deque +from utils.combo_detector import ComboDetector # Assuming utils is in PYTHONPATH or accessible + +class TestComboDetector(unittest.TestCase): + + def setUp(self): + # Using default combos from the ComboDetector class for these tests + self.detector = ComboDetector() + # Test specific combos for more fine-grained tests if needed + self.custom_combos = [ + {'name': 'Test-Jab-Cross', 'sequence': ['jab', 'cross'], 'timing': [0.5]}, + {'name': 'Test-Triple-Jab', 'sequence': ['jab', 'jab', 'jab'], 'timing': [0.3, 0.3]} + ] + self.custom_detector = ComboDetector(predefined_combos=self.custom_combos) + + def test_empty_history(self): + history = deque(maxlen=5) + self.assertIsNone(self.detector.detect_combo(history)) + + def test_history_too_short_for_any_combo(self): + history = deque(maxlen=5) + history.append(('jab', time.time())) + self.assertIsNone(self.detector.detect_combo(history)) + + def test_simple_jab_cross_correct_timing(self): + history = deque(maxlen=5) + history.append((ComboDetector.PUNCH_JAB, time.time() - 0.4)) # 0.4s ago + history.append((ComboDetector.PUNCH_CROSS, time.time())) # now + # Uses default combo: {'name': 'Jab-Cross', 'sequence': ['jab', 'cross'], 'timing': [0.7]} + self.assertEqual(self.detector.detect_combo(history), 'Jab-Cross') + + def test_simple_jab_cross_too_slow(self): + history = deque(maxlen=5) + history.append((ComboDetector.PUNCH_JAB, time.time() - 1.0)) # 1.0s ago + history.append((ComboDetector.PUNCH_CROSS, time.time())) # now + # Default Jab-Cross timing is 0.7s + self.assertIsNone(self.detector.detect_combo(history)) + + def test_jab_jab_cross_correct_timing(self): + history = deque(maxlen=7) + history.append((ComboDetector.PUNCH_JAB, time.time() - 0.8)) # t0 + history.append((ComboDetector.PUNCH_JAB, time.time() - 0.4)) # t0 + 0.4s (within 0.5s for J-J) + history.append((ComboDetector.PUNCH_CROSS, time.time())) # t0 + 0.8s (0.4s after last J, within 0.7s for J-C) + # Default: {'name': 'Jab-Jab-Cross', 'sequence': ['jab', 'jab', 'cross'], 'timing': [0.5, 0.7]} + self.assertEqual(self.detector.detect_combo(history), 'Jab-Jab-Cross') + + def test_jab_jab_cross_first_timing_too_slow(self): + history = deque(maxlen=7) + # Control timestamps precisely for testability + base_time = time.time() + history.append((ComboDetector.PUNCH_JAB, base_time - 1.0)) # t0 + history.append((ComboDetector.PUNCH_JAB, base_time - 0.3)) # t1 = t0 + 0.7s (too slow for J-J's 0.5s) + history.append((ComboDetector.PUNCH_CROSS, base_time)) # t2 = t1 + 0.3s + # Jab-Jab-Cross should fail. Jab-Cross (last two punches) should be detected. + self.assertEqual(self.detector.detect_combo(history), 'Jab-Cross') + + def test_jab_jab_cross_second_timing_too_slow(self): + history = deque(maxlen=7) + history.append((ComboDetector.PUNCH_JAB, time.time() - 1.0)) # t0 + history.append((ComboDetector.PUNCH_JAB, time.time() - 0.8)) # t0 + 0.2s (ok for J-J's 0.5s) + history.append((ComboDetector.PUNCH_CROSS, time.time())) # t0 + 1.0s (0.8s after last J, too slow for J-C's 0.7s) + self.assertIsNone(self.detector.detect_combo(history)) + + def test_incorrect_punch_type_in_sequence(self): + history = deque(maxlen=5) + history.append((ComboDetector.PUNCH_JAB, time.time() - 0.4)) + history.append((ComboDetector.PUNCH_HOOK, time.time())) # Should be CROSS for Jab-Cross + self.assertIsNone(self.detector.detect_combo(history)) + + def test_longer_history_ending_in_valid_combo(self): + history = deque(maxlen=7) + history.append((ComboDetector.PUNCH_UPPERCUT, time.time() - 2.0)) + history.append((ComboDetector.PUNCH_HOOK, time.time() - 1.5)) + history.append((ComboDetector.PUNCH_JAB, time.time() - 0.4)) # Start of Jab-Cross + history.append((ComboDetector.PUNCH_CROSS, time.time())) # End of Jab-Cross + self.assertEqual(self.detector.detect_combo(history), 'Jab-Cross') + + def test_sub_combo_detection_priority(self): + # DEFAULT_COMBOS are sorted by length descending in __init__ + # Jab-Jab-Cross vs Jab-Cross + history = deque(maxlen=7) + # This sequence is a valid Jab-Jab-Cross + history.append((ComboDetector.PUNCH_JAB, time.time() - 0.8)) # t0 + history.append((ComboDetector.PUNCH_JAB, time.time() - 0.4)) # t0 + 0.4s (J-J OK) + history.append((ComboDetector.PUNCH_CROSS, time.time())) # t0 + 0.8s (J-C OK) + # Expected: Jab-Jab-Cross because it's longer and checked first + self.assertEqual(self.detector.detect_combo(history), 'Jab-Jab-Cross') + + # This sequence is a valid Jab-Cross but NOT Jab-Jab-Cross due to first timing + history2 = deque(maxlen=7) + history2.append((ComboDetector.PUNCH_JAB, time.time() - 1.0)) # t0 + history2.append((ComboDetector.PUNCH_JAB, time.time() - 0.3)) # t0 + 0.7s (J-J too slow) + history2.append((ComboDetector.PUNCH_CROSS, time.time())) # t0 + 1.0s (J-C OK from last two punches) + # Expected: Jab-Cross because Jab-Jab-Cross fails on timing + self.assertEqual(self.detector.detect_combo(history2), 'Jab-Cross') + + + def test_custom_combos_correct_timing(self): + history = deque(maxlen=5) + history.append(('jab', time.time() - 0.3)) + history.append(('cross', time.time())) + self.assertEqual(self.custom_detector.detect_combo(history), 'Test-Jab-Cross') + + def test_custom_combos_too_slow(self): + history = deque(maxlen=5) + history.append(('jab', time.time() - 0.6)) # Custom timing is 0.5s + history.append(('cross', time.time())) + self.assertIsNone(self.custom_detector.detect_combo(history)) + + def test_exact_timing_boundary(self): + # Test with timing exactly at the boundary + # Jab-Cross default timing is 0.7s + # Construct timestamps to ensure exact difference + punch1_ts = time.time() + punch2_ts = punch1_ts + 0.7 # Exact difference of 0.7 + + history = deque(maxlen=5) + history.append((ComboDetector.PUNCH_JAB, punch1_ts)) + history.append((ComboDetector.PUNCH_CROSS, punch2_ts)) + self.assertEqual(self.detector.detect_combo(history), 'Jab-Cross', "Should detect combo at exact timing boundary") + + def test_just_over_timing_boundary(self): + # Test with timing just over the boundary + # Construct timestamps to ensure exact difference + punch1_ts = time.time() + punch2_ts = punch1_ts + 0.701 # Exact difference of 0.701 + + history = deque(maxlen=5) + history.append((ComboDetector.PUNCH_JAB, punch1_ts)) + history.append((ComboDetector.PUNCH_CROSS, punch2_ts)) + self.assertIsNone(self.detector.detect_combo(history), "Should not detect combo just over timing boundary") + +if __name__ == '__main__': + # This allows running the tests directly from this file + # You might need to adjust PYTHONPATH if utils is not found: + # Example: export PYTHONPATH=$PYTHONPATH:$(pwd)/.. (if tests is subdir of project root) + unittest.main() diff --git a/utils/combo_detector.py b/utils/combo_detector.py new file mode 100644 index 0000000..75d76c6 --- /dev/null +++ b/utils/combo_detector.py @@ -0,0 +1,160 @@ +import time +from collections import deque + +class ComboDetector: + PUNCH_JAB = "jab" + PUNCH_CROSS = "cross" + PUNCH_HOOK = "hook" + PUNCH_UPPERCUT = "uppercut" + + DEFAULT_COMBOS = [ + { + 'name': 'Jab-Cross', + 'sequence': [PUNCH_JAB, PUNCH_CROSS], + 'timing': [0.7] # Max 0.7 seconds between Jab and Cross + }, + { + 'name': 'Jab-Jab-Cross', + 'sequence': [PUNCH_JAB, PUNCH_JAB, PUNCH_CROSS], + 'timing': [0.5, 0.7] # Max 0.5s between J-J, Max 0.7s between J-C + }, + { + 'name': 'Jab-Cross-Hook', + 'sequence': [PUNCH_JAB, PUNCH_CROSS, PUNCH_HOOK], + 'timing': [0.7, 0.7] # Max 0.7s between J-C, Max 0.7s between C-H + }, + { + 'name': 'Hook-Cross-Hook', + 'sequence': [PUNCH_HOOK, PUNCH_CROSS, PUNCH_HOOK], + 'timing': [0.7, 0.7] + } + ] + + def __init__(self, predefined_combos=None): + if predefined_combos is None: + self.predefined_combos = sorted(self.DEFAULT_COMBOS, key=lambda c: len(c['sequence']), reverse=True) + else: + self.predefined_combos = sorted(predefined_combos, key=lambda c: len(c['sequence']), reverse=True) + + def detect_combo(self, punch_history): + ''' + Detects a combo from a list of recent punches. + + Args: + punch_history: A deque of tuples, where each tuple is (punch_type, timestamp). + The history is ordered from oldest to newest. + + Returns: + The name of the detected combo, or None if no combo is detected. + ''' + if not punch_history or len(punch_history) < 2: + return None + + for combo in self.predefined_combos: + combo_sequence = combo['sequence'] + combo_timings = combo['timing'] + + # Check if the recent history is long enough for this combo + if len(punch_history) < len(combo_sequence): + continue + + # Take the most recent punches from history that match the length of the combo sequence + relevant_history = list(punch_history)[-len(combo_sequence):] + + # Check punch sequence + match = True + for i, expected_punch in enumerate(combo_sequence): + if relevant_history[i][0] != expected_punch: + match = False + break + + if not match: + continue + + # Check timing + # relevant_history is [(punch_type, timestamp), (punch_type, timestamp), ...] + # combo_timings is [max_time_between_punch1_and_punch2, max_time_between_punch2_and_punch3, ...] + # There is one less timing value than there are punches in the sequence. + time_match = True + epsilon = 1e-6 # Using a slightly larger epsilon + for i in range(len(combo_timings)): + time_diff = relevant_history[i+1][1] - relevant_history[i][1] + if time_diff > (combo_timings[i] + epsilon): + time_match = False + break + + if time_match: + # Combo detected + return combo['name'] + + return None + +if __name__ == '__main__': + # Example Usage and Basic Test + detector = ComboDetector() + + # Test Case 1: Jab-Cross + history1 = deque(maxlen=5) + history1.append(("jab", time.time() - 0.5)) + history1.append(("cross", time.time())) + print(f"History 1: {list(history1)}") + detected1 = detector.detect_combo(history1) + print(f"Detected Combo 1: {detected1}") # Expected: Jab-Cross + + # Test Case 2: Jab-Jab-Cross + history2 = deque(maxlen=5) + history2.append(("jab", time.time() - 1.0)) + history2.append(("jab", time.time() - 0.4)) + history2.append(("cross", time.time())) + print(f"History 2: {list(history2)}") + detected2 = detector.detect_combo(history2) + print(f"Detected Combo 2: {detected2}") # Expected: Jab-Cross (Jab-Jab-Cross fails due to timing on J-J > 0.5s) + + # Test Case 3: Jab-Cross-Hook (Correct timing) + history3 = deque(maxlen=5) + history3.append(("jab", time.time() - 1.2)) + history3.append(("cross", time.time() - 0.6)) # 0.6s after jab + history3.append(("hook", time.time())) # 0.6s after cross + print(f"History 3: {list(history3)}") + detected3 = detector.detect_combo(history3) + print(f"Detected Combo 3: {detected3}") # Expected: Jab-Cross-Hook + + # Test Case 4: Jab-Cross-Hook (Incorrect timing - too slow) + history4 = deque(maxlen=5) + history4.append(("jab", time.time() - 2.0)) + history4.append(("cross", time.time() - 0.8)) # 1.2s after jab (too slow for default J-C 0.7) + history4.append(("hook", time.time())) # 0.8s after cross + print(f"History 4: {list(history4)}") + detected4 = detector.detect_combo(history4) + print(f"Detected Combo 4: {detected4}") # Expected: None + + # Test Case 5: Partial match (should not detect) + history5 = deque(maxlen=5) + history5.append(("jab", time.time() - 0.5)) + print(f"History 5: {list(history5)}") + detected5 = detector.detect_combo(history5) + print(f"Detected Combo 5: {detected5}") # Expected: None + + # Test Case 6: Different combo + history6 = deque(maxlen=5) + history6.append(("hook", time.time() - 1.0)) + history6.append(("cross", time.time() - 0.5)) + history6.append(("hook", time.time())) + print(f"History 6: {list(history6)}") + detected6 = detector.detect_combo(history6) + print(f"Detected Combo 6: {detected6}") # Expected: Hook-Cross-Hook + + # Test Case 7: No match + history7 = deque(maxlen=5) + history7.append(("uppercut", time.time() - 1.0)) + history7.append(("jab", time.time() - 0.5)) + history7.append(("hook", time.time())) + print(f"History 7: {list(history7)}") + detected7 = detector.detect_combo(history7) + print(f"Detected Combo 7: {detected7}") # Expected: None + + # Test Case 8: Empty history + history8 = deque(maxlen=5) + print(f"History 8: {list(history8)}") + detected8 = detector.detect_combo(history8) + print(f"Detected Combo 8: {detected8}") # Expected: None diff --git a/utils/data_manager.py b/utils/data_manager.py index 899c061..65f8db3 100644 --- a/utils/data_manager.py +++ b/utils/data_manager.py @@ -41,7 +41,10 @@ def _initialize_database(self): jab_count INTEGER, cross_count INTEGER, hook_count INTEGER, - uppercut_count INTEGER + uppercut_count INTEGER, + combo_successes INTEGER DEFAULT 0, + combo_attempts INTEGER DEFAULT 0, + combo_details TEXT DEFAULT '{}' ) ''') @@ -76,8 +79,8 @@ def save_session_data(self, session_data): # Insert session data into database cursor.execute(''' INSERT INTO sessions - (date, duration, total_punches, punches_per_minute, jab_count, cross_count, hook_count, uppercut_count) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (date, duration, total_punches, punches_per_minute, jab_count, cross_count, hook_count, uppercut_count, combo_successes, combo_attempts, combo_details) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', ( session_data['date'], session_data['duration'], @@ -86,7 +89,10 @@ def save_session_data(self, session_data): punch_types.get('jab', 0), punch_types.get('cross', 0), punch_types.get('hook', 0), - punch_types.get('uppercut', 0) + punch_types.get('uppercut', 0), + session_data.get('combo_stats', {}).get('successes', 0), # combo_successes + session_data.get('combo_stats', {}).get('attempts', 0), # combo_attempts + json.dumps(session_data.get('combo_stats', {}).get('detected_combos', {})) # combo_details_json )) conn.commit() @@ -131,7 +137,8 @@ def get_historical_data(self, limit=10): # Get the most recent sessions cursor.execute(''' SELECT date, duration, total_punches, punches_per_minute, - jab_count, cross_count, hook_count, uppercut_count + jab_count, cross_count, hook_count, uppercut_count, + combo_successes, combo_attempts, combo_details FROM sessions ORDER BY date DESC LIMIT ? @@ -153,6 +160,11 @@ def get_historical_data(self, limit=10): 'cross': session[5], 'hook': session[6], 'uppercut': session[7] + }, + 'combo_stats': { + 'successes': session[8], + 'attempts': session[9], + 'detected_combos': json.loads(session[10]) if session[10] else {} } }) @@ -179,7 +191,8 @@ def get_stats_summary(self): SUM(cross_count) as total_crosses, SUM(hook_count) as total_hooks, SUM(uppercut_count) as total_uppercuts, - SUM(duration) as total_duration + SUM(duration) as total_duration, + SUM(combo_successes) as total_combo_successes FROM sessions ''') @@ -198,7 +211,8 @@ def get_stats_summary(self): 'cross': 0, 'hook': 0, 'uppercut': 0 - } + }, + 'total_combo_successes': 0 } # Calculate the punch distribution percentages @@ -215,7 +229,8 @@ def get_stats_summary(self): 'cross': (result[5] / total_punches) * 100 if result[5] else 0, 'hook': (result[6] / total_punches) * 100 if result[6] else 0, 'uppercut': (result[7] / total_punches) * 100 if result[7] else 0 - } + }, + 'total_combo_successes': result[9] if result[9] is not None else 0 } return summary \ No newline at end of file diff --git a/utils/punch_counter.py b/utils/punch_counter.py index 2ac53ce..58c94c1 100644 --- a/utils/punch_counter.py +++ b/utils/punch_counter.py @@ -46,6 +46,9 @@ def __init__(self, pose_detector): } self.punch_cooldown = 0.5 # seconds + # Stores (punch_type, timestamp) for combo detection + self.punch_event_history = deque(maxlen=7) + # Punch detection parameters self.velocity_threshold = 50 # pixels per frame self.direction_threshold = 0.7 # cosine similarity threshold @@ -235,6 +238,10 @@ def detect_punches(self, keypoints_list): self.punch_counts[punch_type] += 1 self.total_count += 1 + # Record punch event for combo detection + # Use the timestamp associated with the punch from timestamp_history + self.punch_event_history.append((punch_type, self.timestamp_history[wrist_key][-1])) + # Add to detected punches detected_punches.append((punch_type, wrist_coords)) diff --git a/utils/ui_manager.py b/utils/ui_manager.py index 78018b6..209c6df 100644 --- a/utils/ui_manager.py +++ b/utils/ui_manager.py @@ -19,7 +19,7 @@ def __init__(self): # Stats panel dimensions self.panel_width = 200 - self.panel_height = 200 + self.panel_height = 230 # Increased by 30 for combo stats self.panel_padding = 10 # Punch type colors for visualization @@ -30,7 +30,7 @@ def __init__(self): "uppercut": (155, 89, 182) # Purple } - def update_display(self, frame, total_count, punch_counts, session_start_time, sensitivity, paused=False): + def update_display(self, frame, total_count, punch_counts, session_start_time, sensitivity, paused=False, current_detected_combo=None, last_combo_detected_time=0, combo_display_duration=3, combo_stats=None): """ Update the UI elements on the frame @@ -39,6 +39,12 @@ def update_display(self, frame, total_count, punch_counts, session_start_time, s total_count: Total number of punches detected punch_counts: Dictionary with counts for each punch type session_start_time: Start time of the current session + sensitivity: Current sensitivity setting + paused: Boolean indicating if the session is paused + current_detected_combo: Name of the currently detected combo + last_combo_detected_time: Timestamp of the last detected combo + combo_display_duration: How long to display a combo name + combo_stats: Dictionary of combo statistics Returns: Frame with UI elements added @@ -47,7 +53,29 @@ def update_display(self, frame, total_count, punch_counts, session_start_time, s display_frame = frame.copy() # Add semi-transparent overlay for stats panel - self._add_stats_panel(display_frame, total_count, punch_counts, session_start_time, sensitivity) + self._add_stats_panel(display_frame, total_count, punch_counts, session_start_time, sensitivity, combo_stats) + + # Display current combo + if current_detected_combo and (time.time() - last_combo_detected_time < combo_display_duration): + h, w = display_frame.shape[:2] + combo_font_scale = 1.2 + combo_thickness = 2 + (text_w, text_h), _ = cv2.getTextSize(current_detected_combo, self.font, combo_font_scale, combo_thickness) + + text_x = (w - text_w) // 2 + # Position above the stats panel (assuming panel is at top right) + # or in a noticeable central area if panel is not at top. + # Let's try to place it below the center, ensuring it's above instructions. + # Instruction height: (len(instructions) * 25 + 10) -> approx 8*25+10 = 210 + # If panel is at top (h=230), text_y can be panel_height + 50 + text_y = self.panel_height + 50 # Below a top panel + if text_y > h - 220 : # Avoid overlapping with bottom instructions + text_y = (h // 2) + 50 # Fallback to just below center + + cv2.putText(display_frame, current_detected_combo, (text_x, text_y), + self.font, combo_font_scale, (0,0,0), combo_thickness + 3, cv2.LINE_AA) # Black outline + cv2.putText(display_frame, current_detected_combo, (text_x, text_y), + self.font, combo_font_scale, (50, 255, 50), combo_thickness, cv2.LINE_AA) # Bright green color if paused: self._add_paused_overlay(display_frame) @@ -57,7 +85,7 @@ def update_display(self, frame, total_count, punch_counts, session_start_time, s return display_frame - def _add_stats_panel(self, frame, total_count, punch_counts, session_start_time, sensitivity): + def _add_stats_panel(self, frame, total_count, punch_counts, session_start_time, sensitivity, combo_stats=None): """Add the statistics panel to the frame""" h, w = frame.shape[:2] @@ -88,7 +116,7 @@ def _add_stats_panel(self, frame, total_count, punch_counts, session_start_time, self.font, self.font_scale, self.font_color, self.line_thickness) # Add individual punch counts - y_offset = count_y + 5 + y_offset = count_y + 5 # Start y_offset after "Total: X" for punch_type, count in punch_counts.items(): y_offset += 25 color = self.punch_colors.get(punch_type, self.font_color) @@ -96,26 +124,36 @@ def _add_stats_panel(self, frame, total_count, punch_counts, session_start_time, (panel_x + 10, y_offset), self.font, self.font_scale, color, self.line_thickness) - # Add session time + # Session time, Pace, and Combo Stats will follow sequentially + current_stat_y = y_offset + if session_start_time: + current_stat_y += 25 session_duration = datetime.now() - session_start_time minutes, seconds = divmod(session_duration.seconds, 60) time_str = f"Time: {minutes:02d}:{seconds:02d}" cv2.putText(frame, time_str, - (panel_x + 10, y_offset + 30), + (panel_x + 10, current_stat_y), self.font, self.font_scale, self.font_color, self.line_thickness) - # Calculate and display punches per minute minutes_float = session_duration.total_seconds() / 60 if minutes_float > 0: + current_stat_y += 25 ppm = total_count / minutes_float cv2.putText(frame, f"Pace: {ppm:.1f} p/min", - (panel_x + 10, y_offset + 55), + (panel_x + 10, current_stat_y), self.font, self.font_scale, self.font_color, self.line_thickness) - # Show current sensitivity + if combo_stats: + current_stat_y += 25 + cv2.putText(frame, f"Combos: {combo_stats.get('successes', 0)}", + (panel_x + 10, current_stat_y), + self.font, self.font_scale, (0, 255, 255), self.line_thickness) # Cyan color + + # Show current sensitivity - always at the bottom of the panel + sensitivity_y_pos = panel_y + self.panel_height - 10 # Uses updated panel_height cv2.putText(frame, f"Sens.: {sensitivity}", - (panel_x + 10, panel_y + self.panel_height - 10), + (panel_x + 10, sensitivity_y_pos), self.font, self.font_scale, self.font_color, self.line_thickness) def _add_instructions(self, frame):