Volleyball Form Analyzer with AI

Build a full-stack AI app that detects body keypoints to score volleyball form.

Introduction

30 Second Summary

Every volleyball coach watches the same thing: body position. What if AI could watch a video and instantly flag every moment where your form breaks down?

In this project, you will build a full-stack volleyball form analyzer powered by AI pose detection. You'll upload a video clip and get back a form score, a skeleton-overlay video showing joint positions, and a timestamped list of issues.

What You'll Build

A Python and React application that uses YOLO pose estimation to analyze volleyball technique from uploaded video clips.

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

  • A Python pose analysis engine that detects 17 body keypoints and calculates joint angles from video frames.
  • A custom scoring engine with volleyball-specific form rules for elbow angle, knee bend, and shoulder alignment.
  • A FastAPI backend and React frontend that let you upload video and view your form score with skeleton-overlay playback.
  • Secret Mission: Add color-coded severity highlighting to the skeleton overlay and clickable timestamps that jump the video to each issue moment.

Are there any prerequisites?

No prior experience with AI, computer vision, or web development is required. This guide walks you through setting up every tool from scratch, including Python, Node.js, and all project dependencies.

Before We Start

This project combines three moving parts: a Python AI model that detects body joints, a FastAPI web server that processes video uploads, and a React frontend that displays results.

Before writing any code, you need all three environments ready to go. In this step, you'll install Node.js, create the project folder structure, set up a Python virtual environment with AI packages, and scaffold a React app.

In this step, get ready to:
  • Install Node.js v24 LTS for the frontend build tools.
  • Create the project structure with a Python virtual environment and AI packages.
  • Scaffold and verify the React frontend.
Install Node.js v24 LTS

Node.js powers the build tools that compile and serve your React frontend. You need version 20.19+ or 22.12+ for Vite to work.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Check if Node.js is already installed by running:
node --version

✔️ I see version 22 or higher

You're all set. Your Node.js version is compatible with Vite. Move on to the next substep.

ⓧ I see an older version

Your current Node.js version is too old for Vite. You need to upgrade to v24 LTS.

  • Visit https://nodejs.org/en/download in your browser.
  • Download the LTS installer for your operating system (macOS PKG or Windows MSI).
  • Run the installer and accept the default options.
  • Close and reopen your terminal after installation.
  • Verify the new version by running:
node --version

You should see v24.18.0 or similar.

ⓧ Command not found

Node.js is not installed on your machine. Let's install v24 LTS.

  • Visit https://nodejs.org/en/download in your browser.
  • Download the LTS installer for your operating system (macOS PKG or Windows MSI).
  • Run the installer and accept the default options.
  • Close and reopen your terminal after installation.
  • Verify the installation by running:
node --version

You should see v24.18.0 or similar.

  • Also verify npm (the Node.js package manager) by running:
npm --version

You should see a version number printed. This confirms both Node.js and npm are ready.

Still not seeing a version number?

Make sure you closed and reopened your terminal after installing Node.js. The terminal needs to reload its path to find the new command.

On Windows, try opening a new Command Prompt window. On macOS, open a new Terminal tab.

help me troubleshoot my Node.js installation

Create the project structure

Your project needs two separate environments: a Python backend for AI processing and a Node.js frontend for the UI. You'll create a parent folder that holds both.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir volleyball-analyzer
cd volleyball-analyzer
  • Create the backend and frontend subfolders by running:
mkdir backend frontend
  • Confirm the folders exist by running:
ls

You should see backend and frontend listed in the output.

Now set up the Python virtual environment inside backend. A virtual environment keeps this project's packages separate from your system Python.

  • Move into the backend folder by running:
cd backend
  • Create a virtual environment by running:
python -m venv venv

What is a virtual environment?

A virtual environment is an isolated Python installation inside your project folder. Packages you install here won't interfere with other Python projects on your machine.

  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

.\venv\Scripts\Activate.ps1

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

On Windows PowerShell, you might get an error about execution policies. Run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned and try the activation command again.

On macOS, make sure you typed source before the path. Without it, the script won't activate in your current shell.

help me activate my Python virtual environment

  • Install FastAPI and Ultralytics by running:
pip install "fastapi[standard]" ultralytics

What are these packages?

FastAPI is a modern Python web framework for building APIs. The [standard] extras include the server (uvicorn) and file upload support.

Ultralytics provides the YOLO pose estimation model. It automatically installs PyTorch, OpenCV, and NumPy as dependencies.

This installation may take a few minutes because PyTorch is a large package. Wait for it to finish before continuing.

Installation failing?

Make sure (venv) is showing in your terminal prompt. If it's not, you're installing into your system Python instead of the virtual environment.

If you see a permissions error, you likely forgot to activate the virtual environment. Run the activation command again, then retry the install.

help me fix my pip install error

Scaffold the React frontend

With the backend environment ready, you'll now set up the React frontend using Vite as the build tool. Vite creates a fast development environment with hot reloading.

  • Navigate to the frontend folder by running:
cd ../frontend
  • Scaffold a new React project in the current folder by running:
npm create vite@latest . -- --template react

What does this command do?

The . tells Vite to scaffold into the current directory (your frontend folder) instead of creating a new subfolder.

The --template react flag selects the React template so you get a pre-configured project with JSX support.

  • Install the project dependencies by running:
npm install
  • Start the development server to verify everything works by running:
npm run dev

You should see output showing the dev server running at http://localhost:5173. Open that URL in your browser.

You should see the default Vite + React welcome page with a spinning logo and a counter button.

Dev server not starting?

Make sure you ran npm install before npm run dev. The dev server needs the installed dependencies to run.

If you see port 5173 is already in use, another process is using that port. Close other terminal windows or browser tabs that might be running a Vite server.

help me get my Vite dev server running

Keep this server running

Leave the dev server running in this terminal tab. You'll open a second terminal tab later for the backend server. The frontend auto-refreshes when you edit files.

Your environment is fully set up. Next up, you'll build the pose detection engine that extracts body keypoints from video frames.

Build the Pose Detection Engine

Your environment is ready. Now it's time to build the AI brain of this project.

To analyze volleyball form, you need to detect where someone's body joints are in every frame of a video. In this step, you'll build a Python module that loads a YOLO pose estimation model, processes video frame-by-frame, extracts 17 body keypoints, and draws a skeleton overlay onto the output video.

In this step, get ready to:
  • Create the pose analyzer module with YOLO model loading and video processing.
  • Draw skeleton overlays by connecting detected body joints with lines.
  • Calculate joint angles using vector math for future form scoring.
Create the pose analyzer module

