Real-Time Push-Up Counter with YOLO

Build a webcam push-up counter using YOLO11 pose estimation and joint angles.

Introduction

30 Second Summary

Every fitness app counts your steps, but counting exercise reps is a harder problem. Your phone's accelerometer cannot tell whether your arms actually bent far enough to count as a push-up.

In this project, you will build a Python application that watches you through your webcam and counts push-up reps in real time. A pose estimation model detects your body joints, and your code uses trigonometry to decide when a rep is complete.

What You'll Build

You'll run the script, step in front of your webcam, and watch a colored skeleton track your body while a counter on screen ticks up with every push-up you complete.

By the end of this project, you'll have:

  • A live skeleton overlay on your webcam feed that tracks your body as you move around the frame.
  • A real-time angle readout at your elbows that visibly changes as you bend and straighten your arms.
  • A rep counter on screen that accurately increments each time you complete a full push-up, with an indicator toggling between "up" and "down".
  • Secret Mission: Add a form quality checker that warns you when your back is not straight.

Do I need any machine learning experience?

No. The YOLO model handles all the AI work. You just need basic Python proficiency (functions, loops, variables) and a working webcam.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters to you.

Set Up Your Environment

Your push-up counter needs two libraries to work. Ultralytics provides the YOLO11 pose model that detects body keypoints, and OpenCV handles webcam access and video display.

In this step, you will create a project folder, set up a Python virtual environment, and install both libraries so your machine is ready to process video.

In this step, get ready to:
  • Create a project folder and virtual environment.
  • Install Ultralytics and OpenCV.
  • Verify the installation works with a quick import test.
Create your project folder and virtual environment

A virtual environment keeps this project's packages isolated from the rest of your system. You will create a dedicated folder on your Desktop, then set up the environment inside it.

  • Press the Windows key to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open a terminal inside VS Code by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir pushup-counter
cd pushup-counter
  • Confirm the folder exists by running:
pwd

You should see a path ending in pushup-counter.

  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the pushup-counter folder, then click Select Folder.

