Build an AI Tennis Coach

Measure tennis strokes from phone video and create an AI coaching report.

Introduction

30 Second Summary

Phone footage captures every tennis stroke. Replaying it by eye makes improvement hard to measure.

In this project, you will turn side-on phone videos into a local AI tennis coach. Your workflow will measure each stroke before a focused retest checks one coaching cue.

What You'll Build

Your final demo pairs numbered pose footage with a coaching report showing whether your retest moved the target measurement.

By the end of this project, you'll have:

  • A set of numbered pose videos produced locally with MediaPipe plus OpenCV. You can play each clip to inspect full-body tracking through every stroke.
  • Per-stroke CSV measurements where you can inspect wrist speed, body turn, knee bend, plus inferred contact position.
  • A before-and-after coaching report from Claude Code with professional-reference differences, one priority cue, plus an immediate retest verdict.
  • Secret Mission: Adapt the same pipeline to coach 10 repetitions of a golf swing, running stride, or basketball shot.

What do I need before starting?

You need a Mac running macOS 13 or later, Claude Code installed through a paid Claude plan, temporary access to a phone with stable support, a safe filming space, plus permission to use any reference footage.

Before We Start

A useful coaching comparison starts with a clear question. This is your moment to choose the tennis stroke you want to examine and decide what objective measurements should reveal.

Set Up the Local Pose Lab

Your coaching pipeline depends on Python packages that must agree on exact versions. An isolated virtual environment keeps those packages away from the Mac's system Python.

The uv tool gives the project Python 3.12 with repeatable dependency pins. Claude Code creates the local workspace that MediaPipe and OpenCV use.

In this step, get ready to:
  • Prepare uv version 0.13.7 for the project.
  • Scaffold the local workspace with Claude Code.
  • Build the environment for local pose processing.
Install uv 0.13.7

A pinned uv release makes the setup reproducible. Start by checking whether your Terminal already recognizes the required version.

  • Check your installed uv version by running this command:
uv --version

What does this command do?

The command prints the installed uv version. Use the tab that matches what you see.

✔️ I see the required uv version

Your Terminal prints uv 0.13.7. Your Python environment manager is ready for the project.

ⓧ I see an older version

An older release can produce a different environment from the one used in this project. Install the pinned release before creating any project files.

  • Install uv version 0.13.7 by running this command:
curl -LsSf https://astral.sh/uv/0.13.7/install.sh | sh

What does this command do?

The command downloads the installer for uv version 0.13.7. The installer adds the pinned command-line tool to your Mac.

  • Close the current Terminal window after the installer finishes.
  • Press Cmd+Space to open Spotlight.
  • Type Terminal into Spotlight.
  • Press Return to open a fresh Terminal window.
  • Confirm the installed version by running this command:
uv --version

What should I see?

Your fresh Terminal should print uv 0.13.7. This confirms the pinned release is available in the new session.

Still seeing the older version?

Confirm that you closed the original Terminal window after installation. Open another fresh Terminal window before checking again.

If the older version still appears, help me diagnose which uv installation Terminal is using.

ⓧ Command not found

Your Mac needs the pinned uv release before it can create the project environment. The official installer handles the setup.

  • Install uv version 0.13.7 by running this command:
curl -LsSf https://astral.sh/uv/0.13.7/install.sh | sh

What does this command do?

The command downloads the installer for uv version 0.13.7. The installer makes uv available to new Terminal sessions.

  • Close the current Terminal window after the installer finishes.
  • Press Cmd+Space to open Spotlight.
  • Type Terminal into Spotlight.
  • Press Return to open a fresh Terminal window.
  • Verify the installation by running this command:
uv --version

What should I see?

Your fresh Terminal should print uv 0.13.7. That output confirms the installer succeeded.

Terminal still cannot find uv?

Make sure you opened a new Terminal window after the installer completed. Rerun the installer if it stopped before reporting completion.

If the command remains unavailable, help me troubleshoot my uv installation on macOS.

Create the project scaffold with Claude Code

The project needs fixed locations for source videos, the pose model, and generated results. Keeping them inside one named folder gives every later command the same starting point.

  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

The command moves your Terminal session to the Desktop. Your project folder will appear somewhere easy to find.

  • Create the ai-tennis-coach folder by running these commands:
mkdir ai-tennis-coach
cd ai-tennis-coach

What do these commands do?

  • The first command creates the ai-tennis-coach folder on your Desktop.
  • The second command moves your Terminal session into that folder.
  • Start Claude Code inside ai-tennis-coach by running this command:
claude

What happens here?

Claude Code starts with the ai-tennis-coach folder as its project context. It can now create files inside this workspace.

Claude Code may request permission before it creates local files. Review each requested change before approving it.

  • Ask Claude Code to create empty videos, models, and outputs directories inside ai-tennis-coach.
  • Approve the requested directory changes.
  • Ask Claude Code to create requirements.txt with these exact contents:
mediapipe==1.2.2
opencv-contrib-python==5.0.0.93

What does this file control?

  • The mediapipe==1.2.2 pin provides the pose-tracking package used later in the project.
  • The opencv-contrib-python==5.0.0.93 pin provides video reading, drawing, and writing through the cv2 import.
  • Approve the requested requirements.txt file change.
  • Confirm Claude Code reports that the three directories exist.
  • Keep Claude Code running in this Terminal window.

✔️ Awesome, I've got everything!

Your dependency file now contains both required package pins. Claude Code remains open in the project folder.

ⓧ I'd like to double check the full code

mediapipe==1.2.2
opencv-contrib-python==5.0.0.93
Build and verify the Python environment

The folder structure now separates inputs from generated outputs. The final setup creates .venv, installs the pinned packages, and stores the pose model under models.

  • Press Cmd+Space to open Spotlight without closing Claude Code.
  • Type Terminal into Spotlight.
  • Press Return to open a second Terminal window.
  • Move the second Terminal into your project folder by running this command:
cd ~/Desktop/ai-tennis-coach

Why use a second Terminal?

The second Terminal handles environment commands while Claude Code stays open in the first window. Both sessions point to the same local project.

  • Check the project scaffold by running this command:
ls

What should I see?

The file list should include models, outputs, requirements.txt, and videos.

  • Create the Python 3.12 virtual environment by running this command:
uv venv --python 3.12

What does this command create?

The command creates a local .venv environment inside ai-tennis-coach. It also downloads Python 3.12 when that version is unavailable on your Mac.

  • Confirm the command finishes with output that identifies the new .venv environment.

Virtual environment creation failed?

Confirm the second Terminal is inside ~/Desktop/ai-tennis-coach. Check that uv --version reports the pinned release in this window.

If the command still fails, help me debug my uv Python environment.

The first dependency install can take a few minutes while uv downloads the pose-processing packages.

  • Install the pinned project dependencies by running this command:
uv pip install -r requirements.txt