The pose analyzer is the core engine of this project. It takes a video file, runs every frame through a YOLO pose model, and produces an output video with skeleton lines drawn over the detected person.

  • In VS Code, right-click the backend folder in the Explorer sidebar.
  • Click New File and name it pose_analyzer.py.
  • Add the following imports and constants at the top of the file:
import cv2
import numpy as np
from ultralytics import YOLO

KEYPOINT_NAMES = {
    0: "nose", 1: "left_eye", 2: "right_eye", 3: "left_ear", 4: "right_ear",
    5: "left_shoulder", 6: "right_shoulder", 7: "left_elbow", 8: "right_elbow",
    9: "left_wrist", 10: "right_wrist", 11: "left_hip", 12: "right_hip",
    13: "left_knee", 14: "right_knee", 15: "left_ankle", 16: "right_ankle",
}

SKELETON_CONNECTIONS = [
    (5, 7), (7, 9), (6, 8), (8, 10),
    (5, 6), (5, 11), (6, 12), (11, 12),
    (11, 13), (13, 15), (12, 14), (14, 16),
]

CONFIDENCE_THRESHOLD = 0.5

What does this code do?

The three imports bring in everything you need. cv2 (OpenCV) handles video reading and writing. numpy provides the math for angle calculations. YOLO from Ultralytics is the pose estimation model.

KEYPOINT_NAMES maps the 17 COCO body keypoints (nose, eyes, shoulders, elbows, wrists, hips, knees, ankles) to integer indices. SKELETON_CONNECTIONS defines which joints to connect with lines (e.g., shoulder-to-elbow, hip-to-knee). CONFIDENCE_THRESHOLD filters out low-confidence detections so the skeleton only shows joints the model is reasonably sure about.

Now add the main analyze_video function below the constants. This is the orchestrator that ties everything together.

  • Add the following function below CONFIDENCE_THRESHOLD (leave a blank line between them):
def analyze_video(input_path, output_path):
    model = YOLO("yolov8n-pose.pt")
    cap = cv2.VideoCapture(input_path)

    fps = int(cap.get(cv2.CAP_PROP_FPS)) or 30
    width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
    height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))

    fourcc = cv2.VideoWriter_fourcc(*"mp4v")
    out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))

    all_frame_scores = []
    all_issues = []
    frame_num = 0

    while cap.isOpened():
        ret, frame = cap.read()
        if not ret:
            break

        results = model(frame, verbose=False)

        if results[0].keypoints is not None and len(results[0].keypoints.xy) > 0:
            keypoints = results[0].keypoints.xy[0].cpu().numpy()
            confidences = results[0].keypoints.conf[0].cpu().numpy()

            frame = draw_skeleton(frame, keypoints, confidences)

        out.write(frame)
        frame_num += 1

    cap.release()
    out.release()

    return {
        "overall_score": 0,
        "total_frames": frame_num,
        "issues": [],
        "output_video": output_path,
    }

What does this code do?

  • YOLO("yolov8n-pose.pt") loads the nano-sized pose model. The first time you run it, Ultralytics auto-downloads the model weights (about 6 MB).
  • cv2.VideoCapture opens the input video. The function reads properties (FPS, width, height) so the output video matches the original.
  • cv2.VideoWriter_fourcc(*"mp4v") sets the MP4 codec for the output video file.
  • The while loop reads frames one at a time and runs YOLO on each. results[0].keypoints.xy gives pixel coordinates for 17 body joints. results[0].keypoints.conf gives the confidence score for each detection.
  • The function calls draw_skeleton (which you'll write next) to overlay lines on each frame, then writes the modified frame to the output video.

What is pose estimation?

Pose estimation is a computer vision technique that detects body joints (keypoints) in images or video. The COCO format defines 17 keypoints covering the nose, eyes, ears, shoulders, elbows, wrists, hips, knees, and ankles. Each keypoint comes with an (x, y) pixel coordinate and a confidence score from 0 to 1.

  • Save the file. You'll see a warning about the undefined name draw_skeleton. That's expected because you'll add it next.

Seeing import errors in VS Code?

Make sure your VS Code Python interpreter is set to the virtual environment inside backend/venv. Press Ctrl+Shift+P (Windows) or Cmd+Shift+P (macOS), type Python: Select Interpreter, and choose the one pointing to your venv folder.

Help me fix Python import errors in VS Code

Write the skeleton drawing function

The skeleton overlay is what makes the output video useful. It draws green lines between connected joints and red dots at each keypoint, so you can visually see what the AI detected.

  • In pose_analyzer.py, add the draw_skeleton function between CONFIDENCE_THRESHOLD and analyze_video:
def draw_skeleton(frame, keypoints, confidences):
    for start, end in SKELETON_CONNECTIONS:
        if confidences[start] > CONFIDENCE_THRESHOLD and confidences[end] > CONFIDENCE_THRESHOLD:
            pt1 = tuple(keypoints[start].astype(int))
            pt2 = tuple(keypoints[end].astype(int))
            cv2.line(frame, pt1, pt2, (0, 255, 0), 2)

    for i, (point, conf) in enumerate(zip(keypoints, confidences)):
        if conf > CONFIDENCE_THRESHOLD:
            center = tuple(point.astype(int))
            cv2.circle(frame, center, 4, (0, 0, 255), -1)

    return frame

What does this code do?

  • The first loop iterates through SKELETON_CONNECTIONS and draws a green line (cv2.line) between each pair of joints, but only if both joints have confidence above the threshold.
  • The second loop draws a small red circle (cv2.circle) at each keypoint position. The -1 fill parameter makes the circle solid.
  • The confidence check prevents drawing phantom joints. If the model is unsure about a joint's location, it skips it rather than drawing in the wrong place.
  • Save pose_analyzer.py.
  • The warning about draw_skeleton being undefined should now disappear since the function is defined above where it's called.

Still seeing an undefined name warning?

Make sure draw_skeleton is placed above analyze_video in the file. Python reads files top to bottom, so the function definition must appear before it's called.

Help me fix the draw_skeleton function placement

Write the angle calculation function

To score volleyball form, you need to measure angles between joints. For example, how far is the elbow bent during a spike? This requires a function that takes three points (like shoulder, elbow, wrist) and returns the angle at the middle point.

  • In pose_analyzer.py, add the calculate_angle function between CONFIDENCE_THRESHOLD and draw_skeleton:
def calculate_angle(a, b, c):
    ba = np.array(a) - np.array(b)
    bc = np.array(c) - np.array(b)
    cosine = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc) + 1e-6)
    angle = np.degrees(np.arccos(np.clip(cosine, -1.0, 1.0)))
    return angle

