logo

DowanKim

3. 웹에서도 미디어파이프를 쓸 수 있다는 사실, 알고 계신가요

2025년 9월 15일

To Infinity

MediaPipe로 실시간 실루엣 인식 및 파티클 변환

목표

웹캠으로 사람을 인식하고, 실루엣을 3D 파티클로 변환해 실시간으로 표시해야 합니다.

MediaPipe Selfie Segmentation이란?

MediaPipe Selfie Segmentation은 실시간으로 사람을 배경에서 분리하는 모델입니다.

특징:

  • 실시간 처리 (30fps 이상)
  • 클라이언트 사이드 실행
  • 높은 정확도
  • 웹에서 바로 사용 가능

출력:

  • 분할 마스크(segmentation mask): 사람 영역은 흰색(255), 배경은 검은색(0)

1단계: HTML 기본 구조 설정

필요한 DOM 요소

<video id="webcam" autoplay muted playsinline style="display:none;"></video> <canvas id="maskCanvas" style="display:none; visibility:hidden;"></canvas> <canvas id="debugCanvas" style="position:fixed; top:0; left:0; z-index:10;"></canvas>

요소별 역할:

  1. <video id="webcam">: 웹캠 스트림 표시(숨김)
    • autoplay: 자동 재생
    • muted: 음소거
    • playsinline: 모바일 인라인 재생
  2. <canvas id="maskCanvas">: 마스크 처리용(숨김)
  3. <canvas id="debugCanvas">: 디버그 시각화용(상단 고정)

외부 라이브러리 로드

<script src="https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation/selfie_segmentation.js"></script> <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js"></script>

2단계: 초기화 - DOM 요소와 파티클 시스템 준비

DOM 요소 가져오기

