Fitness apps are everywhere, but many struggle with one fundamental task: accurately counting repetitions for exercises more complex than walking or running. If you've ever used an app that misses reps or counts phantom movements, you've experienced the limitations of using a single sensor. The accelerometer in your phone or wearable is noisy, and the gyroscope, while great for rotation, drifts over time. The solution? Sensor fusion.
In this deep-dive tutorial, we'll build a robust repetition counter by fusing accelerometer and gyroscope data. We'll ditch abstract theory and write practical Python code to process raw sensor signals, apply a filter to get a clean orientation estimate, and then build a smart counting algorithm.
This matters to developers because the demand for accurate, real-time feedback in health and fitness tech is exploding. Mastering these techniques will allow you to build more intelligent, reliable, and valuable applications for wearables, mobile, and IoT devices.
Prerequisites:
- Basic understanding of Python.
- Familiarity with concepts like vectors and angles.
- Python 3 and the NumPy library installed.
Understanding the Problem
To build a great solution, we first need to appreciate the flaws in our raw ingredients: the accelerometer and the gyroscope.
An Inertial Measurement Unit (IMU), found in every modern smartphone, typically combines these two sensors.
-
Accelerometer: Measures proper acceleration (including the constant pull of gravity). We can use gravity's direction to figure out the device's orientation (tilt).
- The Good: It's stable over the long term. Gravity isn't going anywhere.
- The Bad: It's very sensitive to external forces. A sudden shake, vibration, or movement noise will corrupt the orientation data.
-
Gyroscope: Measures angular velocity (how fast the device is rotating around an axis). We can integrate this velocity over time to track changes in orientation.
- The Good: It's excellent at detecting quick rotational changes and is immune to the external shaking that plagues accelerometers.
- The Bad: It drifts. Tiny, unavoidable errors in its measurements accumulate over time, causing the calculated angle to become increasingly inaccurate.
If we rely on just one, our rep counter will fail. An accelerometer-only counter will misfire on any jerky movement, while a gyroscope-only counter will eventually drift so much it loses track of the actual orientation.
This is where sensor fusion comes in. We can combine the long-term stability of the accelerometer with the short-term accuracy of the gyroscope to get the best of both worlds.
Prerequisites
Before we start coding, let's set up our environment. You'll need Python 3 and NumPy.
# Make sure you have python3 and pip installed
pip install numpy
# Expected Output:
# Successfully installed numpy-x.x.x
We'll be working with a sample dataset to keep this tutorial focused on the algorithm. You can imagine this data being streamed from a mobile device using a library like React Native's react-native-sensors or directly from an IoT device.
Step 1: Analyzing Our Raw Sensor Data
First, let's define the problem with code. We'll simulate the noisy accelerometer and drifting gyroscope to see what we're up against. For a bicep curl, the main action is the change in the angle of the forearm. We'll focus on a single axis for simplicity.
What we're doing
We'll calculate the device's tilt angle separately from the accelerometer and gyroscope data to visualize their individual flaws.
Implementation
Let's assume we have a function get_sensor_data() that returns (ax, ay, az, gx, gy, gz) from the phone's IMU. For a bicep curl where you're holding the phone, the angle of interest might be the rotation around the X-axis.
# src/data_analysis.py
import numpy as np
# dt: time delta between sensor readings
dt = 0.01
def get_accel_angle(ax, ay, az):
"""Calculate the tilt angle from accelerometer data."""
# Note: This is a simplified calculation for one axis.
# A full implementation would use atan2(ay, az) for pitch, etc.
return np.arctan2(ay, np.sqrt(ax**2 + az**2)) * 180 / np.pi
# Gyroscope data needs to be integrated
gyro_angle_x = 0.0
def get_gyro_angle(gx, dt):
"""Calculate angle by integrating gyroscope data."""
global gyro_angle_x
gyro_angle_x += gx * dt
return gyro_angle_x
# --- Let's simulate some readings ---
# Simulate a bicep curl: angle goes from -90 to 0 and back
true_angle = -90
gyro_drift = 0.1 # A small, constant drift
accel_noise_stddev = 2.5 # Noise in accelerometer readings
# Store our results
simulated_gyro_angles = []
simulated_accel_angles = []
for _ in range(300): # Simulate 3 seconds of data
# Simulate a smooth curl motion
true_angle += 0.6
if true_angle > 0: true_angle = 0 # Cap at the top
# Generate noisy accelerometer data
accel_reading_y = np.sin(np.deg2rad(true_angle)) + np.random.normal(0, accel_noise_stddev)
accel_reading_z = np.cos(np.deg2rad(true_angle)) + np.random.normal(0, accel_noise_stddev)
# Generate drifting gyroscope data
gyro_reading_x = 0.6 / dt # Ideal angular velocity
gyro_reading_x += gyro_drift # Add drift
# Calculate angles
accel_angle = get_accel_angle(0, accel_reading_y, accel_reading_z)
gyro_angle = get_gyro_angle(gyro_reading_x, dt)
simulated_accel_angles.append(accel_angle)
simulated_gyro_angles.append(gyro_angle)
print(f"Final Gyro Angle (with drift): {simulated_gyro_angles[-1]:.2f}")
print(f"Final Accel Angle (should be noisy but around 0): {simulated_accel_angles[-1]:.2f}")
How it works
get_accel_angle: Uses trigonometry (arctan2) to calculate the tilt based on the gravity vector's components.ayandazwill change as the device tilts.get_gyro_angle: Simulates integration. It takes the angular velocity (gx), multiplies it by the time step (dt) to get the change in angle, and adds it to the previous angle.- Simulation: We create a "true" angle and then generate sensor readings from it, adding realistic noise and drift. This shows the problem clearly: the final gyro angle has drifted significantly from the true value (0), while the accelerometer reading is noisy.
Step 2: Fusing the Signals with a Complementary Filter
A Kalman filter is the optimal solution for sensor fusion, but it's complex. A Complementary Filter is a fantastic, computationally cheap alternative that gives excellent results. It's perfect for mobile and embedded systems.
The core idea is simple:
”
FusedAngle = α * (Gyro-based Angle) + (1 - α) * (Accelerometer-based Angle)
We trust the gyroscope for high-frequency changes but "complement" it with the accelerometer's stable low-frequency data. The α (alpha) value determines the balance. A common value is 0.98.
What we're doing
We'll create a function that takes the raw sensor data and the previous fused angle, and returns the new, clean angle.
Implementation
// src/fusion_filter.py
import numpy as np
class ComplementaryFilter:
def __init__(self, alpha=0.98):
self.alpha = alpha
self.angle = 0.0
def process(self, ax, ay, az, gx, dt):
"""
Processes new sensor data to update the fused angle.
"""
# Calculate angle from accelerometer
accel_angle = np.arctan2(ay, np.sqrt(ax**2 + az**2)) * 180 / np.pi
# Integrate gyroscope data to get change in angle
gyro_angle_change = gx * dt
# Apply the complementary filter
# Trust the gyroscope for short-term changes, correct with accelerometer
self.angle = self.alpha * (self.angle + gyro_angle_change) + (1 - self.alpha) * accel_angle
return self.angle
# --- Let's run our simulation again with the filter ---
filter = ComplementaryFilter(alpha=0.98)
fused_angles = []
# Reset simulation variables
gyro_angle_x = 0.0
for _ in range(300):
# (Re-using the same simulation logic from Step 1)
true_angle += 0.6
if true_angle > 0: true_angle = 0
accel_reading_y = np.sin(np.deg2rad(true_angle)) + np.random.normal(0, accel_noise_stddev)
accel_reading_z = np.cos(np.deg2rad(true_angle)) + np.random.normal(0, accel_noise_stddev)
gyro_reading_x = (0.6 / dt) + gyro_drift
fused_angle = filter.process(0, accel_reading_y, accel_reading_z, gyro_reading_x, dt)
fused_angles.append(fused_angle)
print(f"Final Fused Angle: {fused_angles[-1]:.2f}")
How it works
The ComplementaryFilter class maintains the current state (self.angle). In each process call:
- It calculates the current tilt from the accelerometer.
- It calculates the change in angle from the gyroscope.
- It computes the new fused angle by taking 98% of the projected new angle from the gyroscope and adding 2% of the angle measured by the accelerometer. This gently "pulls" the drifting gyro angle back towards the stable (but noisy) accelerometer angle, correcting the drift over time.
The output will be remarkably close to the true value of 0, demonstrating the filter's power. ✨
Step 3: Counting Reps with a State Machine
With a clean signal, we can finally count reps. A naive approach is to just count peaks in the angle data. However, this is fragile. What if the user pauses mid-rep?
A more robust method is a state machine. An exercise like a bicep curl has distinct phases: an "up" phase and a "down" phase. We can track the user's state as they move through these phases.
What we're doing
We'll define states (IDLE, GOING_UP, GOING_DOWN) and angle thresholds that trigger transitions between them. We'll increment our counter only when a full cycle is completed.
Implementation
# src/rep_counter.py
class RepCounter:
def __init__(self, entry_threshold, exit_threshold):
self.count = 0
self.state = "IDLE" # Can be IDLE, GOING_UP, GOING_DOWN
self.entry_threshold = entry_threshold # Angle to start a rep
self.exit_threshold = exit_threshold # Angle to complete a rep
def process(self, angle):
"""
Processes a new angle to update the rep count.
Returns the current rep count.
"""
if self.state == "IDLE":
if angle > self.entry_threshold:
self.state = "GOING_UP"
elif self.state == "GOING_UP":
if angle < self.exit_threshold:
self.state = "GOING_DOWN"
elif self.state == "GOING_DOWN":
if angle >= self.exit_threshold:
self.count += 1
print(f"Repetition {self.count} detected!")
self.state = "IDLE"
return self.count
# --- Let's put it all together ---
# For a bicep curl, a rep might start when the arm passes 45 degrees
# and end when it's near the top (e.g., 140 degrees).
# Let's define thresholds for our fused angle space.
# Assuming fused angle is ~0 at start and ~90 at peak.
rep_counter = RepCounter(entry_threshold=30.0, exit_threshold=75.0)
# Simulate two full reps
full_motion_fused_angles = fused_angles + list(reversed(fused_angles)) # One rep
full_motion_fused_angles += full_motion_fused_angles # Two reps
for angle in full_motion_fused_angles:
rep_counter.process(angle)
print(f"\nFinal Rep Count: {rep_counter.count}")
How it works
- Initialization: The counter starts in the
IDLEstate with 0 reps. We define two angle thresholds. IDLEtoGOING_UP: When the angle first crosses theentry_threshold, we know a rep has started. The state changes toGOING_UP.GOING_UPtoGOING_DOWN: As the user reaches the peak of the movement and starts coming down, the angle will decrease and cross theexit_thresholdon its way down. We change the state toGOING_DOWN.GOING_DOWNtoIDLE: When the user returns to the starting position and the angle goes back above theexit_threshold, we know a full repetition has been successfully completed. We increment the count and reset the state toIDLE, ready for the next one.
This logic is far more resilient to pauses and small, jerky movements than simple peak counting.
Adapting to Other Exercises
The beauty of this framework is its adaptability.
-
Kettlebell Swings: Here, the phone might be in a pocket. The primary motion is a powerful hip hinge, resulting in a cyclical pattern in forward acceleration (
az) and angular velocity. You could apply the same fusion and state-machine logic to the fused pitch angle of the torso or thigh to detect the swing's peak and trough. -
Squats: Similar to kettlebell swings, the phone's vertical movement and pitch angle would provide a clean, cyclical signal to count reps. The thresholds would simply need to be adjusted.
Performance Considerations
- Complementary Filter: This is extremely lightweight. It's just a couple of multiplications and additions per sensor reading, making it perfect for real-time processing on a mobile device or even a microcontroller.
- Kalman Filter: A full Kalman filter implementation is more computationally expensive due to matrix operations. While more accurate, the performance trade-off might not be worth it for simple rep counting. Use it when you need highly precise orientation for applications like VR/AR or robotics.
Alternative Approaches
- Kalman Filter: As mentioned, this is the gold standard. It uses a predictive model of the system's state and is exceptional at handling noise. Libraries like
pykalmanin Python can help, but you'll need a solid grasp of linear algebra to tune it effectively. - Machine Learning / Deep Learning: You can train a neural network on labeled sensor data to classify movements and count reps. This approach can be very powerful and recognize nuanced exercises without manual threshold tuning. However, it requires a large, high-quality dataset and more processing power for inference.
Conclusion
We've successfully gone from noisy, unreliable sensor readings to a smart, state-based repetition counter. By understanding the weaknesses of individual sensors and mitigating them with a simple and effective complementary filter, we created a clean signal. We then used that signal to drive a state machine that robustly counts reps, ignoring the small jitters and pauses that fool simpler algorithms.
This foundation is a launching point for building sophisticated fitness applications. You can now reliably track workouts, provide real-time feedback on form, and create a more engaging and accurate user experience.
Next steps for you:
- Try this with real sensor data from your phone.
- Adapt the thresholds and axes for a different exercise, like squats or push-ups.
- Explore a Kalman filter implementation for a comparison of accuracy.