What does this code do?

  • Given three points (a, b, c), it calculates the angle at point b. Think of it like measuring the bend of an elbow: a is the shoulder, b is the elbow, c is the wrist.
  • It creates two vectors (b-to-a and b-to-c), then uses the dot product formula to find the cosine of the angle between them.
  • The 1e-6 prevents division by zero if two points overlap. The np.clip keeps the cosine value within the valid range for arccos (between -1 and 1).
  • A straight arm returns approximately 180 degrees. A right-angle bend returns approximately 90 degrees.
  • Save pose_analyzer.py.
  • Verify the file has no errors by switching to your terminal (with the venv activated) and running:
python -c "from pose_analyzer import analyze_video; print('Module loaded successfully')"

You should see Module loaded successfully printed in the terminal.

Seeing a ModuleNotFoundError?

Make sure your terminal is in the backend folder and the virtual environment is activated. You should see (venv) at the start of your terminal prompt. If not, run venv\Scripts\activate (Windows) or source venv/bin/activate (macOS/Linux).

Help me fix my module import error

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and that the import test passed.

ⓧ I'd like to double check the full code

Here's the complete backend/pose_analyzer.py file at this point:

import cv2
import numpy as np
from ultralytics import YOLO

KEYPOINT_NAMES = {
    0: "nose", 1: "left_eye", 2: "right_eye", 3: "left_ear", 4: "right_ear",
    5: "left_shoulder", 6: "right_shoulder", 7: "left_elbow", 8: "right_elbow",
    9: "left_wrist", 10: "right_wrist", 11: "left_hip", 12: "right_hip",
    13: "left_knee", 14: "right_knee", 15: "left_ankle", 16: "right_ankle",
}

SKELETON_CONNECTIONS = [
    (5, 7), (7, 9), (6, 8), (8, 10),
    (5, 6), (5, 11), (6, 12), (11, 12),
    (11, 13), (13, 15), (12, 14), (14, 16),
]

CONFIDENCE_THRESHOLD = 0.5


def calculate_angle(a, b, c):
    ba = np.array(a) - np.array(b)
    bc = np.array(c) - np.array(b)
    cosine = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc) + 1e-6)
    angle = np.degrees(np.arccos(np.clip(cosine, -1.0, 1.0)))
    return angle


def draw_skeleton(frame, keypoints, confidences):
    for start, end in SKELETON_CONNECTIONS:
        if confidences[start] > CONFIDENCE_THRESHOLD and confidences[end] > CONFIDENCE_THRESHOLD:
            pt1 = tuple(keypoints[start].astype(int))
            pt2 = tuple(keypoints[end].astype(int))
            cv2.line(frame, pt1, pt2, (0, 255, 0), 2)

    for i, (point, conf) in enumerate(zip(keypoints, confidences)):
        if conf > CONFIDENCE_THRESHOLD:
            center = tuple(point.astype(int))
            cv2.circle(frame, center, 4, (0, 0, 255), -1)

    return frame


def analyze_video(input_path, output_path):
    model = YOLO("yolov8n-pose.pt")
    cap = cv2.VideoCapture(input_path)

    fps = int(cap.get(cv2.CAP_PROP_FPS)) or 30
    width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
    height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))

    fourcc = cv2.VideoWriter_fourcc(*"mp4v")
    out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))

    all_frame_scores = []
    all_issues = []
    frame_num = 0

    while cap.isOpened():
        ret, frame = cap.read()
        if not ret:
            break

        results = model(frame, verbose=False)

        if results[0].keypoints is not None and len(results[0].keypoints.xy) > 0:
            keypoints = results[0].keypoints.xy[0].cpu().numpy()
            confidences = results[0].keypoints.conf[0].cpu().numpy()

            frame = draw_skeleton(frame, keypoints, confidences)

        out.write(frame)
        frame_num += 1

    cap.release()
    out.release()

    return {
        "overall_score": 0,
        "total_frames": frame_num,
        "issues": [],
        "output_video": output_path,
    }

Your pose detection engine is ready. It can load a video, detect 17 body joints per frame, and draw skeleton overlays. Next up, you'll build the scoring rules that turn those raw joint coordinates into actionable form feedback.

Build the Form Scoring Rules Engine

In the last step, you built a pose detection engine that extracts 17 body keypoints from each video frame.

Raw coordinates are just numbers. To provide useful feedback, you need rules that interpret those coordinates as good or bad form. In this step, you will build a scoring engine that evaluates joint angles against volleyball-specific thresholds and flags violations with timestamps.

In this step, get ready to:
  • Create the score_frame function with four volleyball-specific form rules.
  • Record timestamped issues for each rule violation with joint names and angle measurements.
  • Wire scoring into the analyze_video loop with deduplication and result aggregation.
Create the score_frame function with volleyball rules

Each video frame produces a set of keypoint coordinates. The score_frame function takes those coordinates and checks them against four volleyball biomechanics rules.

  • In pose_analyzer.py, find the draw_skeleton function. Add the following score_frame function directly below it (above analyze_video):
def score_frame(keypoints, confidences, frame_num, fps):
    issues = []
    scores = []

    # Rule 1: Right elbow angle (shoulder-elbow-wrist)
    if all(confidences[i] > CONFIDENCE_THRESHOLD for i in [6, 8, 10]):
        angle = calculate_angle(keypoints[6], keypoints[8], keypoints[10])
        if angle < 90:
            issues.append({
                "timestamp": round(frame_num / fps, 1),
                "issue": "Right elbow angle too tight",
                "detail": f"Elbow at {int(angle)} degrees (should be >90)",
                "joint": "right_elbow",
            })
            scores.append(angle / 90 * 100)
        else:
            scores.append(100)

    # Rule 2: Left elbow angle (shoulder-elbow-wrist)
    if all(confidences[i] > CONFIDENCE_THRESHOLD for i in [5, 7, 9]):
        angle = calculate_angle(keypoints[5], keypoints[7], keypoints[9])
        if angle < 90:
            issues.append({
                "timestamp": round(frame_num / fps, 1),
                "issue": "Left elbow angle too tight",
                "detail": f"Elbow at {int(angle)} degrees (should be >90)",
                "joint": "left_elbow",
            })
            scores.append(angle / 90 * 100)
        else:
            scores.append(100)

    # Rule 3: Knee bend during approach (hip-knee-ankle)
    if all(confidences[i] > CONFIDENCE_THRESHOLD for i in [12, 14, 16]):
        angle = calculate_angle(keypoints[12], keypoints[14], keypoints[16])
        if angle < 90 or angle > 160:
            issues.append({
                "timestamp": round(frame_num / fps, 1),
                "issue": "Knee bend out of ideal range",
                "detail": f"Knee at {int(angle)} degrees (ideal: 90-140)",
                "joint": "right_knee",
            })
            scores.append(max(0, 100 - abs(angle - 115)))
        else:
            scores.append(100)

    # Rule 4: Shoulder alignment (left and right shoulder Y-coordinates)
    if all(confidences[i] > CONFIDENCE_THRESHOLD for i in [5, 6]):
        shoulder_diff = abs(float(keypoints[5][1]) - float(keypoints[6][1]))
        frame_height = 480
        tilt_ratio = shoulder_diff / frame_height
        if tilt_ratio > 0.05:
            issues.append({
                "timestamp": round(frame_num / fps, 1),
                "issue": "Shoulders not level",
                "detail": f"Tilt detected ({int(tilt_ratio * 100)}% of frame height)",
                "joint": "shoulders",
            })
            scores.append(max(0, 100 - tilt_ratio * 500))
        else:
            scores.append(100)

    # Aggregate per-rule scores into a single frame score
    frame_score = int(np.mean(scores)) if scores else 50
    return frame_score, issues

