AI Surf Project
Coding · 2025–present
Built with Python, YOLO11, SAM 2, OpenCV, PyTorch
This system tracks a wakesurfer and their board from a camera on a boat, then coaches the rider on their form. I trained my own board detector on labeled footage, this finds the board. The board detector reached 0.684 mAP50.
The footage
For training I used 62 clips of wakesurfing filmed from a boat. This comes out to be around 38 minutes (68,595 frames) at 1080p and 30 fps..
Finding the rider and the board
A YOLO11 pose model finds the rider and their joints; the ankles show where the board is. A second YOLO11 model, trained only on surfboards, (My model) finds the board..
Detectors are chosen in a settings file, so you can switch models without changing the code. An RT-DETR plugin is built in and runs automatically on CUDA or Apple chips, so you can use it on a MacBook on the boat.
Labeling without labeling every frame
Instead of marking the board in every frame, I click the rider and board in the first frame, and Meta's SAM 2 tracks them through the video. Using this method I created labels in a standard format. I use every frame to measure performance, but train the model on every fifth frame that shows the board to avoid using nearly identical frames.
I measured each clip's coverage and dropped the six weakest validation clips. That left 54 training and 8 validation clips: 10,746 labeled frames out of 68,595.
How much of each clip SAM 2 labeled
Show the numbers as a table
| Clip | Split | Dropped | Frames | Board labeled | Rider labeled |
|---|---|---|---|---|---|
| IMG_2172 | Validation | Yes | 1,194 | 2% | 87% |
| IMG_2293 | Validation | Yes | 2,227 | 7% | 25% |
| IMG_8536 | Training | No | 2,029 | 7% | 100% |
| IMG_3632 | Training | No | 1,530 | 13% | 96% |
| IMG_2685 | Training | No | 963 | 13% | 83% |
| IMG_3010 | Training | No | 2,970 | 16% | 100% |
| IMG_3008 | Training | No | 1,237 | 23% | 88% |
| IMG_1969 | Validation | Yes | 985 | 24% | 98% |
| IMG_2105 | Validation | Yes | 2,508 | 24% | 47% |
| IMG_2442 | Validation | Yes | 2,226 | 24% | 41% |
| IMG_2679 | Training | No | 804 | 28% | 100% |
| IMG_6159 | Training | No | 709 | 34% | 94% |
| IMG_2760 | Training | No | 2,603 | 38% | 97% |
| IMG_0377 | Validation | Yes | 1,716 | 41% | 83% |
| IMG_6150 | Training | No | 824 | 53% | 90% |
| IMG_2110 | Validation | No | 202 | 55% | 61% |
| IMG_2689 | Training | No | 1,081 | 63% | 96% |
| IMG_2164 | Validation | No | 1,149 | 65% | 95% |
| IMG_3006 | Training | No | 792 | 68% | 100% |
| IMG_3038 | Training | No | 225 | 72% | 88% |
| IMG_6151 | Training | No | 696 | 74% | 100% |
| IMG_3233 | Training | No | 489 | 79% | 100% |
| IMG_3447 | Training | No | 550 | 81% | 94% |
| IMG_8439 | Training | No | 3,527 | 82% | 99% |
| IMG_0391 | Validation | No | 613 | 83% | 95% |
| IMG_2670 | Training | No | 473 | 84% | 93% |
| IMG_3633 | Training | No | 704 | 84% | 94% |
| IMG_7739 | Training | No | 700 | 84% | 98% |
| IMG_8442 | Training | No | 676 | 85% | 100% |
| IMG_2855 | Training | No | 786 | 85% | 87% |
| IMG_3031 | Training | No | 158 | 85% | 94% |
| IMG_3428 | Training | No | 438 | 88% | 98% |
| IMG_2450 | Training | No | 914 | 90% | 95% |
| IMG_3009 | Training | No | 1,568 | 90% | 98% |
| IMG_3039 | Training | No | 948 | 91% | 100% |
| IMG_7725 | Training | No | 471 | 91% | 99% |
| IMG_1964 | Validation | No | 1,185 | 93% | 95% |
| IMG_2162 | Validation | No | 1,242 | 93% | 97% |
| IMG_2877 | Training | No | 627 | 94% | 100% |
| IMG_6417 | Training | No | 1,420 | 94% | 100% |
| IMG_3018 | Training | No | 1,134 | 94% | 98% |
| IMG_2154 | Validation | No | 1,151 | 95% | 97% |
| IMG_2759 | Training | No | 1,098 | 95% | 100% |
| IMG_3023 | Training | No | 662 | 95% | 95% |
| IMG_6018 | Training | No | 1,188 | 95% | 100% |
| IMG_3036 | Training | No | 590 | 95% | 100% |
| IMG_3034 | Training | No | 907 | 96% | 100% |
| IMG_8440 | Training | No | 2,181 | 96% | 98% |
| IMG_3446 | Training | No | 772 | 96% | 97% |
| IMG_2153 | Validation | No | 1,016 | 97% | 100% |
| IMG_3032 | Training | No | 616 | 97% | 97% |
| IMG_3438 | Training | No | 1,370 | 97% | 100% |
| IMG_6390 | Training | No | 1,067 | 97% | 98% |
| IMG_6022 | Training | No | 1,179 | 98% | 98% |
| IMG_8438 | Training | No | 1,583 | 98% | 99% |
| IMG_3439 | Training | No | 682 | 98% | 100% |
| IMG_6420 | Training | No | 505 | 98% | 99% |
| IMG_3033 | Training | No | 1,889 | 98% | 100% |
| IMG_6019 | Training | No | 1,710 | 99% | 99% |
| IMG_8497-2 | Training | No | 2,134 | 99% | 100% |
| IMG_3028 | Training | No | 343 | 99% | 100% |
| IMG_8493 | Training | No | 3,196 | 99% | 100% |
| IMG_0392 | Validation | No | 715 | 100% | 100% |
| IMG_3017 | Training | No | 2,026 | 100% | 100% |
| IMG_3020 | Training | No | 643 | 100% | 100% |
| IMG_3021 | Training | No | 724 | 100% | 100% |
| IMG_3030 | Training | No | 1,066 | 100% | 100% |
| IMG_3445 | Training | No | 1,145 | 100% | 100% |
Training the board detector
The training was set for 100 rounds but stopped at round 70 to avoid overtraining. The saved model was from round 50. It scored 0.684 on one accuracy measure, which counts a board as correctly detected when the predicted box overlaps at least half of the actual board. It scored 0.533 on a stricter measure. Precision was 0.76 (how often its detections were correct), and recall was 0.72 (how often it found the board).
Training loss kept falling while validation loss rose, a sign of overfitting: hence the early stop.
How the board detector trained
Show the numbers as a table
| Epoch | mAP50 | Validation loss | Training loss |
|---|---|---|---|
| 1 | 0.640 | 1.69 | 1.64 |
| 2 | 0.434 | 2.25 | 0.81 |
| 3 | 0.460 | 2.06 | 0.90 |
| 4 | 0.575 | 1.92 | 0.91 |
| 5 | 0.619 | 1.90 | 0.80 |
| 6 | 0.637 | 1.71 | 0.74 |
| 7 | 0.611 | 1.80 | 0.70 |
| 8 | 0.678 | 1.83 | 0.67 |
| 9 | 0.648 | 1.79 | 0.63 |
| 10 | 0.642 | 1.79 | 0.61 |
| 11 | 0.683 | 1.76 | 0.60 |
| 12 | 0.651 | 1.64 | 0.58 |
| 13 | 0.660 | 1.80 | 0.57 |
| 14 | 0.664 | 1.68 | 0.55 |
| 15 | 0.659 | 1.83 | 0.55 |
| 16 | 0.659 | 1.71 | 0.54 |
| 17 | 0.680 | 1.67 | 0.52 |
| 18 | 0.654 | 1.65 | 0.52 |
| 19 | 0.674 | 1.82 | 0.51 |
| 20 | 0.660 | 1.85 | 0.50 |
| 21 | 0.700 | 1.75 | 0.50 |
| 22 | 0.672 | 1.90 | 0.48 |
| 23 | 0.675 | 1.83 | 0.48 |
| 24 | 0.661 | 1.84 | 0.47 |
| 25 | 0.676 | 1.90 | 0.47 |
| 26 | 0.695 | 1.87 | 0.46 |
| 27 | 0.686 | 1.74 | 0.45 |
| 28 | 0.675 | 1.74 | 0.45 |
| 29 | 0.666 | 1.80 | 0.45 |
| 30 | 0.664 | 1.90 | 0.44 |
| 31 | 0.670 | 1.90 | 0.44 |
| 32 | 0.676 | 1.80 | 0.43 |
| 33 | 0.688 | 1.73 | 0.42 |
| 34 | 0.654 | 1.79 | 0.42 |
| 35 | 0.667 | 1.79 | 0.41 |
| 36 | 0.671 | 1.86 | 0.41 |
| 37 | 0.684 | 1.83 | 0.41 |
| 38 | 0.689 | 1.81 | 0.40 |
| 39 | 0.663 | 2.01 | 0.40 |
| 40 | 0.661 | 1.89 | 0.39 |
| 41 | 0.682 | 1.86 | 0.39 |
| 42 | 0.675 | 1.91 | 0.39 |
| 43 | 0.686 | 1.91 | 0.38 |
| 44 | 0.675 | 1.84 | 0.38 |
| 45 | 0.673 | 1.80 | 0.38 |
| 46 | 0.675 | 1.91 | 0.37 |
| 47 | 0.680 | 1.80 | 0.37 |
| 48 | 0.673 | 1.87 | 0.36 |
| 49 | 0.675 | 1.82 | 0.36 |
| 50 | 0.684 | 1.86 | 0.36 |
| 51 | 0.665 | 1.90 | 0.35 |
| 52 | 0.670 | 1.94 | 0.35 |
| 53 | 0.665 | 2.05 | 0.35 |
| 54 | 0.659 | 1.91 | 0.35 |
| 55 | 0.676 | 1.88 | 0.35 |
| 56 | 0.673 | 1.94 | 0.34 |
| 57 | 0.678 | 1.86 | 0.34 |
| 58 | 0.674 | 1.87 | 0.33 |
| 59 | 0.680 | 1.90 | 0.33 |
| 60 | 0.674 | 1.94 | 0.33 |
| 61 | 0.662 | 1.99 | 0.33 |
| 62 | 0.669 | 1.98 | 0.33 |
| 63 | 0.671 | 1.94 | 0.32 |
| 64 | 0.668 | 1.96 | 0.32 |
| 65 | 0.669 | 1.92 | 0.31 |
| 66 | 0.670 | 1.90 | 0.31 |
| 67 | 0.677 | 1.87 | 0.31 |
| 68 | 0.675 | 1.95 | 0.31 |
| 69 | 0.668 | 1.95 | 0.30 |
| 70 | 0.672 | 1.95 | 0.30 |
Measuring the results
I wrote a tool to compare the model's results with SAM 2's labels. It measures how often the rider and board are found, how accurate their detected positions are, how much those positions jump between frames, how long the model loses track, and how fast it runs. Here are the results for one 613-frame test clip:
One scored clip, frame by frame
Show the numbers as a table
| Board box | From (s) | To (s) | Frames |
|---|---|---|---|
| detected | 0.0 | 1.0 | 30 |
| coasting | 1.0 | 1.1 | 3 |
| detected | 1.1 | 1.3 | 6 |
| coasting | 1.3 | 1.3 | 1 |
| detected | 1.3 | 3.8 | 75 |
| coasting | 3.8 | 4.5 | 20 |
| estimated | 4.5 | 4.6 | 4 |
| detected | 4.6 | 4.7 | 1 |
| coasting | 4.7 | 4.8 | 4 |
| detected | 4.8 | 4.8 | 1 |
| coasting | 4.8 | 5.2 | 10 |
| detected | 5.2 | 5.6 | 13 |
| coasting | 5.6 | 5.7 | 3 |
| detected | 5.7 | 6.7 | 30 |
| coasting | 6.7 | 6.7 | 1 |
| detected | 6.7 | 6.8 | 1 |
| coasting | 6.8 | 6.8 | 1 |
| detected | 6.8 | 10.8 | 120 |
| coasting | 10.8 | 11.5 | 20 |
| detected | 11.5 | 11.5 | 1 |
| coasting | 11.5 | 12.2 | 20 |
| estimated | 12.2 | 12.9 | 22 |
| missing | 12.9 | 12.9 | 1 |
| estimated | 12.9 | 13.0 | 1 |
| detected | 13.0 | 18.4 | 163 |
| coasting | 18.4 | 18.5 | 4 |
| detected | 18.5 | 18.6 | 1 |
| coasting | 18.6 | 19.2 | 20 |
| detected | 19.2 | 20.4 | 36 |
| Rider state | From (s) | To (s) | Frames |
|---|---|---|---|
| SURFING | 0.0 | 0.7 | 20 |
| DOWN | 0.7 | 0.7 | 1 |
| SURFING | 0.7 | 4.2 | 104 |
| DOWN | 4.2 | 4.5 | 10 |
| SURFING | 4.5 | 6.2 | 52 |
| DOWN | 6.2 | 20.4 | 426 |
How it coaches the surfer
From the pose joints, the program measures the rider's form. These are 2D measurements in the camera image, so they're best read as trends:
- Knee bend: the angle at each knee (180° is straight).
- Waist bend: the angle at each hip between shoulder and knee.
- Lean: torso tilt from vertical.
- Stance width: ankle gap divided by the rider's height.
- Foot position: where each ankle sits along the board.
- Knees straighter than 165°: "Bend your knees."
- Hip angle under 120°: "Chest up."
- Leaning over 20°: "Stack over the board."
- Stance under 0.20 or over 0.45 of height: "Feet wider" or "Feet closer."
Cues only run while the rider is surfing. One appears after 8 bad frames in a row (about a quarter second), clears after 20 good ones, and only the most important cue shows at a time.
After a run, a report gives each rule's in-range percentage and worst moment, and lists up to three things to work on. Runs are saved as logs, so old runs can be re-scored with new thresholds.