Squat-Depth Tracker Map - 스쿼트 깊이 판독기

a·6일 전
0

사이드 프로젝트

목록 보기
2/3
post-thumbnail

Squat-Depth Tracker: 브라우저에서 풀 스쿼트 판별하기

스쿼트 랭킹 지도를 만들 계획이라 사용자별 스쿼트가 풀 스쿼트(힙이 무릎보다 낮음) 인지 자동 판별해야 했고, 그 하위 프로젝트로 이 트래커를 만들었다.
우선 영상 포즈를 트래킹하고,
트래킹된 값을 통해 스쿼트 깊이를 계산한다.

PASS
PASS 예시
FAIL
FAIL 예시

depth_ratio가 설정값보다 작다면 실패

많은 사용자의 영상을 서버로 업로드해 처리하면 비용·확장성·개인정보 문제가 생기므로, 영상 분석은 전부 브라우저(클라이언트) 에서 하고, 결과값(깊이·PASS/FAIL 요약)만 서버로 보내도록 설계했다.


왜 MediaPipe Pose Landmarker(WASM/WebGL)인가

환경모델특징장점비고
브라우저MediaPipe Pose LandmarkerBlazePose 계열 33 키포인트가볍고 빠름, 셋업 쉬움, 힙/무릎 추적 안정YOLO 필요 없음, 키포인트 즉시 사용
데스크톱(Electron/네이티브)YOLOv8n-pose (ONNX/OpenVINO)경량 + 정확도 양호GPU 있으면 더 좋음YOLOv3 대비 현대화

이번 프로젝트 요구는 한 명만 추적, 클라이언트 즉시 동작, 낮은 지연/비용, 설치 없이 접속이었고, MediaPipe가 정확히 맞는다. WebAssembly(WASM) + WebGL로 브라우저에서 실행되며, 실제 영상이 서버에 가지 않아 프라이버시에도 유리하다.


WebAssembly(WASM)란

  • 정의: 브라우저에서 돌아가는 저수준 바이너리 형식이다. C/C++/Rust 같은 언어로 빌드하면 나오는 결과물을 브라우저가 네이티브에 가까운 속도로 실행한다.
  • 왜 필요하나: 포즈 추론처럼 수치 연산이 무거운 작업을 JS만으로 돌리면 느리기 쉽다. WASM을 쓰면 연산을 최적화된 네이티브 루틴으로 처리할 수 있다.

어떻게 동작하나

  1. 모델/런타임(C/C++ 기반) → WASM 바이너리(.wasm) 로 컴파일한다.
  2. JS에서 WebAssembly.instantiateStreaming 으로 로드/인스턴스화한다.
  3. JS가 WASM의 함수를 호출하고, WASM은 선언된 메모리(Linear Memory) 를 통해 데이터를 주고받는다.

장점

  • 빠르다: JS보다 훨씬 빠른 경로로 수치연산을 돌린다.
  • 이식성: 같은 바이너리가 크롬/사파리/파이어폭스 등에서 그대로 돈다.
  • 보안 샌드박스: 네이티브처럼 위험하지 않고, 브라우저 샌드박스 안에서 돈다.

이 프로젝트에서의 역할

  • MediaPipe Tasks Vision의 포즈 추론 엔진(BlazePose 등)이 WASM으로 빌드되어 제공된다.
  • 브라우저가 이 WASM을 로드하고, JS에서 PoseLandmarker.detectForVideo()로 호출한다.
  • 덕분에 서버 추론 없이 사용자의 기기에서 실시간 포즈를 뽑을 수 있다.

WebGL이란

  • 정의: 브라우저에서 GPU를 쓰게 해주는 그래픽스 API다. OpenGL ES를 웹으로 가져온 표준이다.
  • 왜 필요하나: 포즈 추론은 텐서 연산/이미지 전처리가 많다. MediaPipe는 가능한 경우 WebGL로 연산을 오프로딩해 GPU 가속을 얻는다.

어떻게 동작하나

  • <canvas>WebGL 컨텍스트로 열고, 셰이더(작은 GPU 프로그램)로 벡터/매트릭스 연산, 텍스처 샘플링 등을 수행한다.
  • MediaPipe의 JS 바인딩이 내부적으로 WebGL 컨텍스트를 만들고 관리한다. 개발자는 보통 직접 셰이더를 쓰지 않는다.

장점

  • GPU 가속: CPU 대비 대량 병렬 연산이 빠르다.
  • 광범위 지원: 모바일/데스크톱 브라우저 대부분 지원(WebGL 1, 2).

이 프로젝트에서의 역할

  • MediaPipe가 입력 프레임을 텍스처로 올리고, 내부 계산(전처리/후처리)을 WebGL로 일부 가속한다.
  • 콘솔에 보였던 GL version: ... WebGL 2.0 로그가 그 증거다.
  • 우리는 별도 WebGL 코드를 작성할 필요 없이, MediaPipe가 자동으로 GPU 경로를 쓴다.

WASM + WebGL 조합이 가져다주는 것

  • 속도: 모델 추론 핵심은 WASM, 픽셀/텐서 처리는 WebGL로 가속되어 브라우저에서도 실시간에 가깝게 돈다.
  • 프라이버시: 영상이 서버로 안 간다. 기기 안에서 추론하고 결과 수치만 서버로 보낸다.
  • 배포 편의: 사용자에게 설치 요구 없음. 링크 접속만으로 실행한다.

한 줄 요약

  • WASM은 “브라우저에서 네이티브급 성능으로 모델을 돌리게 하는 엔진”이고,
  • WebGL은 “그 모델이 쓰는 이미지/텐서 계산을 GPU로 가속하는 도구”다.
    이 둘 덕분에 서버 없이도 브라우저에서 실시간 포즈 추적을 구현하고, 우리 서비스의 핵심인 스쿼트 깊이 판정을 빠르고 프라이버시 친화적으로 수행할 수 있다.

