저는 클라이밍을 합니다. 벽 아래에 서면 뇌가 자동으로 하는 일은 필터링입니다. 내 것이 아닌 아흔 개의 홀드를 무시하고 초록색만 따라갑니다. 저는 휴대폰 사진 한 장으로 같은 일을 해내는 프로그램을 원했습니다. 모든 홀드, 그 정확한 외곽선, 그 색까지.
모델을 파인튜닝해 본 적은 없었습니다. 머신러닝에 관해 읽었고, 장난감 같은 노트북 예제를 돌려봤지만, 막상 손에 쥐고 보니 이해한 것은 거의 없었습니다. 그래서 이 프로젝트에서 직접 알아보기로 했습니다.
전문 용어 없는 파인튜닝
비전 모델을 맨바닥부터 학습시키려면 수백만 장의 이미지와 몇 주의 GPU 시간이 필요합니다. 사이드 프로젝트로 그렇게 하는 사람은 없습니다.
대신 이미 보는 법을 아는 모델을 가져옵니다. 수백만 장의 일상 사진으로 학습해 경계선, 질감, 형태, 그리고 "물체란 경계를 가진 무언가"라는 일반적인 개념을 이미 이해하고 있는 모델입니다. 그 모델을 자신의 사진으로 잠깐 더 학습시킵니다. 시각 능력은 그대로 유지한 채 새로운 개념 하나를 익힙니다. 그것이 파인튜닝입니다.
제가 계속 떠올리는 비유는 이렇습니다. 아이를 갓난아기부터 키우는 게 아닙니다. 숙련된 사진가를 고용해서 오후 한나절 동안 클라이밍 홀드가 어떻게 생겼는지 설명해 주는 것입니다.
가르치기 전의 모델이 하는 일
여기서 베이스 모델은 COCO로 사전학습된 YOLO11입니다. COCO는 일상 물체 80개 카테고리로 이루어진 데이터셋입니다. 사람, 의자, 개, 자동차. 클라이밍 홀드는 그 안에 없습니다.
제 클라이밍장 사진을 넣어 봤습니다. 물체 두 개를 찾았습니다. 둘 다 칼(knife) 이라고 라벨을 붙였습니다.

아래쪽 절반은 같은 사진, 같은 아키텍처, 파인튜닝 이후입니다. 89개의 홀드가 각각 가장자리까지 그려졌고, 각 외곽선은 모델이 읽은 픽셀이 말하는 색으로 칠해졌습니다. 이 간극이 이 글의 전부입니다.
숫자
두 모델을 24,578개의 라벨링된 홀드가 담긴 555장의 홀드아웃 테스트셋에서 평가했습니다. 두 모델 모두 동일한 채점기를 사용했으므로, 행 사이의 유일한 차이는 학습뿐입니다.