What does this command install?

The command reads both pins from requirements.txt. It installs them into the local .venv environment.

  • Wait for the installation command to return to the Terminal prompt.

Dependency installation failed?

Check that requirements.txt contains the two exact package pins from the previous substep. Confirm that .venv exists inside ai-tennis-coach.

If the packages still fail to install, help me debug my pinned MediaPipe and OpenCV installation.

  • Download the official Pose Landmarker Heavy model into models/pose_landmarker.task by running this command:
curl -L https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_heavy/float16/1/pose_landmarker_heavy.task -o models/pose_landmarker.task

What does this download?

The command downloads the official float16 Pose Landmarker Heavy task model. The output option saves it under the exact path expected by the project code.

  • Confirm the model file exists by running this command:
ls models

What should I see?

You should see pose_landmarker.task in the output. This proves the model is stored in the project location used by the pose scripts.

Model file missing?

Confirm the download command ends with models/pose_landmarker.task. Make sure Claude Code created the models directory before the download.

If the download still fails, help me troubleshoot the MediaPipe model download.

Before you run the final check, which two pinned packages do you expect the environment to list?

  • Verify the installed package versions by running this command:
uv pip list

Your pose lab is ready

  • The output includes mediapipe 1.2.2.
  • The output includes opencv-contrib-python 5.0.0.93.

Pinned packages missing?

Confirm this Terminal is inside ~/Desktop/ai-tennis-coach. Rerun the dependency installation command from this folder.

If the list still omits either package, help me inspect my uv environment.

That's the foundation complete. Your local pose lab now has its isolated Python environment, pinned processing tools, project folders, and pose model. Next, you will film the baseline clips and reveal the tracked skeleton.

Film the Baseline and Reveal the Skeleton

Your local pose lab is ready to process video. Its measurements still depend on footage that keeps every joint visible.

A raw clip shows the swing. It cannot prove that the tracker follows your complete body.

MediaPipe finds body landmarks in the first five seconds. OpenCV draws those landmarks onto a new video.

In this step, get ready to:
  • Set up a stable side-on camera view that keeps your complete body visible.
  • Record separate baseline clips for your forehand and backhand.
  • Generate a five-second skeleton overlay to check landmark tracking.
Frame and record your baseline

Consistent framing makes the baseline clips suitable for comparison. This part can feel fiddly because one cropped ankle can weaken the pose check.

  • Use a phone that you own or have permission to borrow.
  • Choose a court or safe open practice space with room for complete strokes.
  • Secure the phone on a tripod or fence clamp.
  • Use a stable courtside surface if neither support is available.
  • Position the camera side-on to your hitting direction.
  • Use the camera preview to keep your complete body inside the frame.
  • Perform one slow practice stroke in front of the fixed camera.
  • Check that your shoulders, wrists, hips, knees, and ankles remain visible.

Why Use a Side-On View?

A side-on view makes forward wrist position visible in the image. It also exposes changes in body height.

A fixed viewpoint keeps the forehand recording comparable with the backhand recording.

  • Record approximately 20 forehands.
  • Return briefly to a ready position after each forehand.
  • Play back the forehand recording to check your complete body.
  • Record approximately 20 backhands without moving the camera.
  • Return briefly to a ready position after each backhand.
  • Play back the backhand recording to check your complete body.

Why Return to Ready Position?

The short pause creates a clearer boundary between repetitions. The analyzer can use those motion cycles to separate individual strokes later.

  • Transfer the forehand recording into the existing videos folder.
  • Rename the forehand recording to baseline_forehand.mp4.
  • Confirm that videos/baseline_forehand.mp4 exists.
  • Transfer the backhand recording into the existing videos folder.
  • Rename the backhand recording to baseline_backhand.mp4.
  • Confirm that videos/baseline_backhand.mp4 exists.
Create the pose-check script

The overlay script reads the opening of your forehand clip. It writes a separate video with connected body landmarks.

Claude Code can create the exact script inside the project. It may request permission before editing the file.

  • Switch back to the Claude Code session already open in ai-tennis-coach.
  • Ask Claude Code to create overlay.py using the full-code reference below.
  • Approve the file edit if Claude Code requests permission.
  • Confirm that overlay.py now exists inside ai-tennis-coach.

✔️ Awesome, I've got everything!

Your overlay.py file is ready. Make sure Claude Code saved the edit before continuing.

ⓧ I'd like to double check the full code

  • Compare your overlay.py file with this complete reference:
from argparse import ArgumentParser
from pathlib import Path

import cv2
import mediapipe as mp
from mediapipe.tasks import python
from mediapipe.tasks.python import vision

CONNECTIONS = [(11, 12), (11, 13), (13, 15), (12, 14), (14, 16), (11, 23), (12, 24), (23, 24), (23, 25), (25, 27), (24, 26), (26, 28)]


def visibility(landmark):
    value = getattr(landmark, "visibility", 1.0)
    return 1.0 if value is None else float(value)


def pixel_point(landmark, width, height):
    return int(landmark.x * width), int(landmark.y * height)


def draw_pose(frame, landmarks):
    height, width = frame.shape[:2]
    for start, end in CONNECTIONS:
        if visibility(landmarks[start]) >= 0.35 and visibility(landmarks[end]) >= 0.35:
            cv2.line(frame, pixel_point(landmarks[start], width, height), pixel_point(landmarks[end], width, height), (0, 220, 0), 3, cv2.LINE_AA)
    for index in sorted({point for pair in CONNECTIONS for point in pair}):
        if visibility(landmarks[index]) >= 0.35:
            cv2.circle(frame, pixel_point(landmarks[index], width, height), 5, (0, 255, 255), -1, cv2.LINE_AA)