목표

  1. 브라우저 단독 스쿼트 깊이 판정(풀 스쿼트 여부)
  2. 스켈레톤 오버레이 + HUD(깊이/상태) 실시간 표시
  3. 안정적 타임스탬프스무딩으로 신뢰 가능한 깊이 시계열 생성
  4. 결과(깊이 최대, PASS/FAIL 요약)만 서버 저장 → 지도 랭킹에 활용

전체 흐름

  1. 사용자가 영상을 업로드하면 <video>로 재생한다.

  2. 매 프레임 MediaPipe Pose Landmarker가 33개 관절 키포인트를 추출한다.

  3. 커스텀 로직(depth.js)이 힙–무릎 깊이 비율(depth_ratio) 을 계산한다.

    • 좌/우 다리를 각각 계산 후 더 안정적인 쪽(대퇴골이 긴 쪽)을 고른다.
    • 스무딩 + 피크 탐지 + 히스테리시스PASS/MIXED/FAIL 요약을 만든다.
  4. <canvas>스켈레톤과 HUD(깊이 수치, 진단 이유)를 그린다.

  5. 요약 결과만 서버로 송신해 지도 랭킹에 쓴다.


핵심 코드와 설명

1) Landmarker 생성(WASM 경로/모델/VIDEO 고정)

// src/lib/pose.js
const { PoseLandmarker, FilesetResolver } = window;

export async function createLandmarker() {
  // WASM/글루코드 로딩: CDN에서 가져와 브라우저에서 실행한다
  const vision = await FilesetResolver.forVisionTasks(
    "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm"
  );

  // 풀 모델(float16) 로드, VIDEO 모드로 초기화한다
  const landmarker = await PoseLandmarker.createFromOptions(vision, {
    baseOptions: {
      modelAssetPath:
        "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_full/float16/1/pose_landmarker_full.task"
    },
    runningMode: "VIDEO",
    numPoses: 1, // 1명만 추적한다(속도/안정성↑)
    // 브라우저 환경 편차를 고려해 민감도를 완화한다
    minPoseDetectionConfidence: 0.2,
    minPosePresenceConfidence: 0.2,
    minTrackingConfidence: 0.2
  });

  // 내부 상태가 꼬여도 VIDEO로 다시 못박아 안정성을 높인다
  await landmarker.setOptions({ runningMode: "VIDEO" });
  return landmarker;
}

createLandmarker() — 모델/런타임 초기화

const { PoseLandmarker, FilesetResolver } = window;
  • 역할: MediaPipe가 브라우저 전역(window)에 주입해 둔 엔트리를 꺼낸다.

    • FilesetResolver: 런타임(WASM, glue JS)을 어디에서 가져올지 알려주는 로더이다.
    • PoseLandmarker: 포즈 추론 클래스(인스턴스 생성의 주인공)이다.
const vision = await FilesetResolver.forVisionTasks(
  "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm"
);
  • 동작: 지정한 CDN 경로에서 WASM 바이너리, WebGL glue code 등을 내려받아 Vision runtime 핸들을 만든다.
  • 이유: 설치 없이 CDN만으로 즉시 실행 가능하고, 브라우저 캐시로 재사용된다.
const landmarker = await PoseLandmarker.createFromOptions(vision, {
  baseOptions: {
    modelAssetPath:
      "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_full/float16/1/pose_landmarker_full.task"
  },
  runningMode: "VIDEO",
  numPoses: 1,
  minPoseDetectionConfidence: 0.2,
  minPosePresenceConfidence: 0.2,
  minTrackingConfidence: 0.2
});
  • modelAssetPath: 추론에 쓰일 가중치 + 그래프가 포함된 .task 파일 경로이다. full/float16은 정확도/속도 균형이 좋다.
  • runningMode: "VIDEO": 프레임 간 시간적 일관성을 가정하고 추적 품질을 높인다(반복 호출은 detectForVideo(video, ts)).
  • numPoses: 1: 한 사람만 트래킹 → 속도/안정성 향상, 다중 인물 오검출 방지.
  • 세 가지 confidence (0.2): 감지 문턱을 낮춰 “먼/어두운/노이즈” 상황에서도 최대한 잡게 한다. 대신 후처리에서 걸러 품질을 보정한다.
await landmarker.setOptions({ runningMode: "VIDEO" });
return landmarker;
  • 안정핀: 드물게 내부 상태가 꼬였을 때를 대비해 VIDEO 모드를 다시 못박는다.

주의

  • VIDEO 모드는 타임스탬프 단조 증가를 강하게 요구한다. 되감기/시크 시에는 reset() 또는 새 인스턴스 재생성이 필요하다.

2) 비디오 프레임 루프 + 안전한 타임스탬프

영상에서 다음 프레임을 받는다 → 시간값(ts) 을 깔끔하게 만든다 → 포즈 추정 detectForVideo(video, ts) 호출 → 그림(오버레이) 그린다 → 다음 프레임 예약.

// src/components/VideoInput.jsx (발췌)
function scheduleNextFrame() {
  const v = videoRef.current;
  if (!v) return;
  // 가능하면 requestVideoFrameCallback, 아니면 requestAnimationFrame으로 폴백한다
  if ("requestVideoFrameCallback" in HTMLVideoElement.prototype) {
    vfcIdRef.current = v.requestVideoFrameCallback(step);
  } else {
    rafRef.current = requestAnimationFrame((t) => step(t, null));
  }
}

