Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 47 additions & 3 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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}")

Expand All @@ -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.")
Expand All @@ -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:
Expand All @@ -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:
Copy link

Copilot AI Jun 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider using a direct truthiness check (e.g., 'if self.punch_counter.punch_event_history:') for improved readability.

Suggested change
if len(self.punch_counter.punch_event_history) > 0:
if self.punch_counter.punch_event_history:

Copilot uses AI. Check for mistakes.
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): combo_stats['attempts'] never gets incremented

Increment 'attempts' when detect_combo is called or when a combo window starts to accurately track attempts.

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()
Comment on lines +126 to +138
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (code-quality): Merge nested if conditions (merge-nested-ifs)

Suggested change
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()
if detected_combo_name and (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()


ExplanationToo much nesting can make code difficult to understand, and this is especially
true in Python, where there are no brackets to help out with the delineation of
different nesting levels.

Reading deeply nested code is confusing, since you have to keep track of which
conditions relate to which levels. We therefore strive to reduce nesting where
possible, and the situation where two if conditions can be combined using
and is an easy win.


# Add visual feedback if punches are detected
if punches_detected:
Expand All @@ -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
Expand Down
139 changes: 139 additions & 0 deletions tests/test_combo_detector.py
Original file line number Diff line number Diff line change
@@ -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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): Extract duplicate code into method (extract-duplicate-method)

# 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
Copy link

Copilot AI Jun 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider using fixed or mocked timestamps in tests to avoid potential flakiness due to reliance on system time.

Copilot uses AI. Check for mistakes.

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()
160 changes: 160 additions & 0 deletions utils/combo_detector.py
Original file line number Diff line number Diff line change
@@ -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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security (return-in-init): return should never appear inside a class init function. This will cause a runtime error.

Source: opengrep

else:
self.predefined_combos = sorted(predefined_combos, key=lambda c: len(c['sequence']), reverse=True)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security (return-in-init): return should never appear inside a class init function. This will cause a runtime error.

Source: opengrep


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

Comment on lines +64 to +70
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (code-quality): We've found these issues:

Suggested change
# Check punch sequence
match = True
for i, expected_punch in enumerate(combo_sequence):
if relevant_history[i][0] != expected_punch:
match = False
break
match = all(
relevant_history[i][0] == expected_punch
for i, expected_punch in enumerate(combo_sequence)
)

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__':
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Demo test code in module; consider moving to separate tests

Consider relocating the __main__ test block to a separate test file to keep production code clean and maintainable.

# 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
Loading