| mAP@50 | 정밀도 | 재현율 | F1 | |
|---|---|---|---|---|
| 파인튜닝 | 0.913 | 0.912 | 0.886 | 0.90 |
| COCO 베이스, 학습 없음 | 0.275 | 0.832 | 0.031 | 0.06 |
재현율이 정직한 열입니다. 실제 홀드 24,578개 중 파인튜닝된 모델은 약 21,800개를 찾습니다. 베이스 모델은 약 760개를 찾습니다. 베이스 모델이 홀드에 서툰 것이 아니라, 홀드가 존재한다는 것 자체를 모르기 때문에 대체로 침묵하는 것입니다.
아래는 전부 그 방법에 관한 이야기입니다.
데이터셋이 곧 프로젝트다
사진을 한 장도 직접 라벨링하지 않았습니다. 열 장쯤 라벨링해 보고 사람들이 왜 이 일에 돈을 지불하는지 이해하기 전까지는 반칙처럼 느껴졌습니다. 대신 Roboflow Universe에서 공개된 클라이밍 홀드 세그멘테이션 데이터셋 세 개를 가져왔습니다. 모두 CC BY 4.0이고, 합쳐서 5,619장입니다.
남의 데이터를 병합하는 일, 거기에 진짜 작업이 있습니다.
익스포트마다 클래스 목록이 달랐습니다. 하나는 hold, 하나는 climbing-hold,
하나는 shapes라고 했습니다. 어떤 라벨 파일은 비어 있었습니다. 어떤 폴리곤은 점이
두 개뿐이었는데, 그것은 선이지 도형이 아닙니다. 어떤 것은 일직선 위의 점 세
개였고, 넓이가 0인 폴리곤입니다. 어노테이션 도구는 정말로 이런 것들을 뱉어냅니다.
63장이 검증에 실패해 격리 폴더로 옮겨졌고, 조용히 학습에 쓰이는 대신 제가 직접
확인할 수 있게 되었습니다. Ultralytics는 잘못된 라벨을 우회해 기꺼이 학습하면서
아무것도 알려주지 않습니다.
어디서든 옹호할 두 가지 결정:
홀드가 아니라 사진 단위로 나눈다. 벽 사진 한 장에는 홀드가 40개 있습니다. 그중 30개가 학습셋에, 10개가 검증셋에 들어가면 검증 점수는 거짓말입니다. 모델이 이미 외운 픽셀로 테스트하는 셈이니까요. 코드는 오직 이미지 전체만 스플릿 사이로 옮깁니다. 그래서 데이터 누수는 제가 하지 않기로 기억해 둔 것이 아니라, 자료 구조가 표현할 수조차 없는 것입니다.
스플릿을 재현 가능하게 만든다. 먼저 안정적인 키로 파일 목록을 정렬하고,
그다음에 고정 시드로 섞습니다. rglob 출력을 바로 섞으면 파일시스템이 다를 때
다른 스플릿이 나옵니다. 이것이 없으면 v2가 v1을 이겼을 때 데이터가 바뀐 것인지
학습이 바뀐 것인지 말할 수 없습니다.
최종 구성: train 4,446 / val 555 / test 555, 시드 42.
클래스는 하나, 색은 없음
뻔한 스키마는 red_hold, blue_hold, green_hold 같은 식입니다. 그것은 잘못된
스키마입니다.
모델은 hold라는 단일 클래스만 학습합니다. 형태만 배우고 그 외에는 배우지
않습니다. 색은 그다음에, 마스크 안쪽의 실제 픽셀을 읽는 평범한 코드가 처리합니다.
모든 면에서 더 낫습니다. 추가 라벨링이 없습니다. 색 이름을 바꿔도 재학습이 필요 없습니다. 그리고 클라이밍장의 보라색 조명에 모델이 흔들릴 수 없습니다. 애초에 색을 물어본 적이 없기 때문입니다.
기분 좋은 부수 효과가 하나 있습니다. Ultralytics의 기본 증강에는 hsv_s=0.7이
포함되어 학습 중 채도를 무작위로 흔들어 모델이 공짜로 더 많은 변형을 보게 합니다.
색이 학습된 클래스였다면 이 증강은 라벨을 적극적으로 오염시켰을 것입니다. 그렇지
않기 때문에 이 증강은 순수한 이득입니다.
여기서 얻은 경험칙은 이 프로젝트를 훨씬 넘어 일반화됩니다. 고전적인 코드가 할 수 없는 일만 신경망에 시켜라.
색, 고전적인 절반
바운딩 박스가 아니라 세그멘테이션을 택한 이유가 바로 이것입니다. 홀드를 감싼 상자 안에는 벽이 잔뜩 들어 있습니다. 정확한 외곽선 안에는 홀드만 들어 있으므로 "이건 무슨 색인가"를 물었을 때 의미 있는 답을 얻을 수 있습니다.
마스크마다:
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
그다음 픽셀에 대해 k=3으로 k-means를 돌리고, 가장 큰 클러스터를 골라 색조 구간으로
이름을 붙입니다. 필요 이상으로 오래 걸린 두 가지 디테일이 있습니다.
색조는 원형입니다. 빨강은 359°와 1° 양쪽에 삽니다. 원시 색조 값으로 클러스터링하면 k-means는 균일한 빨간 홀드를 축의 양 끝에 있는 두 클러스터로 쪼갠 뒤 평균을 내어 180°, 즉 시안으로 만듭니다. 해법은 클러스터링 전에 색조를 채도로 스케일한 원 위에 임베딩하는 것입니다.
h_rad = np.deg2rad(hsv[:, 0])
feats = np.stack([s * np.cos(h_rad), s * np.sin(h_rad), v], axis=1)
순도는 클러스터가 아니라 공간을 기준으로 재야 합니다. k=3의 k-means는 완벽하게
균일한 빨간 홀드조차 맞닿은 세 개의 클러스터로 쪼갭니다. 그래서 "우승 클러스터에
속한 픽셀의 비율"은 누가 봐도 빨간 홀드에 대해 약 0.33을 가리킵니다. 대신 우승
중심점에서 고정 반경 안에 있는 모든 픽셀을 셉니다. 0.6 아래면 답은 unknown이고,
이는 두 가지 색을 가진 홀드나 초크에 파묻힌 홀드에 대한 정직한 응답입니다.