const step = (_when, _metadata) => {
  const video = videoRef.current;
  const canvas = canvasRef.current;
  if (!video || !canvas || video.ended) return;

  const vw = video.videoWidth | 0;
  const vh = video.videoHeight | 0;

  // 프레임이 유효하지 않으면 다음 프레임을 예약한다
  if (vw === 0 || vh === 0 || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
    scheduleNextFrame();
    return;
  }

  // 1) mediaTime(초)을 우선 사용한다
  let mediaS = typeof _metadata?.mediaTime === "number" ? _metadata.mediaTime : video.currentTime;

  // 2) 사용자가 되감기/시크하면 추적 상태와 타임스탬프 앵커를 초기화한다
  if (mediaS < lastMediaTimeRef.current) {
    resetLandmarkerIfNeeded(); // VIDEO 모드 상태 초기화
    resetTimestamps();         // 타임스탬프 앵커 재설정
  }

  // 3) MediaPipe가 요구하는 '단조 증가' ms 타임스탬프를 만든다
  let ts = Math.round(
    (mediaS - startMediaSRef.current) * 1000 + (startPerfMsRef.current - anchorRef.current)
  );
  if (ts <= lastTsRef.current) ts = lastTsRef.current + 1; // 강제로 +1 보장한다

  lastTsRef.current = ts;
  lastMediaTimeRef.current = mediaS;

  // 탐지를 실행한다
  const lmInstance = landmarkerRef.current;
  if (!lmInstance) { scheduleNextFrame(); return; }

  let res;
  try {
    res = lmInstance.detectForVideo(video, ts);
  } catch (err) {
    // 드물게 WebGL/그래프 에러가 날 수 있어 프레임만 스킵한다
    console.error("detectForVideo error", err);
    scheduleNextFrame();
    return;
  }

  // HiDPI 대응: 내부 캔버스는 실제 픽셀, CSS는 비디오 표시 크기로 맞춘다
  const dpr = window.devicePixelRatio || 1;
  canvas.width  = Math.round(vw * dpr);
  canvas.height = Math.round(vh * dpr);
  const rect = video.getBoundingClientRect();
  canvas.style.width  = `${rect.width || vw}px`;
  canvas.style.height = `${rect.height || vh}px`;

  const ctx = canvas.getContext("2d");
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 1 단위 = 영상 1px로 맞춘다
  ctx.clearRect(0, 0, vw, vh);

  // 결과 스키마 호환: 새 버전(landmarks) / 구버전(poseLandmarks)을 모두 지원한다
  const landmarks = res.landmarks ?? res.poseLandmarks ?? [];

  // …(아래에서 깊이 계산/스켈레톤/HUD를 그린다)
  scheduleNextFrame();
};

scheduleNextFrame : “영상이 새 컷으로 바뀔 때 나를 불러줘!” 하고 예약해두는 함수.

function scheduleNextFrame() {
  const v = videoRef.current;
  if (!v) return;
  if ("requestVideoFrameCallback" in HTMLVideoElement.prototype) {
    vfcIdRef.current = v.requestVideoFrameCallback(step);
  } else {
    rafRef.current = requestAnimationFrame((t) => step(t, null));
  }
}
  • rVFC vs rAF

    • requestVideoFrameCallback: 디코더가 새 비디오 프레임을 출력한 시점에 콜백을 준다 → 즉, “실제 새 프레임이 생겼을 때” 불러줌 → 더 정확.
    • requestAnimationFrame: 디스플레이 리프레시에 맞춘다 → 프레임이 바뀌지 않아도 콜백이 온다 → 대체용.
    • rVFC가 있으면 그걸 쓰고, 없으면 rAF로 “폴백”해.

step(_when, _metadata) — “한 프레임 처리”

