ENFRKOantony langlois
blog

Teaching YOLO what a climbing hold is

I climb. Standing under a wall, the thing your brain does automatically is filter: ignore the ninety holds that are not yours, follow the green ones. I wanted a program that could do the same thing from a phone photo. Every hold, its exact outline, its color.

I had never fine-tuned a model before. I had read about machine learning, done the toy notebooks, and understood roughly none of it in my hands. So this was the project where I found out.

Fine-tuning, without the jargon

Training a vision model from nothing takes millions of images and weeks of GPU time. Nobody does that for a side project.

Instead you take a model that already knows how to see, one trained on millions of everyday photos until it understands edges, textures, shapes, and the general idea of "an object is a thing with a boundary." Then you keep training it, briefly, on your own pictures. It keeps the vision and picks up one new concept. That is fine-tuning.

The version I keep coming back to: you are not raising a child from birth. You are hiring an experienced photographer and spending an afternoon telling them what a climbing hold looks like.

What the model does before you teach it

The base model here is YOLO11, pre-trained on COCO, a dataset of 80 everyday object categories. People, chairs, dogs, cars. Climbing hold is not one of them.

I pointed it at a photo of my gym. It found two objects. It labeled both of them knife.

The same climbing wall photographed once, run through the off-the-shelf model on top (two red outlines, nothing else) and the fine-tuned model below (89 colored outlines tracing every hold)
The same climbing wall photographed once, run through the off-the-shelf model on top (two red outlines, nothing else) and the fine-tuned model below (89 colored outlines tracing every hold)

The bottom half is the same photo, same architecture, after fine-tuning. 89 holds, each traced to its edge, each outline drawn in the color the model's pixels say it is. That gap is the entire point of this post.

The numbers

I scored both models on a held-out test set of 555 photos containing 24,578 labeled holds. Same scorer for both, so the only difference between the rows is the training.

Bar chart comparing the fine-tuned model and the COCO base model on four metrics: mAP@50, precision, recall, F1
Bar chart comparing the fine-tuned model and the COCO base model on four metrics: mAP@50, precision, recall, F1
mAP@50PrecisionRecallF1
Fine-tuned0.9130.9120.8860.90
COCO base, no training0.2750.8320.0310.06

Recall is the honest column. Of the 24,578 real holds, the fine-tuned model finds about 21,800. The base model finds about 760. It is not that the base model is bad at holds; it has no idea holds exist, so it mostly stays quiet.

Everything below is the how.


The dataset is the project

I did not label a single photo myself, which felt like cheating until I tried labeling ten and understood why people pay for this. Instead I pulled three public climbing-hold segmentation datasets off Roboflow Universe, all CC BY 4.0, and merged them: 5,619 images.

Merging other people's data is where the actual work is.

Every export used a different class list. One said hold, one said climbing-hold, one said shapes. Some label files were empty. Some polygons had two points, which is a line, not a shape. Some had three collinear points, which is a polygon with zero area. Annotation tools really do emit these. Sixty-three images failed validation and got quarantined into a folder for me to look at rather than silently trained on. Ultralytics will happily train around bad labels and tell you nothing.

Two decisions I would defend anywhere:

Split by photo, never by hold. A wall photo has 40 holds on it. If 30 land in the training set and 10 in the validation set, your validation score is a lie: you are testing on pixels the model already memorized. The code only ever moves whole images between splits, so leaking is not something I remembered not to do, it is something the data structure cannot express.

Make the split reproducible. Sort the file list by a stable key first, then shuffle with a fixed seed. Shuffling rglob output directly gives you a different split on a different filesystem. Without this, when v2 beats v1 you cannot say whether the data changed or the training did.

Final deck: 4,446 train / 555 val / 555 test, seed 42.

One class, no colors

The obvious schema is red_hold, blue_hold, green_hold, and so on. It is the wrong one.

The model learns a single class, hold. It learns shape and nothing else. Color comes afterward from ordinary code reading the actual pixels inside the mask.

This is better on every axis. No extra labeling. Changing a color name never requires a retrain. And the model cannot be confused by the gym's purple uplighting, because it was never asked about color in the first place.

There is a lovely side effect. Ultralytics' default augmentation includes hsv_s=0.7, which randomly yanks saturation around during training so the model sees more variety for free. If color were a learned class, that augmentation would be actively poisoning the labels. Because it is not, the augmentation is pure profit.

The rule of thumb I took away, and it generalizes far past this project: only make the neural network do the thing classical code cannot.

Color, the classical half

Segmentation instead of bounding boxes was the whole reason for this. A box around a hold contains a lot of wall. An exact outline contains only hold, so you can ask "what color is this" and get an answer that means something.

