Vite + React + TS
사이드 프로젝트로 tensorflow로 얼굴 인식 프로젝트를 진행하였습니다.
개발 전체 과정을 정리하였습니다.
사용 방법
- 웹 브라우저에서 애플리케이션이 열림
- "기준 이미지 업로드" 버튼을 클릭하여 비교할 인물의 사진을 업로드
- 카메라가 자동으로 활성화되고 얼굴 인식을 시작함
- "얼굴 비교 시작" 버튼을 클릭하여 카메라로 촬영된 얼굴과 기준 이미지의 일치도를 분석
- 화면에 얼굴 경계 상자와 일치도 퍼센트가 표시됨
- 일치도가 60% 이상이면 동일인으로 판단
- "얼굴 비교 중지" 버튼을 클릭하여 비교를 중지

1. 프로젝트 설정
Vite + React + TypeScript 프로젝트 생성
Vite로 React + TypeScript 프로젝트 생성
npm create vite@latest face-recognition-app -- --template react-ts cd face-recognition-app npm install
라이브러리 설치
face-api.js 라이브러리와 타입 선언 설치
npm install face-api.js
2. 모델 파일 준비
face-api.js 모델 파일 public 폴더에 추가
public폴더 내에models폴더 생성
- face-api.js GitHub 저장소에서 다음 모델 파일을 다운로드하여
public/models폴더에 저장
→ 아래의 curl 명령어 사용하면 한번에 설치 가능
mkdir -p public/models cd public/models curl -O https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights/face_landmark_68_model-weights_manifest.json curl -O https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights/face_landmark_68_model-shard1 curl -O https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights/face_recognition_model-weights_manifest.json curl -O https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights/face_recognition_model-shard1 curl -O https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights/face_recognition_model-shard2 curl -O https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights/ssd_mobilenetv1_model-weights_manifest.json curl -O https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights/ssd_mobilenetv1_model-shard1 curl -O https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights/ssd_mobilenetv1_model-shard2 cd ../..
3. TypeScript 타입 정의
face-api.js의 타입 정의하기 위해
src/types 폴더 생성하고 face-api.d.ts 파일 추가// src/types/face-api.d.ts declare module 'face-api.js';
4. 컴포넌트 구현
App.tsx는 아래와 같이 구성
import { useState } from 'react'; import './App.css'; import FaceRecognition from './components/FaceRecognition'; function App() { const [referenceImage, setReferenceImage] = useState<string | null>(null); const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (file) { const reader = new FileReader(); reader.onload = (e) => { if (e.target?.result) { setReferenceImage(e.target.result as string); } }; reader.readAsDataURL(file); } }; return ( <div className="App"> <header className="App-header"> <h1>얼굴 인식 및 일치도 분석</h1> <div className="upload-section"> <h2>기준 이미지 업로드</h2> <input type="file" accept="image/*" onChange={handleImageUpload} /> </div> {referenceImage && ( <div className="reference-image-container"> <h3>기준 이미지</h3> <img src={referenceImage} alt="기준 얼굴" style={{ maxWidth: '300px' }} /> </div> )} <FaceRecognition referenceImage={referenceImage} /> </header> </div> ); } export default App;
4.2 FaceRecognition 컴포넌트
src/components 폴더 생성하고 FaceRecognition.tsx 파일 생성import { useEffect, useRef, useState } from 'react'; import * as faceapi from 'face-api.js'; interface FaceRecognitionProps { referenceImage: string | null; } const FaceRecognition: React.FC<FaceRecognitionProps> = ({ referenceImage }) => { const videoRef = useRef<HTMLVideoElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null); const [modelsLoaded, setModelsLoaded] = useState<boolean>(false); const [stream, setStream] = useState<MediaStream | null>(null); const [similarity, setSimilarity] = useState<string | null>(null); const [isComparing, setIsComparing] = useState<boolean>(false); const [referenceDescriptor, setReferenceDescriptor] = useState<Float32Array | null>(null); const intervalRef = useRef<number | null>(null); // 모델 로드 useEffect(() => { const loadModels = async () => { const MODEL_URL = '/models'; try { await Promise.all([ faceapi.nets.ssdMobilenetv1.loadFromUri(MODEL_URL), faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL), faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL) ]); setModelsLoaded(true); console.log('Models loaded successfully'); } catch (error) { console.error('Error loading models:', error); } }; loadModels(); return () => { if (stream) { stream.getTracks().forEach(track => track.stop()); } if (intervalRef.current) { window.clearInterval(intervalRef.current); } }; }, []); // 카메라 접근 useEffect(() => { if (modelsLoaded) { startWebcam(); } }, [modelsLoaded]); // 기준 이미지에서 얼굴 특성 추출 useEffect(() => { if (referenceImage && modelsLoaded) { processReferenceImage(); } else { setReferenceDescriptor(null); } }, [referenceImage, modelsLoaded]); const startWebcam = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } }); if (videoRef.current) { videoRef.current.srcObject = stream; } setStream(stream); } catch (error) { console.error('Error accessing webcam:', error); } }; const processReferenceImage = async () => { if (!referenceImage) return; const img = new Image(); img.src = referenceImage; img.onload = async () => { try { const detections = await faceapi.detectSingleFace(img) .withFaceLandmarks() .withFaceDescriptor(); if (detections) { setReferenceDescriptor(detections.descriptor); console.log('Reference face descriptor extracted'); } else { console.error('No face detected in reference image'); setReferenceDescriptor(null); } } catch (error) { console.error('Error processing reference image:', error); } }; }; const startFaceComparison = () => { if (!modelsLoaded || !referenceDescriptor || !videoRef.current || !canvasRef.current) return; setIsComparing(true); intervalRef.current = window.setInterval(async () => { if (videoRef.current && canvasRef.current && referenceDescriptor) { const video = videoRef.current; const canvas = canvasRef.current; const displaySize = { width: video.videoWidth, height: video.videoHeight }; if (canvas.width !== displaySize.width || canvas.height !== displaySize.height) { faceapi.matchDimensions(canvas, displaySize); } const detections = await faceapi.detectAllFaces(video) .withFaceLandmarks() .withFaceDescriptors(); const context = canvas.getContext('2d'); if (context) { context.clearRect(0, 0, canvas.width, canvas.height); if (detections.length > 0) { const currentFaceDescriptor = detections[0].descriptor; const distance = faceapi.euclideanDistance(referenceDescriptor, currentFaceDescriptor); // 유사도 계산 (거리가 0.6 미만이면 동일 인물로 간주) const matchScore = Math.max(0, (1 - distance) * 100).toFixed(2); setSimilarity(matchScore); // 얼굴 경계 상자 그리기 const resizedDetections = faceapi.resizeResults(detections, displaySize); resizedDetections.forEach(detection => { const box = detection.detection.box; // 경계 상자 그리기 context.strokeStyle = parseFloat(matchScore) > 60 ? 'green' : 'red'; context.lineWidth = 2; context.strokeRect(box.x, box.y, box.width, box.height); // 일치도 텍스트 표시 context.font = '16px Arial'; context.fillStyle = parseFloat(matchScore) > 60 ? 'green' : 'red'; context.fillText(`일치도: ${matchScore}%`, box.x, box.y - 10); }); } else { setSimilarity(null); } } } }, 100); }; const stopFaceComparison = () => { setIsComparing(false); setSimilarity(null); if (intervalRef.current) { window.clearInterval(intervalRef.current); intervalRef.current = null; } if (canvasRef.current) { const context = canvasRef.current.getContext('2d'); if (context) { context.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); } } }; return ( <div className="face-recognition-container"> <h2>카메라를 이용한 얼굴 인식</h2> <div className="video-container" style={{ position: 'relative' }}> <video ref={videoRef} autoPlay playsInline muted width="640" height="480" /> <canvas ref={canvasRef} style={{ position: 'absolute', top: 0, left: 0 }} /> </div> <div className="controls"> {!isComparing ? ( <button onClick={startFaceComparison} disabled={!referenceDescriptor} className="compare-btn" > 얼굴 비교 시작 </button> ) : ( <button onClick={stopFaceComparison} className="stop-btn" > 얼굴 비교 중지 </button> )} </div> {similarity && ( <div className="result"> <h3>얼굴 일치도: {similarity}%</h3> <p> {parseFloat(similarity) > 60 ? '✅ 동일 인물로 판단됩니다.' : '❌ 다른 인물로 판단됩니다.'} </p> </div> )} </div> ); }; export default FaceRecognition;
CSS 스타일 추가
src/App.css#root { width: 100%; margin: 0 auto; text-align: center; } .App { width: 100%; display: flex; flex-direction: column; align-items: center; } .App-header { background-color: #242424; min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; color: white; padding: 20px; width: 100%; max-width: 800px; } .upload-section { margin: 20px 0; width: 100%; } .reference-image-container { margin: 20px 0; width: 100%; } .face-recognition-container { margin-top: 30px; display: flex; flex-direction: column; align-items: center; width: 100%; } .video-container { border: 2px solid #666; border-radius: 8px; overflow: hidden; margin: 20px 0; width: 100%; max-width: 640px; } .controls { margin: 15px 0; } .compare-btn, .stop-btn { padding: 10px 20px; font-size: 16px; border: none; border-radius: 4px; cursor: pointer; transition: background-color 0.3s; } .compare-btn { background-color: #4CAF50; color: white; } .compare-btn:disabled { background-color: #cccccc; cursor: not-allowed; } .compare-btn:hover:not(:disabled) { background-color: #45a049; } .stop-btn { background-color: #f44336; color: white; } .stop-btn:hover { background-color: #d32f2f; } .result { margin-top: 20px; padding: 15px; background-color: rgba(255, 255, 255, 0.1); border-radius: 8px; width: 100%; max-width: 640px; }
vite-env.d.ts 수정
타입스크립트가 face-api.js를 인식할 수 있도록
src/vite-env.d.ts 파일에 다음 내용을 추가/// <reference types="vite/client" /> declare module 'face-api.js';
- 프로젝트의
public폴더에models디렉토리가 포함되어 있어야 함.
- 필요한 모든 모델 파일이
public/models/디렉토리에 있어야 함 face_landmark_68_model-weights_manifest.jsonface_landmark_68_model-shard1face_recognition_model-weights_manifest.jsonface_recognition_model-shard1face_recognition_model-shard2ssd_mobilenetv1_model-weights_manifest.jsonssd_mobilenetv1_model-shard1ssd_mobilenetv1_model-shard2
Vite는
public 폴더의 파일을 dist 폴더로 복사하므로 모델 파일이 올바르게 포함되어야 함
- 로컬에서 테스트 빌드를 실행
npm run build
dist폴더 내에models디렉토리와 모든 모델 파일이 있는지 확인