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 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.

| mAP@50 | Precision | Recall | F1 | |
|---|---|---|---|---|
| Fine-tuned | 0.913 | 0.912 | 0.886 | 0.90 |
| COCO base, no training | 0.275 | 0.832 | 0.031 | 0.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.

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@50 | box mAP@50-95 | mask mAP@50 | mask mAP@50-95 | |
|---|---|---|---|---|
| 50 epochs, batch 8 | 0.885 | 0.675 | 0.819 | 0.500 |
| 150 epochs, batch 16, patience 30 | 0.902 | 0.713 | 0.837 | 0.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.