Per mask:

eroded = cv2.erode(mask, kernel, iterations=3)   # step back from the wall edge
pixels = image[eroded.astype(bool)]
hsv    = to_hsv(pixels)
hsv    = hsv[hsv[:, 2] > 0.12]                   # drop shadow pixels

Then k-means with k=3 over the pixels, take the biggest cluster, name it by hue range. Two details that took me longer than they should have.

Hue is circular. Red lives at both 359° and 1°. Cluster on raw hue and k-means splits a solid red hold into two clusters at opposite ends of the axis, then averages them to 180°, which is cyan. The fix is to embed hue on a circle, scaled by saturation, before clustering:

h_rad = np.deg2rad(hsv[:, 0])
feats = np.stack([s * np.cos(h_rad), s * np.sin(h_rad), v], axis=1)

Purity has to be measured against the space, not the cluster. k-means with k=3 will split a perfectly uniform red hold into three touching clusters, so "fraction of pixels in the winning cluster" reads about 0.33 for a hold that is obviously red. Instead: count every pixel within a fixed radius of the winning center. Below 0.6 the answer is unknown, which is the honest response for a two-tone hold or one buried under chalk.

Close crop of the fine-tuned output: pink, orange, green, grey and white holds each traced with an outline in their detected color
Close crop of the fine-tuned output: pink, orange, green, grey and white holds each traced with an outline in their detected color

Training

yolo11s-seg, 10.1M parameters, 20.5 MB on disk. Google Colab GPU. All the training logic lives in one function; the notebook only mounts Drive, stages the dataset onto local disk (reading thousands of images per epoch over the Drive mount is painfully slow), and shells out to the same scripts/train.py I run on my laptop. Two entrypoints, one recipe, nothing to drift.

Runs write straight to Drive and --resume picks the newest last.pt, because Colab will cut you off mid-epoch and it is very demoralizing to lose four hours. Resume deliberately ignores --epochs and --imgsz and reuses the original run's stored args, so a resumed run cannot silently become a different run.

Two runs worth comparing, both measured on the validation split during training:

box mAP@50box mAP@50-95mask mAP@50mask mAP@50-95
50 epochs, batch 80.8850.6750.8190.500
150 epochs, batch 16, patience 300.9020.7130.8370.526

Tripling the epoch budget bought 1.7 points of box mAP@50. The metric that moved most was mAP@50-95, which demands tighter outlines. That is the shape of a model that already knew where the holds were and spent the extra epochs learning exactly where they end.

Reading the checkpoint back for this post, I found something slightly embarrassing: my own spec says train at imgsz=1280 because wall photos have many small holds, and the recorded args on the winning weights say 640. I ran the final training at half the resolution I told myself to. Inference still runs at 1280 and the results above are what they are, so I never noticed. Write your run metadata to disk. You will not remember.

What I am not claiming

The base model I compared against is yolo11n-seg (nano) and the fine-tuned one is yolo11s-seg (small). Not a perfectly controlled comparison. I am comfortable with it because nano's failure is conceptual rather than capacity-bound: it does not have a "hold" class to fire, so no number of parameters saves it. It called a black volume a knife.

That 0.275 mAP@50 for the base model is also flattering. mAP sweeps every confidence level down to near zero, so it hands out credit for lucky low-confidence guesses. At the threshold you would actually deploy at, the base model is blind. Recall and F1 are the honest signal.

Where it is still bad

I ran the model over 73 photos of my own gym, which it has never trained on: 3,138 holds detected. The detection holds up. The color pipeline is the weak half. 17.3% of holds come back unknown, and grey is over-represented at 24.6%.

The reason is chalk. Climbers cover holds in chalk. A pink hold with a white handprint on it genuinely is two colors, and my purity check is doing its job by refusing to guess. Fixing it properly probably means weighting pixels by distance from the mask center, or learning what chalk looks like, which would break my own rule about not making the network do classical work. I have not decided.

Mask mAP@50-95 sits at 0.526. Outlines are roughly right and rarely exact. For route grouping, which is where this goes next, roughly right is enough.

What I actually learned

The training loop was the easy part. Five lines. The hard parts were all the parts nobody photographs: deciding what one class means, making a dataset that rebuilds identically, refusing to let bad labels through, and picking which half of the problem does not need a neural network at all.

Also, 14.5 seconds on my laptop for an 8064×4536 photo, no GPU. Small models are extremely underrated.

The repo is yolov11-climbing-holds-detector. Next up: clustering holds by color into routes, which is what I wanted the whole time.