상태: 초안 (2026-10-13), 실측 반영. SAM 2.1 tiny 의 정확도·속도는 2026-10-11–12 에 서버 GPU(다른 작업과 나눠 쓰던 때)와 CPU 임시 파드에서 잰 값입니다. EfficientTAM 과 SAM 3 는 우리 환경에서 돌려 보지 않았고(미실측) 논문과 공식 저장소의 수치를 조건과 함께 옮겼습니다. 탁구대를 자동으로 찾는 기능은 다른 작업에서 만들고 있어(진행 중) 결과가 나오면 덧붙입니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」으로 표시합니다.
목차
- 1. 왜 모서리 네 개가 필요한가
- 2. 프롬프트 분할: 클릭 한 번이 질문이 된다
- 3. 마스크에서 네 모서리로
- 4. 네 모서리에서 테이블 평면으로: 호모그래피
- 5. 실측
- 6. 정리와 다음
- 참고
1. 왜 모서리 네 개가 필요한가
공 좌표만으로는 점수를 매길 수 없습니다. 「공이 테이블에 닿았나」, 「어느 쪽 반에 닿았나」, 「테이블 끝을 넘어갔나」를 알려면 테이블이 화면 어디에 있는지 알아야 합니다. 탁구대는 크기가 규정으로 정해진 직사각형입니다. 길이 2.74m, 폭 1.525m, 네트 높이 15.25cm 입니다. 그래서 화면에서 네 모서리만 찾으면 나머지는 기하로 계산할 수 있습니다.
다행히 TT Score 는 카메라를 삼각대에 고정한다고 가정합니다. 탁구대는 움직이지 않으니 경기 시작 때 한 번 찾으면 됩니다. 프레임마다 돌아야 하는 공 찾기와 달리, 여기서는 수백 ms 가 걸려도 괜찮습니다. 이 차이가 모델 선택을 바꿉니다.
지금 실시간 화면은 사람이 네 모서리를 직접 탭합니다. 가장 확실하고 계산이 들지 않습니다. 여기에 테이블 두 반을 한 번씩 더 탭하면, 비스듬한 카메라에서도 어느 변이 끝(엔드라인)인지 엔진이 고를 수 있습니다(아래 5절). 이 글의 모델들은 「탭을 줄이거나 없애는 길」입니다.
2. 프롬프트 분할: 클릭 한 번이 질문이 된다
SAM 의 생각
Segment Anything(SAM) 계열은 「무엇을 칠할지」를 학습 때 정하지 않습니다. 대신 사람이 준 프롬프트(점 클릭, 상자, 대략의 마스크)를 받아 그 자리의 물체를 칠합니다. 구조는 세 부분입니다.
- 이미지 인코더: 무거운 부분. 그림 한 장을 한 번만 돌려 특징 지도를 만듭니다.
- 프롬프트 인코더: 클릭 좌표나 상자를 작은 벡터로 바꿉니다.
- 마스크 디코더: 가벼운 부분. 특징 지도와 프롬프트를 받아 마스크를 냅니다.
무거운 인코더를 한 번만 돌리고 가벼운 디코더를 클릭마다 다시 돌리므로, 사람이 클릭을 바꿔 가며 고치는 화면에 잘 맞습니다. TT Score 에서는 사람이 탁구대를 짚는 탭이 그대로 프롬프트가 됩니다.
SAM 2: 영상에서는 메모리로 잇는다
SAM 2(arXiv 2408.00714)는 같은 생각을 영상으로 넓혔습니다. 첫 프레임에서 한 번 짚은 물체를 다음 프레임에서도 따라가야 합니다. 이를 위해 메모리를 둡니다.
- 메모리 인코더: 프레임마다 예측한 마스크를 줄여서 그 프레임의 이미지 특징에 더하고, 가벼운 합성곱으로 섞어 「이 프레임에서 그 물체는 이렇게 생겼다」는 기억을 만듭니다.
- 메모리 뱅크: 최근 프레임의 기억(기본 6개)과 사람이 프롬프트를 준 프레임의 기억을 따로 줄 세워 둡니다. 여기에 마스크 디코더의 출력 토큰에서 뽑은 작은 벡터(object pointer)도 함께 둡니다.
- 메모리 어텐션: 새 프레임의 특징이 먼저 자기 자신을 보고(self-attention), 그다음 메모리 뱅크의 기억과 object pointer 를 봅니다(cross-attention). 이런 블록 4개를 지난 특징이 마스크 디코더로 갑니다.
직관적으로는 「지난 몇 장에서 그 물체가 어떻게 보였는지를 참고해 이번 장에서 찾는다」입니다. 선수가 테이블 모서리를 잠깐 가려도 기억이 있으니 마스크가 끊기지 않습니다.
공식 저장소가 보여 주는 영상 API 는 세 단계입니다.
# facebookresearch/sam2 README.md(Apache-2.0)에서
predictor = build_sam2_video_predictor(model_cfg, checkpoint)
with torch.inference_mode(), torch.autocast("cuda", dtype=torch.bfloat16):
state = predictor.init_state(<your_video>)
frame_idx, object_ids, masks = predictor.add_new_points_or_box(state, <your_prompts>)
for frame_idx, object_ids, masks in predictor.propagate_in_video(state):
...
SAM 2.1(2024-09)의 크기별 수치입니다(공식 README, 데이터센터급 GPU 한 장, 모든 부품 컴파일).
| 체크포인트 | 파라미터 | FPS(논문 환경) | SA-V 시험 J&F |
|---|---|---|---|
| tiny | 3,890만 | 91.2 | 76.5 |
| small | 4,600만 | 84.8 | 76.6 |
| base+ | 8,080만 | 64.1 | 78.2 |
| large | 2억 2,440만 | 39.5 | 79.5 |
코드와 체크포인트가 모두 Apache-2.0 입니다. 우리는 가장 작은 tiny 를 골랐습니다. 탁구대는 크고 단순한 물체라 큰 모델의 이득이 작을 것이라고 보았고, 공 찾기와 같은 카드를 나눠 써야 하기 때문입니다.
EfficientTAM: 메모리를 줄여 휴대폰으로
EfficientTAM(arXiv 2411.18933, 코드·체크포인트 Apache-2.0)은 SAM 2 를 가볍게 만드는 두 가지를 보였습니다.
- 평평한 ViT 인코더. SAM 2 의 계층형 인코더(Hiera) 대신 해상도 하나짜리 작은 ViT(Tiny·Small)를 씁니다.
- 메모리 교차 어텐션을 거칠게. 메모리의 공간 토큰을 2×2 창으로 평균해 4분의 1로 줄인 뒤 어텐션합니다. 이웃한 화소의 기억은 서로 비슷하다는 「국소적 매끄러움」 덕분에, 줄인 토큰이 원래 어텐션을 잘 흉내 낸다고 논문은 설명합니다(전체 어텐션과의 상대 오차 0.03). object pointer 는 줄이지 않습니다.
논문 표(1024×1024 입력)에 따르면 EfficientTAM-S/2 는 3,400만 파라미터로 SA-V 74.0 J&F, 데이터센터급 GPU 109.4 FPS, 휴대폰(iPhone 15 Pro Max) 프레임당 450ms 입니다. 비교한 SAM 2 는 8,100만 파라미터, 74.7 J&F, 43.8 FPS 입니다(논문). 휴대폰 측정의 정밀도·변환 방식은 논문에 적혀 있지 않고, SAM 2.1 과의 비교는 없습니다.
우리에게 의미는 「언젠가 휴대폰 안에서 탁구대를 찾는 길」입니다. 경기 시작 때 한 번이면 되니 프레임당 450ms 도 쓸 만합니다. 아직 우리 환경에서 돌려 보지 않았습니다(미실측).
SAM 3: 글자로 부르기
SAM 3(arXiv 2511.16719, 2025-11)는 프롬프트 개념 분할을 내세웁니다. 「table tennis table」 같은 짧은 글이나 예시 그림을 주면, 그 개념에 해당하는 모든 물체를 찾아 칠하고 영상에서 따라갑니다. 클릭 없이 탁구대를 찾을 수 있다는 점에서 매력적입니다.
그러나 이번에는 넣지 않았습니다.
- 라이선스. Apache 가 아니라 별도의 「SAM License」입니다. 재배포 시 같은 계약을 붙여야 하고, 수출 통제·군사 용도 금지, 역공학 금지, 소송 시 해지 같은 조건이 있습니다. 상업 이용은 명시적으로 다루지 않습니다.
- 접근 승인. 체크포인트를 받으려면 Hugging Face 에서 접근을 신청해야 합니다.
- 크기. 8억 4,800만 파라미터(공식 README)입니다. tiny 의 20배가 넘습니다. 논문은 데이터센터급 GPU 에서 물체 100개 넘는 그림 한 장에 30ms 를 보고했습니다.
- 필요성. 탁구대는 화면에 하나뿐이고 사람이 한 번 짚으면 됩니다. 「모든 개념 찾기」의 장점이 작습니다.
3. 마스크에서 네 모서리로
분할 모델이 주는 것은 마스크(칠한 화소 집합)입니다. 우리가 필요한 것은 꼭짓점 네 개입니다. 이 변환이 생각보다 까다로웠습니다.
실패한 방법 둘
처음에는 마스크 외곽선을 다각형으로 단순화해 꼭짓점을 골랐습니다(approxPolyDP). 네트가 윗면을 두 반으로 가르면 반쪽 테이블을, 선수가 모서리를 가리면 안쪽의 엉뚱한 점을 모서리로 냈습니다.
다음에는 「마스크의 각 열에서 맨 위 = 먼 변, 맨 아래 = 가까운 변」으로 변을 맞췄습니다. 옆면에서는 잘 됐지만, 테이블이 화면에서 비스듬히 돌아간 영상(TT3D 연구실의 비스듬한 카메라)에서 꼭짓점 하나가 370–754px 어긋났습니다. 끝 변을 먼 변으로 읽은 것입니다. 쪽 정확도가 0.99 에서 0.80 으로 떨어졌습니다.
지금 방법: 변마다 직선을 다시 맞춘다
ttscore/calib.py 는 이렇게 합니다.
- 마스크의 바깥 경계 점을 모은다.
- 볼록 껍질을 감싸는 사각형으로 네 변의 대략을 잡는다.
- 변마다 그 근처의 경계 점만으로 직선을 다시 맞춘다. 네트 기둥이나 선수 팔 때문에 점의 절반 가까이가 어긋나도 버티도록, 먼저 반복 중앙값(Siegel) 기울기로 직선을 잡고, 그 직선에서 3px 안의 점만으로 최소제곱을 되풀이한다.
- 이웃한 두 변의 교점을 꼭짓점으로 한다.
- 프레임 하나만 쓰지 않고 몇 프레임(실측은 5장)의 꼭짓점을 중앙값으로 합친다.
# ttscore/calib.py _fit() 에서 줄임 — v = a + b·u 직선, 순수 파이썬
slopes = [_median([(vs[j] - vs[i]) / (us[j] - us[i]) for j in idx if us[j] != us[i]]) for i in idx]
b = _median(slopes) # 반복 중앙값: 점의 절반이 어긋나도 기울기가 버틴다
a = _median([vs[i] - b * us[i] for i in idx])
for _ in range(it): # 그 직선에서 tol(3px) 안의 점만으로 최소제곱을 되풀이
keep = [i for i in range(n) if abs(vs[i] - (a + b * us[i])) <= tol]
...
이 방법의 장점은 가려진 꼭짓점도 보이는 변 두 개가 정한다는 것입니다. 선수가 모서리를 덮고 있어도 그 모서리를 이루는 두 변의 나머지 부분이 보이면 교점은 제자리에 나옵니다. 같은 SAM 마스크에서 비스듬한 영상의 꼭짓점 최대 오차가 370–754px 에서 25–28px 로 줄었고, 쪽 정확도는 0.999 가 되었습니다.
CI 에 numpy 가 없어서 이 모듈은 순수 파이썬입니다. 마스크는 행마다 칠한 구간 {y: [(x0, x1), ...]} 로 받습니다.
윗면만 남기기
SAM 은 테이블 윗면만이 아니라 옆판과 다리까지 칠하거나, 반대로 네트에 막혀 한 반만 칠하기도 합니다. 그래서 탭을 두 번(네트로 갈린 두 반에 하나씩) 주고, 탭한 자리와 색이 비슷한 화소만 남겨 윗면을 옆판과 가릅니다. 이 단계가 그늘에 약했습니다. 연구실 옆면 영상 두 편에서 그늘진 끝을 색 문턱이 잘라 꼭짓점이 87–97px 빗나갔습니다. 이런 사각형은 직사각형으로 읽히지 않는다는 검사(quad_ok)에서 걸러져 예전 규칙으로 돌아가고, 쪽 정확도 0.99 를 지켰습니다.
녹화 영상 분석 작업자에는 아직 SAM 2.1 이 실려 있지 않아, 지금은 탭 자리에서 비슷한 색을 키우는 색 단계만 합니다(batch/calib.py). 이 방법의 모서리 정확도는 재지 않았고(미실측), 그래서 결과에 방법을 남기고 화면은 「네 모서리 직접 짚기」를 권합니다.
4. 네 모서리에서 테이블 평면으로: 호모그래피
직관
평평한 면을 카메라로 찍으면 직사각형이 사다리꼴로 보입니다. 이 「평면 → 평면」 원근 변환을 호모그래피라고 합니다. 3×3 행렬 하나로 표현되고, 마지막 원소를 1 로 고정하면 미지수가 8개입니다. 점 하나의 대응이 식 두 개(x, y)를 주므로 네 점이면 정확히 풀립니다. 화면의 네 모서리와 테이블의 네 꼭짓점(0, 0) … (2.74, 1.525) 을 짝지으면 됩니다.
우리 코드
OpenCV 의 getPerspectiveTransform(네 점)이나 findHomography(여러 점, RANSAC)를 쓰면 한 줄이지만, 엔진은 numpy·OpenCV 없이 돌아야 해서 8×8 연립방정식을 직접 풉니다.
# ttscore/table.py
def homography(src, dst):
"""네 점 대응 src[i] → dst[i] 의 3×3 변환(h33 = 1)."""
a, b = [], []
for (x, y), (u, v) in zip(src, dst):
a.append([x, y, 1, 0, 0, 0, -u * x, -u * y]); b.append(u)
a.append([0, 0, 0, x, y, 1, -v * x, -v * y]); b.append(v)
h = _solve(a, b) # 부분 피벗 가우스 소거
return ((h[0], h[1], h[2]), (h[3], h[4], h[5]), (h[6], h[7], 1.0))
이 변환으로 화면의 점을 테이블 평면 좌표 (u, v) 로 옮깁니다. u 는 길이 방향이고 가운데(네트)가 0 입니다. 그러면 시점과 상관없이 같은 식이 됩니다.
- 쪽: u < 0 이면 L 끝, u > 0 이면 R 끝.
- 네트: u = 0.
- 끝 너머: |u| > 1.37m.
두 가지 함정
첫째, 평면 투영은 공이 테이블에 닿은 순간에만 맞습니다. 공중의 공을 평면에 옮기면 「카메라에서 공을 지나는 직선이 테이블 평면과 만나는 점」이 나와, 공이 높을수록 멀리 밀립니다. 끝 쪽 카메라에서 네트 높이의 공은 50cm 넘게 밀립니다. 그래서 평면 좌표는 바운드 순간의 쪽에만 쓰고, 공중 구간의 판단은 5편의 이벤트 규칙이 따로 합니다. 바운드 순간에도 공 중심은 닿은 점보다 반지름(20mm)만큼 위에 있어서, 그 아래의 닿은 점을 계산해 쪽을 정합니다. 이 보정 하나로 합성 비스듬한 카메라의 승자 정확도가 79.5% 에서 85.6% 로 올랐습니다.
둘째, 네 점만으로는 어느 변이 끝(엔드라인)인지 모릅니다. 어떤 사각형이든 두 가지 방법으로 직사각형에 옮길 수 있기 때문입니다. 엔진은 다음 순서로 정합니다.
- 데이터나 사람이 끝 변을 알려 주면 그것.
- 테이블 두 반에 하나씩 찍은 탭 두 점이 있으면, 그 두 점을 u 로 가장 멀리 가르는 짝.
- 화면 크기를 알면, 화면 가운데를 주점으로 보고 두 소실점이 직교한다는 조건으로 초점거리를 풀어 실제 가로세로 비를 복원(직사각형 복원). 2.74 : 1.525 에 가까운 짝이 맞다.
- 아무것도 없으면 예전 규칙(옆면 가정: x 가 작은 두 점이 한 끝).
왜 평면까지 갔나
예전 엔진은 대각선 교점의 x 좌표(「네트 x」)로 화면을 좌우로 갈랐습니다. 옆면에서는 맞지만 두 가지 문제가 있었습니다. 대각선 교점은 모서리의 세로 오차를 약 8배로 키웁니다(실측: 모서리 6–15px 오차 → 네트 x 27–36px). 그리고 끝 쪽 카메라에서는 그 세로선이 테이블 폭의 가운데 흰 선이 되어, 쪽 판정이 동전 던지기가 됐습니다(합성 끝 쪽 카메라 0.55, 실제 중계 영상 0.60). 평면 좌표로 바꾼 뒤 시점과 상관없이 같은 규칙이 됐습니다.
5. 실측
SAM 2.1 tiny 의 모서리(2026-10-11)
조건: OpenTTGames 실제 경기 12편, 탭 두 점 + 색으로 윗면만 + 네 변 직선 맞춤, 5프레임 중앙값. 비교 대상은 데이터셋이 준 분할 마스크로 구한 모서리(1920×1080).
| 항목 | 값 |
|---|---|
| 모서리 차이 중앙 / 90% / 최대 | 4.7px / 11.2px / 21px |
| 프레임 하나만 쓸 때 | 60장 중 6장이 20px 넘게 빗나감(선수 팔이 끝 변을 가린 프레임) |
| 정답 바운드 중 「테이블 윗면 띠」 밖 | 허용 8px 에서 0.73%, 8px + 공 반지름에서 0.11% |
데이터셋 마스크가 늘 정답은 아니었습니다. 3배 확대로 네트 바닥선을 눈으로 대조하면 두 경기(test_1, game_5)에서는 SAM 쪽이 맞았습니다.
속도와 메모리
| 조건 | 값 |
|---|---|
| 서버 GPU, 다른 작업과 나눠 쓸 때(2026-10-11) | 이미지 인코딩 40ms, 프롬프트 예측 26ms, 영상 추적 물체 하나 36–38ms/프레임, GPU 메모리 0.68–0.96GB |
| CPU 임시 파드(2026-10-12) | 프레임당 중앙 1.14초. GPU 조각이 다 찼던 다른 날에는 5.9초 |
경기 시작 때 한 번이면 CPU 로도 몇 초 안에 끝납니다. 프레임마다 돌릴 일이 없어서 GPU 를 꼭 쓸 필요가 없다는 것이 이 숫자의 의미입니다.
점수에 미친 영향
같은 날 같은 엔진에서 모서리만 바꿨을 때 실제 경기 275랠리의 승자 정확도가 82.5%(마스크 모서리) → 84.7%(SAM 모서리) 였습니다. 먼 변 근처 바운드가 띠 안에 들어온 것이 주된 이유입니다. 그 뒤 엔진이 바뀌면서 SAM 모서리로 93.1% 까지 올랐는데, 그 모서리 파일이 남아 있지 않아 2026-10-13 기준선(91.3%, 녹화본 채점)은 마스크 모서리로 다시 쟀습니다. 둘은 엔진도 달라서 모서리 몫만 떼어 말할 수 없습니다. 「점수판이 서버를 정하는」 경기 전체 모드에서는 마스크 모서리 쪽이 SAM 기록보다 분명히 낮았습니다. 모서리 품질이 점수판 흐름에 크게 걸린다는 뜻입니다.
넣지 않은 것
SAM 2 로 탁구대 말고 다른 것도 해 보았지만 실측 이득이 없어 넣지 않았습니다.
| 시도 | 결과 | 판단 |
|---|---|---|
| SAM 2 로 공 추적 | 선수와 겹친 공 재현율 0.879 → 0.926. 그러나 바운드 정밀도가 떨어져 랠리 승자 이득 0 | 넣지 않음 |
| 선수 마스크로 「가려서 안 보임」 판정 | 겹친 공의 60% 만 잡음 | 넣지 않음 |
| 물리 궤적으로 빈 곳 메우기 | 랠리 승자 −2 ~ −6 | 넣지 않음 |
OpenTTGames 밖에서
| 영상 | 결과 |
|---|---|
| 국제 경기 중계 30클립(640×360) | 데이터의 모서리와 꼭짓점 최대 차 중앙 4.5px, 90% 16px. 한 클립은 관중석까지 칠해 158px |
| 연구실 비스듬한 카메라 3편 | 꼭짓점 최대 오차 25–28px(약 20° 의 날카로운 꼭짓점이라 작은 직선 오차가 교점을 멀리 민다) |
| 연구실 옆면 3편 | 두 편이 그늘 때문에 87–97px. quad_ok 가 걸러 쪽 정확도 0.99 |
6. 정리와 다음
- 카메라가 고정이면 탁구대는 한 번만 찾으면 됩니다. 그래서 큰 모델이 아니라 가장 작은 SAM 2.1 과 기하로 충분했습니다.
- 정확도를 정한 것은 분할 모델보다 마스크를 꼭짓점으로 바꾸는 기하였습니다. 변마다 직선을 다시 맞추는 방법이 가림과 비스듬한 시점을 버텼습니다.
- 꼭짓점이 정해지면 호모그래피로 화면을 테이블 평면에 옮겨, 시점과 상관없이 쪽·네트·끝을 같은 식으로 판정합니다. 단, 바운드 순간에만.
탭 없이 탁구대를 자동으로 찾는 기능(경기 영상에서 탁구대를 찾고 따라가기)은 다른 작업에서 만들고 있습니다. 그 결과와 EfficientTAM 의 실측이 나오면 이 글에 덧붙입니다(진행 중).
티티 한마디. 테이블 모서리가 선수 팔에 가려도 괜찮아요. 보이는 두 변을 길게 이어 만나는 점을 찾으면 되거든요.
원리 더 읽기. SAM 계열의 원 논문과 학습 원리는 비전 모델 읽기 1, 9편: Segment Anything, 10편: SAM 2 와 EfficientTAM, 11편: SAM 3에 정리했습니다.
참고
- SAM 2: https://arxiv.org/abs/2408.00714, https://github.com/facebookresearch/sam2 (Apache-2.0)
- EfficientTAM: https://arxiv.org/abs/2411.18933, https://github.com/yformer/EfficientTAM (Apache-2.0)
- SAM 3: https://arxiv.org/abs/2511.16719, https://github.com/facebookresearch/sam3 (SAM License)
- OpenCV
findHomography·getPerspectiveTransform: https://docs.opencv.org/4.x/d9/d0c/group__calib3d.html - 탁구대 규격(길이 2.74m, 폭 1.525m, 네트 높이 15.25cm): ITTF Handbook https://www.ittf.com/handbook/
- 우리 측정: 저장소
docs/tt/PERFORMANCE.md2.4·2.8·2.10