1) 시스템 구성도

(내용 작성 예정)

2) 데이터베이스 설계

전체 ERD는 부록 D: 데이터베이스 ERD에 기술한다.

3) 영상 분석 파이프라인 설계

측정 단계를 담당하며, 언어 모델이 개입하지 않는 결정론적 구간이다. 같은 영상은 언제나 같은 수치를 산출해야 한다(analysis_metric.pipeline_version으로 산출 버전 기록).

기술 스택

구간 도구 선정 사유
프레임 처리 OpenCV 디코딩·리사이즈·샘플링
사람 검출 Transformers RT-DETR (rtdetr_r50vd_coco_o365) ViTPose가 top-down 방식이라 사람 박스가 선행되어야 함
포즈 추정 Transformers ViTPose (vitpose-base-simple) COCO-17 키포인트, 필수 투입자원(Transformers) 범위 안
수치 계산 NumPy 각도·속도·궤적

필수 투입자원 목록에 MediaPipe가 없어 OpenCV + Transformers로 구성했다. 포즈 추정기는 GPU를 쓰지만 4)절의 판정 모델과 동시에 올리지 않는다 — 추출이 끝나면 모델을 해제하고 VRAM을 비운 뒤 판정 단계로 넘어간다(8GB 제약).

파이프라인의 입출력 규격(COCO-17 × 프레임)을 고정해 두어 추정기를 교체해도 후속 단계에 영향을 주지 않도록 한다.

처리 단계

(A) 포즈 추출 — 원본 프레임을 15fps로 다운샘플링하고 프레임별 COCO-17 관절 좌표와 검출 신뢰도를 추출한다. 다중 인원이 포착된 영상은 가장 큰 사람 박스를 대상 선수로 삼는다. 관중이나 배경 인물이 더 크게 잡히는 구도에서는 이 규칙을 재검토해야 한다.

(B) 정규화 — 어깨 너비로 스케일을 맞추고 골반 중심을 원점으로 이동시킨다. 이 단계를 생략하면 촬영 거리에 따라 같은 자세의 수치가 달라진다.

(C) 구간 분할 — 각속도의 부호 전환과 극값을 이용해 준비·임팩트·마무리 구간을 나눈다. 규칙 기반으로 시작하고, 종목별로 정확도가 부족하면 소규모 분류기를 붙인다. 이 단계에 언어 모델을 쓰지 않는다.

(D) 특징 산출 — 구간별 관절 각도, 각속도 피크, 무게중심 낙차, 좌우 비대칭도, 구간 소요 시간을 계산한다.

def joint_angle(a, b, c):
    """b를 꼭짓점으로 하는 세 점의 각도 (도)."""
    ba, bc = a - b, c - b
    cos = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc) + 1e-8)
    return float(np.degrees(np.arccos(np.clip(cos, -1.0, 1.0))))

산출된 항목은 metric_definition에 정의된 항목별로 analysis_metric_value에 한 행씩 적재한다. 지표 항목이 종목마다 다르므로 컬럼을 고정하지 않는다.

신뢰도 처리

관절이 가려지거나 모션 블러가 심하면 좌표가 튄다. 검출 신뢰도가 임계값 미만인 프레임은 제외하고 시간축 스무딩을 적용한다. 유효 프레임 비율이 기준에 미달하면 분석을 중단하고 재촬영을 요청한다. 낮은 품질의 입력으로 산출한 점수는 근거가 없으므로 제공하지 않는다.

4) AI 에이전트 처리 흐름 설계

판단 단계를 담당한다. 측정값과 채점 루브릭을 입력받아 항목별 등급과 근거를 산출하며, 총점 계산은 하지 않는다.

모델 선정

개발 환경의 GPU는 RTX 3050 (VRAM 8192 MiB, 상시 점유분 제외 약 7.4GB) 이다. 아래 수치는 추정이 아니라 실측이다(agent/scripts/spike_exaone.py).

후보 크기 코드 경로 판정
EXAONE 4.5 33B (VL) 33B 네이티브 불가 — 4bit로도 약 19GB
EXAONE 4.0 32B 32B 네이티브 불가 — 동일
EXAONE 3.5 7.8B / 2.4B 7.8B / 2.4B 원격 코드 보류 — 아래 참고
EXAONE 4.0 1.2B 1.2B 네이티브 채택