VS Code reloads with pushup-counter as your workspace. You should see the folder name in the Explorer sidebar on the left.

  • Open a fresh terminal by pressing Ctrl+`.
  • Create a virtual environment by running this command:
python -m venv venv
  • Activate the virtual environment by running:
venv\Scripts\activate

Your terminal prompt now shows (venv) at the beginning. This confirms the virtual environment is active.

What is a virtual environment?

A virtual environment is an isolated Python installation inside your project folder. Packages you install here stay separate from your system Python, so different projects can use different library versions without conflicts.

Not seeing (venv) in your prompt?

If you see an error about execution policies, PowerShell may be blocking the activation script. Run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned then try the activate command again.

If the venv folder was not created, make sure you ran python -m venv venv from inside the pushup-counter folder.

Still stuck? Help me activate my Python virtual environment on Windows

Install dependencies

With your virtual environment active, you can now install the two packages your project needs.

  • Install Ultralytics and OpenCV by running:
pip install ultralytics==8.4.101 opencv-python==5.0.0.93

This takes a minute or two. You will see packages downloading and a final line that starts with Successfully installed followed by a list of package names.

What does this install?

The ultralytics package bundles everything needed for YOLO models, including PyTorch (CPU version) and numpy. The opencv-python package provides webcam capture and image display functions.

You only typed two package names, but pip automatically installs all their dependencies. That is why you see many more packages in the output.

Seeing a download error?

Make sure (venv) is visible in your terminal prompt. If it is missing, run venv\Scripts\activate first and try the install again.

If you see a network timeout, check your internet connection and re-run the same pip install command. Pip picks up where it left off.

Need more help? Help me fix my pip install error for ultralytics

Verify the installation

Both packages are installed, but let's confirm Python can actually import them without errors.

  • Before you run the next command, what do you think will happen? Will Python be able to find both libraries?
  • Test the imports by running:
python -c "from ultralytics import YOLO; import cv2; print('Ready')"

You should see Ready printed with no errors. This confirms both Ultralytics and OpenCV are installed correctly and importable.

Seeing a ModuleNotFoundError?

If Python cannot find ultralytics or cv2, your terminal is likely using the system Python instead of the virtual environment. Check that (venv) appears in your prompt. If it does not, activate the environment and try again.

If the error persists after activation, re-run the pip install command from the previous substep.

Still stuck? Help me fix my ModuleNotFoundError

Your environment is ready to go. Next up, you will run the YOLO pose model on a test image to see what pose estimation actually looks like.

Run Pose Estimation on a Test Image

Your environment is set up with all the packages you need. Now it's time to see pose estimation in action on a real image.

Before connecting a live webcam, you need to confirm the model works and see what its output looks like. Running inference on a single photo gives you a clear, inspectable result you can study without worrying about frame rates or camera positioning.

In this step, get ready to:
  • Create a Python script that loads the YOLO11n-pose model and runs it on a test image.
  • Inspect the visual output showing detected keypoints and skeleton lines.
  • Print raw keypoint coordinates to understand the data format the model produces.
Create the pose test script

The YOLO11n-pose model detects 17 keypoints on every person it finds in an image. You will load this model, point it at a sample photo, and display the annotated result.

  • Create a new file called pose_test.py in your pushup-counter folder by running this command in your terminal:
New-Item pose_test.py
  • Open pose_test.py in the editor by clicking it in the VS Code Explorer sidebar.
  • Paste the following code into the file:
from ultralytics import YOLO

# Load the YOLO11n-pose model (auto-downloads weights on first run)
model = YOLO("yolo11n-pose.pt")

# Run pose estimation on a sample image from Ultralytics
results = model("https://ultralytics.com/images/bus.jpg")

# Display the annotated result with keypoints and skeleton drawn
for result in results:
    result.show()

What does this code do?

  • YOLO("yolo11n-pose.pt") loads the nano-sized pose estimation model. The first time you run this, it downloads the weights file (~6MB) automatically.
  • model("https://ultralytics.com/images/bus.jpg") downloads the sample image and runs pose detection on it, returning a list of results.
  • result.show() opens a window displaying the image with colored dots at each detected joint, connected by lines forming a skeleton.
  • Save pose_test.py with Ctrl+S.
Run the script and inspect the output

Before you run this, what do you think will appear on your screen? Will it be a plain photo, or something different?

  • Run the script by entering this command in your terminal:
python pose_test.py

On the first run, you will see the model weights downloading (about 6MB). Then a window opens showing the bus photo with colored keypoint dots and skeleton lines drawn on every detected person.

What should I see?

The annotated image shows each person with 17 colored dots at their joints (nose, shoulders, elbows, wrists, hips, knees, ankles). Lines connect the dots to form a stick-figure skeleton overlay.

Close the image window by pressing any key to let the script finish.

Window not appearing?

  • If you see a download error, check your internet connection. The model weights and sample image both need to download on first run.
  • If the window appears frozen or unresponsive, click on it once to bring it into focus, then press any key to close it.

Help me debug why my YOLO pose test script isn't displaying results

Print the raw keypoint data

The visual overlay is helpful, but your push-up counter needs the actual coordinate numbers to calculate angles. You will add print statements to see the raw data the model produces for each detected person.

  • In pose_test.py, find this line inside the for loop:
    result.show()
  • Add the following lines directly below result.show(), keeping the same indentation level (inside the for loop):
    print("Keypoint coordinates (x, y) for each person:")
    print(result.keypoints.xy)
    print("\nKeypoint confidence scores:")
    print(result.keypoints.conf)

What does this data show?

  • result.keypoints.xy gives you a tensor with shape (N, 17, 2). N is the number of people detected. Each person has 17 pairs of (x, y) pixel coordinates.
  • result.keypoints.conf gives you a confidence score (0 to 1) for each keypoint, indicating how sure the model is about that joint's position.
  • The 17 keypoints follow the COCO order: 0=Nose, 5=Left Shoulder, 6=Right Shoulder, 7=Left Elbow, 8=Right Elbow, 9=Left Wrist, 10=Right Wrist, and so on.
  • Save pose_test.py with Ctrl+S.
  • Run the script again:
python pose_test.py
  • Close the image window by pressing any key.
  • In the terminal, you should see arrays of coordinate pairs and confidence values printed for each detected person.

Why does keypoint order matter?

In the next steps, you will pull out specific keypoints by their index to calculate elbow angles. Index 5 is the Left Shoulder, 7 is the Left Elbow, and 9 is the Left Wrist. These three points form the triangle you need for push-up angle math.

Seeing an IndentationError?

Make sure your print() lines are indented with four spaces, matching the result.show() line above them. They must be inside the for loop.

Help me fix an IndentationError in my pose_test.py script

✔️ Awesome, I've got everything!

Great. Double check you have saved pose_test.py and the terminal shows keypoint coordinate arrays after you close the image window.

ⓧ I'd like to double check the full code

Here is the complete pose_test.py file. Make sure yours matches exactly:

from ultralytics import YOLO

model = YOLO("yolo11n-pose.pt")

results = model("https://ultralytics.com/images/bus.jpg")

for result in results:
    result.show()
    print("Keypoint coordinates (x, y) for each person:")
    print(result.keypoints.xy)
    print("\nKeypoint confidence scores:")
    print(result.keypoints.conf)

You have confirmed that YOLO11n-pose detects body keypoints and you know how to access the raw coordinate data. Next up, you will connect this model to your live webcam so it tracks your own body in real time.

Stream Live Webcam Pose Detection

In the previous step, you proved the YOLO11 pose model works by running it on a static image. You saw colored keypoints and skeleton lines drawn on detected people.

Static images are great for inspection, but a push-up counter needs to process video in real time. In this step, you will connect your webcam feed directly to the pose model so you see your own skeleton drawn live, updating every frame.

In this step, get ready to:
  • Create a webcam processing loop that reads frames and passes them to the YOLO pose model.
  • Display the annotated skeleton overlay on your live webcam feed.
  • Observe the model tracking your body in real time as you move.
Set up the webcam capture and model

Your push-up counter needs two things running together: the YOLO pose model (which you already know works) and a live webcam feed from OpenCV.

  • Create a new file called pose_live.py in your pushup-counter folder.
  • Add the following setup code to load the model and connect to your webcam:
from ultralytics import YOLO
import cv2

# Load the YOLO11 pose estimation model
model = YOLO("yolo11n-pose.pt")

# Connect to the default webcam (index 0)
cap = cv2.VideoCapture(0)

What does this code do?

  • cv2.VideoCapture(0) opens a connection to your default webcam. The 0 tells OpenCV to use the first available camera.
  • The model loads the same yolo11n-pose.pt weights you used in the previous step. Since the file already exists locally, it loads instantly.
Add the frame processing loop

The webcam connection is ready, but nothing happens until you read frames in a loop and pass each one through the model.

  • Below the setup code in pose_live.py, add the main processing loop:
while cap.isOpened():
    # Read one frame from the webcam
    ret, frame = cap.read()
    if not ret:
        break

    # Run pose estimation on the frame (verbose=False hides per-frame logs)
    results = model(frame, verbose=False)
    # Get the frame with skeleton overlay drawn on it
    annotated_frame = results[0].plot()

    # Display the annotated frame in a window
    cv2.imshow("YOLO Pose Estimation", annotated_frame)

    # Press 'q' to quit the loop
    if cv2.waitKey(1) & 0xFF == ord("q"):
        break

# Release the webcam and close all windows
cap.release()
cv2.destroyAllWindows()

What does this code do?

  • cap.read() grabs a single frame from the webcam. It returns two values: a success flag and the image data.
  • model(frame, verbose=False) runs pose estimation on that frame. The verbose=False flag suppresses log output that would flood your terminal every frame.
  • results[0].plot() returns the frame as a numpy array with the skeleton and keypoints drawn on it.
  • cv2.waitKey(1) & 0xFF waits 1 millisecond for a keypress and masks the result to 8 bits. If the pressed key is q, the loop breaks.
  • Save pose_live.py.

✔️ Awesome, I've got everything!

Great. Make sure you have saved pose_live.py before running it in the next substep.

ⓧ I'd like to double check the full code

from ultralytics import YOLO
import cv2

model = YOLO("yolo11n-pose.pt")

cap = cv2.VideoCapture(0)

while cap.isOpened():
    ret, frame = cap.read()
    if not ret:
        break

    results = model(frame, verbose=False)
    annotated_frame = results[0].plot()

    cv2.imshow("YOLO Pose Estimation", annotated_frame)

    if cv2.waitKey(1) & 0xFF == ord("q"):
        break

cap.release()
cv2.destroyAllWindows()
Run and observe the live skeleton

Your script is complete. Time to see your own skeleton drawn in real time.

Before you run this, what do you think you will see in the window? Will it look different from the static image result?

  • Run the live pose detection script:
python pose_live.py

A window titled "YOLO Pose Estimation" opens showing your webcam feed with a colored skeleton overlay tracking your body in real time.

  • Move around in front of your camera. Watch the skeleton track your shoulders, elbows, wrists, hips, knees, and ankles as you move.
  • Try stepping out of the frame and back in. The model handles appearing and disappearing gracefully.
  • Press q on your keyboard to close the window when you are done exploring.

What about the frame rate?

The YOLO11n-pose model runs at approximately 19 FPS on CPU. That is fast enough for a smooth, responsive skeleton overlay without a dedicated GPU.

Window not opening or webcam not detected?

  • Make sure no other application (Zoom, Teams, a browser tab) is using your webcam. Only one program can access it at a time.
  • If you see the window open but it is black, check that your webcam is not physically blocked or covered.
  • If you get a permission error, open Windows Settings, go to Privacy & security then Camera, and make sure camera access is enabled for desktop apps.

Why is my webcam not working with OpenCV?

You now have real-time pose detection running on your webcam. The model is drawing a full skeleton, but right now it is purely visual. Next up, you will extract the raw keypoint coordinates and use trigonometry to calculate joint angles, turning those colored dots into actionable measurements.

Calculate Joint Angles from Keypoints

Your live skeleton tracks your body in real time, but those colored lines are purely visual. You cannot count push-ups by watching a skeleton move.

In this step, you will extract specific joint coordinates from the pose data and compute the angle at your elbow using trigonometry. When your arm bends, the angle drops. When it extends, the angle rises. That measurable signal is what turns a pretty skeleton into a real push-up counter.

In this step, get ready to:
  • Write an angle calculation function using trigonometry.
  • Extract elbow keypoints and compute the joint angle live.
  • Display the angle on the webcam feed and test it with arm movements.
Create the angle calculation function

To measure how bent an arm is, you need three points: the shoulder, the elbow, and the wrist. The angle formed at the elbow tells you exactly how extended or flexed the arm is.

  • Create a new file called pushup_counter.py in your pushup-counter folder.
  • Add the imports, model loading, and the angle calculation function by pasting this code:
from ultralytics import YOLO
import cv2
import numpy as np

# Load the YOLO11 pose estimation model
model = YOLO("yolo11n-pose.pt")


def calculate_angle(a, b, c):
    """Calculate the angle at point b given three points a, b, c."""
    a = np.array(a)
    b = np.array(b)
    c = np.array(c)

    # Compute angle using arctan2 (handles all quadrants correctly)
    radians = np.arctan2(c[1] - b[1], c[0] - b[0]) - np.arctan2(a[1] - b[1], a[0] - b[0])
    angle = np.abs(radians * 180.0 / np.pi)

    # Normalize to 0-180 range
    if angle > 180.0:
        angle = 360 - angle

    return angle

What does this code do?

The calculate_angle(a, b, c) function takes three points (shoulder, elbow, wrist) and returns the angle at the middle point (the elbow).

  • np.arctan2 calculates the angle of each arm ray (elbow-to-shoulder and elbow-to-wrist) relative to horizontal. Subtracting gives the angle between them.
  • The if angle > 180.0 check normalizes the result to always be between 0 and 180 degrees, regardless of which direction the arm faces.
  • Save pushup_counter.py.
Extract elbow keypoints and display the angle

The YOLO pose model outputs 17 keypoints per person. Each keypoint has an index in a fixed order defined by the COCO dataset standard. To measure elbow bend, you need index 5 (Left Shoulder), index 7 (Left Elbow), and index 9 (Left Wrist).

  • Add the webcam loop with keypoint extraction below the calculate_angle function by pasting this code at the bottom of pushup_counter.py:
cap = cv2.VideoCapture(0)

while cap.isOpened():
    ret, frame = cap.read()
    if not ret:
        break

    results = model(frame, verbose=False)
    annotated_frame = results[0].plot()

    for result in results:
        if result.keypoints is not None and len(result.keypoints.xy) > 0:
            # Get the first person's 17 keypoints as a numpy array
            keypoints = result.keypoints.xy[0].cpu().numpy()

            # Extract the three points that form the elbow angle
            left_shoulder = keypoints[5]
            left_elbow = keypoints[7]
            left_wrist = keypoints[9]

            left_angle = calculate_angle(left_shoulder, left_elbow, left_wrist)

            # Display the angle near the elbow joint in green
            elbow_pos = tuple(left_elbow.astype(int))
            cv2.putText(annotated_frame, f"{int(left_angle)} deg", elbow_pos,
                        cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)

    cv2.imshow("Push-Up Counter", annotated_frame)

    if cv2.waitKey(1) & 0xFF == ord("q"):
        break

cap.release()
cv2.destroyAllWindows()

What does this code do?

  • result.keypoints.xy[0].cpu().numpy() grabs the first detected person's 17 keypoints and converts them from a PyTorch tensor to a NumPy array you can index.
  • keypoints[5], keypoints[7], and keypoints[9] pull out the Left Shoulder, Left Elbow, and Left Wrist coordinates respectively.
  • cv2.putText() draws the computed angle as green text at the elbow's pixel position on the video frame.
  • The if result.keypoints is not None guard prevents crashes when no person is detected in a frame.
  • Save pushup_counter.py.
Test the angle readout

Before you run the script, think about what you expect to see. If you hold your arm straight out, what angle should the elbow show? What about when you bend it fully?

  • Run the script by entering this command in your terminal:
python pushup_counter.py
  • Stand in front of your webcam so the model can detect your body.
  • Extend your left arm straight out. You should see a green number near your elbow reading approximately 160-180 degrees.
  • Bend your left arm fully. The number should drop to approximately 40-70 degrees.
  • Press q to close the window when you are done.

What should I see?

A webcam window titled "Push-Up Counter" shows your skeleton overlay plus a green number near your left elbow. The number updates every frame as you move. A straight arm reads high (around 170). A fully bent arm reads low (around 50-60). This live angle readout is the measurement your rep counter will use in the next step.

Not seeing the angle number?

  • Make sure your full upper body (shoulder to wrist) is visible in the webcam frame. If your arm is cropped, the model cannot detect those keypoints.
  • Check that your virtual environment is activated. If you see an import error for numpy, run venv\Scripts\activate again.
  • If the window opens but shows no skeleton at all, verify your webcam is not being used by another application.

Why isn't my angle overlay showing on the webcam feed?

✔️ Awesome, I've got everything!

Great. Double check you have saved pushup_counter.py and confirmed the angle changes as you move your arm.

ⓧ I'd like to double check the full code

Your pushup_counter.py file should contain these two sections in order:

  • The imports (YOLO, cv2, numpy), model loading, and the calculate_angle function (from the first chunk above).
  • The webcam loop with keypoint extraction, angle calculation, cv2.putText() overlay, and the quit/cleanup logic (from the second chunk above).

Scroll up and confirm both chunks are present in your file, in that order.

You now have a live angle measurement that tracks your elbow in real time. Next up, you will turn that single number into an automatic rep counter that detects full push-up cycles.

Build the Rep Counter

You can now measure elbow angles in real time. The green number on screen changes as you bend your arm. But a number is not a counter.

To turn angle measurements into push-up reps, you need a state machine. It tracks whether you are in the "up" position or "down" position, and increments the count each time you complete a full down-then-up cycle.

In this step, get ready to:
  • Add state tracking variables for rep count and body position.
  • Implement a state machine that counts full push-up reps from angle thresholds.
  • Test the complete counter by performing push-ups in front of your webcam.
Add state tracking variables

Each video frame is independent. The model does not remember what happened in the previous frame. Your code needs variables that persist across the loop to track whether the learner is currently "up" or "down" and how many reps they have completed.

  • In pushup_counter.py, find this line just before the while loop:
cap = cv2.VideoCapture(0)

while cap.isOpened():
  • Add two state variables between cap = cv2.VideoCapture(0) and while cap.isOpened():, so the section looks like this:
cap = cv2.VideoCapture(0)

rep_count = 0
position = None

while cap.isOpened():

What do these variables do?

rep_count stores the total push-ups completed so far. position tracks whether you are currently in the "up" or "down" phase. It starts as None because you have not started moving yet.

Right now, the script only measures your left elbow angle. A push-up uses both arms equally, so averaging both elbows gives a much more reliable measurement.

  • Inside the if result.keypoints is not None block, find this existing code:
            left_shoulder = keypoints[5]
            left_elbow = keypoints[7]
            left_wrist = keypoints[9]

            left_angle = calculate_angle(left_shoulder, left_elbow, left_wrist)

            elbow_pos = tuple(left_elbow.astype(int))
            cv2.putText(annotated_frame, f"{int(left_angle)} deg", elbow_pos,
                        cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
  • Replace that entire block with the following code, which adds the right arm keypoints and computes the average angle:
            left_shoulder = keypoints[5]
            left_elbow = keypoints[7]
            left_wrist = keypoints[9]

            right_shoulder = keypoints[6]
            right_elbow = keypoints[8]
            right_wrist = keypoints[10]

            left_angle = calculate_angle(left_shoulder, left_elbow, left_wrist)
            right_angle = calculate_angle(right_shoulder, right_elbow, right_wrist)
            avg_angle = (left_angle + right_angle) / 2

            elbow_pos = tuple(left_elbow.astype(int))
            cv2.putText(annotated_frame, f"{int(avg_angle)} deg", elbow_pos,
                        cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)

What changed here?

  • Right Shoulder (index 6), Right Elbow (index 8), and Right Wrist (index 10) are now extracted alongside the left arm.
  • right_angle computes the angle at the right elbow the same way as the left.
  • avg_angle averages both arms so a slightly uneven push-up still registers correctly.
  • The display now shows avg_angle instead of left_angle.
  • Save pushup_counter.py.
  • Run the script to confirm the average angle displays correctly by running:
python pushup_counter.py
  • Bend both arms. The green number near your elbow should still change smoothly, now reflecting the average of both elbows.
  • Press q to quit.

Seeing an error about index out of range?

Make sure the indentation of the new right-arm lines matches the left-arm lines above them. All keypoint extraction lines should be at the same indent level inside the if block.

help me fix an index error in my pushup counter keypoints code

Implement the state machine

The state machine uses two angle thresholds to define a complete rep. When your arms bend and the average angle drops below 90 degrees, you are in the "down" position. When you push back up and the angle rises above 160 degrees, you are in the "up" position. A rep counts only when you transition from "down" to "up".

Why two separate thresholds?

The gap between 90 and 160 degrees creates hysteresis. Without it, small jitters around a single threshold would trigger false counts. The large gap means the counter only fires on a genuine full range-of-motion rep.

  • In pushup_counter.py, find the avg_angle = (left_angle + right_angle) / 2 line.
  • Add the following state machine logic directly below it (before the elbow_pos line):
            if avg_angle < 90:
                position = "down"
            if avg_angle > 160 and position == "down":
                position = "up"
                rep_count += 1

What does this code do?

  • When the average elbow angle drops below 90 degrees, position is set to "down". Your arms are fully bent.
  • When the angle rises above 160 degrees AND position was already "down", the counter increments. This AND condition prevents counting when you simply start with extended arms.

Now you need to display the rep count and current position on screen so you can see the counter working.

  • Find the cv2.imshow("Push-Up Counter", annotated_frame) line near the bottom of the while loop.
  • Add the following two lines directly above it (outside the for loop, at the same indent level as cv2.imshow):
    cv2.putText(annotated_frame, f"Push-ups: {rep_count}", (10, 40),
                cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 0, 255), 3)
    cv2.putText(annotated_frame, f"Position: {position or 'N/A'}", (10, 80),
                cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 0), 2)

What does this code do?

  • The first putText draws the rep count in red at the top-left corner of the frame at position (10, 40).
  • The second putText draws the current position ("up", "down", or "N/A") in yellow just below the rep count at position (10, 80).
  • These lines are outside the for loop because the display should always appear, even if no person is detected in that frame.
  • Save pushup_counter.py.

Not sure about the indentation?

The state machine if statements should be indented 12 spaces (inside the for loop's if block). The cv2.putText display lines should be indented 4 spaces (inside the while loop but outside the for loop).

help me fix the indentation in my pushup counter state machine

Test the complete counter

Your push-up counter is complete. The state machine watches the average elbow angle each frame and counts every full down-then-up cycle.

  • Before you run, predict: what do you think you will see on screen when you first launch the script, before doing any push-ups?
  • Run the script:
python pushup_counter.py

You should see "Push-ups: 0" and "Position: N/A" in the top-left corner. The angle number still appears near your elbow in green.

  • Do 3 push-ups (or simulate by bending and fully extending both arms in front of the camera).
  • Watch the position indicator toggle between "down" and "up" with each phase.
  • Confirm the counter reads "Push-ups: 3" after completing three full reps.
  • Press q to quit.

Tips for simulating push-ups at your desk

If you cannot do push-ups in front of your webcam, stand facing the camera and extend your arms forward. Bend both elbows fully (past 90 degrees), then extend back out straight (past 160 degrees). The counter registers this as one rep.

Counter not incrementing?

  • Make sure you bend past 90 degrees (the position must flip to "down" first). Watch the angle number and confirm it drops below 90.
  • Extend your arms fully. The angle must rise above 160 degrees for the "up" transition to trigger.
  • Check that both elbows are visible to the camera. If one arm is hidden, its keypoint coordinates may be unreliable and skew the average.

help me debug why my push-up counter is not incrementing

✔️ Awesome, I've got everything!

Great. Double check you have saved pushup_counter.py and the counter increments correctly when you do push-ups.

ⓧ I'd like to double check the full code

Here is the complete pushup_counter.py file. Compare your code against it to spot any differences.

Your file has multiple sections. Scroll up and confirm each one is present in this order:

  • Imports and model load (from ultralytics import YOLO, import cv2, import numpy as np, model load).
  • The calculate_angle function (uses np.arctan2).
  • Webcam capture, rep_count = 0, and position = None.
  • While loop with: frame read, model inference, keypoint extraction (both arms), angle calculations, state machine, angle display, rep/position display, cv2.imshow, and quit on q.
  • cap.release() and cv2.destroyAllWindows() at the end.

If something looks off, here are the key sections to verify:

cap = cv2.VideoCapture(0)

rep_count = 0
position = None

while cap.isOpened():
    ret, frame = cap.read()
    if not ret:
        break

    results = model(frame, verbose=False)
    annotated_frame = results[0].plot()
            left_shoulder = keypoints[5]
            left_elbow = keypoints[7]
            left_wrist = keypoints[9]

            right_shoulder = keypoints[6]
            right_elbow = keypoints[8]
            right_wrist = keypoints[10]

            left_angle = calculate_angle(left_shoulder, left_elbow, left_wrist)
            right_angle = calculate_angle(right_shoulder, right_elbow, right_wrist)
            avg_angle = (left_angle + right_angle) / 2
            if avg_angle < 90:
                position = "down"
            if avg_angle > 160 and position == "down":
                position = "up"
                rep_count += 1

            elbow_pos = tuple(left_elbow.astype(int))
            cv2.putText(annotated_frame, f"{int(avg_angle)} deg", elbow_pos,
                        cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
    cv2.putText(annotated_frame, f"Push-ups: {rep_count}", (10, 40),
                cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 0, 255), 3)
    cv2.putText(annotated_frame, f"Position: {position or 'N/A'}", (10, 80),
                cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 0), 2)

    cv2.imshow("Push-Up Counter", annotated_frame)

    if cv2.waitKey(1) & 0xFF == ord("q"):
        break

cap.release()
cv2.destroyAllWindows()

Your push-up counter is working. You have a state machine that reliably counts full reps by tracking the down-then-up cycle of your elbow angles. Next up, you will take on the Secret Mission and add form quality detection to catch bad posture mid-rep.

Secret mission

Add a Form Quality Checker

Your counter tracks reps, but it cannot tell if you are doing them correctly. Bad push-up form leads to injury over time. Can you add a form quality checker that detects when your back is sagging or piking, and warns you in real time?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • pushup-counter project folder on your Desktop (contains all Python scripts, virtual environment, and model weights).
  • Python virtual environment (venv) with ultralytics and opencv-python packages installed.
  • yolo11n-pose.pt model weights file (~6MB, auto-downloaded on first run).

Keep everything running

No action needed. Choose this if you plan to keep experimenting with pose estimation or want to adapt the counter for other exercises like squats or bicep curls.

  • Your pushup-counter folder stays on your Desktop, ready to run anytime.
  • To use it again, open the folder in VS Code, activate the virtual environment with venv\Scripts\activate, and run python pushup_counter.py.

Pause - I'll come back to this later

Since this project is entirely local, there is nothing to pause. Your files sit idle on disk and consume no CPU or memory when the scripts are not running.

  • Make sure the webcam window is closed (press q in the OpenCV window if it is still open).
  • Deactivate the virtual environment by running:
deactivate
  • Your project folder stays on your Desktop. When you come back, reactivate with venv\Scripts\activate and pick up where you left off.

Delete - I don't want to use this again

Remove all project files, the virtual environment, and the downloaded model weights. This frees up the disk space.

  • Make sure the webcam window is closed (press q in the OpenCV window if it is still open).
  • Deactivate the virtual environment if it is still active:
deactivate
  • Delete the entire project folder by running this from your Desktop:

macOS

cd ~/Desktop
rm -rf pushup-counter

Windows

cd ~/Desktop
Remove-Item -Recurse -Force pushup-counter
  • This removes all three Python scripts (pose_test.py, pose_live.py, pushup_counter.py), the virtual environment, and the yolo11n-pose.pt model weights.

Nice Work!

Nice Work!

Nice work! You just built a real-time push-up counter that uses computer vision to track your body and count reps through your webcam.

You've learned how to:

  • Built a real-time webcam pipeline that processes video frames through the YOLO11 pose estimation model, detecting 17 body keypoints per person.
  • Extracted raw keypoint coordinates and computed joint angles using trigonometry (arctan2), turning skeleton data into actionable measurements.
  • Implemented a state machine that tracks the up/down position of your arms and increments a rep counter each time you complete a full push-up cycle.
  • Secret Mission: Added a form quality checker that measures shoulder-hip-ankle alignment and warns you in real time when your back is sagging.

Ready to quiz yourself?