const step = (_when, _metadata) => {
  const video = videoRef.current;
  const canvas = canvasRef.current;
  if (!video || !canvas || video.ended) return;

  const vw = video.videoWidth | 0;
  const vh = video.videoHeight | 0;
  if (vw === 0 || vh === 0 || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
    scheduleNextFrame();
    return;
  }
  • 가드: 영상이 끝났거나, 아직 가로×세로가 0(= 디코더 준비 안 됨)이면 그냥 다음 프레임 예약하고 리턴.
  • | 0은 빠른 정수 변환(비트 OR)이다.
  let mediaS = typeof _metadata?.mediaTime === "number"
    ? _metadata.mediaTime
    : video.currentTime;
  • “이번 프레임의 정확한 시간”을 구하기: rVFC 메타데이터의 정확한 디코딩 시각(초)을 최우선으로 쓰고, 없으면 currentTime으로 대체한다.
  if (mediaS < lastMediaTimeRef.current) {
    resetLandmarkerIfNeeded(); // VIDEO 추적 상태 리셋
    resetTimestamps();         // 타임스탬프 앵커 재설정
  }
  • 사용자가 되감기/시크하면 시간이 줄어들 수 있는데, MediaPipe의 VIDEO 모드는 시간이 항상 앞으로 간다고 가정
    → 그래서 되감기 감지(mediaS < lastMediaTimeRef.current)하면 내부 상태를 리셋:
  let ts = Math.round(
    (mediaS - startMediaSRef.current) * 1000 + (startPerfMsRef.current - anchorRef.current)
  );
  if (ts <= lastTsRef.current) ts = lastTsRef.current + 1;

  lastTsRef.current = ts;
  lastMediaTimeRef.current = mediaS;
  • 단조 증가 ms 타임스탬프

    • MediaPipe에 주는 ts항상 증가해야 한다.
    • mediaS(초)를 ms로 바꾸고, 내부 앵커값을 더해 숫자가 계속 커지도록 계산 → 초기 앵커를 보정해 “프레임 간 +1ms 이상”을 보장한다.
    • 같거나 작으면 lastTs + 1로 강제 상승 → Packet timestamp mismatch 에러 예방이다.
  const lmInstance = landmarkerRef.current;
  if (!lmInstance) { scheduleNextFrame(); return; }

  let res;
  try {
    res = lmInstance.detectForVideo(video, ts);
  } catch (err) {
    console.error("detectForVideo error", err);
    scheduleNextFrame();
    return;
  }
  • 포즈 추정 실행

    • landmarker.detectForVideo(video, ts) 호출.
    • 실패할 수도 있으니 try/catch로 감싸서 한 프레임만 스킵하고 다음으로 넘어감(앱이 죽지 않게).
  const dpr = window.devicePixelRatio || 1;
  canvas.width  = Math.round(vw * dpr);
  canvas.height = Math.round(vh * dpr);
  const rect = video.getBoundingClientRect();
  canvas.style.width  = `${rect.width || vw}px`;
  canvas.style.height = `${rect.height || vh}px`;

  const ctx = canvas.getContext("2d");
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.clearRect(0, 0, vw, vh);
  • HiDPI(레티나) 캔버스 스케일 맞추기 : 눈에 보이는 크기와 진짜 픽셀 크기를 일치시켜서, 점과 선이 정확히 영상 위에 얹히게 한다

    • 맥북 같은 레티나 화면은 CSS 1px이 실제 픽셀 여러 개일 수 있다.
    • 그래서 내부 캔버스 크기 = 비디오 픽셀×devicePixelRatio 로 맞추고,
    • CSS 표시 크기는 비디오 박스에 맞춘다.
    • setTransform(dpr,0,0,dpr,0,0)으로 “좌표 1 = 영상 1픽셀” 스케일을 만든다.
    • 이렇게 해야 레티나에서 오버레이가 몸 위에 정확히 올라간다.
  const landmarks = res.landmarks ?? res.poseLandmarks ?? [];
  • 스키마 가드
    • MediaPipe 버전에 따라 결과가 res.landmarks 또는 res.poseLandmarks로 올 수 있어.
    • const landmarks = res.landmarks ?? res.poseLandmarks ?? [];
      → 둘 다 지원해서 버전 차이로 망가지지 않게.

주의

  • rAF 폴백일 때는 디코딩 프레임이 갱신되지 않아도 콜백이 올 수 있다. mediaTime이 그대로면 연속 중복 추론을 피하고 싶다면 최근 ts와 비교해 건너뛰는 최적화를 추가할 수 있다.

3) 스켈레톤 그리기(가시성 필터 + 화면 크기 기반 두께)

// src/components/VideoInput.jsx (발췌)
const POSE_EDGES = [
  [11,12],[11,13],[12,14],[13,15],[14,16],[15,17],[16,18], // 팔/상체
  [11,23],[12,24],[23,24],                                 // 몸통
  [23,25],[25,27],[27,29],[29,31],                         // 왼다리
  [24,26],[26,28],[28,30],[30,32],                         // 오른다리
];

function drawPose(ctx, lm, vw, vh, { ptR=3, alpha=0.9, visTh=0.5, strokePx=2 } = {}) {
  ctx.save();
  ctx.globalAlpha = alpha;
  ctx.lineWidth = strokePx;
  ctx.lineJoin = "round";
  ctx.lineCap = "round";

  // 뼈대 라인을 한 번에 긋는다(성능상 유리하다)
  ctx.beginPath();
  for (const [a,b] of POSE_EDGES) {
    const pa = lm[a], pb = lm[b];
    if (!pa || !pb) continue;
    if ((pa.visibility??1) < visTh || (pb.visibility??1) < visTh) continue;
    ctx.moveTo(pa.x * vw, pa.y * vh);
    ctx.lineTo(pb.x * vw, pb.y * vh);
  }
  ctx.strokeStyle = "rgba(0,200,255,0.9)";
  ctx.stroke();

  // 관절 점을 찍는다(가시성 낮으면 생략한다)
  for (let i=0;i<lm.length;i++) {
    const p = lm[i];
    if (!p || (p.visibility??1) < visTh) continue;
    ctx.beginPath();
    ctx.arc(p.x * vw, p.y * vh, ptR, 0, Math.PI*2);
    ctx.fillStyle = "rgba(0,255,120,0.9)";
    ctx.fill();
  }
  ctx.restore();
}

const POSE_EDGES = [
  [11,12],[11,13],[12,14],[13,15],[14,16],[15,17],[16,18], // 팔/상체
  [11,23],[12,24],[23,24],                                 // 몸통
  [23,25],[25,27],[27,29],[29,31],                         // 왼다리
  [24,26],[26,28],[28,30],[30,32],                         // 오른다리
];
  • 각 배열 원소 [a, b]는 “인덱스 a 관절 ↔ b 관절을 선으로 이어라”는 뜻.
  • MediaPipe의 랜드마크 인덱스 규약(11=왼어깨, 12=오른어깨, 23/24=힙, 25/26=무릎…)을 그대로 사용.
  • 이 테이블만 바꾸면 그릴 뼈대가 바뀐다(유지보수 용이).
