Je grimpe. Debout sous un mur, ce que votre cerveau fait automatiquement, c'est filtrer : ignorer les quatre-vingt-dix prises qui ne sont pas les vôtres, suivre les vertes. Je voulais un programme capable de faire la même chose à partir d'une photo de téléphone. Chaque prise, son contour exact, sa couleur.
Je n'avais jamais fine-tuné un modèle. J'avais lu des choses sur le machine learning, fait les notebooks jouets, et compris à peu près rien une fois les mains dedans. C'est donc le projet où j'ai découvert.
Le fine-tuning, sans le jargon
Entraîner un modèle de vision à partir de rien demande des millions d'images et des semaines de GPU. Personne ne fait ça pour un projet perso.
À la place, vous prenez un modèle qui sait déjà voir, entraîné sur des millions de photos du quotidien jusqu'à comprendre les contours, les textures, les formes, et l'idée générale qu'« un objet est une chose qui a une frontière ». Puis vous continuez brièvement son entraînement sur vos propres images. Il garde sa vision et acquiert un nouveau concept. Voilà le fine-tuning.
L'image à laquelle je reviens toujours : vous n'élevez pas un enfant depuis la naissance. Vous embauchez un photographe expérimenté et vous passez un après-midi à lui expliquer à quoi ressemble une prise d'escalade.
Ce que fait le modèle avant qu'on lui apprenne
Le modèle de base ici est YOLO11, pré-entraîné sur COCO, un jeu de données de 80 catégories d'objets du quotidien. Personnes, chaises, chiens, voitures. La prise d'escalade n'en fait pas partie.
Je l'ai pointé vers une photo de ma salle. Il a trouvé deux objets. Il a étiqueté les deux couteau.

La moitié basse est la même photo, la même architecture, après fine-tuning. 89 prises, chacune détourée jusqu'à son bord, chaque contour tracé dans la couleur que ses pixels annoncent. Cet écart est tout l'objet de cet article.
Les chiffres
J'ai évalué les deux modèles sur un jeu de test isolé de 555 photos contenant 24 578 prises annotées. Même scorer pour les deux, donc la seule différence entre les lignes, c'est l'entraînement.

