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.
On this page0%
- What you will build
- What you need
- Step 1 — Get pose landmarks
- Step 2 — Calculate the elbow angle
- Step 3 — Count reps with a state machine
- Step 4 — Build the full app
- Step 5 — Adapt it to other exercises
- Step 6 — Add a form check
- Common problems (and fixes)
- What you learned
- FAQ
- How do I count push-ups automatically with Python?
- How do I calculate a joint angle from pose landmarks?
- Why does my rep counter count twice per repetition?
- Can I use the same code for squats and bicep curls?
- Does the camera need to be side-on?
- Next reading on Sythra Articles
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
- Live pose tracking on your webcam
- A live elbow-angle readout
- A rep counter that increments once per complete push-up
- 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:
| 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:
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
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.