---
title: "Build an AI push-up counter with pose estimation in Python"
description: "Use MediaPipe Pose to measure your elbow angle and count push-up reps automatically. The same state machine works for squats, curls and any repeated movement."
url: https://articles.sythra.ai/articles/pushup-counter-pose-estimation-python
slug: pushup-counter-pose-estimation-python
author: "vaibhavkothari"
author_url: https://articles.sythra.ai/writers/vaibhavkothari
date_published: 2026-08-26T08:30:17.365Z
date_modified: 2026-08-29T10:22:15.966Z
topics: ["Python", "Mediapipe", "Opencv", "Computer Vision", "Coding"]
reading_time_minutes: 7
publisher: "Sythra"
publisher_url: https://sythra.ai
access: free
language: en
---

# Build an AI push-up counter with pose estimation in Python

> Use MediaPipe Pose to measure your elbow angle and count push-up reps automatically. The same state machine works for squats, curls and any repeated movement.

Source: https://articles.sythra.ai/articles/pushup-counter-pose-estimation-python · Author: vaibhavkothari · Published: 2026-08-26 · Reading time: 7 min · Topics: Python, Mediapipe, Opencv, Computer Vision, Coding

A rep counter is a great first pose-estimation project because the logic is honest and small: measure one angle, watch it cross two thresholds, count the cycle.

MediaPipe Pose gives you 33 body landmarks from a normal webcam. For push-ups you need three of them — shoulder, elbow, wrist — and the angle they form.

## What you will build

1. Live pose tracking on your webcam
2. A live elbow-angle readout
3. A rep counter that increments once per complete push-up
4. A "down / up" stage indicator

## What you need

```bash
python -m pip install opencv-python mediapipe numpy
```

You also need your camera positioned **side-on**. Facing the camera head-on hides the elbow bend and the counter will never work well.

## Step 1 — Get pose landmarks

```python
import cv2
import mediapipe as mp

mp_pose = mp.solutions.pose
mp_draw = mp.solutions.drawing_utils

cap = cv2.VideoCapture(0)

with mp_pose.Pose(
    min_detection_confidence=0.6,
    min_tracking_confidence=0.6,
    model_complexity=1,           # 0 = fastest, 2 = most accurate
) as pose:

    while True:
        ok, frame = cap.read()
        if not ok:
            break

        result = pose.process(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))

        if result.pose_landmarks:
            mp_draw.draw_landmarks(
                frame, result.pose_landmarks, mp_pose.POSE_CONNECTIONS
            )

        cv2.imshow("Push-up Counter", frame)
        if cv2.waitKey(1) & 0xFF == ord("q"):
            break

cap.release()
cv2.destroyAllWindows()
```

Stand back so your whole upper body is in frame. If the skeleton looks scrambled, you are too close.

## Step 2 — Calculate the elbow angle

Three points make an angle. `np.arctan2` gives the direction of each arm segment; the difference between them is the angle at the middle point.

```python
import numpy as np

def angle_between(a, b, c):
    """Angle at point b, in degrees. Each point is (x, y)."""
    a, b, c = np.array(a), np.array(b), np.array(c)

    radians = np.arctan2(c[1] - b[1], c[0] - b[0]) - np.arctan2(
        a[1] - b[1], a[0] - b[0]
    )
    degrees = np.abs(np.degrees(radians))

    if degrees > 180.0:
        degrees = 360.0 - degrees
    return degrees
```

The `> 180` correction matters. Without it the angle flips to its reflex angle whenever the arm crosses a certain orientation, and your counter jumps randomly.

Pull the three landmarks out:

```python
def elbow_angle(landmarks, side="LEFT"):
    lm = mp_pose.PoseLandmark
    shoulder = landmarks[getattr(lm, f"{side}_SHOULDER").value]
    elbow = landmarks[getattr(lm, f"{side}_ELBOW").value]
    wrist = landmarks[getattr(lm, f"{side}_WRIST").value]

    return angle_between(
        (shoulder.x, shoulder.y), (elbow.x, elbow.y), (wrist.x, wrist.y)
    )
```

Normalised coordinates are fine here — angles do not care about scale.

## Step 3 — Count reps with a state machine

This is the part people over-engineer. You need one variable holding the current stage:

```python
DOWN_ANGLE = 90      # elbow bent
UP_ANGLE = 160       # arms straight

counter = 0
stage = "up"

def update_counter(angle):
    global counter, stage

    if angle > UP_ANGLE:
        stage = "up"
    elif angle < DOWN_ANGLE and stage == "up":
        stage = "down"
        counter += 1
```

Read it slowly: a rep is only counted at the moment you go **down after having been up**. That single `stage == "up"` condition is what stops the counter racing while you hover at the bottom.

The gap between 90 and 160 is deliberate hysteresis. One shared threshold would count dozens of reps as the angle jitters across it.

## Step 4 — Build the full app