What do these four rules check?

  • Rules 1 and 2 check the right and left elbow angles using the shoulder-elbow-wrist triangle. A proper volleyball spike keeps the elbow above 90 degrees.
  • Rule 3 checks knee bend at the hip-knee-ankle joint. The ideal approach angle is 90-140 degrees. Anything outside that range (too straight or too bent) flags an issue.
  • Rule 4 compares left and right shoulder Y-coordinates. If the difference exceeds 5% of the frame height, the shoulders are tilted.
  • Save pose_analyzer.py.
  • You won't see a change yet. The next substep wires this function into the video analysis loop.

Seeing a syntax error on save?

Check that the function is indented consistently. Every line inside score_frame should use 4-space indentation.

Make sure the f-strings use curly braces around int(angle) and int(tilt_ratio * 100) with no extra spaces.

Confirm that score_frame is placed between draw_skeleton and analyze_video. Help me fix a syntax error in my score_frame function

Review the issue recording and score aggregation

Each rule in score_frame follows the same pattern. Understanding this pattern helps you add your own rules later.

How does the scoring math work?

Each rule first checks confidence thresholds. If the model is not confident enough about a joint's position, the rule skips entirely. This prevents false positives from noisy detections.

When a rule fires, it records a dictionary with four fields: timestamp (frame number divided by FPS to get seconds), issue (human-readable label), detail (measured value vs ideal), and joint (which body part is affected).

Scores are proportional. An elbow at 45 degrees scores 45/90 * 100 = 50. A perfect frame scores 100 on every rule. The final frame score averages all rule scores together.

Why use simplified thresholds?

These rules are simplified volleyball biomechanics designed to teach rule-based scoring from coordinate data. Real coaching would require sport-science validated thresholds. The important concept here is the pattern: take raw keypoint data, calculate a geometric measurement, compare it to a threshold, and produce structured feedback.

Wire scoring into the analyze_video loop

The score_frame function exists but nothing calls it yet. You need to invoke it on every frame and collect the results into a final summary.

  • In pose_analyzer.py, scroll to the analyze_video function. Inside the if results[0].keypoints is not None block, find this line:
            frame = draw_skeleton(frame, keypoints, confidences)
  • Add these three lines directly below it:
            frame_score, frame_issues = score_frame(keypoints, confidences, frame_num, fps)
            all_frame_scores.append(frame_score)
            all_issues.extend(frame_issues)

What do these three lines do?

Each frame gets scored individually. The score and any issues detected are collected into lists that accumulate across all frames. After the loop finishes, you will aggregate these into a final result.

  • Now scroll down to the end of the analyze_video function. Find the return statement that currently returns hardcoded values:
    return {
        "overall_score": 0,
        "total_frames": frame_num,
        "issues": [],
        "output_video": output_path,
    }
  • Replace that entire return block with this code that computes the real score and deduplicates issues:
    # Calculate overall score from all frame scores
    overall_score = int(np.mean(all_frame_scores)) if all_frame_scores else 0

    # Deduplicate issues by (timestamp, issue) to avoid repetition
    unique_issues = []
    seen_timestamps = set()
    for issue in all_issues:
        key = (issue["timestamp"], issue["issue"])
        if key not in seen_timestamps:
            seen_timestamps.add(key)
            unique_issues.append(issue)

    return {
        "overall_score": overall_score,
        "total_frames": frame_num,
        "issues": unique_issues[:10],
        "output_video": output_path,
    }

Why deduplicate issues?

A 30-second video at 30 FPS has 900 frames. If an elbow stays bent for half a second, that is 15 identical issues. The deduplication loop uses a set of (timestamp, issue) tuples to keep only the first occurrence. The final list is capped at 10 to avoid overwhelming the user.

  • Save pose_analyzer.py.
  • Verify the module loads without errors by running this in your terminal:
python -c "from pose_analyzer import score_frame, analyze_video; print('Module loaded successfully')"

You should see Module loaded successfully printed with no errors.

Seeing an ImportError or NameError?

Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt.

Check that score_frame is defined at the module level (not accidentally nested inside another function). It should start at the same indentation level as draw_skeleton and analyze_video.

If you see IndentationError, confirm the deduplication block uses 4-space indentation and aligns with the rest of analyze_video's body. Help me debug my pose_analyzer.py import error

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete pose_analyzer.py file after this step:

import cv2
import numpy as np
from ultralytics import YOLO

KEYPOINT_NAMES = {
    0: "nose", 1: "left_eye", 2: "right_eye", 3: "left_ear", 4: "right_ear",
    5: "left_shoulder", 6: "right_shoulder", 7: "left_elbow", 8: "right_elbow",
    9: "left_wrist", 10: "right_wrist", 11: "left_hip", 12: "right_hip",
    13: "left_knee", 14: "right_knee", 15: "left_ankle", 16: "right_ankle",
}

SKELETON_CONNECTIONS = [
    (5, 7), (7, 9), (6, 8), (8, 10),
    (5, 6), (5, 11), (6, 12), (11, 12),
    (11, 13), (13, 15), (12, 14), (14, 16),
]

CONFIDENCE_THRESHOLD = 0.5


def calculate_angle(a, b, c):
    ba = np.array(a) - np.array(b)
    bc = np.array(c) - np.array(b)
    cosine = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc) + 1e-6)
    angle = np.degrees(np.arccos(np.clip(cosine, -1.0, 1.0)))
    return angle


