TT Lab
블로그

한국어

시작하기
블로그

블로그

전체 구조: 휴대폰에서 판정 서버까지, 그리고 기기 밖으로 나가는 것

상태: 초안 (2026-10-26), 실측 반영. 구조는 2026-10-25 기준 저장소 코드에서 읽었고, 숫자는 우리가 직접 잰 값이며 표마다 날짜와 조건을 붙였습니다. 인터넷을 거친 실제 사용자 경로의 지연과 휴대폰 현장 정확도는 아직 미실측입니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」으로 표시합니다.

목차

1. 점수판 하나에 기계가 몇 개 붙어 있나

탁구장에서 TT Lab 을 쓰는 모습은 단순합니다. 휴대폰을 탁구대 옆에 세우고 /tt/play/cam 을 열어 「카메라 켜고 시작」을 누릅니다. 화면에는 카메라 영상 위에 큰 점수판이 떠 있고, 득점한 쪽 화면 반쪽을 누르면 점수가 오릅니다. 서브 차례, 듀스, 게임이 끝났을 때 자리 바꾸기는 점수판이 규칙대로 알려 줍니다.

여기까지는 기계가 휴대폰 하나뿐입니다. 점수 규칙, 이력, 점수 읽어 주기, 녹화가 모두 휴대폰 브라우저 안에서 돕니다. 서버가 하는 일이 생기는 것은 사용자가 셋 중 하나를 고를 때입니다.

  1. 자동 점수를 켠다. 카메라 프레임을 서버의 GPU 작업자에게 보내 랠리 승자를 제안받습니다.
  2. 라이브로 공유한다. 득점 이벤트만 서버로 보내 다른 사람이 실시간으로 봅니다.
  3. 이 경기 기록하기를 누른다. 끝난 경기의 게임 점수를 서버에 남기고 상대 확인을 거쳐 레이팅에 반영합니다.

녹화본을 따로 올려 분석받는 길(/tt/videos)도 있지만, 그것은 사용자가 영상 파일을 직접 올리는 별도 기능입니다.

이 글은 이 구조를 한 장의 지도로 그리고, 칸과 칸 사이에 무엇이 오가는지, 특히 무엇이 기기 밖으로 나가고 무엇이 나가지 않는지를 적습니다. 판정 규칙의 속은 2·3편, 모델의 원리는 4편, 학습은 5편, 기록은 6편에서 엽니다.

2. 네 칸의 지도

① 휴대폰 브라우저 손 점수판 · 이력 · 점수 읽기 · 녹화와 편집 이력은 휴대폰 localStorage 에 — 기본으로 서버에 안 감 라이브 공유 득점 이벤트 기록하기 게임 점수만 자동 점수 512×288 JPEG ↑ 판정 제안 ② 웹 서버 (같은 코드 두 대) 로그인 · 시간 몫 · 자동 점수 중계(WebSocket) 경기 기록 · 레이팅 · 라이브 · 고친 기록 → DB 프레임 → (저장 안 함) ← 판정 올린 영상 내부 API 만 ③ 실시간 판정 작업자 공 → 탁구대 → 이벤트 → 심판 서버 GPU 시간 조각 하나 세션 3개까지 프레임은 창 3장만 메모리에 ④ 녹화본 분석 작업자 같은 엔진으로 올린 영상 분석 DB·저장소 열쇠 없음 동시 작업 1 원본은 30일 뒤 지움 모델 저장소(공유 볼륨) — 엔진 코드 · 모델 코드 · 가중치 sha256 을 대조해 심고, 작업자를 다시 시작하면 그것이 배포 읽기 읽기
그림: 휴대폰 브라우저, 웹 서버, 실시간 판정 작업자, 녹화본 분석 작업자의 네 칸과 그 사이를 오가는 것
칸 하는 일 돌아가는 곳 코드
① 휴대폰 브라우저 점수 규칙·이력·되돌리기, 점수 읽기, 녹화와 쉬는 시간 빼기, 자동 점수 칩, 프레임 보내기 사용자 휴대폰 backend/static/tt/play/ (빌드 없는 ES 모듈)
② 웹 서버 로그인, 자동 점수 중계(WebSocket), 라이브 관람, 경기 기록·레이팅, 고친 기록 저장 웹 서버 두 대(같은 코드) backend/app/tt_*.py (FastAPI)
③ 실시간 판정 작업자 공 찾기, 탁구대 찾기·따라가기, 이벤트 추출, 심판, 「확인 필요」 판단 서버 GPU(24GB급) 한 장의 시간 조각 하나 live/(GPU 부분), ttscore/(순수 파이썬 엔진)
④ 녹화본 분석 작업자 올린 영상을 같은 엔진으로 분석해 경기 기록과 하이라이트를 만듦 같은 서버 GPU 의 조각 하나(동시 작업 1) 엔진 폴더의 batch 부분

