A knee angle is a measurement. A repetition count is a decision about a sequence. Connect them without defining that decision and a plausible skeleton can give a misleading impression of what the camera observed.
This synthetic demonstration contains 241 frames at 24 frames per second. Our pose pipeline records two bottom-to-standing returns, at about 1.79 and 8.17 seconds. Yet the first frame already shows the person down. Only the second event has a complete standing–down–standing cycle visible within the recording.
Watch the pose-counter breakdown
Watch the full breakdown on YouTube · Watch the short demonstration

Follow the input all the way to an event
The input is a 1280 × 720 video, analysed offline with YOLOv8n-pose on a CPU. Pose estimation supplies predicted landmark coordinates and scores. The official Ultralytics pose documentation describes these keypoint outputs. The model does not decide what our application should call a repetition.
This example accepts exactly one detected pose per frame. For each leg, it examines the hip, knee and ankle scores, then chooses the leg with the higher minimum score. That minimum must reach 0.15, and all three coordinates must be finite. This filter selects observations; passing it does not establish landmark correctness.
The hip–knee and ankle–knee vectors meet at the knee. Their normalized dot product and arccosine produce an estimated image-space angle. It depends on the camera view and predicted points. An angle alone contains no memory of whether the person was previously standing or down.
Before counting, the implementation smooths the angles with a centered nine-frame moving average. It pads segment edges and processes each continuous valid segment separately, leaving missing observations as gaps. Because a centered filter uses future frames, this is an offline example. A live version needs a deliberate causal filter and new checks for delay and changed event timing.
Remember a bottom, then accept a return
The counter starts unarmed. A finite angle at or below 70° arms it. A later armed observation at or above 150° records the current frame and disarms it. Another standing frame adds nothing. A missing angle clears the remembered state.
The separated thresholds provide hysteresis: movement around the standing threshold cannot repeatedly count without another bottom observation. These are demonstration settings for this signal, not a definition of correct exercise technique. Here is the complete counting rule; it expects angles that have already been measured and smoothed. Represent a missing numeric angle as float("nan"), rather than passing None or a pose record:
import math
def count_returns(angles, down=70.0, up=150.0):
if not 0 < down < up <= 180:
raise ValueError("Require 0 < down < up <= 180.")
armed, events = False, []
for frame, angle in enumerate(angles):
if not math.isfinite(angle):
armed = False
elif angle <= down:
armed = True
elif armed and angle >= up:
events.append(frame)
armed = False
return events
Code adapted from the course practical source. Copyright © 2026 ARTSCI — Author of the AI/ML/CV course materials. Licensed under GNU AGPL version 3, without warranty. This code notice does not automatically license independent images, audio, video or course presentation. Ultralytics software and model terms remain applicable.
Inspect the frame that changes the count
All 241 frames in this run provide usable leg observations. At frame 42, the smoothed angle is about 148.5°. At frame 43, it is about 150.9°. The counter was armed by an earlier bottom observation, so frame 43 satisfies the upper threshold and records the first return.

| Accepted return | Frame index | Time |
|---|---|---|
| First | 43 | 1.79 s |
| Second | 196 | 8.17 s |
Frame indices start at zero; the times are frame index divided by 24. These are two detections under this rule for this particular clip. They are not an accuracy score against independently labelled movement.
Test the meaning, not just the final total
Run these small examples after the function. They separate state-machine behavior from pose-model quality and make the recording-boundary issue explicit:
# Standing -> bottom -> standing: one accepted return.
assert count_returns([160, 60, 100, 150, 170]) == [3]
# Starting down still counts; it does not prove a full observed cycle.
assert count_returns([60, 150, 160]) == [1]
# A shallow bend never arms the rule.
assert count_returns([160, 145, 160]) == []
# Missing data clears the remembered bottom.
assert count_returns([60, float("nan"), 150]) == []
# Both thresholds are inclusive; remaining high does not count again.
assert count_returns([70, 149.9, 150, 170]) == [2]
The second test matters most here. A clip that begins down immediately arms this rule. Its next return counts even though no preceding descent was recorded. If your requirement is a fully observed cycle, begin with an observed standing state, then require a bottom and a return. That is a different state machine; it should reject the initial partial cycle.
Thresholds also affect timing. Keeping this clip and smoothed signal fixed but raising the upper threshold to 160° moves the events to frames 48 and 200. That change does not by itself establish better accuracy. Inspect which observations satisfy the rule before interpreting a changed total or timestamp.
The same distinction appears in the traffic-counter example: track IDs and accepted crossings answer different questions. For pose data, define the event, inspect recording boundaries and missing observations, then explain each accepted transition. The full notebook, local application and broader practical workflow are developed in my English course, Computer Vision: OpenCV, Machine Learning and YOLO.