def draw_skeleton(frame, keypoints, confidences):
    for start, end in SKELETON_CONNECTIONS:
        if confidences[start] > CONFIDENCE_THRESHOLD and confidences[end] > CONFIDENCE_THRESHOLD:
            pt1 = tuple(keypoints[start].astype(int))
            pt2 = tuple(keypoints[end].astype(int))
            cv2.line(frame, pt1, pt2, (0, 255, 0), 2)

    for i, (point, conf) in enumerate(zip(keypoints, confidences)):
        if conf > CONFIDENCE_THRESHOLD:
            center = tuple(point.astype(int))
            cv2.circle(frame, center, 4, (0, 0, 255), -1)

    return frame


def score_frame(keypoints, confidences, frame_num, fps):
    issues = []
    scores = []

    if all(confidences[i] > CONFIDENCE_THRESHOLD for i in [6, 8, 10]):
        angle = calculate_angle(keypoints[6], keypoints[8], keypoints[10])
        if angle < 90:
            issues.append({
                "timestamp": round(frame_num / fps, 1),
                "issue": "Right elbow angle too tight",
                "detail": f"Elbow at {int(angle)} degrees (should be >90)",
                "joint": "right_elbow",
            })
            scores.append(angle / 90 * 100)
        else:
            scores.append(100)

    if all(confidences[i] > CONFIDENCE_THRESHOLD for i in [5, 7, 9]):
        angle = calculate_angle(keypoints[5], keypoints[7], keypoints[9])
        if angle < 90:
            issues.append({
                "timestamp": round(frame_num / fps, 1),
                "issue": "Left elbow angle too tight",
                "detail": f"Elbow at {int(angle)} degrees (should be >90)",
                "joint": "left_elbow",
            })
            scores.append(angle / 90 * 100)
        else:
            scores.append(100)

    if all(confidences[i] > CONFIDENCE_THRESHOLD for i in [12, 14, 16]):
        angle = calculate_angle(keypoints[12], keypoints[14], keypoints[16])
        if angle < 90 or angle > 160:
            issues.append({
                "timestamp": round(frame_num / fps, 1),
                "issue": "Knee bend out of ideal range",
                "detail": f"Knee at {int(angle)} degrees (ideal: 90-140)",
                "joint": "right_knee",
            })
            scores.append(max(0, 100 - abs(angle - 115)))
        else:
            scores.append(100)

    if all(confidences[i] > CONFIDENCE_THRESHOLD for i in [5, 6]):
        shoulder_diff = abs(float(keypoints[5][1]) - float(keypoints[6][1]))
        frame_height = 480
        tilt_ratio = shoulder_diff / frame_height
        if tilt_ratio > 0.05:
            issues.append({
                "timestamp": round(frame_num / fps, 1),
                "issue": "Shoulders not level",
                "detail": f"Tilt detected ({int(tilt_ratio * 100)}% of frame height)",
                "joint": "shoulders",
            })
            scores.append(max(0, 100 - tilt_ratio * 500))
        else:
            scores.append(100)

    frame_score = int(np.mean(scores)) if scores else 50
    return frame_score, issues


def analyze_video(input_path, output_path):
    model = YOLO("yolov8n-pose.pt")
    cap = cv2.VideoCapture(input_path)

    fps = int(cap.get(cv2.CAP_PROP_FPS)) or 30
    width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
    height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))

    fourcc = cv2.VideoWriter_fourcc(*"mp4v")
    out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))

    all_frame_scores = []
    all_issues = []
    frame_num = 0

    while cap.isOpened():
        ret, frame = cap.read()
        if not ret:
            break

        results = model(frame, verbose=False)

        if results[0].keypoints is not None and len(results[0].keypoints.xy) > 0:
            keypoints = results[0].keypoints.xy[0].cpu().numpy()
            confidences = results[0].keypoints.conf[0].cpu().numpy()

            frame = draw_skeleton(frame, keypoints, confidences)
            frame_score, frame_issues = score_frame(keypoints, confidences, frame_num, fps)
            all_frame_scores.append(frame_score)
            all_issues.extend(frame_issues)

        out.write(frame)
        frame_num += 1

    cap.release()
    out.release()

    overall_score = int(np.mean(all_frame_scores)) if all_frame_scores else 0

    unique_issues = []
    seen_timestamps = set()
    for issue in all_issues:
        key = (issue["timestamp"], issue["issue"])
        if key not in seen_timestamps:
            seen_timestamps.add(key)
            unique_issues.append(issue)

    return {
        "overall_score": overall_score,
        "total_frames": frame_num,
        "issues": unique_issues[:10],
        "output_video": output_path,
    }

Your scoring engine is ready. Next up, you will build the FastAPI backend that accepts video uploads, runs this analysis pipeline, and returns results as JSON.

Build the FastAPI Backend

Your pose detection engine can analyze videos and score form. Now you need a way for the React frontend to send videos to it and get results back.

In this step, you'll create a FastAPI backend server that accepts video uploads over HTTP, passes them to your analyze_video() function, and returns the results as JSON. This is the bridge between your AI pipeline and the browser.

In this step, get ready to:
  • Create a FastAPI app with CORS middleware and static file serving.
  • Build the /analyze POST endpoint that processes uploaded videos.
  • Run and test the backend using FastAPI's auto-generated docs.
Create the FastAPI app with CORS and static files

Your React frontend will run on http://localhost:5173 while the backend runs on a different port. Browsers block cross-origin requests by default, so you need CORS middleware to allow the frontend to talk to the backend.

  • In VS Code, create a new file called main.py inside your backend folder.
  • Add the following code to set up the app, CORS, directories, and static file serving:
import os
import uuid

from fastapi import FastAPI, File, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles

from pose_analyzer import analyze_video

# Create the FastAPI application
app = FastAPI()

# Allow the React dev server to make requests to this backend
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Create directories for uploaded and processed videos
UPLOAD_DIR = "uploads"
OUTPUT_DIR = "outputs"
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(OUTPUT_DIR, exist_ok=True)

# Serve processed videos as static files so the browser can play them
app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs")

What does this code do?

This sets up the foundation for your API server.

  • The CORS middleware tells the browser that requests from http://localhost:5173 (your React app) are allowed.
  • os.makedirs creates uploads and outputs folders if they don't exist yet.
  • The static files mount makes processed videos accessible at /outputs/filename.mp4 so the browser can stream them directly.
  • Save main.py.
Add the /analyze POST endpoint

The frontend will send a video file to a single endpoint. That endpoint needs to save the file, run your analysis pipeline, clean up, and return the results.

  • In main.py, add the following endpoint below the app.mount line:

@app.post("/analyze")
async def analyze(file: UploadFile = File(...)):
    # Generate a unique filename so uploads don't overwrite each other
    file_id = str(uuid.uuid4())
    input_path = os.path.join(UPLOAD_DIR, f"{file_id}.mp4")
    output_path = os.path.join(OUTPUT_DIR, f"{file_id}_analyzed.mp4")

    # Save the uploaded video to disk
    content = await file.read()
    with open(input_path, "wb") as f:
        f.write(content)

    # Run the pose analysis pipeline
    results = analyze_video(input_path, output_path)

    # Remove the original upload to save space
    os.remove(input_path)

    # Return the analysis results as JSON
    return JSONResponse({
        "score": results["overall_score"],
        "total_frames": results["total_frames"],
        "issues": results["issues"],
        "video_url": f"/outputs/{file_id}_analyzed.mp4",
    })

What does this endpoint do?

  • UploadFile = File(...) tells FastAPI to expect a file upload in the request body.
  • Each upload gets a UUID filename to avoid collisions if multiple requests arrive.
  • analyze_video() is your pose detection function from the previous step. It processes the video and writes the skeleton-overlaid output.
  • The original upload is deleted after processing to save disk space. Only the analyzed output stays.
  • The response includes the score, frame count, up to 10 issues, and a URL where the browser can stream the processed video.
  • Save main.py.

Seeing a red underline on the import?

If VS Code shows a warning on from pose_analyzer import analyze_video, make sure your Python interpreter is set to the virtual environment inside backend. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), search for Python: Select Interpreter, and choose the one inside your venv folder.

Help me fix my Python import warning in VS Code.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete backend/main.py file:

import os
import uuid

from fastapi import FastAPI, File, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles

from pose_analyzer import analyze_video

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

UPLOAD_DIR = "uploads"
OUTPUT_DIR = "outputs"
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(OUTPUT_DIR, exist_ok=True)

app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs")


@app.post("/analyze")
async def analyze(file: UploadFile = File(...)):
    file_id = str(uuid.uuid4())
    input_path = os.path.join(UPLOAD_DIR, f"{file_id}.mp4")
    output_path = os.path.join(OUTPUT_DIR, f"{file_id}_analyzed.mp4")

    content = await file.read()
    with open(input_path, "wb") as f:
        f.write(content)

    results = analyze_video(input_path, output_path)

    os.remove(input_path)

    return JSONResponse({
        "score": results["overall_score"],
        "total_frames": results["total_frames"],
        "issues": results["issues"],
        "video_url": f"/outputs/{file_id}_analyzed.mp4",
    })
Run and test the backend

FastAPI includes a development server that auto-reloads when you change code. It also generates interactive API documentation automatically.

  • In your terminal (with the virtual environment still active in the backend folder), start the server by running:
fastapi dev main.py
  • You should see output showing the server is running at http://localhost:8000.
  • Open your browser and navigate to http://localhost:8000/docs.

You should see FastAPI's interactive Swagger UI with a POST /analyze endpoint listed.

  • Click the POST /analyze row to expand it.
  • Click Try it out.
  • Click Choose File and select a short volleyball video clip (a few seconds is fine for testing).
  • Click Execute.

What should I see?

After a few seconds of processing, the response body should show JSON with a score (a number between 0 and 100), total_frames (how many frames were analyzed), an issues array with timestamped form problems, and a video_url path pointing to the processed video.

The first time you run this, YOLO will download the yolov8n-pose.pt model file automatically. This is a one-time download of about 6MB.

Getting a 500 error or timeout?

  • Make sure your virtual environment is active. You should see (venv) in your terminal prompt.
  • Check that pose_analyzer.py is in the same backend folder as main.py.
  • Try a shorter video clip (2-3 seconds). Long videos take more time to process frame by frame.

Help me debug my FastAPI 500 error when uploading a video.

Your backend is live and serving AI-powered form analysis. Next up, you'll build the React frontend to give users a polished interface for uploading videos and viewing their results.

Build the React Frontend

Your backend is running and ready to accept video uploads, process them through the pose detection engine, and return scored results. The missing piece is a user interface that makes this pipeline accessible.

In this step, you'll replace the default React scaffold with a custom volleyball analyzer UI. The frontend handles video uploads, displays the AI-generated score with color coding, plays back the skeleton-overlaid video, and lists every detected form issue with timestamps.

In this step, get ready to:
  • Build the analyzer UI component with upload area, score card, and results display.
  • Implement the upload handler that sends video to the FastAPI backend and manages loading state.
  • Style the app with a clean layout, color-coded scores, and monospace timestamp badges.
Replace App.jsx with the analyzer component

The default Vite scaffold shows a counter demo. You'll replace it entirely with a component that handles three UI states: waiting for upload, processing, and displaying results.

  • Open a new terminal window. Your FastAPI server is using the existing terminal, so you need a second one for the frontend.
  • Navigate to your frontend folder by running this command:
cd ~/Desktop/volleyball-analyzer/frontend
  • Start the React dev server by running:
npm run dev

You should see output showing the dev server running at http://localhost:5173. Leave this terminal running.

  • In VS Code, open frontend/src/App.jsx.
  • Select all the existing content and replace it with the following code:
import { useState } from 'react'
import './App.css'

function App() {
  const [file, setFile] = useState(null)
  const [loading, setLoading] = useState(false)
  const [results, setResults] = useState(null)

  const handleUpload = async () => {
    if (!file) return

    setLoading(true)
    setResults(null)
    const formData = new FormData()
    formData.append('file', file)

    try {
      const response = await fetch('http://localhost:8000/analyze', {
        method: 'POST',
        body: formData,
      })
      const data = await response.json()
      setResults(data)
    } catch (error) {
      console.error('Analysis failed:', error)
    } finally {
      setLoading(false)
    }
  }

  const getScoreClass = (score) => {
    if (score >= 80) return 'good'
    if (score >= 60) return 'okay'
    return 'needs-work'
  }

  return (
    <div className="app">
      <h1>Volleyball Form Analyzer</h1>
      <p className="subtitle">Upload a volleyball clip to analyze your form with AI</p>

      <div className="upload-section">
        <input
          type="file"
          accept="video/*"
          onChange={(e) => setFile(e.target.files[0])}
        />
        <button onClick={handleUpload} disabled={!file || loading}>
          {loading ? 'Analyzing...' : 'Analyze Form'}
        </button>
      </div>

      {loading && (
        <div className="loading">
          <p>Processing video frames with AI pose detection...</p>
          <p className="loading-note">This may take a moment depending on video length.</p>
        </div>
      )}

      {results && (
        <div className="results">
          <div className="score-card">
            <h2>Form Score</h2>
            <div className={`score ${getScoreClass(results.score)}`}>
              {results.score}/100
            </div>
            <p>{results.total_frames} frames analyzed</p>
          </div>

          <div className="video-section">
            <h3>Skeleton Overlay</h3>
            <video controls width="100%">
              <source src={`http://localhost:8000${results.video_url}`} type="video/mp4" />
              Your browser does not support the video tag.
            </video>
          </div>

          {results.issues.length > 0 && (
            <div className="issues-section">
              <h3>Form Issues Detected</h3>
              <ul>
                {results.issues.map((issue, index) => (
                  <li key={index}>
                    <span className="timestamp">{issue.timestamp}s</span>
                    <span className="issue-text">{issue.issue}</span>
                    <span className="detail">{issue.detail}</span>
                  </li>
                ))}
              </ul>
            </div>
          )}
        </div>
      )}
    </div>
  )
}

