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
38 changes: 38 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Repository Guidelines

These instructions apply to the entire repository. Follow them when modifying or adding files.

## Environment Setup
- Use **Python 3.11+**.
- Install dependencies:
```bash
pip install -r requirements.txt
pip install black flake8 # development tools
```
- Verify that all imported modules are listed in `requirements.txt`:
```bash
python utils/verify_requirements.py
```

## Formatting
- Format code with **Black** before committing:
```bash
black .
```
- Use the default line length (88 characters) and 4‑space indentation.

Comment on lines +22 to +23
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Inconsistent line-length settings. The Formatting section mandates Black’s default (88 chars) while Linting uses --max-line-length=120. Align these values to avoid confusion.

Also applies to: 27-28

🤖 Prompt for AI Agents
In AGENTS.md around lines 22 to 23 and 27 to 28, the line length settings are
inconsistent between the Formatting section (88 characters) and the Linting
section (120 characters). Update the Linting section to use the default Black
line length of 88 characters to align both settings and avoid confusion.

## Linting
- Run **flake8** and resolve all issues:
```bash
flake8 --max-line-length=120
```

## Testing
- Execute the test suite with **pytest** and ensure it passes:
```bash
pytest -v
```

## Pull Requests
- Make concise, focused commits using present‑tense messages (e.g., "Add combo detection module").
- Verify formatting, linting, and tests succeed before submitting a PR.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# 🥊 PunchTracker - AI Boxing Assistant 🥊

![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Python](https://img.shields.io/badge/python-v3.8+-blue.svg)
![Python](https://img.shields.io/badge/python-v3.11+-blue.svg)
![TensorFlow](https://img.shields.io/badge/TensorFlow-v2.12+-orange.svg)
![OpenCV](https://img.shields.io/badge/OpenCV-v4.5+-green.svg)
![Status](https://img.shields.io/badge/status-active-success.svg)
Expand Down Expand Up @@ -32,7 +32,7 @@ A real-time boxing assistant application that uses computer vision and machine l

### Prerequisites

- Python 3.8 or higher
- Python 3.11 or higher
- Webcam
- Required libraries (see requirements.txt)

Expand Down
96 changes: 55 additions & 41 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from utils.data_manager import DataManager
from utils.calibration import Calibrator


class PunchTracker:
def __init__(self):
# Initialize components
Expand All @@ -23,54 +24,60 @@ def __init__(self):
self.ui_manager = UIManager()
self.data_manager = DataManager()
self.calibrator = Calibrator()

# Application state
self.is_running = False
self.is_calibrating = False
self.show_debug = False
self.is_paused = False
self.session_start_time = None

# Camera settings
self.camera_id = 0
self.frame_width = 640
self.frame_height = 480
self.cap = None

def initialize_camera(self):
"""Initialize the webcam"""
self.cap = cv2.VideoCapture(self.camera_id)
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.frame_width)
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.frame_height)
if not self.cap.isOpened():
raise ValueError("Could not open camera. Check if it's connected properly.")

def start_session(self):
"""Start a new punching session"""
self.session_start_time = datetime.now()
self.punch_counter.reset_counter()
self.data_manager.create_new_session()
print(f"New session started at {self.session_start_time}")

def end_session(self):
"""End the current session and save data"""
session_duration = (datetime.now() - self.session_start_time).total_seconds()
session_data = {
'date': self.session_start_time.strftime('%Y-%m-%d %H:%M:%S'),
'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
"date": self.session_start_time.strftime("%Y-%m-%d %H:%M:%S"),
"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
),
}
self.data_manager.save_session_data(session_data)
print(f"Session ended. {self.punch_counter.total_count} punches recorded over {session_duration:.1f} seconds.")

print(
f"Session ended. {self.punch_counter.total_count} punches recorded over {session_duration:.1f} seconds."
)

def start_calibration(self):
"""Start the calibration process"""
self.is_calibrating = True
self.calibrator.start_calibration()
print("Calibration started. Follow the on-screen instructions.")

def process_frame(self, frame):
"""Process a single frame from the webcam"""
# Detect poses in the frame
Expand All @@ -83,88 +90,94 @@ 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,
)

if self.is_calibrating:
# Handle calibration mode
calibration_complete, frame = self.calibrator.process_calibration_frame(frame, poses)
calibration_complete, frame = self.calibrator.process_calibration_frame(
frame, poses
)
if calibration_complete:
self.is_calibrating = False
self.punch_counter.apply_calibration(self.calibrator.get_calibration_data())
self.punch_counter.apply_calibration(
self.calibrator.get_calibration_data()
)
print("Calibration completed!")
else:
# Normal processing - detect punches
punches_detected = self.punch_counter.detect_punches(poses)

# Add visual feedback if punches are detected
if punches_detected:
for punch_info in punches_detected:
punch_type, coords = punch_info
# Visual feedback for the punch
cv2.circle(frame, (int(coords[0]), int(coords[1])), 15, (0, 0, 255), -1)

cv2.circle(
frame, (int(coords[0]), int(coords[1])), 15, (0, 0, 255), -1
)

# Update the UI with the latest data
frame = self.ui_manager.update_display(
frame,
self.punch_counter.total_count,
self.punch_counter.get_punch_types_count(),
self.session_start_time,
self.punch_counter.velocity_threshold,
paused=False
paused=False,
)

# Show debug visualization if enabled
if self.show_debug:
frame = self.pose_detector.draw_pose(frame, poses)

return frame

def run(self):
"""Main application loop"""
self.initialize_camera()
self.is_running = True
self.start_session()

try:
while self.is_running:
ret, frame = self.cap.read()
if not ret:
print("Failed to grab frame from camera")
break

# Mirror the frame for a more intuitive display
frame = cv2.flip(frame, 1)

# Process the current frame
display_frame = self.process_frame(frame)

# Display the resulting frame
cv2.imshow('PunchTracker', display_frame)
cv2.imshow("PunchTracker", display_frame)

# Handle keyboard input
key = cv2.waitKey(1) & 0xFF
if key == 27: # ESC key
break
elif key == ord('c'):
elif key == ord("c"):
self.start_calibration()
elif key == ord('d'):
elif key == ord("d"):
self.show_debug = not self.show_debug
elif key == ord('r'):
elif key == ord("r"):
self.start_session()
elif key == ord('s'):
elif key == ord("s"):
stats_image = self.ui_manager.generate_stats_graph(
self.data_manager.get_historical_data(),
self.punch_counter.get_punch_types_count()
self.punch_counter.get_punch_types_count(),
)
cv2.imshow('Punch Statistics', stats_image)
elif key == ord('p'):
cv2.imshow("Punch Statistics", stats_image)
elif key == ord("p"):
self.is_paused = not self.is_paused
elif key == ord('i'):
elif key == ord("i"):
self.punch_counter.increase_sensitivity()
elif key == ord('k'):
elif key == ord("k"):
self.punch_counter.decrease_sensitivity()

finally:
# Cleanup
self.end_session()
Expand All @@ -173,9 +186,10 @@ def run(self):
cv2.destroyAllWindows()
print("Application terminated")


if __name__ == "__main__":
try:
app = PunchTracker()
app.run()
except Exception as e:
print(f"Error: {e}")
print(f"Error: {e}")
25 changes: 13 additions & 12 deletions tests/test_data_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,22 @@

from utils.data_manager import DataManager


def test_create_and_save_session():
with tempfile.TemporaryDirectory() as tmpdir:
dm = DataManager(data_dir=tmpdir)
dm.create_new_session()
session_data = {
'date': '2023-01-01 00:00:00',
'duration': 10.0,
'total_punches': 5,
'punch_types': {'jab': 2, 'cross': 3},
'punches_per_minute': 30.0
"date": "2023-01-01 00:00:00",
"duration": 10.0,
"total_punches": 5,
"punch_types": {"jab": 2, "cross": 3},
"punches_per_minute": 30.0,
}
dm.save_session_data(session_data)
hist = dm.get_historical_data()
assert len(hist) == 1
assert hist[0]['total_punches'] == 5
assert hist[0]["total_punches"] == 5


def test_json_backup_write_failure(tmp_path, capsys):
Expand All @@ -27,11 +28,11 @@ def test_json_backup_write_failure(tmp_path, capsys):
dm = DataManager(data_dir=data_dir)
dm.create_new_session()
session_data = {
'date': '2023-01-01 00:00:00',
'duration': 5.0,
'total_punches': 2,
'punch_types': {'jab': 2},
'punches_per_minute': 24.0
"date": "2023-01-01 00:00:00",
"duration": 5.0,
"total_punches": 2,
"punch_types": {"jab": 2},
"punches_per_minute": 24.0,
}

os.chmod(data_dir, 0o500)
Expand All @@ -40,4 +41,4 @@ def test_json_backup_write_failure(tmp_path, capsys):

output = capsys.readouterr().out
assert "Failed to write backup file" in output
assert len(list(data_dir.glob('session_*.json'))) == 0
assert len(list(data_dir.glob("session_*.json"))) == 0
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): Simplify sequence length comparison (simplify-len-comparison)