| mAP@50 | Précision | Rappel | F1 | |
|---|---|---|---|---|
| Fine-tuné | 0.913 | 0.912 | 0.886 | 0.90 |
| COCO de base, sans entraînement | 0.275 | 0.832 | 0.031 | 0.06 |
Le rappel est la colonne honnête. Sur les 24 578 prises réelles, le modèle fine-tuné en trouve environ 21 800. Le modèle de base en trouve environ 760. Ce n'est pas qu'il soit mauvais sur les prises : il ignore qu'elles existent, donc il se tait la plupart du temps.
Tout ce qui suit, c'est le comment.
Le jeu de données, c'est le projet
Je n'ai annoté aucune photo moi-même, ce qui m'a semblé de la triche jusqu'à ce que j'en annote dix et que je comprenne pourquoi les gens paient pour ça. J'ai plutôt récupéré trois jeux de données publics de segmentation de prises sur Roboflow Universe, tous en CC BY 4.0, et je les ai fusionnés : 5 619 images.
Fusionner les données des autres, c'est là qu'est le vrai travail.
Chaque export utilisait une liste de classes différente. L'un disait hold, un
autre climbing-hold, un autre shapes. Certains fichiers d'annotations étaient
vides. Certains polygones avaient deux points, ce qui est une ligne, pas une
forme. D'autres avaient trois points colinéaires, soit un polygone d'aire nulle.
Les outils d'annotation en produisent vraiment. Soixante-trois images ont échoué
à la validation et ont été mises en quarantaine dans un dossier pour que je les
regarde, plutôt que d'être entraînées en silence. Ultralytics s'entraînera
volontiers autour de mauvaises annotations sans jamais vous le dire.
Deux décisions que je défendrais n'importe où :
Séparer par photo, jamais par prise. Une photo de mur porte 40 prises. Si 30 atterrissent dans le jeu d'entraînement et 10 dans le jeu de validation, votre score de validation est un mensonge : vous testez sur des pixels que le modèle a déjà mémorisés. Le code ne déplace jamais que des images entières entre les splits, donc la fuite n'est pas quelque chose que j'ai pensé à éviter, c'est quelque chose que la structure de données ne peut pas exprimer.
Rendre le split reproductible. Trier la liste de fichiers selon une clé
stable d'abord, puis mélanger avec une graine fixe. Mélanger directement la
sortie de rglob donne un split différent sur un autre système de fichiers. Sans
ça, quand la v2 bat la v1, vous ne pouvez pas dire si ce sont les données ou
l'entraînement qui ont changé.
Deck final : 4 446 train / 555 val / 555 test, graine 42.
Une seule classe, aucune couleur
Le schéma évident, c'est red_hold, blue_hold, green_hold, etc. C'est le
mauvais.
Le modèle apprend une seule classe, hold. Il apprend la forme et rien d'autre.
La couleur vient ensuite, d'un code ordinaire qui lit les pixels réels à
l'intérieur du masque.
C'est meilleur sur tous les axes. Aucune annotation supplémentaire. Changer le nom d'une couleur ne demande jamais de réentraîner. Et le modèle ne peut pas être troublé par l'éclairage violet de la salle, puisqu'on ne lui a jamais posé la question de la couleur.
Il y a un joli effet de bord. L'augmentation par défaut d'Ultralytics inclut
hsv_s=0.7, qui tire la saturation au hasard pendant l'entraînement pour que le
modèle voie plus de variété gratuitement. Si la couleur était une classe apprise,
cette augmentation empoisonnerait activement les annotations. Comme ce n'est pas
le cas, l'augmentation est du profit pur.
La règle empirique que j'en retire, et elle se généralise bien au-delà de ce projet : ne faire faire au réseau de neurones que ce que le code classique ne peut pas faire.
La couleur, la moitié classique
La segmentation plutôt que les boîtes englobantes, c'est toute la raison de ce choix. Une boîte autour d'une prise contient beaucoup de mur. Un contour exact ne contient que la prise, donc vous pouvez demander « de quelle couleur est-ce » et obtenir une réponse qui veut dire quelque chose.
Par masque :
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
Puis k-means avec k=3 sur les pixels, on prend le plus gros cluster, on le nomme
par plage de teinte. Deux détails qui m'ont pris plus de temps qu'ils n'auraient dû.
La teinte est circulaire. Le rouge vit à la fois à 359° et à 1°. Clusterisez sur la teinte brute et k-means coupe une prise rouge uniforme en deux clusters aux extrémités opposées de l'axe, puis les moyenne à 180°, c'est-à-dire cyan. La correction consiste à plonger la teinte sur un cercle, mise à l'échelle par la saturation, avant de clusteriser :
h_rad = np.deg2rad(hsv[:, 0])
feats = np.stack([s * np.cos(h_rad), s * np.sin(h_rad), v], axis=1)
La pureté doit se mesurer contre l'espace, pas contre le cluster. k-means avec
k=3 découpera une prise parfaitement rouge en trois clusters accolés, si bien
que « la fraction de pixels dans le cluster gagnant » affiche environ 0,33 pour
une prise manifestement rouge. À la place : compter tous les pixels dans un rayon
fixe autour du centre gagnant. En dessous de 0,6, la réponse est unknown, ce
qui est la réponse honnête pour une prise bicolore ou enfouie sous la magnésie.