init() { this.video = document.getElementById('webcam'); this.maskCanvas = document.getElementById('maskCanvas'); this.debugCanvas = document.getElementById('debugCanvas'); this.maskCtx = this.maskCanvas.getContext('2d'); this.debugCtx = this.debugCanvas.getContext('2d'); // 캔버스 크기 설정 this.maskCanvas.width = 640; this.maskCanvas.height = 480; this.debugCanvas.width = 640; this.debugCanvas.height = 480; }

캔버스 크기 선택:

  • 640x480: 성능과 품질의 균형
  • 너무 크면 성능 저하, 너무 작으면 품질 저하

파티클 시스템 초기화

this.silhouetteGeometry = new THREE.BufferGeometry(); this.silhouettePositions = new Float32Array(this.particleCount * 3); // 모든 파티클을 초기 위치(0, 0, 0)로 설정 for (let i = 0; i < this.particleCount; i++) { this.silhouettePositions[i * 3] = 0; // X this.silhouettePositions[i * 3 + 1] = 0; // Y this.silhouettePositions[i * 3 + 2] = 0; // Z } this.silhouetteGeometry.setAttribute('position', new THREE.BufferAttribute(this.silhouettePositions, 3));

초기 위치를 (0, 0, 0)으로 설정한 이유:

  • 사람이 감지되기 전에는 보이지 않음
  • 감지되면 즉시 위치 업데이트

3단계: MediaPipe 초기화 및 설정

SelfieSegmentation 인스턴스 생성

setupMediaPipe() { this.selfieSegmentation = new SelfieSegmentation({ locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation/${file}` }); this.selfieSegmentation.setOptions({ modelSelection: 1 }); }

설정 설명:

  • locateFile: 모델 파일 경로 지정(CDN 사용)
  • modelSelection: 1: 일반 모델(0은 경량 모델)

결과 처리 콜백 설정

this.selfieSegmentation.onResults((results) => { // results.segmentationMask: 사람 영역 마스크 이미지 // 여기서 마스크를 처리하고 파티클로 변환 });

onResults 콜백:

  • MediaPipe가 프레임 처리 후 호출
  • results.segmentationMask: ImageData 형태의 마스크

4단계: 웹캠 스트림 연결

Camera 유틸리티 설정

this.cameraUtils = new Camera(this.video, { onFrame: async () => { await this.selfieSegmentation.send({ image: this.video }); }, width: 640, height: 480 }); this.cameraUtils.start();

동작 흐름:

  1. Camera가 웹캠 스트림을 video 요소에 연결
  2. 매 프레임마다 onFrame 호출
  3. selfieSegmentation.send()로 현재 프레임 전송
  4. MediaPipe가 처리 후 onResults 호출

5단계: 마스크를 캔버스에 그리기

마스크 이미지 그리기

this.selfieSegmentation.onResults((results) => { // 캔버스 초기화 this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); // MediaPipe 마스크를 캔버스에 그리기 this.maskCtx.drawImage( results.segmentationMask, 0, 0, this.maskCanvas.width, this.maskCanvas.height ); });

drawImage 사용 이유:

  • ImageData를 Canvas에 그려 픽셀 데이터 접근 가능

6단계: 픽셀 데이터 읽기

ImageData 추출

const imageData = this.maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); const data = imageData.data;

ImageData 구조:

  • data: Uint8ClampedArray
  • 각 픽셀은 4개 값(R, G, B, A)
  • 사람 영역: R=255, G=255, B=255, A=255
  • 배경: R=0, G=0, B=0, A=0

픽셀 샘플링

let pIndex = 0; // 파티클 인덱스 // 4픽셀 간격으로 샘플링 (성능 최적화) for (let y = 0; y < this.maskCanvas.height; y += 4) { for (let x = 0; x < this.maskCanvas.width; x += 4) { const i = (y * this.maskCanvas.width + x) * 4; // RGBA 인덱스 // 사람 영역인지 확인 (R 값이 200 이상) if (data[i] > 200 && pIndex < this.particleCount) { // 파티클 위치 계산 // ... pIndex++; } } }

4픽셀 간격 샘플링 이유:

  • 20,000개 파티클로 충분히 표현 가능
  • 성능 향상(약 16배 감소)
  • 시각적 품질 유지

7단계: 2D 좌표를 3D 좌표로 변환

NDC(Normalized Device Coordinates) 변환

// 픽셀 좌표를 -1 ~ 1 범위로 정규화 let ndcX = (x / this.maskCanvas.width) * 2 - 1; // -1 ~ 1 let ndcY = -((y / this.maskCanvas.height) * 2 - 1); // -1 ~ 1 (Y축 뒤집기)

NDC 변환:

  • 픽셀 좌표(0 ~ 640, 0 ~ 480) → NDC(-1 ~ 1, -1 ~ 1)
  • Y축 뒤집기: Canvas는 위에서 아래, 3D는 아래에서 위

카메라 뷰 크기 계산

getViewSizeAtDepth(camera, depth) { const vFOV = THREE.MathUtils.degToRad(camera.fov); // 수직 시야각 const height = 2 * Math.tan(vFOV / 2) * depth; // 깊이에서의 높이 const width = height * camera.aspect; // 가로세로 비율 return { width, height }; }

수식 설명:

  • tan(vFOV / 2) * depth: 카메라에서 깊이까지의 반 높이
  • 2 * ...: 전체 높이
  • width = height * aspect: 종횡비 유지

3D 월드 좌표로 변환

const viewSize = this.getViewSizeAtDepth(this.camera, this.camera.position.z); const scaleY = viewSize.height * 0.8; // 화면 높이의 80% const scaleX = scaleY * (this.maskCanvas.width / this.maskCanvas.height); // NDC를 3D 좌표로 변환 posAttr[pIndex * 3] = -ndcX * scaleX; // X축 (좌우 반전) posAttr[pIndex * 3 + 1] = ndcY * scaleY; // Y축 posAttr[pIndex * 3 + 2] = 0; // Z축 고정

스케일 계산:

  • scaleY = viewSize.height * 0.8: 화면 높이의 80%로 크기 조정
  • scaleX: 종횡비 유지
  • X축 반전: 웹캠 좌우 반전 보정

8단계: 파티클 위치 업데이트

사용되지 않은 파티클 처리

// 나머지 파티클은 화면 밖으로 이동 for (let i = pIndex; i < this.particleCount; i++) { posAttr[i * 3] = 10000; // 화면 밖 posAttr[i * 3 + 1] = 10000; posAttr[i * 3 + 2] = 10000; } // Three.js에 변경사항 알림 this.silhouetteGeometry.attributes.position.needsUpdate = true;

화면 밖으로 이동:

  • 사람이 작거나 일부만 보일 때 미사용 파티클 처리
  • 10000은 화면 밖 위치로 간주

문제 해결 과정

문제 1: 웹캠이 작동하지 않음

증상: 비디오 요소에 스트림이 표시되지 않음

원인:

  • HTTPS 또는 localhost가 아님
  • 브라우저 권한 미승인
  • 다른 앱이 웹캠 사용 중

해결:

// 에러 처리 추가 this.cameraUtils = new Camera(this.video, { onFrame: async () => { await this.selfieSegmentation.send({ image: this.video }); }, width: 640, height: 480 }); this.cameraUtils.start().catch((error) => { console.error('웹캠 접근 실패:', error); alert('웹캠 접근 권한이 필요합니다.'); });

문제 2: 실루엣이 거꾸로 보임

증상: 파티클 실루엣이 상하 반전

원인: Canvas Y축과 3D Y축 방향 차이

해결:

// Y축 뒤집기 let ndcY = -((y / this.maskCanvas.height) * 2 - 1);

문제 3: 실루엣이 좌우 반전됨

증상: 파티클 실루엣이 좌우 반전

원인: 웹캠 미러링과 좌표계 차이

해결:

// X축 반전 posAttr[pIndex * 3] = -ndcX * scaleX; // 음수로 반전

문제 4: 실루엣 크기가 맞지 않음

원인: 카메라 뷰 크기 계산 오류

해결 과정:

// 초기 시도: 고정 크기 const scale = 10; posAttr[pIndex * 3] = ndcX * scale; // 개선: 카메라 뷰 크기 기반 계산 const viewSize = this.getViewSizeAtDepth(this.camera, this.camera.position.z); const scaleY = viewSize.height * 0.8; // 80%로 조정

문제 5: 성능 저하

증상: 프레임레이트 저하

원인: 모든 픽셀 처리

해결:

// 1픽셀 간격 → 4픽셀 간격으로 샘플링 for (let y = 0; y < this.maskCanvas.height; y += 4) { for (let x = 0; x < this.maskCanvas.width; x += 4) { // ... } }

성능 개선:

  • 샘플링 간격 4픽셀: 약 16배 감소
  • 프레임레이트: 30fps → 60fps

문제 6: 파티클이 업데이트되지 않음

증상: 실루엣이 보이지 않음

원인: needsUpdate 플래그 누락

해결:

// 필수! this.silhouetteGeometry.attributes.position.needsUpdate = true;

문제 7: 실루엣이 깜빡임

증상: 파티클이 깜빡임

원인: MediaPipe 처리 지연

해결:

  • 비동기 처리 유지
  • await로 순차 처리
  • 프레임 스킵 없이 처리

디버그 모드 구현

디버그 캔버스로 시각화

if (this.debugMode) { this.debugCtx.clearRect(0, 0, this.debugCanvas.width, this.debugCanvas.height); this.debugCtx.fillStyle = 'red'; this.debugCtx.fillRect(x, y, 1, 1); }

디버그 모드:

  • 사람 인식 영역을 빨간 점으로 표시
  • 좌표 변환 확인
  • 샘플링 패턴 확인

최종 구현 결과

성공적으로 구현된 기능:

  • 실시간 웹캠 스트림 처리
  • MediaPipe로 사람 실루엣 인식
  • 실루엣을 3D 파티클로 변환
  • 사람 움직임에 실시간 반응
  • 60fps 유지

성능:

  • MediaPipe 처리: 약 30-40ms/프레임
  • 파티클 업데이트: 약 5-10ms/프레임
  • 전체 프레임레이트: 60fps 유지

핵심 개념 정리

  1. MediaPipe Selfie Segmentation: 실시간 사람 분할
  2. ImageData: 픽셀 데이터 접근
  3. NDC 변환: 픽셀 좌표 → 정규화 좌표
  4. 카메라 뷰 크기: 3D 공간에서의 실제 크기 계산
  5. 좌표 변환: 2D 픽셀 → 3D 월드 좌표

이를 통해, 사람 실루엣을 미디어파이프로 인식해, three.js 3d 씬 그래프에 추가(z=0) -> ndc 및 카메라 뷰 비율에 맞게 좌표 변환 -> 2d canvas에 그 좌표대로 그림(4픽셀마다) 을 구현하였습니다.

이후에는, 사람이 인식되면 실루엣 파티클이 인식되고 바로 확대가 시작되는 시스템과(단순하게 canvas좌표를 땡기거나 카메라 좌표를 앞으로 보내면 될듯) 사람 실루엣이 화면을 가득 차게 되는, 즉 더이상의 확대가 무의미 하게 될 때는 터지는 효과가 나타나는 것 까지 구현할 예정입니다.