Sythra

Article

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.

vaibhavkothari· Aug 26, 2026· 7 min readIntermediate
ShareXLinkedIn
Build an AI push-up counter with pose estimation in Python cover

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

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

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.

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 angleThe angle measured the long way round, more than 180 degrees whenever the arm crosses a certain orientation, and your counter jumps randomly.

Pull the three landmarks out:

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:

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 hysteresisA gap between the on and off thresholds so small wobbles cannot trigger repeatedly. One shared threshold would count dozens of reps as the angle jitters across it.

Step 4 — Build the full app

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:

ExerciseAngle atPointsDown / Up
Push-upElbowShoulder, elbow, wrist90 / 160
Bicep curlElbowShoulder, elbow, wrist40 / 160
SquatKneeHip, knee, ankle90 / 170
Sit-upHipShoulder, hip, knee55 / 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:

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)

ProblemFix
Counts two reps per push-upWiden the gap between DOWN_ANGLE and UP_ANGLE
Counts nothingPrint the angle live; your real range may be 100–150
Skeleton is scrambledMove further from the camera, show the full body
Very laggySet model_complexity=0
Angle jumps randomlyMissing the > 180 correction in angle_between
Camera in front does not workFilm 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

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

Keep reading

Related articles

Newsletter

Get new articles

Free essays on learning, Python, and ML — no account required. We’ll only email when there’s something worth reading.

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