export default App

What does this code do?

This component manages three UI states using React's useState hook:

  • file holds the selected video file from the file input.
  • loading toggles the processing indicator while the AI analyzes frames.
  • results stores the JSON response from the backend (score, issues, video URL).

The JSX renders three sections conditionally: the upload area (always visible), the loading indicator (only while processing), and the results panel (only after analysis completes).

  • Save App.jsx.
  • Open http://localhost:5173 in your browser. You should see the heading "Volleyball Form Analyzer" with a file input and an "Analyze Form" button (the button is grayed out until you select a file).

Don't see the UI?

  • Make sure the React dev server is still running in its terminal. If you closed it, run npm run dev again from the frontend folder.
  • Check the browser console for errors. A missing import or syntax typo will prevent the component from rendering.
  • help me debug why my React component isn't rendering
Understand the upload handler and test the full stack

The handleUpload function is the bridge between your frontend and the FastAPI backend. It packages the video file into a FormData object and POSTs it to your analysis endpoint.

How does the upload flow work?

  • FormData is a browser API that packages files exactly how a server expects them. It handles encoding the binary video data automatically.
  • The fetch call sends the video to http://localhost:8000/analyze where your FastAPI endpoint receives it as an UploadFile.
  • The loading state flips to true before the request and back to false in the finally block, so the UI always recovers whether the request succeeds or fails.
  • getScoreClass returns a CSS class name based on the score: good for 80+, okay for 60-79, and needs-work for below 60. You'll map these to colors in the CSS next.
  • Find a short volleyball clip (5-10 seconds is ideal for testing). You can record yourself or download a free clip from a stock video site.
  • In the browser at http://localhost:5173, click the file input and select your video.
  • Click the Analyze Form button.
  • You should see the loading message "Processing video frames with AI pose detection..." appear. After processing completes, a score and issue list should display (the video player and styling will look rough until the next substep).

Keep the video short for testing

YOLO processes every frame individually. A 10-second video at 30fps means 300 model inferences. Stick to clips under 10 seconds while developing to keep wait times reasonable.

Getting a network error or CORS failure?

  • Make sure your FastAPI server is still running in the other terminal at http://localhost:8000. If it crashed, restart it with fastapi dev main.py from the backend folder.
  • Check that the CORS origin in main.py matches your React dev server URL exactly: http://localhost:5173.
  • help me fix my CORS or network error
Style the app with custom CSS

The component works, but the default Vite styles make it hard to read. You need a layout that puts the score front and center, makes the video player prominent, and formats the issue list so timestamps stand out at a glance.

  • Open frontend/src/App.css.
  • Select all the existing content and replace it with the following styles:
.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
  font-family: system-ui, -apple-system, sans-serif;
}

h1 {
  color: #1a1a2e;
  margin-bottom: 0.25rem;
}

.subtitle {
  color: #666;
  margin-bottom: 2rem;
}

.upload-section {
  display: flex;
  gap: 1rem;
  align-items: center;
  margin-bottom: 2rem;
  padding: 1.5rem;
  border: 2px dashed #ccc;
  border-radius: 8px;
}

button {
  padding: 0.75rem 1.5rem;
  background: #4a90d9;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 1rem;
}

button:disabled {
  background: #999;
  cursor: not-allowed;
}

.loading {
  text-align: center;
  padding: 2rem;
  color: #4a90d9;
}

.loading-note {
  font-size: 0.85rem;
  color: #999;
}

.results {
  margin-top: 2rem;
}

.score-card {
  text-align: center;
  padding: 2rem;
  background: #f8f9fa;
  border-radius: 12px;
  margin-bottom: 2rem;
}

.score {
  font-size: 3rem;
  font-weight: bold;
}

.score.good {
  color: #28a745;
}

.score.okay {
  color: #ffc107;
}

.score.needs-work {
  color: #dc3545;
}

.video-section {
  margin-bottom: 2rem;
}

video {
  border-radius: 8px;
  border: 1px solid #eee;
}

.issues-section ul {
  list-style: none;
  padding: 0;
}

.issues-section li {
  display: flex;
  gap: 1rem;
  padding: 0.75rem;
  border-bottom: 1px solid #eee;
  align-items: center;
}

.timestamp {
  background: #e9ecef;
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
  font-family: monospace;
  font-size: 0.85rem;
  white-space: nowrap;
}

.issue-text {
  font-weight: 500;
}

.detail {
  color: #666;
  font-size: 0.9rem;
}

What do these styles do?

  • .score.good, .score.okay, and .score.needs-work map to green, yellow, and red. The getScoreClass function in your JSX dynamically applies the right class based on the returned score.
  • .timestamp uses font-family: monospace so all timestamp badges align neatly regardless of digit count.
  • .upload-section uses a dashed border to create a visual drop zone that signals "put your file here" to users.
  • Save App.css and refresh your browser.
  • You should see a clean, centered layout with a dashed upload area at the top. The heading "Volleyball Form Analyzer" appears in dark text with a gray subtitle beneath it.
  • Upload your test video again and click Analyze Form. After processing, verify the score displays in a large colored number (green, yellow, or red depending on the score), the processed video plays with skeleton overlay, and the issues appear in a formatted list with gray timestamp badges on the left.

Styles not applying?

  • Check that the import './App.css' line is present at the top of App.jsx. Without this import, Vite won't load the styles.
  • Make sure you replaced ALL the default CSS. Leftover Vite styles (like centered text and purple links) will conflict with your custom layout.
  • help me fix my CSS styling issues

✔️ Awesome, I've got everything!

Great. Double check that both files are saved and your app displays the score card, video player, and issue list correctly.

ⓧ I'd like to double check the full code

Here is the complete App.jsx:

import { useState } from 'react'
import './App.css'