Suggested change
assert len(list(data_dir.glob("session_*.json"))) == 0
assert not list(data_dir.glob("session_*.json"))

20 changes: 10 additions & 10 deletions tests/test_punch_counter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@
def test_classify_cross():
pc = PunchCounter()
keypoints = {
'right_wrist': (1.1, 0.0, 1.0),
'right_elbow': (0.5, 0.0, 1.0),
'right_shoulder': (0.0, 0.0, 1.0),
'left_shoulder': (-0.2, 0.0, 1.0),
"right_wrist": (1.1, 0.0, 1.0),
"right_elbow": (0.5, 0.0, 1.0),
"right_shoulder": (0.0, 0.0, 1.0),
"left_shoulder": (-0.2, 0.0, 1.0),
}
punch_type = pc._classify_punch_type(keypoints, 'right', velocity=60)
punch_type = pc._classify_punch_type(keypoints, "right", velocity=60)
assert punch_type == pc.CROSS


def test_classify_uppercut():
pc = PunchCounter()
keypoints = {
'left_wrist': (0.0, -1.1, 1.0),
'left_elbow': (0.0, -0.5, 1.0),
'left_shoulder': (0.0, 0.0, 1.0),
'right_shoulder': (0.5, 0.0, 1.0),
"left_wrist": (0.0, -1.1, 1.0),
"left_elbow": (0.0, -0.5, 1.0),
"left_shoulder": (0.0, 0.0, 1.0),
"right_shoulder": (0.5, 0.0, 1.0),
}
punch_type = pc._classify_punch_type(keypoints, 'left', velocity=60)
punch_type = pc._classify_punch_type(keypoints, "left", velocity=60)
assert punch_type == pc.UPPERCUT
Loading