왜 엔진을 두 덩어리로 나눴나

③ 안을 다시 보면 두 층입니다. GPU 와 OpenCV 가 필요한 부분(live/)과, 공 좌표·이벤트만 받는 순수 파이썬 엔진(ttscore/)입니다. 엔진은 GPU 도 OpenCV 도 필요 없는 함수 묶음이라 CI 에서 영상 없이 합성 궤적만으로 시험합니다. 공 모델을 바꿔 끼워도 심판 규칙은 그대로 시험을 통과해야 합니다. 이 분리는 앞 시리즈 1편에서 「인식·이벤트·심판·느린 확인」의 네 층으로 자세히 설명했습니다.

경기 규칙은 한 번 더 복제되어 있습니다. 휴대폰 화면은 서버를 부르지 않고도 점수를 세야 하므로, 파이썬 규칙 엔진(ttscore/rules.py)을 자바스크립트로 옮긴 backend/static/tt/play/lib/scorekeeper.mjs 를 씁니다. 라이브 관람 서버(backend/app/tt_watch.py)에도 같은 규칙의 셋째 사본이 있습니다. 사본이 셋이면 언젠가 어긋나기 마련이라, 파이썬 시험과 node 시험이 같은 사례 파일(backend/static/tt/play/data/rules-cases.json)을 읽게 했습니다. 한쪽만 고치면 다른 쪽 시험이 떨어집니다.

휴대폰 쪽: 빌드 없는 모듈과 서비스 워커

화면 코드는 번들러 없이 브라우저가 바로 읽는 ES 모듈입니다. 모듈 주소에는 내용 해시(?v=)를 붙인 import map 을 써서, 코드가 바뀌면 주소가 바뀌고 옛 캐시와 섞이지 않게 합니다. 탁구 구역에는 서비스 워커(/tt/sw.js, 범위 /tt/)가 있어 화면 코드는 캐시하지만 API 와 기록 화면은 캐시하지 않습니다. 탁구장 와이파이가 끊겨도 손 점수판은 계속 돌아야 하고, 점수 기록은 늘 서버의 최신 값을 봐야 하기 때문입니다.

/tt/play/cam 의 첫 화면은 무거운 덩어리(자동 점수, 녹화, 라이브)를 처음에 부르지 않고 쓸 때 부릅니다. 손 점수판만 쓰는 사람이 대부분이기 때문입니다.

3. 자동 점수를 켜면 일어나는 일

자동 점수는 이 구조에서 가장 긴 길입니다. 한 프레임이 점수 제안이 되어 돌아오기까지를 순서대로 따라가 봅니다.

  1. 브라우저가 프레임을 줄여 보낸다. 카메라 영상을 512×288 로 줄이고 JPEG 품질 0.7 로 압축해 초당 30장까지 보냅니다(backend/static/tt/play/cam-auto.js, lib/live.mjs). 화면에는 거울처럼 뒤집어 보여 줘도 보내는 것은 뒤집지 않은 원본입니다. 프레임 앞에는 8바이트 머리(순번, 찍은 시각 ms)가 붙습니다. 답을 받지 못한 프레임이 3장 쌓이면 새 프레임을 보내지 않습니다. 느린 회선에서 대기열이 끝없이 길어지는 것을 막는 장치입니다.
  2. 웹 서버가 문을 지킨다. WebSocket 중계(backend/app/tt_live.py)는 Google 로그인 회원만 받고, 자리와 시간 몫을 셉니다. 프레임은 지나가기만 합니다.
  3. 작업자가 프레임을 공 좌표로 바꾼다. 밝기 늘리기 → 공 모델(BlurBall) → 직전 공 근처의 후보 고르기 → 최근 2.5초의 공 궤적 유지 → 짧은 공백 메우기 → 이벤트 추출 → 학습한 바운드 거르기(veto) 순서입니다(live/core.py).
  4. 엔진이 판정한다. 「경기 시작」 신호를 받은 뒤에만 심판이 돌고, 랠리가 끝나면 승자·이유·신뢰도와 「확인 필요」 여부를 냅니다(3편).
  5. 브라우저가 칩으로 묻는다. 판정은 점수판에 바로 들어가지 않고 칩으로 뜹니다. 사람이 답하지 않으면 확실한 판정은 4초, 확인 필요 판정은 9초 뒤에 그대로 들어갑니다(3편).