function drawPose(ctx, lm, vw, vh, { ptR=3, alpha=0.9, visTh=0.5, strokePx=2 } = {}) {
  ctx.save();
  ctx.globalAlpha = alpha;
  ctx.lineWidth = strokePx;
  ctx.lineJoin = "round";
  ctx.lineCap = "round";
  • 옵션

    • ptR: 관절점 반지름(px)
    • alpha: 전체 투명도(영상 위와 잘 어울리게)
    • visTh: visibility 임계값(신뢰도). 관절이 가려졌거나 모델이 확신 없으면 수치가 낮아진다. 이 값보다 낮으면 그리지 않음 → 노이즈 감소.
    • strokePx: 선 두께
  • save/restore: 이 함수가 바꾼 상태가 바깥그림에 영향을 주지 않게 한다.

  ctx.beginPath();
  for (const [a,b] of POSE_EDGES) {
    const pa = lm[a], pb = lm[b];
    if (!pa || !pb) continue;
    if ((pa.visibility??1) < visTh || (pb.visibility??1) < visTh) continue;
    ctx.moveTo(pa.x * vw, pa.y * vh);
    ctx.lineTo(pb.x * vw, pb.y * vh);
  }
  ctx.strokeStyle = "rgba(0,200,255,0.9)";
  ctx.stroke();
  • 라인 최적화:
    • pa.x * vw: 정규화(0~1) → 픽셀 좌표 변환.
    • beginPath 후 모든 선을 이어 그리고(move/line을 모두 누적한 뒤) 마지막에 stroke()를 한 번만 호출 → 호출 수 감소로 성능↑.
  • 가시성 필터: 두 점 중 하나라도 신뢰도 낮으면 그 선은 건너뛰기.
  for (let i=0;i<lm.length;i++) {
    const p = lm[i];
    if (!p || (p.visibility??1) < visTh) continue;
    ctx.beginPath();
    ctx.arc(p.x * vw, p.y * vh, ptR, 0, Math.PI*2);
    ctx.fillStyle = "rgba(0,255,120,0.9)";
    ctx.fill();
  }
  ctx.restore();
}
  • 관절 원: 각 점을 1회식 arc + fill. 라벨(번호)을 찍고 싶으면 fillText를 추가하면 된다(디버그용 권장).

왜 이렇게 설계했나?

  • 가시성(visibility) 임계값: 옷, 가려짐, 프레임 블러가 있을 때 헛그림 방지.
    → 잘 보이는 관절만 쓰면 추정 안정도와 시인성이 올라가.
  • 라인을 한 번에 stroke: 캔버스 API 호출 수를 줄여 성능 확보(특히 모바일).
    정규화 좌표 × 비디오 픽셀: MediaPipe는 해상도와 무관하게 0~1 좌표를 주니까, 실제 화면에 맞추려면 꼭 곱해줘야 해.
  • 두께/반지름은 외부에서 스케일링: 화면이 작을 때 너무 얇거나, 클 때 너무 굵지 않도록 상위에서 ptR, strokePx를 비디오 크기 기반으로 계산해서 넘겨주는 패턴이 좋아.
    예) ptR = clamp(min(vw, vh) * 0.01, 4, 10)처럼.

4) 깊이 계산(좌·우 독립 계산 → 더 안정적인 쪽 선택)

// src/lib/depth.js (발췌)
const LHIP=23, RHIP=24, LKNEE=25, RKNEE=26;

function visiblePoint(lm, idx, W, H){
  const p = lm?.[idx];
  if (!p || (p.visibility ?? 1) < VIS_TH) return null;
  return { x: p.x * W, y: p.y * H };
}

function measureLeg(lm, hipIdx, kneeIdx, W, H){
  const hip = visiblePoint(lm, hipIdx, W, H);
  const knee = visiblePoint(lm, kneeIdx, W, H);
  if (!hip || !knee) return null;

  // femur: 힙–무릎 2D 거리(정규화 분모)
  const femur = Math.hypot(hip.x - knee.x, hip.y - knee.y);
  if (!femur) return null;

  // depthRaw: 힙이 무릎보다 아래(y가 큼)일수록 양수
  const depthRaw = hip.y - knee.y;
  const depth = depthRaw > 0 ? depthRaw / femur : 0;

  return { femur, depth };
}

function pickBestLeg(lm, W, H){
  const right = measureLeg(lm, RHIP, RKNEE, W, H);
  const left  = measureLeg(lm, LHIP, LKNEE, W, H);
  if (!right && !left) return null;
  if (!right) return { side: "left",  ...left  };
  if (!left)  return { side: "right", ...right };
  // 대퇴골이 더 긴 쪽(보통 카메라 각도상 더 정면인 다리)을 선택한다
  return right.femur >= left.femur
    ? { side:"right", ...right }
    : { side:"left",  ...left  };
}
const LHIP=23, RHIP=24, LKNEE=25, RKNEE=26;
  • 인덱스 상수화: MediaPipe 포즈 인덱스: 왼/오른쪽 힙, 무릎 번호를 상수로 정의.
    이렇게 해두면 아래 함수에서 “어느 점을 써야 하는지”가 명확
function visiblePoint(lm, idx, W, H){
  const p = lm?.[idx];
  if (!p || (p.visibility ?? 1) < VIS_TH) return null;
  return { x: p.x * W, y: p.y * H };
}
  • lm은 0~1 정규화 좌표라서 픽셀 좌표로 바꾸기 위해 W,H 곱함.
  • visibility(신뢰도)가 VIS_TH(예: 0.3)보다 낮으면 쓸모없는 점으로 보고 버림 → 가림/흔들림 때 엉뚱한 계산 방지.
function measureLeg(lm, hipIdx, kneeIdx, W, H){
  const hip = visiblePoint(lm, hipIdx, W, H);
  const knee = visiblePoint(lm, kneeIdx, W, H);
  if (!hip || !knee) return null;

  const femur = Math.hypot(hip.x - knee.x, hip.y - knee.y);
  if (!femur) return null;

  const depthRaw = hip.y - knee.y;     // y는 아래로 클수록 큼
  const depth = depthRaw > 0 ? depthRaw / femur : 0;

  return { femur, depth };
}
  • 핵심 수식: depth = (hip.y - knee.y) / femur
    • 한쪽 다리만 골라 측정(왼쪽 또는 오른쪽).
    • 아래로 내려갈수록 y가 커져서, 힙이 무릎 아래일 때만 양수로 잡힌다.
    • femur(대퇴골 길이)로 나눠 영상·체형 간 비교 가능한 비율로 만든다.(정규화의 분모.)
    • 힙이 아직 위에 있으면 0으로 클램프(음수 억제).