L'entraînement
yolo11s-seg, 10,1 M de paramètres, 20,5 Mo sur disque. GPU Google Colab. Toute
la logique d'entraînement tient dans une fonction ; le notebook se contente de
monter Drive, de copier le jeu de données sur le disque local (lire des milliers
d'images par époque à travers le montage Drive est atrocement lent), puis
d'appeler le même scripts/train.py que je lance sur mon portable. Deux points
d'entrée, une seule recette, rien qui puisse diverger.
Les runs écrivent directement sur Drive et --resume reprend le last.pt le plus
récent, parce que Colab vous coupe en plein milieu d'une époque et qu'il est très
démoralisant de perdre quatre heures. La reprise ignore délibérément --epochs et
--imgsz et réutilise les arguments stockés du run d'origine, de sorte qu'un run
repris ne peut pas devenir silencieusement un run différent.
Deux runs qui valent la comparaison, tous deux mesurés sur le split de validation pendant l'entraînement :
| box mAP@50 | box mAP@50-95 | mask mAP@50 | mask mAP@50-95 | |
|---|---|---|---|---|
| 50 époques, batch 8 | 0.885 | 0.675 | 0.819 | 0.500 |
| 150 époques, batch 16, patience 30 | 0.902 | 0.713 | 0.837 | 0.526 |
Tripler le budget d'époques a rapporté 1,7 point de box mAP@50. La métrique qui a le plus bougé est mAP@50-95, qui exige des contours plus serrés. C'est la signature d'un modèle qui savait déjà où étaient les prises et qui a passé les époques supplémentaires à apprendre exactement où elles s'arrêtent.
En relisant le checkpoint pour cet article, j'ai trouvé quelque chose de
légèrement embarrassant : ma propre spec dit d'entraîner à imgsz=1280 parce que
les photos de mur contiennent beaucoup de petites prises, et les arguments
enregistrés sur les poids gagnants disent 640. J'ai lancé l'entraînement final à
la moitié de la résolution que je m'étais prescrite. L'inférence tourne quand même
à 1280 et les résultats ci-dessus sont ce qu'ils sont, donc je ne l'ai jamais
remarqué. Écrivez les métadonnées de vos runs sur disque. Vous ne vous en
souviendrez pas.
Ce que je ne prétends pas
Le modèle de base auquel je me compare est yolo11n-seg (nano) et le fine-tuné
est yolo11s-seg (small). La comparaison n'est pas parfaitement contrôlée. Je
l'assume parce que l'échec du nano est conceptuel plutôt que lié à sa capacité :
il n'a pas de classe « prise » à déclencher, donc aucun nombre de paramètres ne le
sauve. Il a appelé un volume noir un couteau.
Ce 0,275 de mAP@50 pour le modèle de base est également flatteur. La mAP balaie tous les niveaux de confiance jusqu'à près de zéro, donc elle crédite les suppositions chanceuses à faible confiance. Au seuil auquel vous déploieriez réellement, le modèle de base est aveugle. Le rappel et le F1 sont le signal honnête.
Là où c'est encore mauvais
J'ai passé le modèle sur 73 photos de ma propre salle, sur lesquelles il ne s'est
jamais entraîné : 3 138 prises détectées. La détection tient. Le pipeline couleur
est la moitié faible. 17,3 % des prises reviennent en unknown, et le gris est
surreprésenté à 24,6 %.
La raison, c'est la magnésie. Les grimpeurs couvrent les prises de magnésie. Une prise rose avec une empreinte de main blanche dessus est réellement bicolore, et mon contrôle de pureté fait son travail en refusant de deviner. Corriger ça proprement voudrait sans doute dire pondérer les pixels par leur distance au centre du masque, ou apprendre à quoi ressemble la magnésie, ce qui casserait ma propre règle sur le fait de ne pas confier au réseau du travail classique. Je n'ai pas tranché.
Le mask mAP@50-95 stagne à 0,526. Les contours sont à peu près justes et rarement exacts. Pour le regroupement en voies, qui est la suite, à peu près juste suffit.
Ce que j'ai vraiment appris
La boucle d'entraînement était la partie facile. Cinq lignes. Les parties dures étaient toutes celles que personne ne photographie : décider ce que veut dire une classe unique, fabriquer un jeu de données qui se reconstruit à l'identique, refuser de laisser passer les mauvaises annotations, et choisir quelle moitié du problème n'a pas du tout besoin d'un réseau de neurones.
Aussi : 14,5 secondes sur mon portable pour une photo de 8064×4536, sans GPU. Les petits modèles sont extrêmement sous-estimés.
Le dépôt est yolov11-climbing-holds-detector. La suite : regrouper les prises par couleur en voies, ce que je voulais depuis le début.