def main():
    parser = ArgumentParser(description="Create a short pose-check overlay.")
    parser.add_argument("video", type=Path)
    parser.add_argument("--output", type=Path, default=Path("outputs/pose_check.mp4"))
    parser.add_argument("--model", type=Path, default=Path("models/pose_landmarker.task"))
    parser.add_argument("--seconds", type=float, default=5.0)
    args = parser.parse_args()
    if not args.video.exists():
        raise FileNotFoundError(f"Video not found: {args.video}")
    if not args.model.exists():
        raise FileNotFoundError(f"Model not found: {args.model}")
    args.output.parent.mkdir(parents=True, exist_ok=True)
    capture = cv2.VideoCapture(str(args.video))
    if not capture.isOpened():
        raise RuntimeError(f"OpenCV could not open: {args.video}")
    fps = float(capture.get(cv2.CAP_PROP_FPS))
    width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
    height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
    if fps <= 0 or width <= 0 or height <= 0:
        raise RuntimeError("The video did not report a usable frame rate or size.")
    writer = cv2.VideoWriter(str(args.output), cv2.VideoWriter_fourcc(*"MJPG"), fps, (width, height))
    options = vision.PoseLandmarkerOptions(base_options=python.BaseOptions(model_asset_path=str(args.model)), running_mode=vision.RunningMode.VIDEO, num_poses=1, min_pose_detection_confidence=0.5, min_pose_presence_confidence=0.5, min_tracking_confidence=0.5)
    limit = max(1, int(args.seconds * fps))
    frame_index = 0
    with vision.PoseLandmarker.create_from_options(options) as landmarker:
        while frame_index < limit:
            ok, frame = capture.read()
            if not ok:
                break
            rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
            result = landmarker.detect_for_video(mp_image, int(frame_index * 1000.0 / fps))
            if result.pose_landmarks:
                draw_pose(frame, result.pose_landmarks[0])
                status, color = "Pose detected", (0, 220, 0)
            else:
                status, color = "No pose detected", (0, 0, 255)
            cv2.putText(frame, status, (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1.0, color, 2, cv2.LINE_AA)
            writer.write(frame)
            frame_index += 1
    capture.release()
    writer.release()
    print(f"Wrote {frame_index} annotated frames to {args.output}")


if __name__ == "__main__":
    main()

How Does the Overlay Work?

  • The CONNECTIONS list defines the body segments drawn between landmarks.
  • The visibility helper filters landmarks with weak confidence.
  • The draw_pose function adds green lines plus yellow joint markers.
  • The main function reads the requested video duration before writing the result.

Does the File Look Different?

Replace Claude Code's draft with the exact reference above. Confirm that the file is named overlay.py.

If the contents still differ, help me compare my overlay file with the project reference.

Generate and inspect the overlay

The final check processes only the first five seconds of your forehand baseline. This keeps the test focused on tracking quality.

Before you run the check, make your prediction: the athlete alone or the equipment too.

  • Generate the five-second pose check by asking Claude Code to run this command:
uv run python overlay.py videos/baseline_forehand.mp4 --output outputs/pose_check.mp4 --seconds 5

What Does This Command Do?

  • The uv run python portion uses the managed Python environment.
  • The videos/baseline_forehand.mp4 value selects the forehand baseline.
  • The --output outputs/pose_check.mp4 option sets the output path.
  • The --seconds 5 option limits the check to five seconds.

You should see a line beginning with Wrote. That line points to outputs/pose_check.mp4.

Was the Video Not Created?

  • Confirm that overlay.py exists inside ai-tennis-coach.
  • Confirm that the input is named videos/baseline_forehand.mp4.
  • Confirm that the model remains at models/pose_landmarker.task.

If those paths match, help me diagnose why my pose-check video was not created.

  • Ask Claude Code to open outputs/pose_check.mp4 in your Mac's video player.
  • Watch the complete five-second result for gaps in the skeleton.

✔️ The skeleton stays connected

You'll see green lines connecting your shoulders, wrists, hips, knees, and ankles. The status reads Pose detected while landmarks are available.

The racket remains unmarked. The ball remains unmarked.

You have your first visible win. The local lab can now draw your body skeleton over real footage.

ⓧ Key joints disappear

  • Move the phone farther from the hitting area.
  • Check the preview for your complete body.
  • Improve the lighting around the practice area.
  • Remove visual obstructions between the camera and your body.
  • Secure the phone before recording again.
  • Record both baseline clips again with the corrected framing.
  • Replace the existing clips using the same baseline filenames.
  • Return to the pose-check command above to run it again.

Still Losing Landmarks?

Use clothing that contrasts with the background. Keep every required joint visible throughout the stroke.

If the skeleton still breaks apart, help me improve my pose-tracking camera setup.

Your baseline footage has passed its first visual test. Next, you'll separate the recordings into individual strokes and turn the tracked movement into body-based metrics.

Turn Repeated Strokes into Numbers

Your skeleton overlay proved that MediaPipe can track your major joints through a stroke. Those landmarks still form one continuous stream with no boundaries between repetitions.

This step uses wrist-speed peaks to separate that stream into strokes. It replaces invisible racket data with named body-motion proxies that the camera can support.

In this step, get ready to:
  • Create the repeated-movement analyzer plus its coaching instructions.
  • Analyze the forehand baseline plus the backhand baseline.
  • Check stroke alignment plus the exported measurements.
Build the repeated-stroke analyzer

A stroke becomes measurable when the script can find a repeatable event in time. Here, each local peak in dominant-wrist speed acts as the center of one stroke window.

Why use wrist-speed peaks?

The phone footage cannot reveal literal racket speed or ball contact. The dominant wrist provides a visible body landmark that moves with the hitting arm.

The script treats peak wrist speed as an inferred contact proxy. This keeps every exported claim tied to something the pose model can observe.

  • Switch back to the Claude Code session in ai-tennis-coach.
  • Ask Claude Code to create coach.py from the complete project code.
  • Ask Claude Code to create coaching_prompt.md from the complete project code.
  • Inspect the project file list in Claude Code.

You should see coach.py plus coaching_prompt.md beside the existing overlay.py file.

✔️ Awesome, I've got everything!

Great. Save coach.py before checking the coaching instructions.

ⓧ I'd like to double check the full code

from argparse import ArgumentParser
from pathlib import Path
import csv
import math
import statistics

import cv2
import mediapipe as mp
from mediapipe.tasks import python
from mediapipe.tasks.python import vision

LEFT_SHOULDER, RIGHT_SHOULDER = 11, 12
LEFT_ELBOW, RIGHT_ELBOW = 13, 14
LEFT_WRIST, RIGHT_WRIST = 15, 16
LEFT_HIP, RIGHT_HIP = 23, 24
LEFT_KNEE, RIGHT_KNEE = 25, 26
LEFT_ANKLE, RIGHT_ANKLE = 27, 28
CONNECTIONS = [(LEFT_SHOULDER, RIGHT_SHOULDER), (LEFT_SHOULDER, LEFT_ELBOW), (LEFT_ELBOW, LEFT_WRIST), (RIGHT_SHOULDER, RIGHT_ELBOW), (RIGHT_ELBOW, RIGHT_WRIST), (LEFT_SHOULDER, LEFT_HIP), (RIGHT_SHOULDER, RIGHT_HIP), (LEFT_HIP, RIGHT_HIP), (LEFT_HIP, LEFT_KNEE), (LEFT_KNEE, LEFT_ANKLE), (RIGHT_HIP, RIGHT_KNEE), (RIGHT_KNEE, RIGHT_ANKLE)]
METRIC_FIELDS = ["peak_wrist_speed_shoulder_widths_s", "shoulder_turn_range_deg", "hip_turn_range_deg", "knee_bend_at_contact_deg", "contact_forward_shoulder_widths", "contact_height_shoulder_widths"]


def visibility(landmark):
    value = getattr(landmark, "visibility", 1.0)
    return 1.0 if value is None else float(value)


def distance_2d(a, b):
    return math.hypot(a[0] - b[0], a[1] - b[1])


def distance_3d(a, b):
    return math.sqrt(sum((a[index] - b[index]) ** 2 for index in range(3)))


def midpoint_2d(a, b):
    return (a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0


def joint_angle(a, b, c):
    first = tuple(a[index] - b[index] for index in range(3))
    second = tuple(c[index] - b[index] for index in range(3))
    denominator = distance_3d(first, (0.0, 0.0, 0.0)) * distance_3d(second, (0.0, 0.0, 0.0))
    if denominator <= 1e-9:
        return float("nan")
    cosine = sum(first[index] * second[index] for index in range(3)) / denominator
    return math.degrees(math.acos(max(-1.0, min(1.0, cosine))))


def moving_average(values, window):
    window, smoothed, running_sum = max(1, int(window)), [], 0.0
    for index, value in enumerate(values):
        running_sum += value
        if index >= window:
            running_sum -= values[index - window]
        smoothed.append(running_sum / min(index + 1, window))
    return smoothed


def percentile(values, fraction):
    ordered = sorted(values)
    if not ordered:
        return 0.0
    position = (len(ordered) - 1) * fraction
    lower, upper = math.floor(position), math.ceil(position)
    if lower == upper:
        return ordered[lower]
    weight = position - lower
    return ordered[lower] * (1.0 - weight) + ordered[upper] * weight


def make_record(result, arm):
    if not result.pose_landmarks or not result.pose_world_landmarks:
        return None
    normalized, world = result.pose_landmarks[0], result.pose_world_landmarks[0]
    wrist_index = RIGHT_WRIST if arm == "right" else LEFT_WRIST
    required = [LEFT_SHOULDER, RIGHT_SHOULDER, wrist_index, LEFT_HIP, RIGHT_HIP, LEFT_KNEE, RIGHT_KNEE, LEFT_ANKLE, RIGHT_ANKLE]
    if any(visibility(normalized[index]) < 0.35 for index in required):
        return None
    normalized_points = [(point.x, point.y, point.z) for point in normalized]
    world_points = [(point.x, point.y, point.z) for point in world]
    if not all(math.isfinite(value) for index in required for value in world_points[index]):
        return None
    shoulder_width = distance_2d(normalized_points[LEFT_SHOULDER], normalized_points[RIGHT_SHOULDER])
    if shoulder_width <= 1e-6:
        return None
    return {"normalized": normalized_points, "world": world_points, "visibility": [visibility(point) for point in normalized], "shoulder_width": shoulder_width, "wrist_index": wrist_index}


def capture_pose_data(video_path, model_path, arm):
    capture = cv2.VideoCapture(str(video_path))
    if not capture.isOpened():
        raise RuntimeError(f"OpenCV could not open: {video_path}")
    fps = float(capture.get(cv2.CAP_PROP_FPS))
    width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
    height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
    if fps <= 0 or width <= 0 or height <= 0:
        raise RuntimeError("The video did not report a usable frame rate or size.")
    options = vision.PoseLandmarkerOptions(base_options=python.BaseOptions(model_asset_path=str(model_path)), running_mode=vision.RunningMode.VIDEO, num_poses=1, min_pose_detection_confidence=0.5, min_pose_presence_confidence=0.5, min_tracking_confidence=0.5)
    records, frame_index = [], 0
    with vision.PoseLandmarker.create_from_options(options) as landmarker:
        while True:
            ok, frame = capture.read()
            if not ok:
                break
            rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            result = landmarker.detect_for_video(mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb), int(frame_index * 1000.0 / fps))
            records.append(make_record(result, arm))
            frame_index += 1
    capture.release()
    return records, fps, width, height


def compute_wrist_speeds(records, fps, speed_factor):
    speeds = [0.0] * len(records)
    for index in range(1, len(records)):
        previous, current = records[index - 1], records[index]
        if previous is None or current is None:
            continue
        wrist_index = current["wrist_index"]
        pixel_distance = distance_2d(previous["normalized"][wrist_index], current["normalized"][wrist_index])
        shoulder_width = (previous["shoulder_width"] + current["shoulder_width"]) / 2.0
        speeds[index] = pixel_distance * fps * speed_factor / shoulder_width
    return moving_average(speeds, max(3, int(fps * 0.10)))


def find_peaks(values, fps, expected_strokes, min_gap_seconds):
    candidates = [index for index in range(1, len(values) - 1) if values[index] >= values[index - 1] and values[index] > values[index + 1] and values[index] > 0]
    threshold = percentile([value for value in values if value > 0], 0.60)
    strong = [index for index in candidates if values[index] >= threshold]
    pool = strong if len(strong) >= expected_strokes else candidates
    minimum_gap, selected = max(1, int(fps * min_gap_seconds)), []
    for index in sorted(pool, key=lambda item: values[item], reverse=True):
        if all(abs(index - other) >= minimum_gap for other in selected):
            selected.append(index)
        if len(selected) == expected_strokes:
            break
    return sorted(selected)


def unwrap_angles(angles):
    if not angles:
        return []
    unwrapped = [angles[0]]
    for angle in angles[1:]:
        while angle - unwrapped[-1] > 180.0:
            angle -= 360.0
        while angle - unwrapped[-1] < -180.0:
            angle += 360.0
        unwrapped.append(angle)
    return unwrapped


def turn_range(records, start, end, left_index, right_index):
    angles = []
    for record in records[start : end + 1]:
        if record is not None:
            left, right = record["world"][left_index], record["world"][right_index]
            angles.append(math.degrees(math.atan2(right[2] - left[2], right[0] - left[0])))
    unwrapped = unwrap_angles(angles)
    return max(unwrapped) - min(unwrapped) if unwrapped else float("nan")


def build_metric_rows(records, speeds, peaks, fps, window_seconds, facing):
    rows, half_window = [], max(1, int(fps * window_seconds / 2.0))
    forward_sign = 1.0 if facing == "right" else -1.0
    for position, peak in enumerate(peaks):
        previous_midpoint = (peaks[position - 1] + peak) // 2 + 1 if position else 0
        next_midpoint = (peak + peaks[position + 1]) // 2 if position + 1 < len(peaks) else len(records) - 1
        start, end = max(previous_midpoint, peak - half_window), min(next_midpoint, peak + half_window)
        record = records[peak]
        if record is None:
            continue
        world, normalized = record["world"], record["normalized"]
        knee_angles = [angle for angle in (joint_angle(world[LEFT_HIP], world[LEFT_KNEE], world[LEFT_ANKLE]), joint_angle(world[RIGHT_HIP], world[RIGHT_KNEE], world[RIGHT_ANKLE])) if math.isfinite(angle)]
        knee_bend = 180.0 - statistics.fmean(knee_angles) if knee_angles else float("nan")
        hip_center = midpoint_2d(normalized[LEFT_HIP], normalized[RIGHT_HIP])
        wrist, shoulder_width = normalized[record["wrist_index"]], record["shoulder_width"]
        rows.append({"stroke": len(rows) + 1, "start_s": round(start / fps, 3), "contact_proxy_s": round(peak / fps, 3), "end_s": round(end / fps, 3), "peak_wrist_speed_shoulder_widths_s": round(max(speeds[start : end + 1]), 3), "shoulder_turn_range_deg": round(turn_range(records, start, end, LEFT_SHOULDER, RIGHT_SHOULDER), 3), "hip_turn_range_deg": round(turn_range(records, start, end, LEFT_HIP, RIGHT_HIP), 3), "knee_bend_at_contact_deg": round(knee_bend, 3), "contact_forward_shoulder_widths": round(forward_sign * (wrist[0] - hip_center[0]) / shoulder_width, 3), "contact_height_shoulder_widths": round((hip_center[1] - wrist[1]) / shoulder_width, 3), "start_frame": start, "contact_frame": peak, "end_frame": end})
    return rows


def pixel_point(point, width, height):
    return int(point[0] * width), int(point[1] * height)


def draw_record(frame, record):
    if record is None:
        return
    height, width = frame.shape[:2]
    points, visibilities = record["normalized"], record["visibility"]
    for start, end in CONNECTIONS:
        if visibilities[start] >= 0.35 and visibilities[end] >= 0.35:
            cv2.line(frame, pixel_point(points[start], width, height), pixel_point(points[end], width, height), (0, 220, 0), 3, cv2.LINE_AA)
    for index in sorted({point for pair in CONNECTIONS for point in pair}):
        if visibilities[index] >= 0.35:
            cv2.circle(frame, pixel_point(points[index], width, height), 5, (0, 255, 255), -1, cv2.LINE_AA)


def write_annotated_video(video_path, output_path, records, speeds, rows, fps, width, height):
    capture = cv2.VideoCapture(str(video_path))
    writer = cv2.VideoWriter(str(output_path), cv2.VideoWriter_fourcc(*"MJPG"), fps, (width, height))
    frame_index = 0
    while frame_index < len(records):
        ok, frame = capture.read()
        if not ok:
            break
        draw_record(frame, records[frame_index])
        active = next((row for row in rows if row["start_frame"] <= frame_index <= row["end_frame"]), None)
        if active:
            title = f"Stroke {active['stroke']}"
            if frame_index == active["contact_frame"]:
                title += " | inferred contact proxy"
            cv2.putText(frame, title, (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 255), 2, cv2.LINE_AA)
        cv2.putText(frame, f"Wrist speed proxy: {speeds[frame_index]:.2f} shoulder-widths/s", (20, 75), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (255, 255, 255), 2, cv2.LINE_AA)
        if records[frame_index] is None:
            cv2.putText(frame, "Pose not reliable in this frame", (20, 110), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 0, 255), 2, cv2.LINE_AA)
        writer.write(frame)
        frame_index += 1
    capture.release()
    writer.release()