EXAONE 3.5 계열을 쓰지 못한 이유는 VRAM이 아니라 의존성이다. 3.5는 trust_remote_code로 자체 모델링 코드를 내려받는데, 그 코드가 특정 transformers 버전 창에만 맞는다. transformers 4.x에서는 RopeParameters import 실패, 5.15에서는 create_causal_mask()의 인자명이 input_embedsinputs_embeds로 바뀌어 TypeError가 난다. 버전을 고정하면 쓸 수 있지만, 팀 전원이 재현해야 하는 환경에서 상류 변경에 깨지는 경로는 피한다. 반면 EXAONE 4.0/4.5는 transformers에 네이티브로 통합되어 있어 원격 코드가 필요 없다. 8GB에 들어가면서 네이티브인 것은 4.0 1.2B뿐이다.

실측 결과

항목 4bit NF4 bf16
가중치 VRAM 927 MiB 2,440 MiB
피크 VRAM 970 MiB 2,477 MiB
잔여 VRAM 7,221 MiB 5,714 MiB
적재 시간 44.2초 7.5초
생성 속도 11.5 tok/s 24.4 tok/s

양자화하지 않는다. 1.2B는 bf16으로도 2.4GB뿐이라 8GB에 여유가 크고, 4bit는 bitsandbytes 역양자화 오버헤드 때문에 적재가 6배, 생성이 2배 느리다. 작은 모델에서 양자화는 손해다.

잔여 VRAM이 5.7GB 남는다는 것은 모델 규모를 올릴 여지가 크다는 뜻이다. 정식 서비스에서 24GB GPU로 32B를 올리기 전에, 8GB 안에서 더 큰 네이티브 모델이 나오면 우선 검토한다.

이 선택에서 파생되는 두 가지 결정이 있다.

비전 모델을 쓰지 않는다. EXAONE 계열에서 비전-언어 모델은 4.5의 33B뿐이며 8GB에 적재할 수 없다. 따라서 에이전트는 키프레임 이미지를 보지 못하고 수치만으로 판정한다. 이는 3)절 파이프라인의 중요도를 높인다. 측정값에 없는 정보는 판정에 영원히 반영되지 않으므로, 특징 항목을 충분히 확보하고 시선·표정처럼 수치화할 수 없는 항목은 루브릭에서 제외한다. 역설적으로 모든 판정 근거가 재현 가능한 수치에서 나오므로 시스템의 재현성은 오히려 유리해진다.

포즈 모델과 판정 모델을 동시에 올리지 않는다. 포즈 추출을 모두 끝내고 GPU를 비운 뒤 판정 모델을 적재한다.

서빙 구성

필수 투입자원 목록에 llama.cpp·Ollama가 없으므로 Hugging Face Transformers로 직접 적재한다. 모델이 2.4GB뿐이라 별도 서빙 서버 없이 프로세스 내에서 다룬다. 정식 서비스 단계에서 24GB 이상 GPU로 옮길 때 vLLM 도입을 검토하되, 요청 스키마와 루브릭이 동일하므로 판정 백엔드 교체만으로 전환된다.

출력 스키마 강제

응답 형식을 제약 디코딩(outlines)으로 강제한다. “JSON으로 출력하라”고 지시하는 것이 아니라 스키마에 맞지 않는 토큰을 생성 시점에 차단한다.

이 방식이 필수임은 실측으로 확인됐다. 강제 없이 생성시키면 모델이 응답을 마크다운 코드펜스로 감싸고 reasoning 같은 임의 키를 쓴다 — JSON 파싱이 실패한다.

스키마 설계가 판정 정확도를 바꾼다

제약 디코딩은 스키마에 적힌 순서대로 토큰을 생성한다. 따라서 필드 배치가 곧 사고 순서가 된다. 같은 모델·같은 입력으로 측정한 결과다.

스키마 구성 결과
grade 먼저 0등급 (오답) — 근거를 따지기 전에 등급부터 확정
evidencegrade 로 순서만 변경 0등급 (오답) — evidence가 수치를 되풀이할 뿐 대조하지 않음
comparison(대조 추론) 필드 추가 1등급 (정답)