탁구대는 이 흐름 밖에서 따로 찾습니다. 경기 시작 전에 한 번 「탁구대 찾기」를 하고(공 모델보다 훨씬 무거운 분할 모델을 씀), 그 뒤에는 광류로 매 프레임 따라갑니다(4편).

시간 몫과 한도

GPU 는 비싸고, 공유 자원입니다. 그래서 한도가 여러 겹입니다. 아래는 코드의 기본값입니다(운영 설정 파일에 따로 값이 있는지는 확인 못 함).

한도 값 자리
한 번 켜서 쓸 수 있는 시간 20분 웹 중계
한 사람의 하루 몫 40분 웹 중계
사이트 전체 하루 몫 360분 웹 중계
동시에 쓰는 자리 3 웹 중계·작업자 둘 다
프레임 하나의 크기 120,000바이트 웹 중계
초당 프레임 36장(순간 10장 더) 웹 중계
아무것도 안 올 때 끊김 15초(작업자는 20초) 웹 중계·작업자
탁구대 미리 찾기 사람당 시간당 30번 웹 중계

연결이 끊기면 브라우저는 1·2·4·8·15초 간격으로 다섯 번까지 다시 붙습니다. 로그인이 필요하거나 몫을 다 쓴 경우처럼 다시 붙어도 소용없는 닫힘(닫는 코드 4401·4429·4430·4431 등)에는 다시 붙지 않습니다.

실측: 동시 세션이 늘면

작업자 한 개가 세 세션까지 받습니다. 세 세션이 동시에 들어오면 GPU 한 장의 시간 조각 하나를 셋이 나눠 씁니다. 다음은 2026-10-11 에 같은 GPU 를 음성 합성 서비스와 나눠 쓰는 상태에서 잰 값입니다(docs/tt/PERFORMANCE.md 4.2).

동시 세션 세션당 처리 fps 프레임 왕복 시간
1 29.4 p99 약 32ms
3 16–20 p50 70–95ms, p99 약 127ms

세 세션일 때는 보낸 프레임의 21–30% 가 처리되기 전에 새 프레임에 덮여 버려졌습니다. 작업자의 GPU 루프는 하나이고, 루프가 돌 때마다 세션들의 가장 최근 창만 한 배치로 묶어 처리하며 밀린 창은 새 창이 덮도록 만들었기 때문입니다(live/server.py 머리말, 「지연 우선」). 점수 판정에는 프레임을 하나도 빠뜨리지 않는 것보다 늦지 않는 것이 중요합니다. 대가는 공이 빠른 랠리에서 30fps 가 실제로는 16–20fps 가 된다는 점이고, 앞 시리즈 5편에서 보였듯 이 엔진은 프레임률이 낮을수록 「확인 없이 틀림」이 늘어납니다.

실측: 랠리가 끝나고 점수가 뜨기까지

목표는 「랠리 끝 → 판정 1.5초 이내」입니다(연구 원장의 실시간 예산). 엔진은 마지막 바운드 뒤 1.5초 동안 새 바운드가 없거나 공이 1초 동안 안 보이면 랠리가 끝났다고 봅니다. 즉 예산의 상당 부분이 「정말 끝났나」를 기다리는 시간입니다.

측정 조건 값
판정 지연(랠리 끝 → 판정) 공개 영상 시험 7경기, 깨끗한 조건, 실시간 경로 흉내(2026-10-13·14, 원장 E00·E07) p90 1.31초
판정 지연 촬영 조건 여덟 가지 E2E(2026-10-14–15, PERFORMANCE 2.16) 중앙 0.67–0.90초
탁구대 제안이 뜨기까지 카메라 점수판 E2E(2026-10-14, PERFORMANCE 2.13) 2.8–3.4초
탁구대 찾기 한 번 운영 실시간 작업자에 시험 그림 211회, 서버 GPU(24GB급), 예열 뒤(2026-10-25–26, 원장 E44) 중앙 1.73초, p95 2.14초
탁구대 찾기 첫 호출 같은 조건, 모델을 메모리에 올리는 첫 번째 12.9초
인터넷을 거친 실제 사용자 경로 — 미실측

끝 문턱을 줄여 지연을 낮추는 시도(공이 사라지면 더 빨리 끝내기 등)는 여러 번 했지만 모두 기각했습니다(원장 E03·E10·E24·E28). 일찍 끝내면 길게 이어지는 수비 랠리에서 점수를 너무 일찍 내 버립니다.

4. 배포: 코드와 가중치를 모델 저장소에서 읽는다

웹과 작업자는 배포 방식이 다릅니다. 이 차이가 실제 사고를 한 번 낳았으므로 자세히 적습니다.