function pickBestLeg(lm, W, H){
  const right = measureLeg(lm, RHIP, RKNEE, W, H);
  const left  = measureLeg(lm, LHIP, LKNEE, W, H);
  if (!right && !left) return null;
  if (!right) return { side: "left",  ...left  };
  if (!left)  return { side: "right", ...right };
  return right.femur >= left.femur
    ? { side:"right", ...right }
    : { side:"left",  ...left  };
}
  • 왜 femur가 긴 쪽을 고르나: 보통 카메라에 더 직각인 다리가 더 길게(왜곡 적게) 보인다 → 측정 안정성↑.

  • 중요: 분자(힙-무릎 y차)와 분모(해당 다리 femur)를 같은 다리에서 가져와 정규화 일관성을 지킨다.

    • 섞어 쓰면 2.x 같은 과대한 ratio가 튀기 쉽다.

주의

  • 완전 정면샷(무릎/힙 겹침)에서는 y 차이가 작아 오차가 크다. 별도의 side 폭(어깨폭 px) 가드로 프레임을 제외한다.
  • 가려짐/크롭은 가시성·프레이밍·정규화 신뢰를 무너뜨려서 기본값으로 FAIL/UNSURE 처리한다.

5) 시계열 스무딩 + 피크 탐지 + 히스테리시스(PASS 요약)

// src/lib/depth.js (발췌)
export function summarizeDepth(series, opts={}){
  const TH_HIGH = opts.TH_HIGH ?? TH;           // PASS 기준선
  const TH_LOW  = opts.TH_LOW  ?? (TH * 0.85);  // 히스테리시스 하한
  const HOLD_N  = opts.HOLD_N  ?? HOLD;         // 바닥 구간 유지 프레임
  const MIN_GAP = opts.MIN_GAP ?? 12;           // 피크 후 쿨다운
  const MIN_PROM= opts.MIN_PROM?? 0.04;         // 최소 prominence

  // 1) 스무딩: median5 → 이동평균
  const s = series.slice();
  for (let i=0;i<s.length;i++){
    if (s[i]==null) continue;
    const w = [s[i-2],s[i-1],s[i],s[i+1],s[i+2]].filter(v=>v!=null).sort((a,b)=>a-b);
    if (w.length>=3) s[i] = w[(w.length/2)|0];
  }
  const t = s.map((v,i)=> (v==null? null
                : (i>0 && i<s.length-1 ? (s[i-1]+s[i]+s[i+1]) / [s[i-1],s[i],s[i+1]].filter(x=>x!=null).length
                                       : v)));

  // 2) 피크 탐지(히스테리시스 + prominence + hold)
  let pass=0, fail=0, depthMax=0;
  for (let i=2; i<t.length-2; i++){
    const c = t[i];
    if (c==null) continue;

    // 지역 최대 여부
    const w=[t[i-2],t[i-1],t[i],t[i+1],t[i+2]];
    const isPeak = w.every((v,idx)=> idx===2 || v==null || c >= v);
    if (!isPeak || c < TH_LOW) continue;

    // prominence: 주변 저점 대비 충분히 돌출되었는지 확인한다
    const L = Math.max(0, i-10), R = Math.min(t.length-1, i+10);
    let leftMin=Infinity, rightMin=Infinity;
    for (let k=L; k<i; k++) if (t[k]!=null) leftMin = Math.min(leftMin, t[k]);
    for (let k=i+1; k<=R; k++) if (t[k]!=null) rightMin= Math.min(rightMin, t[k]);
    const base = Math.max(isFinite(leftMin)?leftMin:0, isFinite(rightMin)?rightMin:0);
    if (c - base < MIN_PROM) continue;

    // 바닥 근처 프레임이 HOLD_N 이상이면 PASS 후보로 인정한다
    let hold=0;
    for (let k=i-2; k<=i+2; k++){
      const v=t[k]; if (v!=null && v>=TH_LOW) hold++;
    }
    const ok = (c >= TH_HIGH) && (hold >= HOLD_N);

    if (ok) { pass++; depthMax = Math.max(depthMax, c); i += MIN_GAP; }
    else    { /* 과잉 FAIL 방지를 위해 미약한 후보는 건너뛴다 */ i += Math.max(4, (MIN_GAP/2)|0); }
  }

  const summary = pass>0 && fail===0 ? "PASS" : (pass>0 ? "MIXED" : "FAIL");
  return { summary, pass, fail, threshold: TH_HIGH, hold: HOLD_N, depthRatioMax: depthMax || undefined };
}

결과값

SUMMARY: PASS
PASS: 1 | FAIL: 0
TH: 0.1 | HOLD: 2
max depth_ratio: 0.13

와 같이 최대 깊이를 통해 PASS / FAIL 출력.