측정값 171.3도에 대해 평문 생성은 “165도 초과 → 1등급”으로 맞게 추론했는데, grade 우선 스키마는 0등급을 냈다. 순서를 바꾸는 것만으로는 부족했고, 등급별 기준을 하나씩 대조하는 필드를 스키마에 명시해야 정답이 나왔다.

{
  "comparison": "X는 171.3도다. 2등급 기준 140~165도를 벗어난다.
                 1등급 기준 165도 초과에 해당한다. 따라서 1등급.",
  "evidence":   "측정값 knee_angle_at_impact: 171.3도",
  "metric_ref": "knee_angle_at_impact",
  "grade":      1
}

comparison은 사용자에게 노출하지 않는 중간 산출물이며, 소형 모델에서 이 필드가 사실상 사고 과정 역할을 한다. metric_ref도 이 필드를 넣은 뒤에야 정확해졌다 (그 전에는 무관한 impact_frame을 근거로 지목했다).

생성은 그리디 디코딩(do_sample=False)으로 고정한다. 샘플링을 켜면 같은 입력에서 다른 등급이 나온다.

소형 모델 보완 전략

1.2B급은 루브릭 전체를 한 번에 판정할 만큼 지시 준수력이 높지 않다. 위 comparison 필드에 더해 세 가지로 보완한다.

항목별 분해 호출 — 루브릭 항목 하나당 한 번씩 호출한다. 각 호출의 컨텍스트가 짧아져 정확도가 오른다. 자체 호스팅이라 호출 수 증가에 따른 비용이 없다.

앵커 예시 내장 — 등급마다 실제 측정값 예시를 2~3개 루브릭에 첨부해 등급 경계를 고정한다. 대형 모델에서는 선택 사항이지만 소형 모델에서는 필수다.

프리픽스 캐싱 — 시스템 프롬프트에 루브릭을 고정 배치하면 두 번째 호출부터 해당 구간의 연산이 생략된다. 시스템 프롬프트에 타임스탬프나 영상 ID를 넣으면 캐시가 무효화되므로 넣지 않는다.

정확도가 여전히 부족하면 다음 순서로 대응한다. 순서를 건너뛰면 무엇이 효과를 냈는지 판별할 수 없다.

  • 루브릭의 수치 임계값을 더 구체적으로 기술
  • 앵커 예시 추가
  • 8GB에 들어가는 더 큰 네이티브 모델로 상향 (잔여 VRAM 5.7GB)
  • 32B 모델로 상향 (GPU 교체 필요)
  • 골든셋 200건 이상 확보 후 LoRA 미세조정

점수 합산

def aggregate(judgments: dict, rubric: dict) -> dict:
    weights = {c["id"]: c["weight"] for c in rubric["criteria"]}
    total = sum(weights[cid] * (j["grade"] / 2.0) for cid, j in judgments.items())
    return {"score": round(total * 100), "breakdown": judgments}

가중합은 애플리케이션 코드가 계산한다. 언어 모델에 총점을 맡기지 않으므로 같은 판정에서는 항상 같은 점수가 나오고, 가중치 조정 시 재분석 없이 재계산된다.

실행 구조와 성능

분석은 비동기로 처리한다. 포즈 추출과 모델 추론을 합해 영상 1건당 수십 초가 소요되므로 앱에서 동기 대기시키지 않는다. analysis_job이 작업 상태와 소요 시간을 기록한다.

구간 소요 시간
판정 모델 적재 (bf16, 1회) 7.5~11초 (실측)
판정 (1.2B, 항목 5개 순차) 24~29초 (실측)
포즈 추출 (10초 클립) 미측정 — 실제 클립 확보 후
특징 산출 1초 미만 (실측)

재현성은 실측으로 확인됐다. 동일 측정값으로 5개 항목 판정을 3회 반복해 모두 80점, 표준편차 0.00이 나왔다(목표 3점 이내). 그리디 디코딩과 스키마 강제의 결합이 의도대로 동작한다.

RTX 3050은 메모리 대역폭이 제한적이라 생성 속도가 낮다. 위 수치는 개발 환경 기준이며 서비스 목표 응답 시간의 근거로 삼지 않는다. 성능 요구사항은 정식 서빙 환경에서 재측정한다.

5) 화면 설계

(내용 작성 예정)

← 목차로


위로

Copyright © 2026 Super-Sub

This site uses Just the Docs, a documentation theme for Jekyll.