웹은 이미지로

웹 서버는 흔한 방식입니다. 저장소에 올리면 CI 가 시험을 돌리고 컨테이너 이미지를 굽고, GitOps 저장소의 이미지 태그를 바꾸면 ArgoCD 가 개발·운영에 자동으로 맞춥니다. 화면 코드(backend/static/tt/)도 이 이미지 안에 들어 있습니다.

작업자는 이미지가 아니라 공유 볼륨으로

GPU 작업자는 새 이미지를 굽지 않습니다. 같은 GPU 서버에서 이미 쓰고 있던 AI 서비스용 이미지(PyTorch 2.9, 파이썬 3.11)를 그대로 쓰고, 엔진 코드·BlurBall 모델 코드·가중치·OpenCV 휠을 모델 저장소(공유 볼륨) 에서 읽어 얹습니다. 엔진 코드는 자주 바뀌지만 PyTorch 같은 무거운 의존성은 거의 바뀌지 않으니, 코드를 고칠 때마다 수 GB 짜리 CUDA 이미지를 다시 굽지 않아도 됩니다. 대신 이미지의 파이썬 판이 고정되는 값을 치르는데, 그 값이 4편의 SAM 3 이야기에서 다시 나옵니다.

코드와 가중치를 심는 스크립트(live/seed-pvc.sh)는 이렇게 합니다.

  1. 가중치의 sha256 을 코드에 적힌 값과 대조합니다. 공 모델 기본 가중치 3545206c…, 탁구대 모델(SAM 2.1·EfficientTAM)은 공식 배포 묶음을 받아 sha256 을 대조한 뒤 풉니다.
  2. 새 파일을 .new 폴더에 먼저 받고, 다 받은 뒤 한 번에 바꿔 넣습니다. 반쯤 받은 코드를 작업자가 읽는 일을 막습니다.
  3. 작업자를 다시 시작합니다. 이것이 곧 배포입니다.

작업자는 가중치를 읽을 때도 sha256 을 다시 대조하고, PyTorch 의 weights_only=True 로만 읽습니다(live/blurball.py). 가중치 파일은 피클이라 임의 코드를 실행할 수 있는데, 이 옵션은 텐서 말고 다른 객체를 만들지 못하게 막습니다.

기능은 환경 변수 스위치로

새 기능은 거의 모두 코드 기본값은 꺼짐, 환경 변수로 켬 방식입니다. 2026-10-25 기준 실시간 작업자 설정에 켜져 있는 스위치는 이렇습니다.

스위치 무엇을 바꾸나 편
TTSCORE_BOUNCE_VETO 학습한 1D CNN 이 규칙이 낸 가짜 바운드를 버림 3·4편
TTSCORE_BRIGHTEN 공 찾기 전에 프레임 밝기를 늘림 4편
TTSCORE_REVIEW_CAL 「확인 필요」를 신뢰도 0.6 대신 학습한 회귀로 정함 3·4편
TTSCORE_DIRECT_MISS 서버 쪽 첫 바운드를 못 본 서브의 판정 방향 3편
TTSCORE_EDGE_REVIEW 끝선 근처 착지는 확인 필요로 3편
TT_TABLE_FINDERS 탁구대 찾기 모델의 순서 4편
TT_TABLE_KP 학습한 꼭짓점 모델을 탁구대 찾기에 합침(2026-10-25 켬) 4편

반대로 미세조정한 공 가중치(TTSCORE_BALL_WEIGHTS)와 탄도 메우기(TTSCORE_BALLISTIC_FILL)는 실험을 마쳤지만 켜는 조건을 채우지 못해 설정에 없습니다(꺼짐). 스위치 방식의 장점은 끄는 일이 쉽다는 것입니다. 코드와 가중치는 그대로 두고 설정 한 줄만 지우면 예전 동작으로 돌아갑니다.

이 방식이 낳은 사고

단점도 실제로 겪었습니다. 작업자 설정은 웹과 달리 자동 배포 밖에 있어 사람이 적용하고, 코드는 따로 심습니다. 그래서 설정과 코드가 따로 움직일 수 있습니다. 2026-10-18 에는 설정에서 TTSCORE_EDGE_REVIEW=1 이 켜졌는데 모델 저장소의 엔진 코드에는 그 기능이 아직 없던 상태가 실제로 있었습니다(원장 E41). 스위치는 켜져 있지만 아무 일도 하지 않았고, 오류도 나지 않았습니다. 코드를 다시 심어 해결했고, 그 뒤로는 기능을 켤 때 「설정 적용 → 코드 심기 → 다시 시작 → 작업자 안의 코드에 그 기능이 있는지 확인」 순서를 지킵니다.

