스쿼트 랭킹 지도를 만들 계획이라 사용자별 스쿼트가 풀 스쿼트(힙이 무릎보다 낮음) 인지 자동 판별해야 했고, 그 하위 프로젝트로 이 트래커를 만들었다.
우선 영상 포즈를 트래킹하고,
트래킹된 값을 통해 스쿼트 깊이를 계산한다.
PASS 예시
|
FAIL 예시
|
depth_ratio가 설정값보다 작다면 실패
많은 사용자의 영상을 서버로 업로드해 처리하면 비용·확장성·개인정보 문제가 생기므로, 영상 분석은 전부 브라우저(클라이언트) 에서 하고, 결과값(깊이·PASS/FAIL 요약)만 서버로 보내도록 설계했다.
| 환경 | 모델 | 특징 | 장점 | 비고 |
|---|---|---|---|---|
| 브라우저 | MediaPipe Pose Landmarker | BlazePose 계열 33 키포인트 | 가볍고 빠름, 셋업 쉬움, 힙/무릎 추적 안정 | YOLO 필요 없음, 키포인트 즉시 사용 |
| 데스크톱(Electron/네이티브) | YOLOv8n-pose (ONNX/OpenVINO) | 경량 + 정확도 양호 | GPU 있으면 더 좋음 | YOLOv3 대비 현대화 |
이번 프로젝트 요구는 한 명만 추적, 클라이언트 즉시 동작, 낮은 지연/비용, 설치 없이 접속이었고, MediaPipe가 정확히 맞는다. WebAssembly(WASM) + WebGL로 브라우저에서 실행되며, 실제 영상이 서버에 가지 않아 프라이버시에도 유리하다.
WebAssembly.instantiateStreaming 으로 로드/인스턴스화한다.PoseLandmarker.detectForVideo()로 호출한다.<canvas> 를 WebGL 컨텍스트로 열고, 셰이더(작은 GPU 프로그램)로 벡터/매트릭스 연산, 텍스처 샘플링 등을 수행한다.GL version: ... WebGL 2.0 로그가 그 증거다.사용자가 영상을 업로드하면 <video>로 재생한다.
매 프레임 MediaPipe Pose Landmarker가 33개 관절 키포인트를 추출한다.
커스텀 로직(depth.js)이 힙–무릎 깊이 비율(depth_ratio) 을 계산한다.
<canvas>로 스켈레톤과 HUD(깊이 수치, 진단 이유)를 그린다.
요약 결과만 서버로 송신해 지도 랭킹에 쓴다.
// 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"
);
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: 한 사람만 트래킹 → 속도/안정성 향상, 다중 인물 오검출 방지.await landmarker.setOptions({ runningMode: "VIDEO" });
return landmarker;
주의
reset() 또는 새 인스턴스 재생성이 필요하다.영상에서 다음 프레임을 받는다 → 시간값(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은 빠른 정수 변환(비트 OR)이다. let mediaS = typeof _metadata?.mediaTime === "number"
? _metadata.mediaTime
: video.currentTime;
currentTime으로 대체한다. if (mediaS < lastMediaTimeRef.current) {
resetLandmarkerIfNeeded(); // VIDEO 추적 상태 리셋
resetTimestamps(); // 타임스탬프 앵커 재설정
}
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 타임스탬프
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) 호출. 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(레티나) 캔버스 스케일 맞추기 : 눈에 보이는 크기와 진짜 픽셀 크기를 일치시켜서, 점과 선이 정확히 영상 위에 얹히게 한다
setTransform(dpr,0,0,dpr,0,0)으로 “좌표 1 = 영상 1픽셀” 스케일을 만든다. const landmarks = res.landmarks ?? res.poseLandmarks ?? [];
res.landmarks 또는 res.poseLandmarks로 올 수 있어.const landmarks = res.landmarks ?? res.poseLandmarks ?? [];주의
mediaTime이 그대로면 연속 중복 추론을 피하고 싶다면 최근 ts와 비교해 건너뛰는 최적화를 추가할 수 있다.// 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 관절을 선으로 이어라”는 뜻.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();
}
arc + fill. 라벨(번호)을 찍고 싶으면 fillText를 추가하면 된다(디버그용 권장).왜 이렇게 설계했나?
- 가시성(visibility) 임계값: 옷, 가려짐, 프레임 블러가 있을 때 헛그림 방지.
→ 잘 보이는 관절만 쓰면 추정 안정도와 시인성이 올라가.- 라인을 한 번에 stroke: 캔버스 API 호출 수를 줄여 성능 확보(특히 모바일).
정규화 좌표 × 비디오 픽셀: MediaPipe는 해상도와 무관하게 0~1 좌표를 주니까, 실제 화면에 맞추려면 꼭 곱해줘야 해.- 두께/반지름은 외부에서 스케일링: 화면이 작을 때 너무 얇거나, 클 때 너무 굵지 않도록 상위에서
ptR, strokePx를 비디오 크기 기반으로 계산해서 넘겨주는 패턴이 좋아.
예)ptR = clamp(min(vw, vh) * 0.01, 4, 10)처럼.
// 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;
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;
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) / femurfemur(대퇴골 길이)로 나눠 영상·체형 간 비교 가능한 비율로 만든다.(정규화의 분모.)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가 튀기 쉽다.주의
// 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은 “이 프레임은 신뢰 못 함(가림/정면/키포인트 불안정)”이란 뜻. 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)));
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에서 이웃 값들보다 크면(지역최대).c가 TH_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;
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);
i-2…i+2) 중 TH_LOW 이상인 프레임 수 ≥ HOLD_N 이면 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++하고, i += MIN_GAP으로 다음 몇 프레임은 건너뛰어 중복 카운트 방지.depthMax에 계속 저장 → 요약값으로 보여줌.주의
series에 null이 많으면(가림/정면) 피크가 성립하기 어렵다. 입력 단계에서 정면 가드(SIDE_PX) 로 무효 프레임을 줄여라.튜닝 팁
TH_HIGH↓, HOLD_N↓, MIN_PROM↓.TH_HIGH↑, HOLD_N↑, MIN_PROM↑, MIN_GAP↑.결과 스키마 변화로 오버레이가 사라지는 문제가 있었다.
→ const landmarks = res.landmarks ?? res.poseLandmarks ?? [];로 양쪽 스키마를 모두 가드해 해결했다.
레티나(HiDPI)에서 오버레이가 어긋나는 문제가 있었다.
→ 캔버스 내부 크기를 videoWidth×devicePixelRatio로, CSS 크기를 표시에 맞추고, ctx.setTransform(dpr,0,0,dpr,0,0)으로 좌표계를 맞춰 영상과 정확히 겹치게 했다.
depth_ratio가 과대(예: 2.x)로 나오는 문제가 있었다.
→ 한 프레임에서 같은 다리로 분자/분모를 계산하도록 좌·우 독립 측정 후 대퇴골이 긴 쪽을 선택하는 방식으로 교정했다.
되감기/영상 교체 후 추적이 멈추는 문제가 있었다.
→ VIDEO 모드는 시간 단조 증가 전제라 불연속 ts에 민감하다. 파일 교체/되감기 시 reset/close & recreate로 인스턴스를 새로 만들고, ts는 항상 증가하도록 강제해 해결했다.
모바일에서 HUD/마커가 너무 작아지는 문제가 있었다.
→ 폰트·마커·스트로크를 렌더링되는 비디오 폭 기반으로 계산하고 min/max 클램프를 걸어 항상 읽기 좋은 크기를 유지하게 했다.
증상: MediaPipe Tasks 버전 업 이후 detectForVideo() 결과에 poseLandmarks가 비어 있고, 스켈레톤 오버레이가 그려지지 않음.
원인: 새 버전에서 포즈 키포인트가 poseLandmarks가 아닌 landmarks로 들어옴(스키마 드리프트).
해결: 양쪽을 모두 허용하는 가드로 호환성 복구.
const landmarks = res.landmarks ?? res.poseLandmarks ?? [];
이렇게 읽으면 구버전/신버전 모두 지원된다.
증상: 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
이렇게 하면 오버레이 좌표가 영상과 정확히 일치한다.
증상: 실제로는 얕은 스쿼트인데 max depth_ratio가 2.3 같은 비현실적인 수치로 뜸.
원인: 힙–무릎 델타와 분모(대퇴골 길이)를 다른 다리에서 가져오는 프레임이 섞여 정규화 기준이 일관되지 않음.
해결: depth.js를 수정해 좌·우 다리를 각각 독립 계산하고, 대퇴골이 더 긴 쪽 한 다리를 고른 뒤 그 다리의 힙–무릎 델타와 femur로 동일하게 정규화.
증상: 두 번째 영상 로드나 뒤로 스크럽 후 detectForVideo()가 빈 결과를 계속 반환, HUD가 0에서 멈춤.
원인: MediaPipe VIDEO 모드는 시간이 단조 증가한다는 가정이 있어서, 불연속 타임스탬프(되감기/파일 교체)에 민감함. 같은 인스턴스를 재사용하면 내부 상태가 꼬임.
해결:
파일 교체/되감기 시 기존 landmarker를 close()하고 새 인스턴스를 생성.
프레임 루프에서는 새 landmarker 준비 전까지 탐지 호출을 생략.
타임스탬프는 항상 단조 증가하도록 보정:
if (mediaS < lastMediaTime) { landmarker.reset(); resetTimestamps(); }
if (ts <= lastTs) ts = lastTs + 1;
이렇게 하면 “Packet timestamp mismatch”류 에러와 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