def write_csv(path, rows, label, arm, facing, speed_factor):
    fields = ["label", "arm", "facing", "speed_factor", "stroke", "start_s", "contact_proxy_s", "end_s", *METRIC_FIELDS]
    with path.open("w", newline="", encoding="utf-8") as file:
        writer = csv.DictWriter(file, fieldnames=fields)
        writer.writeheader()
        for row in rows:
            writer.writerow({"label": label, "arm": arm, "facing": facing, "speed_factor": speed_factor, **{field: row[field] for field in fields if field in row}})


def write_summary(path, rows, label, arm, facing, speed_factor):
    lines = [f"# {label}", "", f"Valid strokes: {len(rows)}", f"Tracked arm: {arm}", f"Facing direction after normalization: {facing}", f"Speed factor: {speed_factor}", "", "| Metric | Mean | Median |", "| --- | ---: | ---: |"]
    for field in METRIC_FIELDS:
        values = [float(row[field]) for row in rows if math.isfinite(float(row[field]))]
        mean_value = statistics.fmean(values) if values else float("nan")
        median_value = statistics.median(values) if values else float("nan")
        lines.append(f"| {field} | {mean_value:.3f} | {median_value:.3f} |")
    lines.extend(["", "Limitations: Wrist speed is a body proxy, not racket speed. Contact is inferred at the wrist-speed peak, not detected from the ball. Monocular pose values are best used for consistent comparisons, not laboratory biomechanics."])
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def safe_name(label):
    return "".join(character if character.isalnum() or character in "-_" else "_" for character in label)