```python
import cv2
import numpy as np
import mediapipe as mp

mp_pose = mp.solutions.pose
mp_draw = mp.solutions.drawing_utils

DOWN_ANGLE, UP_ANGLE = 90, 160
counter, stage = 0, "up"


def angle_between(a, b, c):
    a, b, c = np.array(a), np.array(b), np.array(c)
    radians = np.arctan2(c[1] - b[1], c[0] - b[0]) - np.arctan2(
        a[1] - b[1], a[0] - b[0]
    )
    degrees = np.abs(np.degrees(radians))
    return 360.0 - degrees if degrees > 180.0 else degrees


cap = cv2.VideoCapture(0)

with mp_pose.Pose(min_detection_confidence=0.6, min_tracking_confidence=0.6) as pose:
    while True:
        ok, frame = cap.read()
        if not ok:
            break

        result = pose.process(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))

        if result.pose_landmarks:
            lm = result.pose_landmarks.landmark
            P = mp_pose.PoseLandmark

            shoulder = (lm[P.LEFT_SHOULDER.value].x, lm[P.LEFT_SHOULDER.value].y)
            elbow = (lm[P.LEFT_ELBOW.value].x, lm[P.LEFT_ELBOW.value].y)
            wrist = (lm[P.LEFT_WRIST.value].x, lm[P.LEFT_WRIST.value].y)

            angle = angle_between(shoulder, elbow, wrist)

            if angle > UP_ANGLE:
                stage = "up"
            elif angle < DOWN_ANGLE and stage == "up":
                stage = "down"
                counter += 1

            h, w = frame.shape[:2]
            ex, ey = int(elbow[0] * w), int(elbow[1] * h)
            cv2.putText(frame, f"{int(angle)}", (ex + 10, ey),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 120, 40), 2)

            mp_draw.draw_landmarks(
                frame, result.pose_landmarks, mp_pose.POSE_CONNECTIONS
            )

        cv2.rectangle(frame, (0, 0), (260, 90), (255, 120, 40), cv2.FILLED)
        cv2.putText(frame, "REPS", (15, 25),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1)
        cv2.putText(frame, str(counter), (12, 78),
                    cv2.FONT_HERSHEY_SIMPLEX, 2, (255, 255, 255), 3)
        cv2.putText(frame, "STAGE", (130, 25),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1)
        cv2.putText(frame, stage, (120, 70),
                    cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)

        cv2.imshow("Push-up Counter", frame)
        if cv2.waitKey(1) & 0xFF == ord("q"):
            break

cap.release()
cv2.destroyAllWindows()
```

## Step 5 — Adapt it to other exercises

Only the three landmarks and the two thresholds change:

| Exercise | Angle at | Points | Down / Up |
|---|---|---|---|
| Push-up | Elbow | Shoulder, elbow, wrist | 90 / 160 |
| Bicep curl | Elbow | Shoulder, elbow, wrist | 40 / 160 |
| Squat | Knee | Hip, knee, ankle | 90 / 170 |
| Sit-up | Hip | Shoulder, hip, knee | 55 / 120 |

Everything else — the state machine, the drawing, the loop — stays identical.

## Step 6 — Add a form check

The genuinely useful upgrade. A push-up with a sagging back should not count:

```python
def back_is_straight(lm, P):
    hip_angle = angle_between(
        (lm[P.LEFT_SHOULDER.value].x, lm[P.LEFT_SHOULDER.value].y),
        (lm[P.LEFT_HIP.value].x, lm[P.LEFT_HIP.value].y),
        (lm[P.LEFT_KNEE.value].x, lm[P.LEFT_KNEE.value].y),
    )
    return hip_angle > 160
```

Only count a rep when `back_is_straight()` is also true, and print a warning otherwise. This is the difference between a toy and something you would actually use.

## Common problems (and fixes)

| Problem | Fix |
|---|---|
| Counts two reps per push-up | Widen the gap between `DOWN_ANGLE` and `UP_ANGLE` |
| Counts nothing | Print the angle live; your real range may be 100–150 |
| Skeleton is scrambled | Move further from the camera, show the full body |
| Very laggy | Set `model_complexity=0` |
| Angle jumps randomly | Missing the `> 180` correction in `angle_between` |
| Camera in front does not work | Film from the side; head-on hides the elbow bend |

## What you learned

- Reading 33 body landmarks with MediaPipe Pose
- Calculating a joint angle from three points with `arctan2`
- Why the reflex-angle correction is required
- Counting cycles with a two-threshold state machine
- Why hysteresis prevents double counting

```remember
# Remember this
Three points -> one joint angle, via `arctan2`
Reflex correction -> angles above 180° must be folded back
Two thresholds -> one to go down, another to come up
---
One threshold double-counts. Hysteresis is what makes the count stable.
```


## FAQ

### How do I count push-ups automatically with Python?

Track your body with MediaPipe Pose, compute the angle at the elbow using the shoulder, elbow and wrist landmarks, and increment a counter each time the angle drops below about 90 degrees after having been above about 160.

### How do I calculate a joint angle from pose landmarks?

Take the three landmark coordinates, use `np.arctan2` on each segment, subtract, convert to degrees, and if the result exceeds 180 subtract it from 360 to get the true interior angle.

### Why does my rep counter count twice per repetition?

Your two thresholds are too close together, so small jitter re-triggers the transition. Widen the gap — for example 90 down and 160 up — and only count on the down transition.

### Can I use the same code for squats and bicep curls?

Yes. Swap the three landmarks (hip, knee, ankle for squats) and adjust the two thresholds. The counting logic does not change at all.

### Does the camera need to be side-on?

Yes for push-ups. A head-on view hides the elbow bend, so the measured angle barely changes through the movement.

## Next reading on Sythra Articles

- [Count fingers with MediaPipe](https://articles.sythra.ai/articles/count-fingers-mediapipe-python)
- [Build a virtual mouse with OpenCV and MediaPipe](https://articles.sythra.ai/articles/virtual-mouse-opencv-mediapipe)
- [Real-time face detection: MediaPipe vs Haar](https://articles.sythra.ai/articles/face-detection-python-mediapipe-vs-haar)

## Glossary (terms defined in this article)
- **reflex angle** (medium) — The angle measured the long way round, more than 180 degrees
- **hysteresis** (advanced) — A gap between the on and off thresholds so small wobbles cannot trigger repeatedly