export function summarizeDepth(series, opts={}){
  const TH_HIGH = opts.TH_HIGH ?? TH;           // PASS 기준
  const TH_LOW  = opts.TH_LOW  ?? (TH * 0.85);  // 히스테리시스 하한
  const HOLD_N  = opts.HOLD_N  ?? HOLD;         // 바닥 유지 프레임 수
  const MIN_GAP = opts.MIN_GAP ?? 12;           // 피크 후 쿨다운
  const MIN_PROM= opts.MIN_PROM?? 0.04;         // prominence 최소
  • 의미

    • TH_HIGH: 진짜 PASS로 인정할 높이(깊이) 문턱이다.
    • TH_LOW: 들어갈 때의 낮은 문턱(히스테리시스)이다. 문턱 주변 흔들림에 강해진다.
    • HOLD_N: 바닥 근처 유지 프레임 수로 “순간 툭 찍고 올라감”을 거른다.
    • MIN_GAP: 한 번 PASS를 잡고 나면 중복 계수 방지를 위해 i를 건너뛴다.
    • MIN_PROM: 주변 저점 대비 얼마나 확실히 내려갔다가 올랐는지(돌출) 기준이다.
  const s = series.slice();
  for (let i=0;i<s.length;i++){
    if (s[i]==null) continue;
    const w = [s[i-2],s[i-1],s[i],s[i+1],s[i+2]].filter(v=>v!=null).sort((a,b)=>a-b);
    if (w.length>=3) s[i] = w[(w.length/2)|0];
  }
  • series는 프레임마다 계산된 depth_ratio 배열.
    예: [0, 0.02, 0.05, 0.12, 0.18, 0.2, 0.19, 0.1, 0.03, …]
  • null은 “이 프레임은 신뢰 못 함(가림/정면/키포인트 불안정)”이란 뜻.
  • Median(윈도 5): 주변 5개(실제 유효값만) 중 중간값으로 바꿔 스파이크를 줄인다.
  • 효과: 갑자기 튄 프레임에 덜 민감해진다.
  const t = s.map((v,i)=> (v==null? null
                : (i>0 && i<s.length-1 ? (s[i-1]+s[i]+s[i+1]) / [s[i-1],s[i],s[i+1]].filter(x=>x!=null).length
                                       : v)));
  • MA(이동평균, 윈도 3): 한 번 더 부드럽게 만들어 피크 탐지 안정성을 높인다.
  let pass=0, fail=0, depthMax=0;
  for (let i=2; i<t.length-2; i++){
    const c = t[i];
    if (c==null) continue;

    // 지역 최대
    const w=[t[i-2],t[i-1],t[i],t[i+1],t[i+2]];
    const isPeak = w.every((v,idx)=> idx===2 || v==null || c >= v);
    if (!isPeak || c < TH_LOW) continue;
  • 지역최대 isPeak: 인덱스 i에서 이웃 값들보다 크면(지역최대).
  • 히스테리시스: 그 값 cTH_LOW보다 작으면 “충분히 깊지 않다” → 스킵.
    // prominence
    const L = Math.max(0, i-10), R = Math.min(t.length-1, i+10);
    let leftMin=Infinity, rightMin=Infinity;
    for (let k=L; k<i; k++) if (t[k]!=null) leftMin = Math.min(leftMin, t[k]);
    for (let k=i+1; k<=R; k++) if (t[k]!=null) rightMin= Math.min(rightMin, t[k]);
    const base = Math.max(isFinite(leftMin)?leftMin:0, isFinite(rightMin)?rightMin:0);
    if (c - base < MIN_PROM) continue;
  • prominence 체크: c가 주변 구간(좌우 10프레임)의 저점들보다 얼마나 높은지 계산.
    c - base < MIN_PROM이면 “진짜 앉은 게 아니라 그냥 흔들림” → 스킵.
    • 작은 흔들림/카메라 잡음으로 생긴 미세 피크 제거에 매우 효과적이다.
    // HOLD
    let hold=0;
    for (let k=i-2; k<=i+2; k++){
      const v=t[k]; if (v!=null && v>=TH_LOW) hold++;
    }
    const ok = (c >= TH_HIGH) && (hold >= HOLD_N);
  • 피크 주변 5프레임(i-2…i+2) 중 TH_LOW 이상인 프레임 수 ≥ HOLD_N 이면
    “바닥에서 잠깐이라도 멈춘 제대로 된 스쿼트”라고 인정.
  • 그리고 c ≥ TH_HIGH(정말 깊음)까지 만족하면 PASS로 카운트.
    if (ok) { pass++; depthMax = Math.max(depthMax, c); i += MIN_GAP; }
    else    { i += Math.max(4, (MIN_GAP/2)|0); }
  }

  const summary = pass>0 && fail===0 ? "PASS" : (pass>0 ? "MIXED" : "FAIL");
  return { summary, pass, fail, threshold: TH_HIGH, hold: HOLD_N, depthRatioMax: depthMax || undefined };
}
  • PASS면 pass++하고, i += MIN_GAP으로 다음 몇 프레임은 건너뛰어 중복 카운트 방지.
  • PASS가 아닐 땐 너무 민감하게 FAIL을 올리지 않도록 기본은 무시(원하면 주석 해제해 FAIL++ 가능).
  • 동시에 가장 깊었던 값은 depthMax에 계속 저장 → 요약값으로 보여줌.

주의

  • seriesnull이 많으면(가림/정면) 피크가 성립하기 어렵다. 입력 단계에서 정면 가드(SIDE_PX) 로 무효 프레임을 줄여라.

튜닝 팁

  • 느슨하게: TH_HIGH↓, HOLD_N↓, MIN_PROM↓.
  • 엄격하게: TH_HIGH↑, HOLD_N↑, MIN_PROM↑, MIN_GAP↑.
  • 실제 데이터(라벨링 샘플)로 ROC 감각을 잡는 게 가장 빠르다.

