상태: 초안 (2026-10-26), 실측 반영. 규칙은 2026-10-25 기준 저장소 코드(
ttscore/,backend/static/tt/play/)에서 읽었고, 숫자는 공개 경기 영상으로 우리가 직접 잰 값입니다(날짜·조건은 표마다). 휴대폰으로 찍은 실제 탁구장 영상의 정확도는 라벨 있는 영상이 아직 없어 미실측입니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」으로 표시합니다.
목차
- 1. 자동 판정은 점수판이 아니라 「제안」이다
- 2. 공 좌표에서 이벤트로
- 3. 심판: 「누가 쳤나」를 보지 않는다
- 4. 랠리 묶기: 죽은 공, 승자, 재동기화
- 5. 「확인 필요」: 네 갈래
- 6. 몸풀기·연습과 경기를 어떻게 가르나
- 7. 칩: 사람과 기계의 역할 분담
- 8. 실측: 지금 어디까지 맞나
- 9. 정리
- 참고문헌
- 함께 읽기
1. 자동 판정은 점수판이 아니라 「제안」이다
먼저 위치를 분명히 해 둡니다. 카메라 점수판의 진짜 점수는 2편에서 본 사람이 넣고 고친 이력입니다. 자동 판정은 그 이력에 「이번 랠리는 A 득점, 이유는 아웃, 신뢰도 0.83」 같은 제안을 하나씩 내밀 뿐이고, 확정된 제안만 이력의 득점 칸이 됩니다(backend/static/tt/play/lib/camauto.mjs 머리말 「점수판은 하나다」). 그래서 자동 판정이 틀려도 사람이 고친 이력과 따로 맞출 것이 없습니다.
이 글은 공 좌표가 들어온 순간부터 칩이 점수가 되기까지를 따라갑니다.
- 공 좌표 → 이벤트(바운드·네트·아웃·끝)
- 이벤트 → 심판 상태기계 → 랠리 승자
- 랠리 묶기, 죽은 공, 재동기화
- 「확인 필요」 판단
- 칩과 자동 반영, 사람과 기계의 역할 분담
- 그 사이사이에서 몸풀기·연습을 경기와 가르는 장치
공 좌표가 어떻게 나오는지는 4편, 앞 시리즈의 5편은 이 엔진이 66.9% 에서 93.1% 로 오른 역사를 다룹니다. 이 글은 지금 운영에서 도는 규칙의 전체 그림입니다.
2. 공 좌표에서 이벤트로
작업자는 공 좌표를 최근 2.5초만 들고 있다가(EventFeed, KEEP_S=2.5), 새 표본이 들어올 때마다 짧은 공백(3표본 이하)을 메우고 이벤트를 다시 뽑습니다(ttscore/events.py 의 detect_events). 실시간이라 미래를 볼 수 없으니 최근 구간을 계속 다시 읽는 방식입니다.
바운드: 다섯 조건을 모두
바운드는 다음 조건을 모두 만족할 때만 냅니다.
| 조건 | 내용 | 왜 |
|---|---|---|
| V 자 | 화면 y 가 커지다(내려옴) 작아짐(올라감) | 튀는 공의 기본 모양 |
| 탁구대 띠 안 | 탁구대 윗면 사각형 안. 변마다 8px 여유, 끝 변은 평면에서 0.15m 밖까지 | 바운드 순간 공 중심은 닿은 점보다 반지름(20mm)만큼 위라 평면에 옮기면 밀린다 |
| 간격 | 직전 바운드와 0.12초 이상 | 같은 바운드를 두 번 세지 않게 |
| 가로 방향 유지 | 앞뒤 가로 이동이 둘 다 2px 이상이면서 방향이 반대면 버림 | 탁구대에 튄 공은 계속 가고, 라켓에 맞은 공은 되돌아간다. 라켓 타구도 V 를 그린다 |
| 네트 근처 V 버림 | 네트 x ±2% 안이면서 네트 맞음 ±2프레임 안의 V 는 버림 | 네트에 걸린 공의 흔들림 |
끝 쪽에서 찍은 화면(카메라가 엔드라인 뒤)이면 하나를 더 봅니다. 길이 방향 속력이 0.3m/s 이상이어야 합니다. 끝 쪽 화면에서는 화면 y 에 높이와 거리가 섞여 가짜 V 가 잘 생기기 때문입니다.
바운드의 쪽(L·R)은 평면에서
바운드가 어느 선수 쪽에 떨어졌는지는 화면의 왼쪽·오른쪽이 아니라 탁구대 평면 좌표로 정합니다. 탁구대 네 꼭짓점으로 화면 → 평면(2.74m × 1.525m) 호모그래피를 만들고(4편), 공이 닿은 점을 평면에 옮겨 길이 방향 좌표 u 의 부호로 쪽을 정합니다(ttscore/table.py 의 ball_side). 이렇게 하면 옆에서 찍든 끝에서 찍든 같은 식이 됩니다. 사각형이 직사각형으로 읽히지 않을 만큼 찌그러졌으면(꼭짓점이 틀렸을 가능성) 예전 규칙, 곧 네트 x 기준선의 왼쪽·오른쪽으로 돌아갑니다. 찌그러진 사각형의 평면 변환은 네트 선을 기울여 쪽을 뒤집기 때문입니다.
평면 투영은 공이 탁구대에 닿은 순간에만 맞습니다. 공중의 공을 평면에 옮기면 카메라에서 공을 지나는 직선이 평면과 만나는 점이 나오고, 공이 높을수록 멀리 밀립니다. 그래서 평면 좌표는 바운드의 쪽에만 쓰고, 공중 구간의 판단은 따로 합니다.
가로 속도: 판정의 열쇠
바운드마다 길이 방향 가로 속도를 함께 싣습니다. 바운드 뒤 vx, 바로 앞 vx_in, 단위는 테이블 길이/초입니다. 화소가 아니라 탁구대 길이로 나눈 값이라 카메라 거리와 해상도에 상관없습니다. 이 숫자 하나가 다음 절의 심판에서 서브와 넘겨주기와 가짜 바운드를 가릅니다.
네트, 아웃, 끝
- 네트: 공이 네트 x ±4% 를 지나면서 앞뒤 6프레임의 가로 속도 비가 0.7 아래로 떨어지면(앞 속도가 0.3 이상일 때만) 네트에 닿았다고 봅니다. 옆면 화면에서만 냅니다.
- 아웃: 바운드 뒤에만 켭니다. 공이 탁구대 끝 너머로 나가 윗면 띠 아래로 떨어지면 아웃이고, 쪽은 넘어간 끝입니다.
- 끝(end): 마지막 바운드 뒤 1.5초 동안 새 바운드가 없거나, 공이 1초 동안 안 보이면 랠리가 끝났다고 봅니다. 1.5초는 실제 랠리 안 바운드 간격의 98% 가 1.5초 이내라는 실측에서 왔습니다(공개 경기 12편, 2026-10-11).
신뢰도
이벤트마다 신뢰도가 붙습니다. conf = min(검출 신뢰도, 공백 신뢰도) 이고, 공백 신뢰도는 1 − 놓친 프레임 / (fps × 0.25초) 입니다. 앞에서 공을 0.25초 넘게 놓쳤으면 0 입니다. 그 사이에 바운드 하나를 놓쳤을 수 있으니 믿을 수 없다는 뜻입니다. 검출 신뢰도만 따로(det_conf)도 싣는데, 서브 첫 바운드에서 쓰입니다.
3. 심판: 「누가 쳤나」를 보지 않는다
심판(ttscore/referee.py 의 RallyReferee)의 핵심 생각은 머리말 첫 줄에 있습니다. 누가 쳤는지는 영상에서 직접 보지 않고 바운드 순서에서 추론한다. 공이 S 쪽에 바운드했다면, 다음에는 S 쪽 선수가 쳐서 반대쪽에 한 번 떨어뜨려야 합니다. 그러지 못하면(같은 쪽에 또 바운드, 바운드 없이 나감, 아무 일 없이 끝남) S 쪽 선수가 집니다. 서브만 예외로, 서버 쪽에 한 번 그리고 리시버 쪽에 한 번 떨어져야 합니다.
라켓 접촉은 영상에서 가장 찾기 어려운 사건입니다. 공이 라켓에 가리고, 번지고, 선수 몸에 묻힙니다. 이 설계는 그 사건 없이 점수를 매깁니다. 대가도 분명합니다. 바운드 하나를 놓치거나 가짜 바운드 하나가 끼면 「누가 칠 차례인가」가 통째로 어긋납니다. 그래서 아래 규칙의 대부분은 가짜 바운드와 랠리 밖의 공을 거르는 장치입니다.
상태는 넷입니다. serve0(서브 대기) → serve1(서브 진행) 또는 direct(직행) → rally(랠리) → done(끝).
서브 대기: 서브의 시작만 받는다
랠리와 랠리 사이에도 공은 많이 움직입니다. 공을 주우러 가고, 상대에게 넘겨주고, 서브 전에 자기 쪽에 몇 번 튀기고, 손에 든 공이 흔들립니다. 이것들이 이벤트로 잡히면 없던 점수가 생깁니다. 실제로 경기 전체를 엔진에 넣었던 첫 판에서는 판정 49개 중 20개가 랠리가 아니었습니다(공 넘겨주기 13, 서브 전 자기 쪽 튀기기 5 …, 2026-10-11, docs/tt/PERFORMANCE.md 2.6).
그래서 서브 대기에서는 서브의 시작처럼 보이는 공만 받습니다.
# ttscore/referee.py _start() 에서 줄임
f = self._forward(ev, self.server, serve=True) # 서버 쪽에서 네트 쪽으로 가는 가로 속도(바운드 앞뒤 중 큰 쪽)
if ev.side == self.server:
if f is not None and f < SERVE_MIN_VX: # 0.7 테이블 길이/초
return False # 서브가 아니다 — 버리고 계속 기다린다
self.phase = "serve1"
return True
if f is not None and f >= TOWARD_NET_VX: # 리시버 쪽에, 서버 쪽에서 날아온 방향으로 떨어진 공
self.phase, self.expect = "direct", self.server
self.conf = min(self.conf, AMBIGUOUS_CONF) # 0.5 — 서버 쪽 바운드를 못 봤으니 확인 필요
return True
return False
조건은 둘입니다. 점수판이 아는 서버 쪽에 떨어졌고, 네트 쪽으로 0.7 테이블 길이/초 이상 움직여야 합니다. 그 밖의 공은 버리고 신뢰도도 깎지 않습니다. 0.7 이라는 숫자의 근거는 실측입니다(공개 경기 12편, 2026-10-11).
- 실제 서브 271개의 첫 바운드는 네트 쪽 속도가 모두 1.07 이상이었습니다(바운드 앞뒤 중 큰 쪽).
- 0:0 부터 이어지는 5경기에서 랠리 밖 서버 쪽 공 55개는, 서브처럼 보인 4개(경기 전 연습 서브, 라벨 없는 서브)를 빼면 0.61 이하였습니다. 넘겨받는 공은 음수, 자기 쪽 튀기기는 0 근처입니다.
점수판 서버 반대쪽에서 「서브처럼」 시작하는 공은 받지 않습니다. 리시버가 서버에게 공을 던져 주면 리시버 쪽에서 네트 쪽으로 튀고 서버 쪽에 떨어집니다. 모양은 서브와 같지만 쪽이 반대입니다. 양쪽을 모두 서브로 받는 변형은 5경기의 가짜 판정을 3 에서 16 으로 늘렸습니다.
예외가 하나 있습니다. 리시버 쪽에, 서버 쪽에서 날아온 방향으로(네트에서 멀어지며 0.7 이상) 떨어진 공입니다. 서버 쪽 바운드를 놓친 서브일 수도, 서버 쪽에 안 튄 서브 실패일 수도 있습니다. 이것은 direct(직행) 상태로 받고 신뢰도를 0.5 로 낮춰 확인 필요로 보냅니다. 서브 첫 바운드는 토스와 라켓에 가려 놓치는 일이 잦습니다. 그래서 직행 뒤 리시버 쪽에 또 떨어지거나 그대로 끝나면, 스위치(TTSCORE_DIRECT_MISS, 켜져 있음)에 따라 리시버가 받지 못한 것(서버 득점) 으로 봅니다. 실제 영상 실험(원장 E27)에서 「또 떨어짐」 판정 60개 중 48개, 「끝」 판정 49개 중 36개가 서버 득점이었기 때문입니다.
서브 진행: 1초 안에 리시버 쪽으로
serve1 에서는 리시버 쪽 바운드를 기다립니다.
- 리시버 쪽 바운드가 서버 바운드 뒤 1.0초 안에 와야 정상 서브입니다(실제 최대 0.55초, 30fps·느린 서브 여유). 넘으면 서브 실패(리시버 득점).
- 서버 쪽에 또 떨어졌는데 공이 여전히 네트 쪽으로(0.7 이상) 간다면, 네트 위를 지나며 잡힌 가짜 바운드로 보고 서브를 계속 봅니다(실제 5건). 멈추거나 되돌아오면 네트에 걸려 자기 쪽에 떨어진 서브 실패입니다(실제 2건, 가로 속도 0 근처).
- 바운드 없이 아웃이나 끝이 오면 서브 실패(
serve_out)입니다.
렛: 다음 이벤트까지 미룬다
서브 중 네트에 닿았고(net) 리시버 쪽에 바운드했으면 렛 후보입니다. 바로 렛을 선언하지 않고 다음 이벤트까지 미룹니다.
- 다음 바운드가 서버 쪽이면, 리시버가 받아 넘긴 공이 떨어진 것입니다. 네트 검출이 틀렸거나 선수가 렛을 부르지 않고 이어 친 것이므로, 렛을 거두고 랠리를 잇되 확인 필요(0.5)로 둡니다.
- 그렇지 않으면 렛입니다. 점수는 없습니다(
winner_side=None).
처음에는 렛을 바로 냈는데, 렛 판정 뒤의 「죽은 공」 규칙(4절)이 이어진 진짜 랠리의 바운드를 모두 버려 그 랠리 점수가 사라지는 일이 실시간 경로에서 두 번 재현됐습니다(2026-10-13). 미루는 판은 그 문제를 고쳤고, 오프라인 랠리 단위 결과는 바뀌지 않았습니다.
랠리: 기대하는 쪽을 뒤집어 가며
랠리에서는 「다음 바운드가 떨어져야 할 쪽」(expect)만 들고 갑니다.
# referee.py feed() 의 랠리 부분에서 줄임
f = self._forward(ev, ev.side) # 바운드 뒤 가로 속도
if f is not None and f >= TOWARD_NET_VX: # 네트 쪽으로 가는 「바운드」 는 가짜 — 버린다
return None
if ev.side == self.expect: # 기대한 쪽: 정상. 다음은 반대쪽
self.expect = flip(self.expect)
return None
if ev.t - prev_bounce_t < DOUBLE_BOUNCE_SURE_S: # 0.25초 안의 같은 쪽 두 바운드 — 미룬다
self.pending = ev
return None
return self._call(flip(ev.side), "double_bounce", ev) # 같은 쪽에 또: 그 쪽 선수가 진다
- 네트 쪽으로 가는 바운드는 가짜. 랠리 중 S 쪽의 진짜 바운드는 상대가 친 공이라 S 쪽 끝으로, 곧 네트에서 멀어지며 갑니다. 네트 쪽으로 0.7 이상 가는 「바운드」 는 네트 위를 지나는 공이나 타구를 잘못 읽은 것입니다(실제 랠리 중 1,094개 중 56개). 이 규칙 하나로 실제 랠리 승자가 235 에서 241 로 늘었습니다.
- 투 바운드. 같은 쪽에 또 떨어지면 그 쪽 선수가 받지 못한 것입니다.
- 빠른 투 바운드는 미룬다. 같은 쪽 두 바운드가 0.25초보다 가까우면 진짜 짧은 공인지, 바운드 직후의 타구를 바운드로 읽은 것인지 가를 수 없습니다. 실측에서 이런 판정 39개 중 16개(41%)가 틀렸습니다(나머지 판정의 오답률 8%). 그래서 신뢰도를 0.5 로 낮추고 다음 이벤트까지 미룹니다. 다음 바운드가 반대쪽이면(리턴이 떨어짐) 둘째 바운드는 가짜였으니 버리고 랠리를 잇습니다. 이 규칙으로 판정이 바뀐 실제 랠리 9개가 모두 틀림에서 맞음이 되었습니다.
아웃과 끝: 마지막 바운드 쪽 선수가 못 받았다
- 아웃: 방금 바운드한 쪽 끝을 넘어가는 아웃은 무시합니다. 그 쪽 선수가 탁구대 높이 아래에서 받으러 가는 공(커트, 중진 드라이브)이기 때문입니다. 못 받으면 끝(end)이 정합니다.
- 그 밖의 아웃이나 끝은 「마지막으로 바운드한 쪽 선수가 받아 넘기지 못함」입니다. 그 선수가 지고, 이유는
out또는no_return입니다.
판정의 신뢰도
판정 하나의 신뢰도는 그 랠리 안 이벤트 신뢰도의 최솟값입니다. 단 서브 첫 바운드는 공백을 뺀 검출 신뢰도(det_conf)로 셉니다. 서브 첫 바운드 앞의 공백은 토스와 라켓에 가려 생긴 것이고 그 사이에 놓칠 바운드가 없기 때문입니다. 이것을 빼기 전에는 서브 첫 바운드의 70% 가 신뢰도 0 이었고, 그것이 「확인 필요 86%」의 주원인이었습니다(PERFORMANCE 2.6).
4. 랠리 묶기: 죽은 공, 승자, 재동기화
심판 하나는 랠리 하나를 맡습니다. 랠리를 이어 붙여 점수판으로 만드는 일은 ttscore/analyze.py 의 LiveScorer 가 합니다.
- 경기 시작 앞은 버린다. 「경기 시작」 시각(
start_t) 앞의 이벤트는 모두 버립니다. 6절의 주제입니다. - 죽은 공 2초. 판정이 나면 그 뒤 2초 동안(이벤트가 이어지면 계속 연장) 또는 끝(end)이 올 때까지 이벤트를 버립니다. 판정이 바운드나 아웃에서 나면 공은 아직 살아 움직입니다. 계속 튀는 서브, 투 바운드 뒤 구르는 공, 렛 뒤 받아 친 공입니다. 그것을 다음 랠리의 서브로 읽으면 없던 점수가 생깁니다.
- 쪽에서 선수로. 심판은 L·R 쪽으로 승자를 냅니다. 점수판(
rules.Scoreboard)이 자리 바꾸기를 반영해 그 쪽에 지금 누가 서 있는지로 바꿔 득점을 줍니다. 다음 랠리 심판의 서버 쪽은 점수판의 서브 차례로 새로 정합니다. - 재동기화 신호. 점수판이 한 점 어긋나면 서브 차례가 어긋나고, 그러면 진짜 서브가 「서버 반대쪽에서 시작한 공」으로 버려집니다. 그래서 엔진은 랠리마다 실제로 본 서브 쪽을 따로 잽니다. 앞 랠리 끝 뒤부터 이번 랠리 시작 + 0.5초까지의 마지막 「서브 모양 짝」(S 쪽 바운드가 네트 쪽 0.7 이상 → 1.0초 안 반대쪽 바운드)의 S 입니다. 이것이 점수판 서버 쪽과 한 번이라도 다르면 신호를 냅니다. 점수는 바꾸지 않고 사람에게 「서브가 맞나요?」라고 묻기만 합니다. 실측에서 라벨 서브 ±0.8초 안의 짝은 라벨 서버와 267개 중 266개가 같았습니다. 「두 번 연속 어긋나면」으로 하지 않은 이유는, 점수판이 한 점 어긋나면 서브가 두 점마다 바뀌므로 서버 쪽은 한 랠리 걸러 어긋나 두 번 연속은 거의 오지 않기 때문입니다(어긋난 랠리 64개 중 두 번 연속 규칙은 10개만, 한 번 규칙은 60개를 잡고 거짓 경보 2, PERFORMANCE 2.7).
5. 「확인 필요」: 네 갈래
판정마다 「확인 필요」 표시가 붙을 수 있습니다. 판정은 그대로 두고 사람에게 한 번 보라고 하는 것입니다. 표시가 붙는 길은 넷입니다.
| 갈래 | 조건 | 사유(why) |
|---|---|---|
| ① 보정 회귀 | 학습한 로지스틱 회귀가 낸 「판정이 틀렸을 확률」 > 0.0442(보정 v2). 보정을 끄면 예전 규칙 「신뢰도 < 0.6」 | cal + 가장 크게 민 특징 묶음 둘(공 약함·공 놓침·긴 랠리·끝 불분명·서브 못 봄·서브 쪽 다름·판정 종류) |
| ② 끝선 띠 | 착지가 끝 변 경계 ±0.08m 안 | edge |
| ③ 신호 엇갈림·공 약함 | 공 모델끼리 엇갈리거나, 랠리 구간에서 공이 보인 프레임 비율이 0.5 미만이면 신뢰도를 0.59 로 깎음 | conflict 등 |
| ④ 탁구대 | 랠리가 탁구대를 다시 찾는 중이거나 놓친 구간에 걸침 | table |
여기에 재동기화 신호(resync)가 붙은 판정도 칩에서 따로 묻습니다.
왜 신뢰도 하나로는 안 되나
처음 규칙은 「랠리 신뢰도 < 0.6 이면 확인 필요」였습니다. 2026-10-14 에 이 신뢰도가 정말 틀림을 가르는지 쟀더니 가르지 못했습니다(원장 E20, 실시간 경로 그대로, 공개 시험 7경기 × 열화 11조건, 판정 597개). 신뢰도 0.0–0.1 구간의 정답률이 0.86, 0.9–1.0 구간도 0.86 이었고, 틀림을 가르는 AUC 는 0.51(동전 던지기)이었습니다. 30fps 실시간 경로에서는 공을 한 번만 놓쳐도 최솟값이 0.6 아래로 떨어지기 때문입니다.
그래서 판정 순간에 엔진이 이미 아는 값 19개로 작은 로지스틱 회귀를 배웠습니다(ttscore/reviewcal.py). 신뢰도, 검출 신뢰도 최솟값, 바운드 평균 신뢰도, 바운드·이벤트 수, 약한 이벤트·공백 이벤트 수, 랠리 길이, 마지막이 「끝」인가, 서브 모양 짝을 봤나, 그 쪽이 점수판과 다른가, 판정 사유 같은 것들입니다. 계산은 판정마다 내적 하나라 실시간 비용이 없습니다. 원리와 학습은 4편에서 다룹니다.
| 판 | 조건 | 확인 없이 틀림(†) | 확인 필요 | 틀림 가르기 AUC |
|---|---|---|---|---|
| 신뢰도 < 0.6 | 시험 7경기 × 11조건(판정 597, E20) | 26 | 395 | 0.51 |
| 보정 v1 | 같음 | 12 | 430 | — |
| 보정 v2(운영) | 시험 7경기 × 11조건(판정 661, 라벨 고침 반영, E25) | 20 → 8 | 450 → 497 | 0.71 → 0.73 |
v2 행의 「→」 앞은 같은 조건의 v1 입니다. †v2 행은 확인 없이 지나간 유령 판정까지 합친 수라 위 두 행과 바로 견주지 않습니다. 보정은 확인 필요를 조금 늘리는 대신 사람이 믿고 넘기는 오답을 크게 줄이는 쪽으로 문턱을 골랐습니다. 문턱은 학습 경기 단위 5겹 교차 검증에서 「확인 필요가 지금 이하인 문턱 중 놓친 오답 최소」로 정했습니다.
사유를 붙이는 이유도 실측에서 나왔습니다. 보정 전에는 사유가 비어 있어 칩이 무엇을 확인하라는 것인지 말하지 못했습니다(원장 E19-2). 지금은 회귀가 확인 필요로 정한 판정마다 「틀렸을 쪽으로 가장 크게 민 특징」을 사람 말 묶음 둘까지 실어 보냅니다.
끝선 띠
공이 끝선에 맞았는지(엣지) 바로 밖에 떨어졌는지는 카메라 한 대로 가르기 가장 어려운 판정입니다. 착지 자리가 끝 변 경계 ±0.08m 안이면, 승자는 그대로 두고 확인 필요로 보냅니다(TTSCORE_EDGE_REVIEW, 원장 E34).
6. 몸풀기·연습과 경기를 어떻게 가르나
엔진은 연습 서브를 가를 수 없다
탁구 경기는 몸풀기 랠리로 시작합니다. 서로 몇 분 동안 공을 주고받고, 연습 서브도 넣어 봅니다. 문제는 연습 서브가 진짜 서브와 같은 모양이라는 것입니다. 서버 쪽에 한 번, 리시버 쪽에 한 번 떨어지고, 네트 쪽 속도도 진짜와 같습니다.
실측 사례가 있습니다. 공개 시험 경기 하나(test_4)는 시작 2.05초에 경기 전 연습 서브가 찍혀 있습니다. 서버 쪽에서 네트 쪽으로 4.2 테이블 길이/초, 공은 네트를 넘어갔습니다. 3절의 서브 인정 규칙으로는 진짜 서브와 구별할 방법이 없습니다. 엔진은 이것을 첫 점으로 세었고, 그 한 점이 경기 끝까지 서브 차례를 어긋나게 해 이 경기의 「랠리 뒤 점수 맞음」은 21랠리 중 0이었습니다(PERFORMANCE 2.6).
영상만으로 연습을 가르는 방법도 쟀습니다. 「점수판 첫 서버 쪽의 첫 완전한 서브 앞을 버림」(N1)과 「어느 쪽이든 첫 서브 모양 짝 앞을 버림」(N2)입니다. 둘 다 나아지지 않았습니다(신호 없는 엔진 대비 5경기 점수 맞음 24 → 24, 승자 41 → 40, 12편 전체 판정 없음 18 → 21). test_4 의 진짜 첫 서브가 네트에 걸린 서브 실패라 「완전한 서브」가 아니었고, 두 규칙 모두 진짜 첫 점까지 버렸습니다. 연습 서브 사례가 1개뿐이라 규칙을 데이터로 정할 수도 없었습니다. 그래서 넣지 않았습니다.
결론은 단순합니다. 경기가 언제 시작했는지는 사람이 알려 준다. 선수에게는 쉬운 일이고, 기계에게는 거의 불가능한 일입니다.
「경기 시작」 신호가 가는 길
카메라 점수판의 몸풀기 상태(2편)가 바로 이 신호를 받기 위한 문입니다.
- 화면. 카메라를 켜면 몸풀기 상태입니다(
CamMatch.startedAt === null). 득점 칸을 눌러도 점수를 넣지 않고 「경기 시작」 단추를 흔듭니다. 리모컨의 시작 키는 몸풀기 중에만 듣습니다. - 시작. 「경기 시작」을 누르면
startMatch()가 경기 시작 시각을 이력에 적고,tt:match-start사건으로{type:'score', on:true, start, server_side}를 냅니다. - 시계 맞추기. 자동 점수 화면(
cam-auto.js)이 그것을 받아scoreOn()을 부르고, 기기 시계(Date.now)로 적힌 시작 시각을 프레임 시계(작업자와 연결한 순간이 0)로 바꿉니다(startOffset). 작업자는 기기 시계를 모르고 프레임에 붙은 시각만 알기 때문입니다. - 작업자. 웹 중계를 거쳐 작업자의 세션이
scorer.start(server_side, t)를 부르고,LiveScorer를 새로 만듭니다. 지난 판정과 구간은 잊고, 이 시각 앞의 이벤트는 모두 버립니다. - 이중 잠금. 브라우저는 경기가 시작되지 않았으면 이 신호를 보내지 않고, 작업자는 신호를 받기 전에는 판정하지 않습니다(공 위치와 바운드 표시만 보냄). 브라우저 쪽도 몸풀기 중이나 경기가 끝난 뒤에 온 판정은 제안으로 만들지 않습니다(
autoProposal이 null, 코드 주석 「몸풀기 랠리는 점수가 아니다」).
서브 쪽도 따라갑니다. 점수판이 바뀔 때마다(득점, 되돌리기, 사람이 서브권을 고침) 지금 서버 쪽을 작업자에 보내고, 엔진은 그것으로 다음 랠리의 서버 쪽을 맞춥니다. 사람이 고친 서버 쪽이 이깁니다.
실측: 신호 하나의 효과
공개 경기 중 0:0 부터 라벨이 규칙과 맞게 이어지는 5경기(49랠리)에서, 「경기 시작」 신호를 사람이 첫 서브 직전에 눌렀다고 흉내 내 잰 값입니다(2026-10-11, PERFORMANCE 2.7).
| 지표(5경기 49랠리) | 신호 없음 | 신호 있음 |
|---|---|---|
| 랠리 뒤 점수(점수·게임) 맞음 | 27 / 49 | 36 / 49 |
| 승자 맞음 | 44 / 49 | 45 / 49 |
| 라벨 랠리가 아닌 판정(가짜 점수) | 3 | 2 |
test_4 하나가 0/21 에서 9/21 로 올랐습니다. 신호를 첫 서브 0.3·1·2·3초 전에 눌러도 결과가 같았고, 5초 전(연습 서브보다 앞)에 누르면 효과가 없었습니다. 그래서 화면은 「몸풀기가 끝나고 첫 서브 직전에」 누르게 안내합니다.
경기 중의 「연습 같은 공」
경기가 시작된 뒤에도 랠리 사이의 공은 계속 움직입니다. 이것은 3·4절의 장치들이 거릅니다.
- 서브 대기 거르기: 넘겨주기, 자기 쪽 튀기기, 손에 든 공, 바닥의 공을 버립니다. 같은 5경기에서 가짜 판정이 30 에서 5 로, 랠리 뒤 점수 맞음이 8/49 에서 24/49 로 바뀌었습니다(PERFORMANCE 2.6, 서브 인정 규칙 전·후).
- 죽은 공 2초: 판정 직후 튀는 공을 버립니다.
- 탁구대 구간: 탁구대를 놓쳤거나 다시 찾는 동안의 공은 판정에 넣지 않고, 그 구간에 걸친 랠리는 확인 필요로 보냅니다.
남은 한계
- 진짜 서브 모양의 공은 여전히 점수가 된다. 경기 중 렛을 선수끼리 다시 하거나, 랠리 사이에 연습 삼아 서브를 넣으면 엔진은 진짜 서브와 가르지 못합니다. 서브 인정 규칙만 넣었을 때 5경기에 남은 가짜 판정 5개 중 3개가 이런 공이었습니다(연습 서브, 렛으로 추정되는 라벨 없는 서브).
- 「경기 시작」을 안 누르면 몸풀기 랠리가 점수가 됩니다. 손 점수판에서는 몸풀기 중 득점 칸이 시작 단추를 흔들어 알려 주지만, 사람이 시작을 너무 일찍(연습 서브 전에) 누르면 소용없습니다.
- 게임 사이 쉬는 시간에는 따로 「멈춤」 신호가 없습니다. 이 시간의 공은 서브 대기 거르기에만 기댑니다.
점수판과 상관없는 「연습」 기능들
이름이 비슷해 헷갈리는 기능 둘은 점수판·자동 판정과 연결되어 있지 않습니다.
- 반응 속도 랠리(
/tt/play/reaction-rally): 화면의 공이 포어·백 × 짧게·길게의 네 구역 중 하나로 오는 혼자 하는 반응 게임입니다. 카메라 판정을 쓰지 않습니다. - 연습 루틴(
/tt/practice): 수준과 시간(15–180분)을 고르면 몸풀기·기술·게임·정리 순서의 연습 계획을 만들어 줍니다. 기록은 연습 기록으로 남고 레이팅과는 별개입니다(6편).
7. 칩: 사람과 기계의 역할 분담
판정이 칩이 되기까지
작업자가 판정을 보내면 브라우저는 그것을 그때의 왼쪽 선수 기준으로 바꿔 칩으로 띄웁니다. 작업자는 원본 영상의 L·R 로 말하고, 화면은 거울일 수 있고, 자리는 게임마다 바뀌기 때문입니다. 렛과 판정 없음은 제안하지 않습니다.
칩에는 네 단추가 있습니다. 「맞음」, 「(왼쪽 선수) 득점」, 「(오른쪽 선수) 득점」, 「무효」.
답이 없으면 그대로 넣는다
멀리 세운 휴대폰을 랠리마다 누를 사람은 없습니다. 그래서 답이 없으면 그대로 넣습니다(backend/static/tt/play/lib/scorestatus.mjs).
| 판정 | 답 없이 기다리는 시간 | 근거 |
|---|---|---|
| 확실 | 4초 | — |
| 확인 필요 | 9초 | 사람이 칩을 보고 답할 시간(약 2.5초 + 「잠깐만요」 3초)보다 넉넉하고, 실제 영상의 판정 사이 간격(3.8–17.4초) 안쪽 |
이 규칙에는 사연이 있습니다. 2026-10-13 에 공개 경기 영상을 운영 작업자에 흘려 보니, 카메라 점수판은 50초 동안 판정 넷을 받고도 0:0 이었습니다. 판정 대부분이 확인 필요였고, 화면은 확인 필요를 사람이 답할 때까지 넣지 않다가 다음 판정이 오면 버렸습니다. 그리고 왜 안 들어가는지 말하지 않았습니다. 지금은 확인 필요도 9초 뒤에 넣고, 9초가 되기 전에 다음 판정이 오면 앞 판정을 그때 넣습니다.
칩 줄은 둘까지입니다. 셋째 판정이 오면 가장 오래된 것부터 그대로 넣고, 한 번 뜬 칩은 최소 2.5초 보여 줍니다.
답의 처리
| 답 | 이력에 들어가는 칸 | 고친 기록 |
|---|---|---|
| 「맞음」 | 득점(출처 auto) | confirm |
| 시간이 지나 그대로 | 득점(출처 auto) | 남기지 않음(사람이 확인한 것이 아님) |
| 판정과 같은 선수 | 득점(출처 auto) | confirm |
| 판정과 다른 선수 | 득점(출처 tap) | flip |
| 「무효」 | 넣지 않음 | void |
손이 칩의 답이 된다
가장 중요한 규칙은 이것입니다. 칩이 떠 있는 동안 득점 칸을 누르면, 그 손이 칩의 답이 됩니다(handOverride). 같은 선수면 맞음, 다른 선수면 고침입니다. 이 규칙이 없으면 사람이 습관대로 손 점수를 누르고, 4초 뒤 자동 판정도 들어가 한 랠리가 2점이 됩니다.
정리하면 역할은 이렇게 나뉩니다.
| 사람 | 기계 | |
|---|---|---|
| 경기가 언제 시작했나 | 정한다(「경기 시작」) | 그 앞을 버린다 |
| 누가 서브하나 | 고칠 수 있고, 고치면 이긴다 | 점수판에서 받아 쓰고, 다르면 「서브가 맞나요?」라고 묻는다 |
| 랠리 승자 | 칩에 답하거나 손으로 누른다(손이 답) | 제안한다. 답이 없으면 4·9초 뒤 넣는다 |
| 애매한 판정 | 본다 | 확인 필요와 사유를 붙인다 |
| 잘못 들어간 점수 | 이력에서 지운다(2편) | — |
사람이 칩에 답한 기록(고친 기록)은 서버에 남을 수 있습니다. 영상은 담지 않고, 학습에 쓰도록 동의한 것만 내보냅니다. 그 기록이 어디로 가고 지금까지 어떻게 쓰였는지는 6편에서 다룹니다.
8. 실측: 지금 어디까지 맞나
모든 행은 공개 경기 영상(OpenTTGames 시험 7경기)에서 잰 값입니다. 학습과 문턱 고르기에는 다른 경기를 썼고, 시험 경기는 마지막에 봅니다. 다만 규칙 엔진 자체는 2026-10-11–12 에 시험 경기를 보며 다듬어졌으므로 규칙 기준선의 시험 점수는 낙관적입니다(원장의 누수 경고).
| 측정 | 조건 | 결과 |
|---|---|---|
| 운영 흐름 고정 기준표 | 시험 7경기 × 열화 11조건, 라벨 638, 운영과 같은 경로 + 바운드 거르기·보정 v2·직행 규칙(2026-10-16, 원장 E25·E27) | 맞음 508, 확인 없이 틀림 8, 확인 필요 497, 유령(공이 없는데 낸 점수) 51, 판정 지연 p90 1.32초 |
| 카메라 점수판 처음부터 끝까지 | 시험 7경기 851초를 가짜 카메라 30fps 로 실제 브라우저·작업자 코드에 흘림, 라벨 58랠리(2026-10-17, 원장 E35) | 맞음 47, 틀림 7, 판정 없음 4, 유령 9, 확인 없이 틀림 0, 확인 필요 53/65 |
| 조건별 공개 영상 | 촬영 조건 여덟 가지 27클립(2026-10-14–16, PERFORMANCE 2.16–2.17) | 세로로 세운 휴대폰 영상은 공이 보인 프레임 5%, 판정 0 |
| 휴대폰으로 찍은 실제 탁구장 | — | 미실측(라벨 있는 영상 0편) |
두 줄이 지금의 성격을 말해 줍니다. 확인 없이 틀리는 일은 드물지만, 확인 필요가 많습니다. 카메라 점수판 처음부터 끝까지 시험에서 판정 65개 중 53개가 확인 필요였습니다. 9초 자동 반영이 그 대가를 사람 대신 치르고 있는 셈이고, 「확인 필요는 줄이되 확인 없이 틀림은 늘리지 않기」가 다음 개선의 목표입니다.
9. 정리
- 자동 판정은 점수판이 아니라 제안입니다. 진짜 점수는 사람이 넣고 고친 이력 하나입니다.
- 심판은 라켓을 보지 않고 바운드 순서와 가로 속도 0.7 이라는 숫자 하나로 서브·넘겨주기·가짜 바운드를 가릅니다.
- 연습 서브는 진짜와 같은 모양이라 엔진이 가를 수 없습니다. 그래서 「경기 시작」은 사람이 누르고, 그 신호가 화면 → 프레임 시계 → 작업자 → 엔진으로 가서 그 앞을 모두 버립니다. 이 신호 하나로 5경기 점수 맞음이 27/49 에서 36/49 가 되었습니다.
- 「확인 필요」는 신뢰도 하나로는 틀림을 가르지 못해(AUC 0.51) 학습한 보정으로 바꿨고, 칩은 답이 없으면 4·9초 뒤 그대로 들어가며 사람의 손이 칩의 답이 됩니다.
티티 한마디. 몸풀기 서브랑 진짜 서브는 나한테 똑같이 생겼어요. 그러니까 「경기 시작」은 꼭 첫 서브 바로 전에 눌러 줘요. 그 한 번이 열 점보다 중요해요.
참고문헌
- International Table Tennis Federation. ITTF Statutes 2026, Chapter 2: The Laws of Table Tennis, §§2.10–2.15. https://db.ittf.com/document
- R. Voeikov, N. Falaleev, R. Baikulov. "TTNet: Real-time Temporal and Spatial Video Analysis of Table Tennis." CVPR Workshops, 2020. arXiv:2004.09927. 바운드·네트를 신경망이 직접 찍는 방식의 대표 선행 연구로, 우리는 이벤트를 공 궤적의 규칙으로 뽑는다는 점이 다릅니다.
- Extended OpenTTGames(랠리 종료 원인·승자 라벨). arXiv:2512.19327. 이 글의 랠리 승자 정답.
- 우리 측정: 저장소
docs/tt/PERFORMANCE.md2.6·2.7·2.16·2.17, 연구 원장 E20·E25·E27·E34·E35. 코드ttscore/events.py·referee.py·analyze.py·reviewcal.py,backend/static/tt/play/lib/camauto.mjs·scorestatus.mjs.
함께 읽기
- 이 시리즈 2편: 손 점수판과 경기 규칙 엔진: 「상태가 아니라 이력」, 몸풀기 상태.
- 이 시리즈 4편: 모델의 원리와 논문: 바운드 거르기 1D CNN 과 확인 필요 보정 회귀를 어떻게 배웠나.
- 앞 시리즈 TT Score 모델 해부 5편: 이벤트와 심판: 66.9% 에서 93.1% 까지 판마다 바꾼 것과 치른 것.
- 비전 모델 읽기 2, 10편: 정밀 이벤트 찾기: 이벤트를 학습으로 찾는 방법.