학습
yolo11s-seg, 파라미터 1,010만 개, 디스크에서 20.5 MB. Google Colab GPU. 모든 학습
로직은 함수 하나에 들어 있습니다. 노트북은 Drive를 마운트하고, 데이터셋을 로컬
디스크로 옮기고(에폭마다 Drive 마운트를 통해 수천 장을 읽는 것은 견딜 수 없이
느립니다), 제 노트북에서 돌리는 것과 똑같은 scripts/train.py를 호출할 뿐입니다.
진입점은 둘, 레시피는 하나, 어긋날 것이 없습니다.
학습 실행은 Drive에 바로 기록하고 --resume은 가장 최근의 last.pt를 집어
옵니다. Colab은 에폭 도중에 연결을 끊어 버리고, 네 시간을 잃는 것은 매우
사기를 꺾는 일이기 때문입니다. 재개는 --epochs와 --imgsz를 의도적으로
무시하고 원래 실행에 저장된 인자를 재사용합니다. 그래서 재개된 실행이 조용히
다른 실행으로 변할 수 없습니다.
비교할 만한 두 번의 실행입니다. 둘 다 학습 중 검증 스플릿에서 측정했습니다.
| box mAP@50 | box mAP@50-95 | mask mAP@50 | mask mAP@50-95 | |
|---|---|---|---|---|
| 50 에폭, 배치 8 | 0.885 | 0.675 | 0.819 | 0.500 |
| 150 에폭, 배치 16, patience 30 | 0.902 | 0.713 | 0.837 | 0.526 |
에폭 예산을 세 배로 늘려 box mAP@50에서 1.7포인트를 얻었습니다. 가장 많이 움직인 지표는 더 촘촘한 외곽선을 요구하는 mAP@50-95입니다. 홀드가 어디에 있는지는 이미 알고 있었고, 추가된 에폭을 홀드가 정확히 어디서 끝나는지 배우는 데 쓴 모델의 모습입니다.
이 글을 쓰려고 체크포인트를 다시 읽다가 조금 창피한 것을 발견했습니다. 제 스펙에는
벽 사진에 작은 홀드가 많으니 imgsz=1280으로 학습하라고 적혀 있는데, 우승한 가중치에
기록된 인자는 640이었습니다. 최종 학습을 제가 스스로 정한 해상도의 절반으로 돌린
것입니다. 추론은 여전히 1280에서 돌아가고 위의 결과도 그대로여서 전혀 눈치채지
못했습니다. 실행 메타데이터를 디스크에 기록하세요. 기억하지 못할 테니까요.
주장하지 않는 것
비교 대상인 베이스 모델은 yolo11n-seg(nano)이고 파인튜닝된 쪽은
yolo11s-seg(small)입니다. 완벽하게 통제된 비교는 아닙니다. 그래도 괜찮다고 보는
이유는, nano의 실패가 용량 문제라기보다 개념 문제이기 때문입니다. 발화시킬 "홀드"
클래스 자체가 없으니 파라미터 수로는 구제되지 않습니다. 그 모델은 검은 볼륨을
칼이라고 불렀습니다.
베이스 모델의 mAP@50 0.275라는 값도 후한 편입니다. mAP는 모든 신뢰도 수준을 거의 0까지 훑기 때문에 운 좋은 저신뢰도 추측에도 점수를 줍니다. 실제로 배포할 임계값에서 베이스 모델은 눈이 먼 상태입니다. 재현율과 F1이 정직한 신호입니다.
아직 나쁜 지점
학습에 한 번도 쓰이지 않은 제 클라이밍장 사진 73장에 모델을 돌렸습니다. 홀드
3,138개가 검출되었습니다. 검출은 버텨 줍니다. 약한 절반은 색 파이프라인입니다.
홀드의 17.3%가 unknown으로 돌아오고, 회색이 24.6%로 과대 표현됩니다.
이유는 초크입니다. 클라이머는 홀드를 초크로 뒤덮습니다. 흰 손자국이 찍힌 분홍 홀드는 실제로 두 가지 색이고, 제 순도 검사는 추측을 거부함으로써 제 몫을 하고 있습니다. 제대로 고치려면 마스크 중심으로부터의 거리로 픽셀에 가중치를 주거나, 초크가 어떻게 생겼는지 학습시켜야 할 텐데, 후자는 고전적인 작업을 신경망에 시키지 말라는 제 규칙을 깹니다. 아직 정하지 못했습니다.
mask mAP@50-95는 0.526에 머물러 있습니다. 외곽선은 대체로 맞고 거의 정확하지는 않습니다. 다음 단계인 루트 그룹핑에는 대체로 맞는 정도면 충분합니다.
실제로 배운 것
학습 루프는 쉬운 부분이었습니다. 다섯 줄. 어려운 부분은 아무도 사진 찍지 않는 것들이었습니다. 단일 클래스가 무엇을 의미하는지 정하기, 동일하게 재구축되는 데이터셋 만들기, 잘못된 라벨을 통과시키지 않기, 그리고 문제의 어느 절반이 애초에 신경망을 전혀 필요로 하지 않는지 고르기.
그리고 8064×4536 사진 한 장에 제 노트북에서 GPU 없이 14.5초. 작은 모델은 지독하게 과소평가되어 있습니다.
저장소는 yolov11-climbing-holds-detector 입니다. 다음 차례: 홀드를 색으로 묶어 루트를 만드는 것, 처음부터 원했던 바로 그것입니다.