def main():
    parser = ArgumentParser(description="Segment repeated strokes and export body-motion proxies.")
    parser.add_argument("video", type=Path)
    parser.add_argument("--label", required=True)
    parser.add_argument("--arm", choices=("left", "right"), default="right")
    parser.add_argument("--facing", choices=("left", "right"), default="right")
    parser.add_argument("--expected-strokes", type=int, default=20)
    parser.add_argument("--min-gap-seconds", type=float, default=0.7)
    parser.add_argument("--window-seconds", type=float, default=1.4)
    parser.add_argument("--speed-factor", type=float, default=1.0)
    parser.add_argument("--model", type=Path, default=Path("models/pose_landmarker.task"))
    parser.add_argument("--output-dir", type=Path, default=Path("outputs"))
    args = parser.parse_args()
    if not args.video.exists():
        raise FileNotFoundError(f"Video not found: {args.video}")
    if not args.model.exists():
        raise FileNotFoundError(f"Model not found: {args.model}")
    if args.expected_strokes < 1 or args.speed_factor <= 0:
        raise ValueError("Stroke count and speed factor must be positive.")
    args.output_dir.mkdir(parents=True, exist_ok=True)
    records, fps, width, height = capture_pose_data(args.video, args.model, args.arm)
    speeds = compute_wrist_speeds(records, fps, args.speed_factor)
    peaks = find_peaks(speeds, fps, args.expected_strokes, args.min_gap_seconds)
    if not peaks:
        raise RuntimeError("No stroke peaks were found. Check the pose overlay and filming angle.")
    rows = build_metric_rows(records, speeds, peaks, fps, args.window_seconds, args.facing)
    name = safe_name(args.label)
    csv_path, summary_path, video_path = args.output_dir / f"{name}_metrics.csv", args.output_dir / f"{name}_summary.md", args.output_dir / f"{name}_annotated.mp4"
    write_csv(csv_path, rows, args.label, args.arm, args.facing, args.speed_factor)
    write_summary(summary_path, rows, args.label, args.arm, args.facing, args.speed_factor)
    write_annotated_video(args.video, video_path, records, speeds, rows, fps, width, height)
    print(f"Detected {len(rows)} of {args.expected_strokes} expected strokes.")
    print(f"Metrics: {csv_path}\nSummary: {summary_path}\nAnnotated video: {video_path}")
    if len(rows) < args.expected_strokes:
        print("Review the overlay, then tune --min-gap-seconds or --window-seconds if needed.")