트러블슈팅 요약

  1. 결과 스키마 변화로 오버레이가 사라지는 문제가 있었다.
    const landmarks = res.landmarks ?? res.poseLandmarks ?? [];양쪽 스키마를 모두 가드해 해결했다.

  2. 레티나(HiDPI)에서 오버레이가 어긋나는 문제가 있었다.
    → 캔버스 내부 크기를 videoWidth×devicePixelRatio로, CSS 크기를 표시에 맞추고, ctx.setTransform(dpr,0,0,dpr,0,0)으로 좌표계를 맞춰 영상과 정확히 겹치게 했다.

  3. depth_ratio가 과대(예: 2.x)로 나오는 문제가 있었다.
    → 한 프레임에서 같은 다리로 분자/분모를 계산하도록 좌·우 독립 측정 후 대퇴골이 긴 쪽을 선택하는 방식으로 교정했다.

  4. 되감기/영상 교체 후 추적이 멈추는 문제가 있었다.
    → VIDEO 모드는 시간 단조 증가 전제라 불연속 ts에 민감하다. 파일 교체/되감기 시 reset/close & recreate로 인스턴스를 새로 만들고, ts는 항상 증가하도록 강제해 해결했다.

  5. 모바일에서 HUD/마커가 너무 작아지는 문제가 있었다.
    → 폰트·마커·스트로크를 렌더링되는 비디오 폭 기반으로 계산하고 min/max 클램프를 걸어 항상 읽기 좋은 크기를 유지하게 했다.


Troubleshooting (실전 이슈 & 해결)

1) Pose 결과 스키마 변화로 오버레이가 사라짐

  • 증상: MediaPipe Tasks 버전 업 이후 detectForVideo() 결과에 poseLandmarks가 비어 있고, 스켈레톤 오버레이가 그려지지 않음.

  • 원인: 새 버전에서 포즈 키포인트가 poseLandmarks가 아닌 landmarks로 들어옴(스키마 드리프트).

  • 해결: 양쪽을 모두 허용하는 가드로 호환성 복구.

    const landmarks = res.landmarks ?? res.poseLandmarks ?? [];

    이렇게 읽으면 구버전/신버전 모두 지원된다.


2) 레티나(HiDPI)에서 오버레이가 몸에서 떠보임

  • 증상: MacBook Pro 등 HiDPI 화면에서 스켈레톤이 실제 인물에서 수 픽셀씩 떠보임.

  • 원인: 캔버스가 CSS 크기 기준으로만 그려져, 실제 디바이스 픽셀 밀도(devicePixelRatio)를 반영하지 못함.

  • 해결: 매 프레임 캔버스를 영상 실제 픽셀 × devicePixelRatio로 리사이즈하고, CSS는 비디오 박스에 맞추며, 드로잉 전에 스케일 변환 적용.

    const dpr = window.devicePixelRatio || 1;
    canvas.width  = Math.round(video.videoWidth  * dpr);
    canvas.height = Math.round(video.videoHeight * dpr);
    
    const rect = video.getBoundingClientRect();
    canvas.style.width  = `${rect.width}px`;
    canvas.style.height = `${rect.height}px`;
    
    const ctx = canvas.getContext("2d");
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 1 논리단위 = 영상 1px

    이렇게 하면 오버레이 좌표가 영상과 정확히 일치한다.


3) depth_ratio가 과장되어 2.x처럼 크게 나옴

  • 증상: 실제로는 얕은 스쿼트인데 max depth_ratio가 2.3 같은 비현실적인 수치로 뜸.

  • 원인: 힙–무릎 델타와 분모(대퇴골 길이)를 다른 다리에서 가져오는 프레임이 섞여 정규화 기준이 일관되지 않음.

  • 해결: depth.js를 수정해 좌·우 다리를 각각 독립 계산하고, 대퇴골이 더 긴 쪽 한 다리를 고른 뒤 그 다리의 힙–무릎 델타와 femur로 동일하게 정규화.

    • 보정 후 얕은 스쿼트에서 2.x 같은 과대값이 사라지고, 깊이 순서가 직관과 잘 맞는다.

4) 영상 교체/되감기 뒤 포즈가 “멈춘” 것처럼 보임

  • 증상: 두 번째 영상 로드나 뒤로 스크럽 후 detectForVideo()가 빈 결과를 계속 반환, HUD가 0에서 멈춤.

  • 원인: MediaPipe VIDEO 모드는 시간이 단조 증가한다는 가정이 있어서, 불연속 타임스탬프(되감기/파일 교체)에 민감함. 같은 인스턴스를 재사용하면 내부 상태가 꼬임.

  • 해결:

    1. 파일 교체/되감기 시 기존 landmarker를 close()하고 새 인스턴스를 생성.

    2. 프레임 루프에서는 새 landmarker 준비 전까지 탐지 호출을 생략.

    3. 타임스탬프는 항상 단조 증가하도록 보정:

      if (mediaS < lastMediaTime) { landmarker.reset(); resetTimestamps(); }
      if (ts <= lastTs) ts = lastTs + 1;

    이렇게 하면 “Packet timestamp mismatch”류 에러와 HUD 정지 현상이 사라진다.


5) 모바일/작은 화면에서 HUD 글자·관절 마커가 너무 작음

  • 증상: 고정 폰트/반지름(16~20px) 설정으로 화면이 작아질수록 가독성 급락.

  • 원인: 해상도·렌더 폭을 고려하지 않는 하드코딩된 스타일.

  • 해결: 렌더링되는 비디오 폭 기반 비율로 폰트/반지름/스트로크를 계산하고 최소/최대 클램프 적용.

    const scaleBase = Math.min(vw, vh);
    const ptR      = clamp(scaleBase * 0.01, 4, 10);
    const strokePx = clamp(ptR * 0.6, 2, 8);
    const fontPx   = clamp(vw * 0.05, 20, 48);

    화면 크기와 관계없이 HUD가 항상 읽기 좋은 크기로 유지된다.

참고 :
https://github.com/pjbelo/mediapipe-js-demos?utm_source=chatgpt.com
https://github.com/rishic3/DepthCheck?utm_source=chatgpt.com

0개의 댓글