앞선 글에서 가장 앞에 두었던 “실제 슈팅 영상 확보”를 처리했다. 4K 25fps 10.2초 클립으로 [A] 포즈 추출부터 [D] 합산까지 처음 통과시켰고, 그 과정에서 합성 데이터로는 드러나지 않던 문제 셋을 잡았다.
실클립이 아니면 알 수 없었던 것들
scipy가 빠져 있었다. 업로드하자마자 NameError: name 'affine_transform' is not defined가 났다. 우리 코드에 없는 이름이라 추적해 보니 transformers의 ViTPose 전처리기였다. 히트맵을 원본 좌표로 되돌릴 때 scipy.ndimage를 쓰는데, 그 임포트가 is_scipy_available()로 감싸져 있어 없으면 조용히 넘어가고 실행 시점에 터진다. 선택 의존성처럼 보이지만 영상 경로에서는 필수다.
검출 실패 프레임의 NaN이 임팩트를 가로챘다. scipy를 넣자 이번엔 “입력 품질 미달 — 동작 전후가 잘린 영상으로 보인다”가 떴다. 진단해 보니 클립은 멀쩡했다.
사람 검출 실패 프레임: 11개 [0,1,2,...,10]
유효 프레임 108/128 = 84% ← check_quality 기준 70%는 통과
argmax(velocity) = 0
각속도 상위 8개가 전부 velocity=nan, conf=0.000
무효 프레임 제외 시 argmax = 44
RT-DETR이 앞 11프레임에서 사람을 못 찾자 pose.py가 zeros((17,3))으로 채웠고, 세 점이 모두 (0,0)이라 joint_angle이 NaN을 냈다. np.argmax는 NaN을 최대값으로 취급한다. 그래서 임팩트가 0번으로 잡혔고, 경계 가드가 “잘린 영상” 이라는 틀린 진단을 냈다. 프레임별 무릎각을 뽑아 보면 31→48 구간이 179° → 57° → 170°로 교과서적인 슈팅이다. 진짜 임팩트는 44번이었다.
노이즈 문제가 아니라 정의의 문제라 스무딩은 넣지 않았다. 유효 프레임만 후보로 두면 원본 그대로 44번이 나온다. 같은 버그가 peak_frame_offset에도 있었는데, 이쪽은 판정 근거로 넘어가는 값이라 조용히 틀린 점수가 나올 뻔했다.
항상 0인 지표를 근거로 쓰고 있었다. 판정문을 읽다가 “각속도 피크 시점이 임팩트 직전(0 프레임)이므로 가속 구간 확보에 실패했다”를 발견했다. segment_phases가 임팩트를 각속도가 최대인 프레임으로 정의하므로, 같은 시계열에서 잰 피크 시점은 정의상 항상 임팩트와 일치한다. 즉 이 지표는 언제나 0이었고, 모델은 그 0을 결함으로 읽어 25점 항목을 0점 처리했다.
지표를 빼고 루브릭 deferred의 swing_acceleration_timing으로 옮겼다. 공 검출기가 붙어 임팩트를 발-공 접촉 시점으로 독립 정의해야 되살아난다.
등급 판정을 언어 모델에서 코드로 옮겼다
지표를 정리하고 나니 합성 경로 점수가 80점에서 55점으로 떨어졌다. 차이는 한 항목이었다.
측정값 swing_knee_angle_at_impact = 141.7
루브릭 2등급 = "140~165도"
판정 "141.7도다. 2등급 기준 140~165도 미만이며, 0등급 기준 140도 미만에 해당한다"
141.7은 140보다 크다. EXAONE 1.2B가 경계값 비교를 틀렸고, 3회 반복 모두 같은 오답이었다. 예전에는 각속도 지표가 함께 들어가 우연히 2등급이 나왔던 것이라, 근거가 하나로 줄자 가려져 있던 약점이 드러난 셈이다.
스키마 필드 순서를 조정해 추론 필드를 넣는 튜닝은 이미 해 둔 상태였는데도 경계값은 고쳐지지 않았다. 루브릭의 등급 정의가 이미 수치 구간이므로 판정에 추론이 필요 없다는 결론으로 갔다. 측정과 판단의 분리 원칙을 등급 결정에까지 적용한 것이다.
루브릭이 구간을 선언하고,
bands:
metric: swing_knee_angle_at_impact
2: [[140, 165]]
1: [[165, 175]]
0: [[null, 140], [175, null]]
Criterion.grade_for()가 등급을 정한다. 양끝을 포함하고 2 → 1 → 0 순으로 먼저 맞는 등급을 취한다 — 경계값은 높은 등급으로 간다. 산문 기준(“150~170도”와 “135~150도”)은 150에서 겹쳐 어느 쪽인지 불분명했는데, 코드로 내리면서 이 모호함을 없애야 했다. 경계 29건을 검증했다.
언어 모델의 몫은 근거 문장 생성만 남았다. 스키마에서 grade를 뺐으니 등급을 뒤집을 경로 자체가 없다. 합성 경로는 80점으로 복귀했고, 이번엔 141.7이 정확히 2등급이라서다.
부수적으로 판정 시간이 27.0초에서 18.7초로 줄었다. 등급 도출용으로 쓰던 comparison 필드를 없애 모델이 쓸 토큰이 줄어든 결과다.
결과물은 점수가 아니라 칭호로
화면에 총점과 배점표가 보이던 것을 접었다. 루브릭이 검수 전이라 점수 자체가 provisional이고, 선수에게 보여줄 산출물은 칭호이기 때문이다. 항목마다 비유를 하나 잡고 등급에 따라 굴리는 방식으로 썼다.
| 항목 | 2등급 | 1등급 | 0등급 |
|---|---|---|---|
| 디딤발 무릎 굴곡 | 흔들리지 않는 축 | 설익은 축 | 무너지는 축 |
| 차는 다리 무릎 신전 | 채찍이 된 다리 | 덜 감긴 채찍 | 풀린 채찍 |
| 상체 기울기 | 공을 눌러 담는 상체 | 곧추선 상체 | 젖혀진 상체 |
| 골반 회전 | 활처럼 열리는 골반 | 절반만 열린 골반 | 잠긴 골반 |
| 팔로스루 | 공을 배웅하는 발 | 짧아진 배웅 | 멈춰 선 스윙 |
같은 비유가 등급을 따라 변하니 카드가 바뀌었을 때 무엇이 나아지고 나빠졌는지가 단어만으로 읽힌다. 칭호는 UI가 아니라 루브릭 titles에 뒀다 — 지도자가 검수할 문구이지 화면 장식이 아니다. 임계값과 함께 검수 대상에 올린다.
실측치 갱신
전 구간을 단계별로 쟀다 (RTX 3050, 4K 25fps 10.2초 → 128프레임).
| 단계 | 시간 | 비고 |
|---|---|---|
| [A] 포즈 추출 | 33.1초 | peak VRAM 643MB |
| [B] 특징 추출 | 0.00초 | |
| [C] 모델 적재 | 4.6초 | 적재 후 2449MB |
| [C] 판정 5항목 | 18.7초 | 항목당 3.7초 |
| [D] 합산 | 0.0000초 |
이전 문서의 “적재 51초”는 다운로드가 포함된 값이었다. 순수 적재는 5초 미만이다. 상주 VRAM도 어림치 2.7GB에서 실측 2.4GB로 정정했다. 포즈 추출이 이제 전 구간의 절반 이상이라 4K를 그대로 RT-DETR에 넣는 부분이 다음 최적화 지점이다.
문서화하다가 sampled_fps가 목표값을 담고 있는 것도 발견해 고쳤다. 간격이 정수라 25fps에 target 15를 주면 실효 12.5fps인데 15로 기록되고 있었다. 이 값을 쓰는 곳이 아직 없어 드러나지 않았지만, 임팩트 44번 프레임은 2.93초가 아니라 3.52초다. 환산을 한 곳으로 모으려고 PoseResult.frame_to_seconds()도 넣었다.
다음 할 일
- 지도자 검수 — 각도 임계값과 칭호 문구를 함께 확정
- 골든셋 라벨링 후 QWK 측정
- MySQL 적재 (
analysis_metric_value) — 이때frame_to_seconds를 쓴다 - 포즈 추출 최적화 — 입력 해상도를 낮췄을 때 키포인트 품질이 얼마나 떨어지는지
테스트는 20개에서 30개로 늘었다. 오늘 잡은 세 문제가 전부 합성 데이터로는 재현되지 않는 종류였다는 점이 남는다.