if __name__ == "__main__":
    main()

What does the analyzer do?

  • The capture functions turn every reliable video frame into normalized plus world-coordinate pose records.
  • The speed functions normalize dominant-wrist movement by shoulder width.
  • The peak functions separate repetitions using time gaps between local speed maxima.
  • The export functions create an annotated video plus a CSV file plus a Markdown summary.

✔️ Awesome, I've got everything!

Save coaching_prompt.md so Claude can use the measurement limits in the next step.

ⓧ I'd like to double check the full code

# Coaching analysis instructions

Read every relevant `outputs/*_metrics.csv` and `outputs/*_summary.md` file before writing the report.

## Compare

1. Compare `player_before_forehand` with `pro_forehand`.
2. Compare `player_before_backhand` with `pro_backhand`.
3. Use the raw per-stroke rows to notice consistency, not only the averages.
4. Compare wrist speed only when the professional clip is real-time or has a known `speed_factor`.
5. If an after file exists, compare it only with the matching player-before stroke recorded from the unchanged camera.

## Interpret carefully

- `peak_wrist_speed_shoulder_widths_s` is a dominant-wrist speed proxy, not racket speed.
- Turn ranges come from monocular shoulder and hip landmarks.
- `knee_bend_at_contact_deg` is bend relative to a straight 180-degree knee at the inferred contact frame.
- Contact position is the wrist relative to the hip center at peak wrist speed. It is not ball contact.
- Do not diagnose injury, claim professional biomechanics, or invent missing values.

## Write `outputs/coaching_report.md`

Include a compact table of player and professional medians for each stroke, which stroke differs more consistently from its reference, the one largest repeatable difference, exactly one short physical cue, a limitations paragraph, and any available before-and-after table with valid stroke counts and an honest verdict.

Why keep separate coaching instructions?

The analyzer produces measurements without deciding what they mean for your technique. The coaching prompt tells Claude which comparisons are valid.

It also preserves the limits of wrist speed plus inferred contact. Those boundaries stop a body-tracking result from becoming a racket or ball claim.

Analyze both baseline clips

The arm setting identifies the wrist that drives stroke segmentation. The facing setting gives forward contact position a consistent direction.

  • Keep --arm right when your dominant arm is right.
  • Ask Claude Code to change the arm value to left when your dominant arm is left.
  • Keep --facing right when you face right in the side-on clip.
  • Ask Claude Code to change the facing value to left when you face left in the side-on clip.

Before you run the forehand analysis, how many distinct wrist-speed peaks do you expect the script to keep?

  • Analyze a right-handed forehand filmed while facing right by running:
uv run python coach.py videos/baseline_forehand.mp4 --label player_before_forehand --arm right --facing right --expected-strokes 20

What does this command produce?

  • The label creates output names beginning with player_before_forehand.
  • The expected stroke count limits peak selection to 20 repetitions.
  • The analyzer writes one annotated video plus one metrics file plus one summary file.

You should see a detected-stroke count followed by paths for the metrics plus summary plus annotated video. Processing the full clip can take a moment because every frame passes through pose tracking.

Forehand analysis did not finish?

Confirm that coach.py is saved beside overlay.py. Confirm that the forehand video still uses the exact name videos/baseline_forehand.mp4.

If pose tracking stops during the clip, replay outputs/pose_check.mp4 to confirm that the full body remains visible. Help me diagnose my forehand analysis.

Before you analyze the backhand, do you expect it to create a separate set of three output files?

  • Ask Claude Code to rerun the analysis for videos/baseline_backhand.mp4 with the label player_before_backhand.
  • Set the backhand analysis to your selected dominant arm.
  • Set the backhand analysis to your recorded facing direction.
  • Keep the expected stroke count at 20.

You should now see matching annotated video plus metrics plus summary paths for the backhand. Your two baseline clips have become separate measurement sets.

Backhand outputs missing?

Check that Claude Code used videos/baseline_backhand.mp4 as the input. Check that the label is exactly player_before_backhand.

A different label creates different output filenames. Help me find my backhand outputs.

Check peak alignment and metric honesty

A detected peak is useful only when it lines up with one visible repetition. The annotated videos make that timing check possible before you trust the tables.

Before you play the results, do you expect each stroke number to cover one complete repetition?

  • Ask Claude Code to reveal the outputs folder in Finder.
  • Open outputs/player_before_forehand_annotated.mp4 from Finder.
  • Watch where each stroke number appears.
  • Pause when the inferred contact-proxy label appears.
  • Open outputs/player_before_backhand_annotated.mp4 from Finder.
  • Check the backhand stroke numbers against the visible repetitions.

Each numbered window should cover one stroke. The inferred contact-proxy label should appear near a dominant-wrist speed peak.

  • Select outputs/player_before_forehand_metrics.csv in Claude Code.
  • Confirm that the file contains up to 20 data rows.
  • Select outputs/player_before_backhand_metrics.csv in Claude Code.
  • Confirm that the file contains up to 20 data rows.

What do the metric columns mean?

  • The peak_wrist_speed_shoulder_widths_s column measures dominant-wrist speed in shoulder widths per second.
  • The shoulder_turn_range_deg plus hip_turn_range_deg columns measure rotation ranges from pose landmarks.
  • The knee_bend_at_contact_deg column measures bend relative to a straight knee at the inferred contact frame.
  • The contact_forward_shoulder_widths plus contact_height_shoulder_widths columns locate the wrist relative to the hip center.

Duplicate stroke numbers indicate that nearby wrist-speed peaks survived the time-gap filter. Missing or oversized windows indicate that the temporal settings need adjustment.

  • Ask Claude Code to rerun the affected analysis with a slightly larger --min-gap-seconds value when one stroke receives duplicate peaks.
  • Ask Claude Code to rerun the affected analysis with a different --window-seconds value when a numbered window misses part of a repetition.
  • Replay the regenerated annotated video.
  • Confirm that each retained peak now matches one visible stroke.

Peaks still look wrong?

Use a larger minimum gap when two peaks belong to one stroke. Adjust the window duration when the stroke number starts too late or remains visible across another repetition.

Keep the same arm plus facing settings while tuning time. Help me tune stroke segmentation.

That is the central measurement loop working. Your videos now produce repeatable body-based evidence without pretending to see the racket or ball.

Your forehand plus backhand baselines are segmented and measured. Next, you will compare them with consistently filmed professional references to choose one coaching cue.

Compare with a Pro and Choose One Cue

Your baseline forehand and backhand clips now produce stroke-level body proxies. Those numbers become coaching evidence when you compare them with consistent references.