같은 이유로 2026-10-25 에 꼭짓점 모델(TT_TABLE_KP)을 켰을 때도, 설정 파일만 보지 않고 운영 작업자에 시험 그림을 실제로 보내 응답에 꼭짓점 모델의 흔적이 있는지 확인했습니다. 212번의 응답 모두에 꼭짓점 모델이 돌았다는 기록이 있었습니다(원장 E44, 4편).

칸 사이의 울타리

작업자는 사용자의 영상을 받는 자리라 울타리를 여러 겹 쳤습니다.

5. 무엇이 기기 밖으로 나가고, 무엇이 안 나가나

휴대폰 안에 머무는 것 서버로 가는 것 카메라 원본 영상 원본 해상도 자동 점수를 켤 때만 512×288 JPEG · 저장 안 함 경기 이력 전체 localStorage 「이 경기 기록하기」 게임 점수만 녹화 파일 · 편집본 폰에 저장 「라이브로 공유」 득점 이벤트만 점수 읽어 주기 브라우저 음성 합성 칩에 답한 기록 영상 없음 · 동의 표시 설정 · 리모컨 키 localStorage 올린 영상(별도 기능) 줄여 올림 · 30일 보관 기기 경계 오른쪽은 모두 사용자가 켜거나 누를 때만 일어난다. 켜지 않으면 서버로 아무것도 가지 않는다.
그림: 기능별로 휴대폰 안에 머무는 것과 서버로 가는 것

TT Lab 탁구 화면의 원칙은 backend/static/tt/play/cam.js 머리말에 한 줄로 적혀 있습니다. 카메라 영상은 기기 밖으로 나가지 않는다. 기능별로 정리하면 이렇습니다.

기능 휴대폰 안에 머무는 것 서버로 가는 것 서버에 남는 것
손 점수판 경기 이력 전체(localStorage), 설정 없음 없음
점수 읽어 주기 브라우저 음성 합성(speechSynthesis) 없음 없음
녹화·쉬는 시간 빼기 녹화 파일(폰에 저장), 움직임 표시 없음 없음
라이브로 공유 영상 득점·되돌리기·끝 이벤트(순번 붙임) 라이브 경기와 이벤트 열
이 경기 기록하기 랠리별 이력 게임 점수, 경기 길이, 상대 경기 기록, 레이팅
자동 점수 원본 해상도 영상 512×288 JPEG 프레임(초당 최대 30장) 프레임은 저장하지 않음. 고친 판정 기록(동의 표시 포함, 영상 없음)
녹화본 올려 분석 — 사용자가 고른 영상 파일(브라우저가 360p60 500kbps 로 줄여 올림) 원본 30일, 분석 결과

자동 점수는 이 원칙의 예외입니다. 공을 찾으려면 GPU 가 영상을 봐야 하고, 지금은 그 GPU 가 서버에 있기 때문입니다. 그래서 예외를 좁게 만들었습니다.

녹화본 분석은 사용자가 파일을 직접 골라 올리는 기능이라 성격이 다릅니다. 원본은 30일 뒤 지우고, 매일 새벽 정리 작업이 돕니다.

장기 계획: GPU 를 휴대폰으로

자동 점수를 원칙 안으로 되돌리는 길은 공 찾기를 휴대폰 안에서 하는 것입니다. 공 모델은 149만 파라미터로 작지만, 휴대폰 브라우저에서 60fps 를 낼 수 있는지는 아직 미실측입니다. 지금은 「서버에 보내되 저장하지 않는다」가 현실적인 타협입니다.

6. 정리

티티 한마디. 점수판은 원래 폰 하나면 충분해요. 서버는 「공 좀 같이 봐 줘」 할 때만 부르는 친구예요. 그 친구도 본 장면을 일기장에 적지 않기로 약속했고요.

참고문헌

  1. T. Gossard, F. Radovic, A. Ziegler, A. Zell. "BlurBall: Joint Ball and Motion Blur Estimation for Table Tennis Ball Tracking." CVPR Workshops (CVsports), 2026. arXiv:2509.18387.
  2. N. Ravi 외. "SAM 2: Segment Anything in Images and Videos." ICLR, 2025. arXiv:2408.00714.
  3. Y. Xiong 외. "Efficient Track Anything." ICCV, 2025. arXiv:2411.18933.
  4. 우리 측정: 저장소 docs/tt/PERFORMANCE.md 2.13·2.16·4.2, 연구 원장 E00·E07·E41·E44.

함께 읽기

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다