Tools: From Shaky Squats to Perfect Form: Master Workout Analysis with Dynamic Time Warping (DTW)

Tools: From Shaky Squats to Perfect Form: Master Workout Analysis with Dynamic Time Warping (DTW)

Source: Dev.to

Why Euclidean Distance Fails (and why DTW is King 👑) ## The Architecture: From Wrist to Result ## Step 1: Capturing Motion with Swift & Core Motion ## Step 2: The Logic - Comparing Sequences with FastDTW ## Step 3: Going Production-Ready ## Key Considerations for Production: ## Conclusion: The Future of Wearables is Qualitative So, you’ve been hitting your home workouts, but are you actually doing those squats correctly, or are you just "aggressively vibrating" in your living room? In the world of fitness tracking and motion analysis, counting repetitions is easy. The real challenge—the "Holy Grail" for developers—is quality assessment. How do we know if a user's range of motion is deep enough? How do we detect a "lazy" pushup? Today, we are diving deep into the world of Dynamic Time Warping (DTW), Core Motion, and Apple Watch data processing. We’ll learn how to take raw accelerometer and gyroscope data and turn it into a "Standardness Score." When comparing two motion sequences (a "Gold Standard" squat vs. your actual squat), they are rarely the same length. One person might squat in 2 seconds; another might take 3. If you use simple Euclidean distance, the "time shift" makes the sequences look completely different. Dynamic Time Warping (DTW) solves this by "stretching" or "compressing" the time axis to find the optimal alignment between two sequences. This makes it perfect for Human Activity Recognition (HAR). Before we get into the code, let's look at the data flow. We capture raw motion, clean it, and then run it through our DTW engine to compare it against a pre-recorded "perfect" rep. First, we need to extract high-frequency data from the Apple Watch. For workout analysis, a sampling rate of 50Hz - 100Hz is ideal. Pro-tip: Don't just use raw accelerometer data. userAcceleration removes the constant 1g of gravity, making your signal much cleaner! 🚀 Once we have our data, we need to compare it to our template. Since DTW can be computationally expensive ($O(N^2)$), we use FastDTW, which provides a linear $O(N)$ approximation. In a production environment, you might send this data to a Python-based backend or use a C++ implementation wrapped in Swift. Here is how the logic looks using NumPy and FastDTW: Building a prototype is easy, but making it work for thousands of users with different body types is hard. For instance, how do you handle different arm lengths in a pushup? If you're looking for more production-ready examples and advanced motion-processing patterns, I highly recommend checking out the technical deep-dives over at WellAlly Blog. They cover some incredible architectural patterns for scaling wearable data pipelines and using AI to refine human activity recognition beyond simple algorithms. We are moving away from "How many?" to "How well?". By combining Core Motion for data collection and Dynamic Time Warping for sequence analysis, we can build apps that act like a digital personal trainer. Whether you’re building the next big fitness app or just tinkering with your Apple Watch, mastering motion analysis is a superpower. What are you building next? Let me know in the comments! 👇 Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK: graph TD A[Apple Watch: Core Motion] -->|Raw Accel/Gyro| B[Data Preprocessing] B -->|Noise Filter/Normalization| C[Feature Extraction] C -->|Motion Vector| D{DTW Algorithm} E[Standard 'Gold' Template] -->|Reference Vector| D D -->|Alignment Score| F[Form Quality Feedback] F -->|Output| G[Real-time UI: 'Go Deeper!'] Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: graph TD A[Apple Watch: Core Motion] -->|Raw Accel/Gyro| B[Data Preprocessing] B -->|Noise Filter/Normalization| C[Feature Extraction] C -->|Motion Vector| D{DTW Algorithm} E[Standard 'Gold' Template] -->|Reference Vector| D D -->|Alignment Score| F[Form Quality Feedback] F -->|Output| G[Real-time UI: 'Go Deeper!'] CODE_BLOCK: graph TD A[Apple Watch: Core Motion] -->|Raw Accel/Gyro| B[Data Preprocessing] B -->|Noise Filter/Normalization| C[Feature Extraction] C -->|Motion Vector| D{DTW Algorithm} E[Standard 'Gold' Template] -->|Reference Vector| D D -->|Alignment Score| F[Form Quality Feedback] F -->|Output| G[Real-time UI: 'Go Deeper!'] CODE_BLOCK: import CoreMotion class MotionManager { let motionManager = CMMotionManager() var motionDataLog: [[Double]] = [] func startTracking() { guard motionManager.isDeviceMotionAvailable else { return } // We want high frequency for DTW precision motionManager.deviceMotionUpdateInterval = 1.0 / 50.0 motionManager.startDeviceMotionUpdates(to: .main) { (data, error) in guard let data = data else { return } // Extracting Gravity and User Acceleration let frame = [ data.userAcceleration.x, data.userAcceleration.y, data.userAcceleration.z, data.rotationRate.x, data.rotationRate.y, data.rotationRate.z ] self.motionDataLog.append(frame) } } } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: import CoreMotion class MotionManager { let motionManager = CMMotionManager() var motionDataLog: [[Double]] = [] func startTracking() { guard motionManager.isDeviceMotionAvailable else { return } // We want high frequency for DTW precision motionManager.deviceMotionUpdateInterval = 1.0 / 50.0 motionManager.startDeviceMotionUpdates(to: .main) { (data, error) in guard let data = data else { return } // Extracting Gravity and User Acceleration let frame = [ data.userAcceleration.x, data.userAcceleration.y, data.userAcceleration.z, data.rotationRate.x, data.rotationRate.y, data.rotationRate.z ] self.motionDataLog.append(frame) } } } CODE_BLOCK: import CoreMotion class MotionManager { let motionManager = CMMotionManager() var motionDataLog: [[Double]] = [] func startTracking() { guard motionManager.isDeviceMotionAvailable else { return } // We want high frequency for DTW precision motionManager.deviceMotionUpdateInterval = 1.0 / 50.0 motionManager.startDeviceMotionUpdates(to: .main) { (data, error) in guard let data = data else { return } // Extracting Gravity and User Acceleration let frame = [ data.userAcceleration.x, data.userAcceleration.y, data.userAcceleration.z, data.rotationRate.x, data.rotationRate.y, data.rotationRate.z ] self.motionDataLog.append(frame) } } } COMMAND_BLOCK: import numpy as np from fastdtw import fastdtw from scipy.spatial.distance import euclidean def calculate_form_score(user_sequence, template_sequence): """ user_sequence: Numpy array of shape (N, 6) template_sequence: Numpy array of shape (M, 6) """ # 1. Normalize data (Z-score normalization) u_norm = (user_sequence - np.mean(user_sequence)) / np.std(user_sequence) t_norm = (template_sequence - np.mean(template_sequence)) / np.std(template_sequence) # 2. Run FastDTW distance, path = fastdtw(u_norm, t_norm, dist=euclidean) # 3. Convert distance to a 0-100 score # Lower distance = Higher similarity max_allowable_distance = 50.0 # Tuned based on your dataset score = max(0, 100 - (distance / len(path)) * 10) return score # Example usage perfect_squat = np.load("perfect_squat.npy") current_rep = np.array([[...], [...]]) # Data from Watch print(f"Form Accuracy: {calculate_form_score(current_rep, perfect_squat)}%") Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: import numpy as np from fastdtw import fastdtw from scipy.spatial.distance import euclidean def calculate_form_score(user_sequence, template_sequence): """ user_sequence: Numpy array of shape (N, 6) template_sequence: Numpy array of shape (M, 6) """ # 1. Normalize data (Z-score normalization) u_norm = (user_sequence - np.mean(user_sequence)) / np.std(user_sequence) t_norm = (template_sequence - np.mean(template_sequence)) / np.std(template_sequence) # 2. Run FastDTW distance, path = fastdtw(u_norm, t_norm, dist=euclidean) # 3. Convert distance to a 0-100 score # Lower distance = Higher similarity max_allowable_distance = 50.0 # Tuned based on your dataset score = max(0, 100 - (distance / len(path)) * 10) return score # Example usage perfect_squat = np.load("perfect_squat.npy") current_rep = np.array([[...], [...]]) # Data from Watch print(f"Form Accuracy: {calculate_form_score(current_rep, perfect_squat)}%") COMMAND_BLOCK: import numpy as np from fastdtw import fastdtw from scipy.spatial.distance import euclidean def calculate_form_score(user_sequence, template_sequence): """ user_sequence: Numpy array of shape (N, 6) template_sequence: Numpy array of shape (M, 6) """ # 1. Normalize data (Z-score normalization) u_norm = (user_sequence - np.mean(user_sequence)) / np.std(user_sequence) t_norm = (template_sequence - np.mean(template_sequence)) / np.std(template_sequence) # 2. Run FastDTW distance, path = fastdtw(u_norm, t_norm, dist=euclidean) # 3. Convert distance to a 0-100 score # Lower distance = Higher similarity max_allowable_distance = 50.0 # Tuned based on your dataset score = max(0, 100 - (distance / len(path)) * 10) return score # Example usage perfect_squat = np.load("perfect_squat.npy") current_rep = np.array([[...], [...]]) # Data from Watch print(f"Form Accuracy: {calculate_form_score(current_rep, perfect_squat)}%") - Signal Windowing: Use a sliding window to detect the start and end of a rep before running DTW. - Coordinate Space: Always transform motion data into a "World Coordinate" system so that the orientation of the Watch doesn't break your algorithm. - Battery Life: Don't run DTW every millisecond. Batch your reps and process them after the set is complete or in small chunks.