In this step, you use Claude Code to compare the player and professional CSV measurements. A mismatched camera angle can distort spatial differences. Unknown slow motion can distort wrist-speed differences.

In this step, get ready to:
  • Add authorized professional forehand and backhand reference clips.
  • Analyze both references with settings that match the baseline recordings.
  • Generate a coaching report with one body-based priority cue.
Choose comparable professional references

Finding authorized footage with matched framing can be fiddly. Aim for side-on clips that keep the athlete's full body visible.

Why do framing and timing matter?

A monocular pose estimate depends on the camera view. Similar framing makes shoulder turn, hip turn, knee bend, and contact-position proxies more comparable.

Playback timing directly affects the wrist-speed proxy. Real-time footage or slow motion with a known factor supports a timing correction.

  • Press Cmd+Space to open macOS search.
  • Type Finder and press Enter.
  • Locate an authorized side-on professional forehand clip with the full body visible.
  • Copy the forehand clip into the ai-tennis-coach/videos folder.
  • Rename the forehand clip to videos/pro_forehand.mp4.
  • Locate an authorized side-on professional backhand clip using the same framing criteria.
  • Copy the backhand clip into the ai-tennis-coach/videos folder.
  • Rename the backhand clip to videos/pro_backhand.mp4.

You should now see videos/pro_forehand.mp4 and videos/pro_backhand.mp4 inside the project.

Classify the playback timing

Real-time footage keeps the default speed factor. A known 4x slow-motion clip uses --speed-factor 4.

An unknown playback factor makes wrist-speed comparison unreliable. The spatial metrics can still support a comparison when the camera view is closely matched.

  • Record the forehand timing here: 1, known numeric factor, or unknown.
  • Record the backhand timing here: 1, known numeric factor, or unknown.
Analyze both reference clips

Fair comparisons reuse the matching baseline command. Only the video path, label, and known playback correction should change.

Which settings stay fixed?

Keep --arm, --facing, --min-gap-seconds, and --window-seconds identical to the matching baseline analysis.

Use --speed-factor only when the playback factor is known. An unknown factor means the final report must exclude wrist-speed comparisons for that reference.

  • Switch back to the Terminal panel from earlier.
  • Press the Up Arrow key until the baseline forehand analysis command appears.
  • Replace videos/baseline_forehand.mp4 with videos/pro_forehand.mp4.
  • Replace player_before_forehand with pro_forehand.
  • Preserve the baseline forehand analysis settings.
  • Use 1, known numeric factor, or unknown to decide whether the command needs a numeric --speed-factor.
  • Press Enter to analyze the professional forehand clip.

You will see output paths for outputs/pro_forehand_metrics.csv, outputs/pro_forehand_summary.md, and outputs/pro_forehand_annotated.mp4. That gives you the first matched reference dataset.

  • Press the Up Arrow key until the baseline backhand analysis command appears.
  • Replace videos/baseline_backhand.mp4 with videos/pro_backhand.mp4.
  • Replace player_before_backhand with pro_backhand.
  • Preserve the baseline backhand analysis settings.
  • Use 1, known numeric factor, or unknown to decide whether the command needs a numeric --speed-factor.
  • Press Enter to analyze the professional backhand clip.

You will see output paths for outputs/pro_backhand_metrics.csv, outputs/pro_backhand_summary.md, and outputs/pro_backhand_annotated.mp4. Both player baselines now have professional references measured with comparable settings.

Reference peaks look wrong?

Confirm that the full body stays visible throughout the reference clip. Missing wrists or hips can remove usable pose records.

Recall the matching baseline command again if the reference shows duplicate peaks or missed strokes. A changed gap or window value can shift the segmentation.

If the mismatch continues, help me diagnose the professional-reference stroke peaks.

Generate and audit the coaching report

The report turns the metric tables into one focused coaching decision. Its instructions require raw stroke consistency, matched timing, and explicit measurement limits.

  • Return to the Claude Code session from earlier.
  • Allow access to the project files if Claude Code asks for permission during this task.
  • Prepare the coaching report by pasting this prompt into Claude Code:
Read coaching_prompt.md and follow it. Write outputs/coaching_report.md.

What does this prompt do?

  • Claude Code reads the comparison rules in coaching_prompt.md.
  • Claude Code inspects the relevant player and professional metric files.
  • Claude Code writes the comparison to outputs/coaching_report.md.
  • Press Enter to submit the prompt.
  • Wait until Claude Code confirms that it wrote outputs/coaching_report.md.

Before you inspect the finished report, which stroke do you expect the numerical comparison to identify as weaker?

  • Ask Claude Code to display outputs/coaching_report.md.
  • Check that the report identifies the more consistently weaker stroke.
  • Check that compact tables show the player and professional medians.
  • Check that the report highlights the largest repeatable numerical differences.
  • Count the body-based priority cues in the report. You should find exactly one.
  • Check that an unknown playback factor excludes the affected wrist-speed comparison.
  • Scan the report for claims about racket speed, ball contact, injury risk, or professional-level technique.
  • Request a revision if any unsupported claim appears.

You now have a numbers-first coaching report with one focused cue. The comparison stays grounded in measurements that the phone video can support.

Your comparison now points to one focused change. Next, you will practise that cue and test whether the target proxy moves.

Practise, Refilm, and Measure the Change

Your Claude Code report has done the hard sorting. It names the weaker stroke plus one physical cue to test.

That recommendation earns trust when a repeat recording shows whether the target proxy moved. This step turns your cue into a controlled before-and-after experiment.

In this step, get ready to:
  • Practise only the selected cue for five focused minutes.
  • Capture five retest repetitions from the unchanged camera position.
  • Turn the retest measurements into a before-and-after verdict.
Practise one cue safely

Practising one cue keeps the experiment focused on the difference named in your report. Five minutes gives you a short same-session test without changing the purpose of the movement.

Your safety matters more than completing the comparison. Pain is your signal to stop the movement immediately.

  • Return to the priority cue in outputs/coaching_report.md.
  • Identify the target proxy connected to that cue.
  • Keep the camera support in its baseline position.
  • Practise only the selected cue for five focused minutes.
  • Stop immediately if the movement causes pain.

You now have a controlled intervention to test. The unchanged camera position keeps the retest comparable with your baseline.

Measure five retest strokes

A fair retest uses the same stroke type plus the same camera framing. Matching the analysis settings also prevents the software configuration from creating a false difference.

  • Choose the branch that matches the weaker stroke named in your report.

Forehand is weaker

  • Record exactly five forehands from the unchanged camera position.
  • Play the retest clip on your phone.

You should see five complete repetitions. Your shoulders plus your wrists plus your hips plus your knees plus your ankles should remain in frame.

  • Transfer the clip into the videos folder inside ai-tennis-coach using your usual phone-to-Mac transfer method.
  • Name the transferred file player_after_forehand.mp4.

