3. 웹에서도 미디어파이프를 쓸 수 있다는 사실, 알고 계신가요
2025년 9월 15일
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>
요소별 역할:
<video id="webcam">: 웹캠 스트림 표시(숨김)autoplay: 자동 재생muted: 음소거playsinline: 모바일 인라인 재생
<canvas id="maskCanvas">: 마스크 처리용(숨김)<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();
동작 흐름:
Camera가 웹캠 스트림을video요소에 연결- 매 프레임마다
onFrame호출 selfieSegmentation.send()로 현재 프레임 전송- 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 유지
핵심 개념 정리
- MediaPipe Selfie Segmentation: 실시간 사람 분할
- ImageData: 픽셀 데이터 접근
- NDC 변환: 픽셀 좌표 → 정규화 좌표
- 카메라 뷰 크기: 3D 공간에서의 실제 크기 계산
- 좌표 변환: 2D 픽셀 → 3D 월드 좌표
이를 통해, 사람 실루엣을 미디어파이프로 인식해, three.js 3d 씬 그래프에 추가(z=0) -> ndc 및 카메라 뷰 비율에 맞게 좌표 변환 -> 2d canvas에 그 좌표대로 그림(4픽셀마다) 을 구현하였습니다.
이후에는, 사람이 인식되면 실루엣 파티클이 인식되고 바로 확대가 시작되는 시스템과(단순하게 canvas좌표를 땡기거나 카메라 좌표를 앞으로 보내면 될듯) 사람 실루엣이 화면을 가득 차게 되는, 즉 더이상의 확대가 무의미 하게 될 때는 터지는 효과가 나타나는 것 까지 구현할 예정입니다.