
내 사진 한 장으로 퍼스널 컬러 찾기 [3편] - 브라우저에서 얼굴 찾기

- 만들 내용 -

2편에서 이미지 업로드 UI를 만들고 Canvas에 그리는 것까지 했습니다. 사용자가 사진을 올리면 화면에 표시되고, "퍼스널 컬러 분석하기" 버튼도 달아 뒀죠. 다만 버튼을 눌러도 "3편에서 구현합니다"라는 알림만 뜨고 아무 일도 일어나지 않았습니다.
이번 편에서 그 버튼에 진짜 기능을 넣습니다. 사진에서 얼굴을 찾고, 볼과 이마 피부색을 뽑아내는 겁니다. "브라우저에서 얼굴 인식이 된다고?"라고 생각할 수 있는데, 됩니다. Google이 만든 MediaPipe라는 라이브러리를 쓰면 서버 없이 브라우저만으로 얼굴의 478개 지점을 잡아냅니다. 머신러닝 이론을 몰라도 괜찮고요. npm으로 설치하고 함수 몇 개 호출하면 끝입니다.
다만, MediaPipe는 내부적으로 ML 모델을 다운로드하고 로딩하는 과정이 있어서, 코드가 순서대로 한 줄씩 실행되는 게 아니라 "기다리는" 패턴이 필요합니다. 2편에서 async/await를 잠깐 써봤는데, 이번에 제대로 이해하고 넘어갑니다.
브라우저에서 쓸 수 있는 얼굴 인식 라이브러리
브라우저에서 얼굴을 인식하는 JavaScript 라이브러리는 크게 세 가지입니다. 각각 어떤 특징이 있는지 비교해 봅니다.
TensorFlow.js BlazeFace는 Google이 만든 경량 얼굴 감지 모델입니다. 모델 크기가 3MB 이하로 가볍고, 얼굴의 위치(bounding box)와 눈, 코, 입 등 6개 핵심 포인트를 반환합니다. 문제는 6개 포인트로는 볼이나 이마의 정확한 위치를 특정할 수 없다는 점입니다. 퍼스널 컬러 분석에는 피부 영역을 세밀하게 잡아야 하니 포인트 수가 부족하죠.
face-api.js는 API가 직관적입니다. detectSingleFace().withFaceLandmarks()라고 쓰면 68개 랜드마크가 나옵니다. 68개면 볼과 이마를 대략 잡을 수 있습니다. 그런데 치명적인 문제가 하나 있는데, 원본 저장소가 2020년 이후 업데이트되지 않았고, 커뮤니티 포크(vladmandic/face-api)마저 2025년 2월에 archived 처리됐습니다. 지금 시점에서 신규 프로젝트에 쓰기에는 리스크가 큽니다.
이번 프로젝트에서는 MediaPipe FaceLandmarker를 씁니다. Google이 적극적으로 유지보수하고 있고, 얼굴의 478개 3D 랜드마크를 반환합니다. 볼 중앙, 이마, 관자놀이까지 정밀하게 특정할 수 있어서 피부색 추출에 최적입니다. npm 패키지(@mediapipe/tasks-vision)로 제공되니 Vite 프로젝트에 바로 연결할 수 있습니다.
| 라이브러리 | 랜드마크 수 | 유지보수 | 퍼스널 컬러 적합성 |
|---|---|---|---|
| TensorFlow.js BlazeFace | 6개 | 활발 | 부족 (피부 영역 특정 불가) |
| face-api.js | 68개 | 중단 (2025.02 archived) | 가능하나 리스크 |
| MediaPipe FaceLandmarker | 478개 | 활발 (Google 공식) | 최적 |
async/await: "기다리는" 코드 작성법
MediaPipe 코드를 작성하기 전에 async/await를 제대로 이해하고 넘어가겠습니다. 2편에서 showPreview 함수에 async가 붙어 있었고, 그 안에서 await readFileAsDataURL(file)이라고 썼습니다. "Promise가 완료될 때까지 기다린다"라고 간단히 넘어갔는데, 이번에 원리를 제대로 짚어 봅니다.
왜 필요한가
JavaScript는 기본적으로 코드를 위에서 아래로 한 줄씩 실행합니다. 1편과 2편에서 작성한 대부분의 코드가 그랬죠.
const a = 1 + 2 // 즉시 실행, a = 3 const b = a * 10 // 즉시 실행, b = 30 console.log(b) // 즉시 실행, 30 출력
그런데 어떤 작업은 시간이 걸립니다. 파일을 읽거나, 네트워크에서 데이터를 받거나, ML 모델을 로딩하는 일이 그렇습니다. MediaPipe의 얼굴 인식 모델은 약 5MB 크기인데, 이걸 다운로드하고 메모리에 올리는 데 몇 초가 걸립니다.
만약 JavaScript가 이 작업이 끝날 때까지 멈추고 기다린다면, 그동안 화면이 완전히 멈춰버립니다. 스크롤도 안 되고, 버튼도 안 눌리죠. 사용자 입장에서는 브라우저가 죽은 것처럼 보입니다.
그래서 JavaScript는 시간이 걸리는 작업을 "백그라운드에서 돌리고, 끝나면 알려줘" 방식으로 처리하는데, 이게 비동기 처리입니다.
Promise 복습
2편에서 Promise를 처음 봤습니다. imageLoader.js의 readFileAsDataURL 함수가 Promise를 반환했죠.
export function readFileAsDataURL(file) { return new Promise((resolve, reject) => { const reader = new FileReader() reader.addEventListener('load', (event) => { resolve(event.target.result) // 성공: 결과 전달 }) reader.addEventListener('error', () => { reject(new Error('파일을 읽는 중 오류가 발생했습니다.')) // 실패: 에러 전달 }) reader.readAsDataURL(file) }) }
Promise는 "나중에 결과를 돌려주겠다"는 약속을 담은 객체입니다. resolve가 호출되면 약속이 지켜진 것이고, reject가 호출되면 약속이 깨진 것입니다.
async 함수
async 키워드를 함수 앞에 붙이면 그 함수는 "비동기 함수"가 됩니다.
async function loadData() { // 이 함수 안에서 await를 사용할 수 있습니다 }
async 함수는 항상 Promise를 반환합니다. 함수 안에서 값을 return하면 그 값이 Promise에 감싸져서 나옵니다. 하지만 이 세부 사항은 몰라도 됩니다. 중요한 건 "이 함수 안에서 await를 쓸 수 있다"는 점입니다.
await 키워드
await는 Promise가 완료될 때까지 기다린 뒤 그 결과를 반환합니다. 반드시 async 함수 안에서만 사용할 수 있습니다.
async function processImage(file) { // readFileAsDataURL은 Promise를 반환합니다 // await가 Promise 완료를 기다린 뒤 결과를 dataUrl에 넣습니다 const dataUrl = await readFileAsDataURL(file) // dataUrl을 바로 사용할 수 있습니다 console.log(dataUrl) }
await 없이 같은 코드를 쓰면 이렇게 됩니다.
function processImage(file) { const result = readFileAsDataURL(file) console.log(result) // Promise 객체가 출력됨 (데이터가 아님!) }
await 없이 호출하면 Promise 객체 자체가 반환됩니다. 파일 읽기가 아직 끝나지 않았으니 결과가 아니라 "결과를 줄게"라는 약속만 받은 겁니다.
2편의 showPreview 함수를 다시 보겠습니다.
export async function showPreview(file, elements) { const dataUrl = await readFileAsDataURL(file) // 1. 파일 읽기 완료까지 대기 const img = await loadImage(dataUrl) // 2. 이미지 로딩 완료까지 대기 drawImageToCanvas(canvas, img) // 3. 캔버스에 그리기 (즉시 실행) }
await가 있으니 1번이 끝나야 2번이 시작되고, 2번이 끝나야 3번이 실행됩니다. 위에서 아래로 순서대로 읽히니까 직관적입니다. await의 진짜 장점은 비동기 코드를 동기 코드처럼 읽을 수 있다는 점입니다.
try/catch로 에러 잡기
await로 기다리던 Promise가 실패하면(reject되면) 에러가 발생합니다. try/catch로 잡으면 됩니다.
async function safeProcess(file) { try { const dataUrl = await readFileAsDataURL(file) const img = await loadImage(dataUrl) console.log('성공:', img.width, 'x', img.height) } catch (error) { console.log('실패:', error.message) } }
try 블록 안에서 에러가 발생하면 catch 블록으로 넘어갑니다. 2편에서 showPreview를 try/catch로 감싼 것도 같은 이유입니다. 파일 읽기나 이미지 로딩이 실패했을 때 사용자에게 알려주려고요.
MediaPipe에서는 ML 모델 다운로드, 초기화, 얼굴 감지 등 여러 단계에서 실패할 수 있습니다. 네트워크가 느리면 모델 다운로드가 실패하고, 이미지에 얼굴이 없으면 감지가 실패합니다. try/catch가 이런 상황을 처리합니다.
MediaPipe FaceLandmarker 설치와 초기화
패키지 설치
프로젝트 폴더에서 다음 명령어로 MediaPipe Vision 패키지를 설치합니다.
npm install @mediapipe/tasks-vision
이번에는 -D 플래그를 붙이지 않습니다. 2편에서 Tailwind CSS를 설치할 때는 -D를 붙였는데, Tailwind는 빌드 시점에만 쓰는 도구였거든요. MediaPipe는 앱이 실행될 때도 필요하니까 일반 의존성으로 설치합니다.
얼굴 인식 모듈 만들기
src/utils/faceDetector.js 파일을 새로 만듭니다.
// src/utils/faceDetector.js import { FilesetResolver, FaceLandmarker } from '@mediapipe/tasks-vision' let faceLandmarker = null /** * MediaPipe FaceLandmarker를 초기화합니다. * ML 모델을 다운로드하고 메모리에 로딩합니다. * 처음 한 번만 호출하면 됩니다. */ export async function initFaceLandmarker() { const vision = await FilesetResolver.forVisionTasks( "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.32/wasm" ) faceLandmarker = await FaceLandmarker.createFromOptions(vision, { baseOptions: { modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task", delegate: "GPU" }, runningMode: "IMAGE", numFaces: 1 }) return faceLandmarker } /** * FaceLandmarker가 초기화되었는지 확인합니다. */ export function isReady() { return faceLandmarker !== null } /** * 이미지에서 얼굴 랜드마크를 감지합니다. * 반환값: 478개 랜드마크 배열 또는 null (얼굴을 못 찾은 경우) */ export function detectFace(imageSource) { if (!faceLandmarker) { throw new Error('FaceLandmarker가 초기화되지 않았습니다.') } const result = faceLandmarker.detect(imageSource) if (!result.faceLandmarks || result.faceLandmarks.length === 0) { return null } return result.faceLandmarks[0] }
코드를 하나씩 뜯어보겠습니다.
맨 위에서 @mediapipe/tasks-vision 패키지에서 FilesetResolver와 FaceLandmarker 두 가지를 가져옵니다. FilesetResolver는 MediaPipe가 내부적으로 사용하는 WebAssembly(WASM) 런타임을 로딩하는 도구입니다. FaceLandmarker가 실제로 얼굴 인식을 하는 클래스고요.
let faceLandmarker = null이 모듈 수준 변수입니다. 이 변수는 faceDetector.js 파일 안에서만 접근 가능합니다. export를 붙이지 않았으니 외부에서 직접 읽거나 바꿀 수 없죠. initFaceLandmarker가 초기화한 뒤 이 변수에 저장해두고, detectFace와 isReady가 이 변수를 참조합니다.
initFaceLandmarker 함수가 두 단계로 초기화합니다.
첫 번째 await에서 FilesetResolver.forVisionTasks를 호출합니다. 이 함수는 MediaPipe의 WASM 런타임 파일을 CDN에서 다운로드합니다. WASM은 브라우저에서 고성능 연산을 수행하기 위한 기술인데, MediaPipe의 ML 추론 엔진이 WASM으로 만들어져 있거든요. CDN URL을 넘기면 필요한 파일을 알아서 가져옵니다.
두 번째 await에서 FaceLandmarker.createFromOptions를 호출합니다. 이때 ML 모델 파일(약 5MB)이 Google 스토리지에서 다운로드됩니다. modelAssetPath가 그 URL입니다. delegate: "GPU"는 브라우저의 WebGL을 이용해 GPU에서 추론을 수행하라는 설정인데, GPU가 없는 환경에서는 자동으로 CPU로 전환됩니다. runningMode: "IMAGE"는 정지 이미지를 분석하겠다는 뜻이고, numFaces: 1은 얼굴 하나만 찾겠다는 뜻입니다.
CDN URL에서 WASM 런타임 버전을 @0.10.32로 고정한 것에 주목합니다. @latest를 쓰면 항상 최신 버전을 가져오니 편하지만, MediaPipe가 메이저 업데이트를 했을 때 npm 패키지와 CDN의 WASM 런타임 사이에 호환성이 깨질 수 있습니다. 튜토리얼 재현을 위해 버전을 고정하는 게 안전합니다. ML 모델 URL은 Google이 관리하는 경로라 하위 호환성이 유지되니 latest를 그대로 씁니다.
한 가지 더 알아둘 점이 있습니다. 현재 구조에서는 WASM 런타임과 ML 모델을 외부 CDN에서 다운로드하므로 인터넷 연결이 필수입니다. WASM 파일과 모델을 프로젝트에 직접 포함하면 오프라인에서도 동작하지만, 번들 크기가 크게 증가하므로 이 튜토리얼에서는 CDN 방식을 사용합니다.
두 번의 await가 있으니 이 함수는 시간이 걸립니다. 네트워크 속도에 따라 2~5초 정도 걸립니다. 한번 초기화하면 이후 detectFace 호출은 수십 밀리초 안에 끝납니다.
detectFace 함수에는 async가 없습니다. faceLandmarker의 detect 메서드가 동기로 실행되거든요. 모델이 이미 메모리에 올라가 있으니 기다릴 게 없습니다. detect에 이미지 소스(Canvas 요소, Image 요소 등)를 넘기면 결과가 바로 나옵니다. 얼굴을 찾으면 478개 랜드마크 배열을, 못 찾으면 null을 반환합니다.
main.js에서 초기화 시작하기
MediaPipe 초기화는 앱이 시작될 때 바로 시작해야 합니다. 사용자가 사진을 올리고 "분석하기" 버튼을 누르는 시점에 초기화를 시작하면, 모델 다운로드 때문에 몇 초를 기다려야 합니다. 앱이 로드되자마자 백그라운드에서 초기화해두면, 사용자가 사진을 고르는 동안 모델이 준비됩니다.
src/main.js를 다음과 같이 수정합니다.
// src/main.js import './style.css' import renderApp from './components/app.js' import { initFaceLandmarker } from './utils/faceDetector.js' const appContainer = document.querySelector('#app') renderApp(appContainer) // MediaPipe 얼굴 인식 모델을 백그라운드에서 미리 로드 initFaceLandmarker() .then(() => console.log('FaceLandmarker 준비 완료')) .catch((error) => console.error('FaceLandmarker 초기화 실패:', error))
initFaceLandmarker()를 호출하되 await를 붙이지 않았습니다. await 없이 호출하면 초기화가 백그라운드에서 진행되고, 그동안 화면은 정상적으로 렌더링됩니다. .then()과 .catch()는 Promise의 또 다른 사용법입니다. await와 try/catch 대신 쓸 수 있는 방식인데, 여기서는 결과를 기다릴 필요 없이 콘솔에 로그만 남기면 되니 이 방식이 깔끔합니다.
478개 랜드마크 이해하기
MediaPipe FaceLandmarker는 얼굴에서 478개 지점의 좌표를 반환합니다. 이 지점들을 "랜드마크"라고 합니다.
얼굴 메시란
478개 랜드마크를 선으로 연결하면 얼굴 형태의 그물망(메시)이 됩니다. 3D 게임에서 캐릭터 얼굴을 표현할 때 쓰는 폴리곤 메시와 같은 원리죠. 각 랜드마크에는 0번부터 477번까지 고유 번호가 있고, 항상 같은 얼굴 부위를 가리킵니다. 예를 들어 0번은 입술 윗부분, 1번은 코 끝 바로 아래, 10번은 이마 중앙 상단입니다. 어떤 사진을 넣든 10번 랜드마크는 항상 이마 부근을 가리킵니다.
478개 중 0~467번(468개)이 얼굴 표면의 메시 포인트이고, 468~477번(10개)은 눈동자 위치를 잡는 홍채 랜드마크입니다. 이번 프로젝트에서는 얼굴 표면 랜드마크만 쓰면 됩니다.
좌표 체계
각 랜드마크는 x, y, z 세 개의 좌표를 가지고 있습니다.
// detectFace가 반환하는 랜드마크 하나의 형태 { x: 0.547, y: 0.312, z: -0.028 }
x와 y는 0에서 1 사이의 정규화된 값입니다. x가 0이면 이미지의 왼쪽 끝을, 1이면 오른쪽 끝을 의미합니다. y가 0이면 위쪽, 1이면 아래쪽이고요. z는 깊이(앞뒤)인데, 이번 프로젝트에서는 쓰지 않습니다.
정규화된 좌표를 실제 픽셀 좌표로 바꾸려면 이미지 크기를 곱하면 됩니다.
const pixelX = Math.round(landmark.x * canvas.width) const pixelY = Math.round(landmark.y * canvas.height)
Math.round로 반올림하는 이유는 픽셀 좌표가 정수여야 하기 때문입니다. 이미지의 (123.7, 456.2) 위치에는 픽셀이 없으니까요.
피부 영역 랜드마크
478개 랜드마크 중에서 피부색 추출에 적합한 영역은 볼과 이마입니다. 이 부위를 선택한 이유가 있습니다.
볼은 얼굴에서 가장 넓고 평평한 피부 영역이라 그림자가 비교적 덜 지고, 화장을 하더라도 자연스러운 피부색이 드러납니다. 이마도 피부 면적이 넓어서 피부 톤을 파악하기 좋고요.
반면 코와 입 주변은 그림자가 많이 지고, 눈 주위는 색소 침착이 있을 수 있어서 피부 본연의 색을 추출하기 어렵습니다.
MediaPipe 공식 문서의 얼굴 메시 인덱스 맵을 참고해서, 볼과 이마에 해당하는 랜드마크 번호를 선별했습니다.
// 피부색 추출에 사용할 랜드마크 인덱스 const SKIN_LANDMARKS = { leftCheek: [93, 132, 58, 172, 136, 150, 149, 176, 148], rightCheek: [323, 361, 288, 397, 365, 379, 378, 400, 377], forehead: [10, 67, 69, 104, 108, 109, 151, 338, 299, 297] }
왼쪽 볼 9개, 오른쪽 볼 9개, 이마 10개로 총 28개 랜드마크입니다. 양쪽 볼을 모두 사용하는 이유는 조명 때문입니다. 한쪽에만 조명이 비치면 반대편 볼은 어둡게 나옵니다. 양쪽 모두에서 픽셀을 추출하고 평균을 내면 조명 편향이 줄어듭니다.
Canvas에서 픽셀 추출하기
랜드마크 좌표를 알면 그 위치의 픽셀 색상을 읽을 수 있습니다. 2편에서 이미지를 Canvas에 그려뒀으니, Canvas API의 getImageData를 사용합니다.
getImageData 사용법
getImageData는 캔버스의 특정 영역에 있는 모든 픽셀의 색상 데이터를 반환합니다.
const ctx = canvas.getContext('2d') const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) const data = imageData.data // Uint8ClampedArray
data는 Uint8ClampedArray라는 타입의 배열입니다. 이름이 복잡해 보이지만, 0에서 255 사이의 정수만 담을 수 있는 배열이라는 뜻입니다. 이 배열에는 모든 픽셀의 R, G, B, A 값이 순서대로 들어 있습니다.
[R, G, B, A, R, G, B, A, R, G, B, A, ...] ← 픽셀 0 → ← 픽셀 1 → ← 픽셀 2 →
첫 번째 픽셀(왼쪽 위 모서리)의 R은 data[0], G는 data[1], B는 data[2], A(투명도)는 data[3]입니다. 두 번째 픽셀의 R은 data[4]부터 시작합니다. 한 픽셀당 4개의 값을 차지하니까요.
특정 좌표 (x, y)의 픽셀 색상을 읽으려면 이렇게 계산합니다.
const offset = (y * canvas.width + x) * 4 const r = data[offset] const g = data[offset + 1] const b = data[offset + 2]
y * canvas.width + x는 2차원 좌표를 1차원 배열 인덱스로 변환하는 공식입니다. y행의 x번째 픽셀이 배열에서 몇 번째인지를 계산하는 거죠. 여기에 4를 곱하면 RGBA 값의 시작 위치가 됩니다.
왜 전체 이미지를 한 번에 읽는가
랜드마크 28개 각각에서 getImageData를 호출하는 방법도 있지만, 전체 이미지 데이터를 한 번에 가져와서 배열에서 읽는 게 훨씬 빠릅니다. getImageData 호출 자체에 오버헤드가 있거든요. 전체 이미지를 한 번 읽고, 필요한 좌표의 값을 배열 인덱스로 접근하면 매우 빠릅니다.
5x5 샘플링
랜드마크 하나는 점 하나의 좌표입니다. 점 하나의 픽셀만 읽으면 노이즈에 취약합니다. 카메라 센서 노이즈나 JPEG 압축 아티팩트 때문에 한 픽셀의 색상이 주변과 미세하게 다를 수 있습니다.
그래서 랜드마크를 중심으로 5x5 영역(25개 픽셀)의 색상을 읽고 평균을 냅니다. 28개 랜드마크 x 25개 픽셀 = 700개 픽셀을 샘플링하는 셈입니다. 700개면 개별 픽셀의 노이즈가 평균에 미치는 영향이 작아집니다.
여기서 한 가지 짚고 넘어갈 점이 있습니다. 2편에서 drawImageToCanvas 함수가 이미지를 캔버스 크기에 맞게 축소했었죠. 4000x3000 원본 사진이 640x480 정도로 줄어들 수 있습니다. 이 상태에서 5x5 픽셀 샘플링은 축소된 캔버스 기준이므로, 원본 이미지 기준으로는 더 넓은 영역에 해당합니다. 피부색 평균을 구하는 목적에는 오히려 유리합니다. 더 넓은 영역의 색상이 자연스럽게 평균에 반영되니까요. 다만 정밀한 픽셀 단위 분석이 필요한 경우에는 원본 크기의 오프스크린 캔버스를 별도로 만들어야 합니다. 이 프로젝트에서는 피부색 평균이 목적이므로 축소된 캔버스를 그대로 사용합니다.
피부 분석 모듈 만들기
src/utils/skinAnalyzer.js 파일을 새로 만듭니다.
// src/utils/skinAnalyzer.js /** * 피부색 추출에 사용할 랜드마크 인덱스 * MediaPipe 얼굴 메시 기준, 볼과 이마의 평평한 피부 영역 */ export const SKIN_LANDMARKS = { leftCheek: [93, 132, 58, 172, 136, 150, 149, 176, 148], rightCheek: [323, 361, 288, 397, 365, 379, 378, 400, 377], forehead: [10, 67, 69, 104, 108, 109, 151, 338, 299, 297] } /** * Canvas에서 랜드마크 위치의 피부 픽셀을 추출합니다. * 각 랜드마크 주변 5x5 영역을 샘플링합니다. * 반환값: [{ r, g, b }, ...] 배열 */ export function extractSkinPixels(canvas, landmarks) { const ctx = canvas.getContext('2d') const width = canvas.width const height = canvas.height // 전체 이미지 데이터를 한 번에 읽기 const imageData = ctx.getImageData(0, 0, width, height) const data = imageData.data const pixels = [] const allIndices = [ ...SKIN_LANDMARKS.leftCheek, ...SKIN_LANDMARKS.rightCheek, ...SKIN_LANDMARKS.forehead ] for (const index of allIndices) { const landmark = landmarks[index] const cx = Math.round(landmark.x * width) const cy = Math.round(landmark.y * height) // 랜드마크 중심으로 5x5 영역 샘플링 const half = 2 for (let dy = -half; dy <= half; dy++) { for (let dx = -half; dx <= half; dx++) { const x = Math.min(Math.max(cx + dx, 0), width - 1) const y = Math.min(Math.max(cy + dy, 0), height - 1) const offset = (y * width + x) * 4 pixels.push({ r: data[offset], g: data[offset + 1], b: data[offset + 2] }) } } } return pixels } /** * 픽셀 배열의 평균 RGB 색상을 계산합니다. * 반환값: { r, g, b } */ export function calculateAverageColor(pixels) { const sum = { r: 0, g: 0, b: 0 } for (const pixel of pixels) { sum.r += pixel.r sum.g += pixel.g sum.b += pixel.b } const count = pixels.length return { r: Math.round(sum.r / count), g: Math.round(sum.g / count), b: Math.round(sum.b / count) } } /** * Canvas에 랜드마크를 시각적으로 표시합니다. (디버깅 및 시각적 피드백용) */ export function drawLandmarks(canvas, landmarks, indices) { const ctx = canvas.getContext('2d') const width = canvas.width const height = canvas.height ctx.fillStyle = '#8b5cf6' for (const index of indices) { const landmark = landmarks[index] const x = landmark.x * width const y = landmark.y * height ctx.beginPath() ctx.arc(x, y, 3, 0, Math.PI * 2) ctx.fill() } }
extractSkinPixels 함수를 단계별로 살펴보겠습니다.
먼저 ctx.getImageData(0, 0, width, height)로 캔버스 전체의 픽셀 데이터를 한 번에 읽어옵니다. 이후 반복문에서는 이 배열에서 값을 읽기만 하니 추가 비용이 거의 없습니다.
allIndices 배열은 스프레드 연산자(...)로 세 영역의 인덱스를 하나로 합칩니다. [...SKIN_LANDMARKS.leftCheek, ...SKIN_LANDMARKS.rightCheek, ...SKIN_LANDMARKS.forehead]는 세 배열의 원소를 모두 펼쳐서 새 배열로 만드는 문법이죠.
각 랜드마크에 대해 정규화 좌표를 픽셀 좌표로 변환한 뒤, 중심을 기준으로 -2에서 +2까지 이동하면서 5x5 영역의 픽셀을 읽습니다. Math.min(Math.max(cx + dx, 0), width - 1)은 캔버스 경계 밖으로 나가지 않도록 좌표를 클램핑합니다. 이마 상단의 랜드마크는 이미지 가장자리에 가까울 수 있거든요.
drawLandmarks 함수는 분석에 직접 필요하지는 않지만, 디버깅과 사용자 피드백에 유용합니다. 어떤 지점에서 피부색을 추출했는지 캔버스 위에 보라색 점으로 표시합니다. ctx.arc(x, y, 3, 0, Math.PI * 2)는 반지름 3픽셀의 원을 그리는 Canvas API 호출입니다.
한 가지 주의할 점이 있습니다. drawLandmarks를 호출하면 캔버스 위에 점이 그려지니, 이 이후에 getImageData를 호출하면 점의 색상도 포함됩니다. 그래서 반드시 피부 픽셀을 추출한 뒤에 랜드마크를 그려야 합니다. 순서가 중요합니다.
모든 것을 연결하기
새로 만든 모듈들을 기존 코드에 연결합니다.
previewUI.js 수정
2편에서 분석 버튼 아래에 넣어둔 "3편에서 구현 예정" 텍스트를 제거합니다. src/components/previewUI.js를 열고, createPreviewUI 함수 안의 HTML 문자열에서 해당 <p> 태그를 찾아 삭제합니다.
수정 전 코드입니다.
<button id="analyze-btn" class="w-full py-3 bg-purple-500 text-white rounded-xl font-medium hover:bg-purple-600 transition-colors"> 퍼스널 컬러 분석하기 </button> <p class="text-xs text-gray-400 mt-2">3편에서 구현 예정</p>
수정 후 코드입니다. '3편에서 구현 예정'이라고 적어두었던 <p> 태그 한 줄만 삭제합니다.
<button id="analyze-btn" class="w-full py-3 bg-purple-500 text-white rounded-xl font-medium hover:bg-purple-600 transition-colors"> 퍼스널 컬러 분석하기 </button>
나머지 코드는 그대로 유지합니다. createPreviewUI와 showPreview 함수는 이 한 줄 외에 변경할 부분이 없습니다.
app.js 수정: 분석 기능 연결
src/components/app.js를 수정합니다. 2편 대비 달라진 부분은 import 추가와 분석 버튼 핸들러, 그리고 결과 표시 영역 추가입니다.
// src/components/app.js import createUploadUI from './uploadUI.js' import createPreviewUI, { showPreview } from './previewUI.js' import { detectFace, isReady } from '../utils/faceDetector.js' import { extractSkinPixels, calculateAverageColor, drawLandmarks, SKIN_LANDMARKS } from '../utils/skinAnalyzer.js' import { rgbToHex } from '../utils/colors.js' export default function renderApp(container) { container.innerHTML = ` <div class="min-h-screen bg-gradient-to-b from-purple-50 to-pink-50"> <header class="py-6 sm:py-8"> <div class="max-w-2xl mx-auto px-4 text-center"> <h1 class="text-2xl sm:text-3xl font-bold text-gray-800"> 퍼스널 컬러 분석기 </h1> <p class="mt-2 text-sm sm:text-base text-gray-500"> 사진 한 장으로 나에게 어울리는 색을 찾아보세요 </p> </div> </header> <main class="max-w-2xl mx-auto px-4 pb-16"> <div id="upload-area" class="bg-white rounded-2xl shadow-md p-4 sm:p-6"> </div> <div id="preview-area" class="mt-6 hidden"> </div> <div id="result-area" class="mt-6 hidden"> </div> </main> </div> ` const uploadArea = container.querySelector('#upload-area') const previewArea = container.querySelector('#preview-area') const resultArea = container.querySelector('#result-area') let previewElements = null createUploadUI(uploadArea, async (file) => { if (!previewElements) { previewElements = createPreviewUI(previewArea) // 다시 선택 버튼 previewElements.resetBtn.addEventListener('click', () => { previewArea.classList.add('hidden') resultArea.classList.add('hidden') resultArea.innerHTML = '' uploadArea.classList.remove('hidden') previewElements = null previewArea.innerHTML = '' }) // 분석 버튼 previewElements.analyzeBtn.addEventListener('click', async () => { const analyzeBtn = previewElements.analyzeBtn if (!isReady()) { alert('얼굴 인식 모델을 불러오는 중입니다. 잠시 후 다시 시도해 주세요.') return } try { analyzeBtn.disabled = true analyzeBtn.textContent = '분석 중...' const canvas = previewElements.canvas // 1. 얼굴 랜드마크 감지 const landmarks = detectFace(canvas) if (!landmarks) { alert('얼굴을 찾을 수 없습니다. 얼굴이 잘 보이는 사진을 선택해 주세요.') return } // 2. 피부 픽셀 추출 (랜드마크 그리기 전에!) const skinPixels = extractSkinPixels(canvas, landmarks) const avgColor = calculateAverageColor(skinPixels) const hex = rgbToHex(avgColor.r, avgColor.g, avgColor.b) // 3. 랜드마크 시각화 (추출 이후에 그리기) const allIndices = [ ...SKIN_LANDMARKS.leftCheek, ...SKIN_LANDMARKS.rightCheek, ...SKIN_LANDMARKS.forehead ] drawLandmarks(canvas, landmarks, allIndices) // 4. 결과 표시 resultArea.classList.remove('hidden') resultArea.innerHTML = ` <div class="bg-white rounded-2xl shadow-md p-4 sm:p-6"> <h2 class="text-lg font-semibold text-gray-700 mb-4"> 피부색 추출 결과 </h2> <div class="flex items-center gap-4"> <div class="w-20 h-20 rounded-xl shadow-inner border border-gray-100" style="background-color: ${hex}"></div> <div> <p class="text-sm text-gray-600">평균 피부색: ${hex}</p> <p class="text-sm text-gray-600"> RGB(${avgColor.r}, ${avgColor.g}, ${avgColor.b}) </p> <p class="text-sm text-gray-500 mt-1"> 추출 픽셀: ${skinPixels.length}개 </p> </div> </div> <p class="text-xs text-gray-400 mt-4"> 4편에서 이 색상 데이터로 퍼스널 컬러를 판별합니다 </p> </div> ` } catch (error) { console.error('분석 오류:', error) alert('분석 중 오류가 발생했습니다. 다른 사진을 시도해 주세요.') } finally { analyzeBtn.disabled = false analyzeBtn.textContent = '퍼스널 컬러 분석하기' } }) } // 업로드 영역 숨기고 미리보기 표시 uploadArea.classList.add('hidden') previewArea.classList.remove('hidden') resultArea.classList.add('hidden') resultArea.innerHTML = '' // 이미지 미리보기 표시 try { await showPreview(file, previewElements) } catch (error) { alert('이미지를 불러올 수 없습니다. 다른 파일을 선택해 주세요.') previewArea.classList.add('hidden') uploadArea.classList.remove('hidden') previewElements = null previewArea.innerHTML = '' } }) }
2편과 달라진 부분을 짚어 봅니다.
import 구문이 세 줄 추가됐습니다. faceDetector.js에서 detectFace와 isReady를, skinAnalyzer.js에서 extractSkinPixels, calculateAverageColor, drawLandmarks, SKIN_LANDMARKS를, 1편에서 만들었던 colors.js에서 rgbToHex를 가져옵니다. colors.js는 2편에서 import를 지웠었는데, 3편에서 다시 필요해져서 가져온 겁니다.
HTML 구조에 id="result-area" 영역이 추가됐습니다. 분석 결과를 표시할 공간입니다. hidden 클래스가 붙어 있으니 분석이 완료되기 전에는 보이지 않습니다.
분석 버튼의 이벤트 핸들러가 핵심입니다. 2편에서는 alert만 띄웠는데, 이번에는 실제 분석을 수행합니다. 흐름을 따라가 봅니다.
isReady()로 MediaPipe가 초기화됐는지 먼저 확인합니다. 앱 시작과 동시에 백그라운드로 초기화가 시작되지만, 사용자가 매우 빠르게 사진을 올리고 버튼을 누르면 아직 준비가 안 됐을 수 있습니다. 그때는 안내 메시지를 띄우고 넘어갑니다.
detectFace(canvas)에 캔버스 요소를 직접 전달합니다. MediaPipe의 detect 메서드는 HTMLCanvasElement를 받을 수 있습니다. 캔버스에 그려진 이미지를 분석해서 랜드마크를 돌려주죠. 반환값이 null이면 얼굴을 못 찾은 겁니다. 풍경 사진이나 얼굴이 너무 작은 사진에서 이런 일이 생깁니다.
코드 실행 순서에 특히 주의해야 합니다. extractSkinPixels를 먼저 호출하고, drawLandmarks를 나중에 호출합니다. 앞서 설명한 대로, 랜드마크 점을 먼저 그리면 그 점의 보라색이 피부 픽셀에 섞이기 때문입니다.
finally 블록에서 버튼 상태를 원래대로 되돌립니다. try 안에서 성공하든 catch에서 에러를 잡든, finally는 항상 실행됩니다. 여기서 중요한 점이 하나 있습니다. try 블록 안에서 return으로 함수를 빠져나가더라도 finally는 실행됩니다. 위 코드에서 얼굴을 찾지 못해 alert 후 return하는 분기가 있는데, 이때도 finally가 실행되어 버튼 상태가 정상으로 돌아옵니다. finally가 보장하는 "항상"은 어떤 상황에서도 반드시 실행된다는 의미입니다. 분석 중에는 버튼 텍스트를 "분석 중..."으로 바꾸고 disabled로 비활성화했다가, 끝나면 원래 텍스트로 복구합니다.
완성된 프로젝트 구조
3편을 마친 프로젝트의 파일 구조입니다.
personal-color/ ├── index.html ├── vite.config.js ├── package.json ├── src/ │ ├── main.js <- 진입점 (수정: MediaPipe 초기화 추가) │ ├── style.css <- Tailwind CSS (변경 없음) │ ├── components/ │ │ ├── app.js <- 메인 앱 (수정: 분석 로직 추가) │ │ ├── uploadUI.js <- 업로드 UI (변경 없음) │ │ └── previewUI.js <- 미리보기 UI (수정: 안내 텍스트 제거) │ ├── utils/ │ │ ├── colors.js <- 색상 유틸리티 (변경 없음) │ │ ├── imageLoader.js <- 이미지 로딩 (변경 없음) │ │ ├── faceDetector.js <- [새로 추가] 얼굴 랜드마크 감지 │ │ └── skinAnalyzer.js <- [새로 추가] 피부 픽셀 추출 │ └── styles/ └── public/
2편에서 7개였던 소스 파일이 9개로 늘었습니다. 새로 추가된 두 파일은 모두 utils/ 폴더에 들어갔습니다. 화면과 직접 관계없는 분석 로직이니 utils/가 맞습니다. 1편에서 정한 "화면은 components, 로직은 utils" 규칙을 그대로 따르고 있습니다.
모듈 간의 의존 관계를 보겠습니다.
src/main.js ├── src/components/app.js │ ├── src/components/uploadUI.js │ ├── src/components/previewUI.js │ │ └── src/utils/imageLoader.js │ ├── src/utils/faceDetector.js [3편 추가] │ ├── src/utils/skinAnalyzer.js [3편 추가] │ └── src/utils/colors.js └── src/utils/faceDetector.js (초기화)
faceDetector.js가 두 곳에서 import됩니다. main.js에서 초기화용으로, app.js에서 감지용으로. 같은 모듈을 여러 곳에서 import해도 문제가 없습니다. ES 모듈은 한 번만 실행되니 let faceLandmarker = null도 하나만 존재합니다. main.js에서 초기화하면 app.js에서 isReady()를 호출했을 때 같은 변수를 참조합니다.
눈치가 빠른 분이라면 app.js가 꽤 길어졌다는 걸 알아채셨을 겁니다. 2편에서 약 70줄이던 코드가 130줄 가까이 늘었습니다. 분석 로직 호출, 결과 HTML 생성이 이벤트 핸들러 안에 모여 있기 때문입니다. faceDetector.js와 skinAnalyzer.js를 utils/에 분리한 것처럼, 결과 표시 부분도 별도 컴포넌트로 분리하는 것이 이상적입니다. 4편에서 퍼스널 컬러 판별 로직까지 추가되면 app.js가 더 커질 수 있으니, 그때 결과 표시 영역을 resultUI.js로 분리하겠습니다. 코드가 커지면 나누는 것, 이것이 모듈화의 기본 감각입니다.
동작 확인
개발 서버를 실행해서 결과를 확인합니다.
npm run dev
브라우저를 열면 기존과 같은 업로드 화면이 보입니다. 브라우저의 개발자 도구(F12) 콘솔을 열어두면, 몇 초 후에 "FaceLandmarker 준비 완료" 메시지가 나옵니다. 이 메시지가 보이면 MediaPipe 모델이 준비된 겁니다.
얼굴이 포함된 사진을 업로드합니다. 셀카가 가장 좋습니다. 미리보기 화면이 나타나면 "퍼스널 컬러 분석하기" 버튼을 누릅니다.
버튼을 누르면 "분석 중..."으로 텍스트가 바뀌었다가, 잠시 뒤 캔버스 위에 보라색 점들이 나타납니다. 볼과 이마에 점이 찍혀 있는 게 보입니다. 이 점들이 피부색을 추출한 위치죠. 그 아래에 결과 카드가 나타나는데, 추출한 평균 피부색이 색상 사각형으로 표시되고, HEX 코드와 RGB 값, 추출된 픽셀 수(700개)가 함께 나옵니다.
문제가 생겼을 때
얼굴을 찾지 못했다는 메시지가 뜨면 몇 가지를 확인합니다. 사진에서 얼굴이 전체 화면의 20% 이상을 차지하는 게 좋습니다. 너무 멀리서 찍은 사진이나 측면 각도가 심한 사진은 감지가 안 될 수 있습니다. 정면 또는 약간 비스듬한 각도의 셀카가 가장 잘 됩니다.
콘솔에 "FaceLandmarker가 초기화되지 않았습니다"라는 에러가 뜨면, 페이지를 새로고침하고 콘솔에서 "FaceLandmarker 준비 완료" 메시지를 확인한 뒤 다시 시도합니다. 네트워크가 느리면 모델 다운로드에 시간이 더 걸립니다.
마무리: 피부색 데이터가 준비됐습니다
3편에서 한 일을 정리합니다. async/await의 원리를 이해했고, MediaPipe FaceLandmarker를 설치하고 초기화하는 방법을 배웠습니다. 478개 랜드마크에서 볼과 이마에 해당하는 28개를 선별하고, Canvas의 getImageData로 해당 위치의 픽셀 색상을 추출했습니다. 두 개의 새로운 모듈(faceDetector.js, skinAnalyzer.js)이 utils/ 폴더에 추가됐고, 분석 버튼이 실제로 동작합니다.
지금 결과 화면에 표시되는 것은 평균 피부색의 RGB 값입니다. 이 숫자만으로는 웜톤인지 쿨톤인지, 봄/여름/가을/겨울 중 어디에 속하는지 알 수 없습니다. 4편에서는 이 RGB 값을 HSL로 변환하고, 색조(Hue)와 채도(Saturation), 명도(Lightness)를 분석해서 퍼스널 컬러를 판별합니다. 1편에서 만들어둔 rgbToHsl 함수가 드디어 진짜 역할을 하게 됩니다. 완성된 사이트는 Vercel에 무료로 배포하는 것까지 해볼 예정입니다.
참고 자료






댓글
댓글을 작성하려면 이 필요합니다.