function App() {
  const [file, setFile] = useState(null)
  const [loading, setLoading] = useState(false)
  const [results, setResults] = useState(null)

  const handleUpload = async () => {
    if (!file) return

    setLoading(true)
    setResults(null)
    const formData = new FormData()
    formData.append('file', file)

    try {
      const response = await fetch('http://localhost:8000/analyze', {
        method: 'POST',
        body: formData,
      })
      const data = await response.json()
      setResults(data)
    } catch (error) {
      console.error('Analysis failed:', error)
    } finally {
      setLoading(false)
    }
  }

  const getScoreClass = (score) => {
    if (score >= 80) return 'good'
    if (score >= 60) return 'okay'
    return 'needs-work'
  }

  return (
    <div className="app">
      <h1>Volleyball Form Analyzer</h1>
      <p className="subtitle">Upload a volleyball clip to analyze your form with AI</p>

      <div className="upload-section">
        <input
          type="file"
          accept="video/*"
          onChange={(e) => setFile(e.target.files[0])}
        />
        <button onClick={handleUpload} disabled={!file || loading}>
          {loading ? 'Analyzing...' : 'Analyze Form'}
        </button>
      </div>

      {loading && (
        <div className="loading">
          <p>Processing video frames with AI pose detection...</p>
          <p className="loading-note">This may take a moment depending on video length.</p>
        </div>
      )}

      {results && (
        <div className="results">
          <div className="score-card">
            <h2>Form Score</h2>
            <div className={`score ${getScoreClass(results.score)}`}>
              {results.score}/100
            </div>
            <p>{results.total_frames} frames analyzed</p>
          </div>

          <div className="video-section">
            <h3>Skeleton Overlay</h3>
            <video controls width="100%">
              <source src={`http://localhost:8000${results.video_url}`} type="video/mp4" />
              Your browser does not support the video tag.
            </video>
          </div>

          {results.issues.length > 0 && (
            <div className="issues-section">
              <h3>Form Issues Detected</h3>
              <ul>
                {results.issues.map((issue, index) => (
                  <li key={index}>
                    <span className="timestamp">{issue.timestamp}s</span>
                    <span className="issue-text">{issue.issue}</span>
                    <span className="detail">{issue.detail}</span>
                  </li>
                ))}
              </ul>
            </div>
          )}
        </div>
      )}
    </div>
  )
}

export default App

And the complete App.css:

.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
  font-family: system-ui, -apple-system, sans-serif;
}

h1 {
  color: #1a1a2e;
  margin-bottom: 0.25rem;
}

.subtitle {
  color: #666;
  margin-bottom: 2rem;
}

.upload-section {
  display: flex;
  gap: 1rem;
  align-items: center;
  margin-bottom: 2rem;
  padding: 1.5rem;
  border: 2px dashed #ccc;
  border-radius: 8px;
}

button {
  padding: 0.75rem 1.5rem;
  background: #4a90d9;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 1rem;
}

button:disabled {
  background: #999;
  cursor: not-allowed;
}

.loading {
  text-align: center;
  padding: 2rem;
  color: #4a90d9;
}

.loading-note {
  font-size: 0.85rem;
  color: #999;
}

.results {
  margin-top: 2rem;
}

.score-card {
  text-align: center;
  padding: 2rem;
  background: #f8f9fa;
  border-radius: 12px;
  margin-bottom: 2rem;
}

.score {
  font-size: 3rem;
  font-weight: bold;
}

.score.good {
  color: #28a745;
}

.score.okay {
  color: #ffc107;
}

.score.needs-work {
  color: #dc3545;
}

.video-section {
  margin-bottom: 2rem;
}

video {
  border-radius: 8px;
  border: 1px solid #eee;
}

.issues-section ul {
  list-style: none;
  padding: 0;
}

.issues-section li {
  display: flex;
  gap: 1rem;
  padding: 0.75rem;
  border-bottom: 1px solid #eee;
  align-items: center;
}

.timestamp {
  background: #e9ecef;
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
  font-family: monospace;
  font-size: 0.85rem;
  white-space: nowrap;
}

.issue-text {
  font-weight: 500;
}

.detail {
  color: #666;
  font-size: 0.9rem;
}

Secret mission

Color-Coded Severity and Clickable Timestamp Navigation

Your analyzer detects form issues, but the skeleton overlay treats every joint the same. What if problem areas lit up red in real-time, and you could click any timestamp to jump straight to that moment in the video?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The volleyball-analyzer project folder on your Desktop (contains backend, frontend, Python virtual environment, YOLO model weights, and processed video outputs).
  • Running dev servers (FastAPI backend on port 8000 and React frontend on port 5173).
  • Node.js v24 LTS (system-level install).

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment further with the volleyball analyzer.

  • Your FastAPI and React dev servers will keep running until you close the terminal windows.
  • Node.js stays installed on your system for future JavaScript projects.
  • The YOLO model weights (yolov8n-pose.pt) stay cached in the project folder for fast re-runs.

Pause - I'll come back to this later

Stop the running servers to free up memory and terminal space, but keep all files intact so you can resume later.

  • Switch to the terminal running the React dev server and press Ctrl+C to stop it.
  • Switch to the terminal running the FastAPI server and press Ctrl+C to stop it.
  • Your project files, virtual environment, and Node.js installation all remain in place for next time.

Delete - I don't want to use this again

Remove all project resources and optionally uninstall Node.js to start fresh.

  • Stop both dev servers by pressing Ctrl+C in each terminal.
  • Delete the entire project folder by running these commands:

macOS

cd ~/Desktop
rm -rf volleyball-analyzer

Windows

cd ~\Desktop
rmdir /s /q volleyball-analyzer

This removes all Python code, the virtual environment, node_modules, YOLO model weights, and any processed videos.

  • To uninstall Node.js on Windows, open Settings, select Apps, find Node.js in the list, and click Uninstall.
  • To uninstall Node.js on macOS (if installed via Homebrew), run:
brew uninstall node

If you installed Node.js using the official macOS installer (.pkg), you will need to manually remove the installed files from /usr/local/bin/node and /usr/local/lib/node_modules.

Nice Work!

Nice Work!

Nice work! You just built a full-stack AI-powered volleyball form analyzer that processes video through a pose estimation model, scores athletic form against custom rules, and presents the results in a polished web interface.

You've learned how to:

  • Build a YOLO pose estimation pipeline that extracts 17 body keypoints from video frames and draws skeleton overlays with OpenCV.
  • Design a custom form scoring engine that calculates joint angles using vector math and flags violations against volleyball-specific rules.
  • Wire together a FastAPI backend and React frontend that handles video uploads, runs AI inference, and displays scores with a timestamped issue breakdown.
  • Secret Mission: Added color-coded severity highlighting to the skeleton overlay and clickable timestamp navigation to jump to every issue moment in the video.

Ready to quiz yourself?