Before Claude Code runs the analysis, do you expect all five repetitions to produce valid rows? Keep your prediction in mind.

  • Ask Claude Code to run the matching forehand retest by submitting this prompt:
Reuse the player_before_forehand analysis command from this Claude Code session.
Change the video to videos/player_after_forehand.mp4.
Change the label to player_after_forehand.
Change --expected-strokes to 5.
Keep --arm, --facing, --min-gap-seconds, and --window-seconds unchanged.
Run the analysis.

Why Reuse the Baseline Settings?

  • The prompt changes the recording plus the label plus the expected stroke count.
  • The matching baseline settings preserve a fair temporal comparison.
  • The --expected-strokes 5 value asks the peak selector for five repetitions.

Claude Code should report a detected count against five expected strokes. It should also print paths for the retest metrics plus the summary plus the annotated video.

  • Play outputs/player_after_forehand_annotated.mp4 with the same Mac video player you used for the baseline.

You should see each detected retest stroke numbered. The inferred contact frame should be marked.

Backhand is weaker

  • Record exactly five backhands from the unchanged camera position.
  • Play the retest clip on your phone.

You should see five complete repetitions. Your shoulders plus your wrists plus your hips plus your knees plus your ankles should remain in frame.

  • Transfer the clip into the videos folder inside ai-tennis-coach using your usual phone-to-Mac transfer method.
  • Name the transferred file player_after_backhand.mp4.

Before Claude Code runs the analysis, do you expect all five repetitions to produce valid rows? Keep your prediction in mind.

  • Ask Claude Code to run the matching backhand retest by submitting this prompt:
Reuse the player_before_backhand analysis command from this Claude Code session.
Change the video to videos/player_after_backhand.mp4.
Change the label to player_after_backhand.
Change --expected-strokes to 5.
Keep --arm, --facing, --min-gap-seconds, and --window-seconds unchanged.
Run the analysis.

Why Reuse the Baseline Settings?

  • The prompt changes the recording plus the label plus the expected stroke count.
  • The matching baseline settings preserve a fair temporal comparison.
  • The --expected-strokes 5 value asks the peak selector for five repetitions.

Claude Code should report a detected count against five expected strokes. It should also print paths for the retest metrics plus the summary plus the annotated video.

  • Play outputs/player_after_backhand_annotated.mp4 with the same Mac video player you used for the baseline.

You should see each detected retest stroke numbered. The inferred contact frame should be marked.

Detected Fewer Than Five Strokes?

A lower valid count means MediaPipe could not keep the required body landmarks reliable for every repetition. The generated count still records how many strokes were usable.

  • Check the annotated retest for frames where a wrist or lower-body joint leaves the picture.
  • Reuse the matching baseline analysis settings to preserve the comparison.
  • Rerecord the five repetitions if the full body leaves the frame.

If the analysis does not create all three retest outputs, help me debug the retest analysis.

Update the coaching report

The retest becomes evidence when the report compares it with the matching baseline. Claude Code needs the raw CSV rows plus the summaries so its verdict reflects valid strokes.

Before Claude Code updates the report, do you expect the target proxy to move in the intended direction? Keep your prediction in mind.

  • Ask Claude Code to update the coaching report by submitting this prompt:
Reread coaching_prompt.md and all relevant baseline, reference, and retest CSV and summary files.
Update outputs/coaching_report.md with the before-and-after result.
Retain the player-versus-reference comparison and exactly one priority cue.
Add a before-and-after table for the weaker stroke.
State the valid retest-stroke count.
Give an honest plain-language verdict about whether the target proxy changed.

What Does This Prompt Protect?

  • The prompt makes Claude Code reread the measurements before changing the report.
  • The original player-versus-reference comparison remains available for context.
  • The verdict stays tied to the target body proxy plus the valid retest count.
  • Review the before-and-after table in outputs/coaching_report.md.
  • Confirm the table compares the weaker stroke with its matching baseline.
  • Confirm the report states the number of valid retest strokes.
  • Check that the verdict says whether the target proxy changed.
  • Check that the report retains exactly one priority cue.

That closes the coaching loop. You now have a same-session result showing what changed after five minutes of focused practice.

Secret mission

Coach a Different Repeated Movement

Your tennis analyzer already turns repeated strokes into body-based measurements. Adapt the same pipeline to 10 repetitions of another movement. Tune the timing yourself before producing one evidence-based cue.

Clean Up Your Resources

Clean Up Your Resources

This project creates only local files, so there are no new ongoing metered cloud costs beyond the Claude Code plan you already use. Choose whether to keep the ai-tennis-coach folder, pause by closing Claude Code, or delete the folder entirely.

Resources you used:

  • The local Python 3.12 environment plus requirements.txt, overlay.py, coach.py, and coaching_prompt.md inside ai-tennis-coach.
  • The pose model stored at models/pose_landmarker.task.
  • The baseline, retest, professional-reference, and bonus-movement footage stored in videos.
  • The annotated videos, CSV measurements, summaries, coaching notes, and coaching report stored in outputs.

Keep everything running

No action is needed. Choose this if you want to analyze later recordings with the same model, scripts, and settings.

  • Leave ai-tennis-coach unchanged.
  • Keep only footage that you own or still have permission to analyze.
  • Reuse the saved Python environment when you return.
  • Keep future Claude Code activity within your existing paid plan limits.

Pause - I'll come back to this later

Close the active coding session while preserving every local file. There are no analysis services or cloud resources to stop.

  • Close the Terminal window that is running Claude Code.
  • Leave ai-tennis-coach untouched.
  • Return to the same folder when you are ready to analyze another recording.

Delete - I don't want to use this again

Deleting the project folder is a significant cleanup because every script, model, recording, measurement, and report is stored together. The rest of your Mac remains untouched.

  • Close the Terminal window that is running Claude Code.
  • Move the ai-tennis-coach folder to Trash.
  • Open Trash to confirm ai-tennis-coach is listed there.

That is the full local cleanup done: the Python environment, pose model, videos, measurements, and reports have moved to Trash together.

Nice Work!

Nice Work!

Outstanding work! Your local tennis coach now uses MediaPipe with OpenCV to turn side-on phone footage into movement evidence that Claude Code converts into focused coaching feedback.

You've learned how to:

  • Create an annotated pose video that shows whether the full-body skeleton stays reliable. Use that visual proof before trusting any measurements.
  • Segment repeated strokes around dominant-wrist speed peaks. Export per-stroke CSV measurements for wrist speed, body turn, knee bend, and inferred contact position.
  • Compare baseline strokes with professional-reference footage. Use the largest repeatable difference to select one coaching cue. Test its immediate effect with a before-and-after scorecard.
  • Complete the optional Secret Mission to apply the same measurement discipline in a new context. Preserve the pipeline's explicit body-proxy limitations throughout the extension.

Ready to quiz yourself?