Face-api.js10분
Face-api.js + TensorFlow 이마 판별 테스트
2025년 4월 25일
0425
좌측 판별하는 테스트 코드 React 로 이식 중 생긴 문제점
javascriptimport { useRef, useState, useEffect, useCallback, RefObject } from "react";
interface Point {
x: number;
y: number;
z: number;
}
interface Landmark {
x: number;
y: number;
}
interface FaceApiReturnType {
startFaceDetection: () => Promise<void>;
stopFaceDetection: () => void;
isCapturing: boolean;
capturedImage: string | null;
resetCapture: () => void;
faceAngle: number;
isModelLoaded: boolean;
isTargetAngleReached: boolean;
opencvLoaded: boolean;
}
interface FaceApiProps {
videoRef?: RefObject<HTMLVideoElement | null>;
canvasRef?: RefObject<HTMLCanvasElement | null>;
targetAngle?: number;
onCapture?: (image: string) => void;
}
const useFaceApi = ({ videoRef: externalVideoRef, canvasRef: externalCanvasRef, targetAngle = -45, onCapture }: FaceApiProps = {}): FaceApiReturnType => {
// OpenCV 관련 변수들
const faceCascadeRef = useRef<any>(null);
const capRef = useRef<any>(null);
const frameRef = useRef<any>(null);
const grayRef = useRef<any>(null);
const internalVideoRef = useRef<HTMLVideoElement | null>(null);
const internalCanvasRef = useRef<HTMLCanvasElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
// 외부에서 전달된 ref 또는 내부 ref 사용
const videoRef = externalVideoRef || internalVideoRef;
const canvasRef = externalCanvasRef || internalCanvasRef;
// 상태 관리
const [isCapturing, setIsCapturing] = useState<boolean>(true);
const [isModelLoaded, setIsModelLoaded] = useState<boolean>(false);
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const [faceAngle, setFaceAngle] = useState<number>(0);
const [lastCaptureTime, setLastCaptureTime] = useState<number>(0);
const [isTargetAngleReached, setIsTargetAngleReached] = useState<boolean>(false);
const [opencvLoaded, setOpencvLoaded] = useState<boolean>(false);
// 상수 정의
const captureDelay = 2000; // 연속 촬영 방지를 위한 딜레이 (ms)
const angleThreshold = 8; // ±8도 오차 허용
// 3D 모델 포인트 - 표준 얼굴의 주요 랜드마크 위치
const modelPoints = [
{ x: 0.0, y: 0.0, z: 0.0 }, // 코끝
{ x: -150.0, y: -150.0, z: -125.0 }, // 왼쪽 입 끝
{ x: 150.0, y: -150.0, z: -125.0 }, // 오른쪽 입 끝
{ x: -200.0, y: 170.0, z: -135.0 }, // 왼쪽 눈
{ x: 200.0, y: 170.0, z: -135.0 }, // 오른쪽 눈
{ x: 0.0, y: 330.0, z: -65.0 }, // 턱
];
// OpenCV.js가 로드되면 실행
const onFaceApiReady = useCallback(() => {
console.log("FaceApi 초기화 시작");
setOpencvLoaded(true);
loadFaceCascade();
}, []);
// 얼굴 감지 모델 로드
const loadFaceCascade = useCallback(() => {
if (!window.cv) {
console.error("OpenCV.js is not loaded");
return;
}
console.log("얼굴 감지 모델 로드 시작");
// 얼굴 감지용 Haar 캐스케이드 로드
faceCascadeRef.current = new window.cv.CascadeClassifier();
// XML 파일을 로드
const faceCascadeFile = "https://cdnjs.cloudflare.com/ajax/libs/opencv.js/4.7.0/haarcascade_frontalface_default.xml";
// Utils 클래스 정의 (원본 코드에서 가져옴)
class Utils {
createFileFromUrl(path: string, url: string, callback: () => void) {
const request = new XMLHttpRequest();
request.open("GET", url, true);
request.responseType = "arraybuffer";
request.onload = function () {
if (request.readyState === 4) {
if (request.status === 200) {
const data = new Uint8Array(request.response);
window.cv.FS_createDataFile("/", path, data, true, false, false);
callback();
} else {
console.error("Failed to load " + url + " status: " + request.status);
}
}
};
request.send();
}
}
const utils = new Utils();
utils.createFileFromUrl("haarcascade_frontalface_default.xml", faceCascadeFile, () => {
faceCascadeRef.current.load("haarcascade_frontalface_default.xml");
console.log("얼굴 감지 모델 로드 완료");
loadLandmarksModel();
});
}, []);
// 랜드마크 모델 로드 함수
const loadLandmarksModel = useCallback(() => {
// 실제 앱에서는 추가적인 DNN 모델 로드가 필요할 수 있음
// 여기서는 단순화를 위해 즉시 다음 단계로 진행
console.log("랜드마크 모델 로드 완료");
setIsModelLoaded(true);
}, []);
// 랜드마크 추정 함수 (단순화된 구현)
const estimateLandmarks = useCallback((face: { x: number; y: number; width: number; height: number }): Landmark[] => {
const centerX = face.x + face.width / 2;
const centerY = face.y + face.height / 2;
const eyeOffsetY = face.height * 0.25;
const eyeOffsetX = face.width * 0.15;
const mouthOffsetY = face.height * 0.25;
const mouthOffsetX = face.width * 0.15;
// 단순화된 랜드마크 좌표
return [
{ x: centerX, y: centerY }, // 코
{ x: centerX - mouthOffsetX, y: centerY + mouthOffsetY }, // 왼쪽 입 끝
{ x: centerX + mouthOffsetX, y: centerY + mouthOffsetY }, // 오른쪽 입 끝
{ x: centerX - eyeOffsetX, y: centerY - eyeOffsetY }, // 왼쪽 눈
{ x: centerX + eyeOffsetX, y: centerY - eyeOffsetY }, // 오른쪽 눈
{ x: centerX, y: centerY + face.height * 0.3 }, // 턱
];
}, []);
// 헤드 포즈 추정 함수
const estimateHeadPose = useCallback((landmarks: Landmark[]): number => {
// 왼쪽 눈과 오른쪽 눈 사이의 거리
const eyeDistance = Math.sqrt(Math.pow(landmarks[4].x - landmarks[3].x, 2) + Math.pow(landmarks[4].y - landmarks[3].y, 2));
// 코와 눈 중심 사이의 관계 계산
const eyeCenterX = (landmarks[3].x + landmarks[4].x) / 2;
const eyeCenterY = (landmarks[3].y + landmarks[4].y) / 2;
const noseX = landmarks[0].x;
// 코가 눈 중심에서 얼마나 벗어났는지 계산
const deviation = (eyeCenterX - noseX) / eyeDistance;
// 정면: 0, 완전 측면: 약 90도로 매핑
const yawAngle = deviation * 90;
return yawAngle;
}, []);
// 프레임 캡처 함수
const captureFrame = useCallback(() => {
if (!canvasRef.current) return;
setIsCapturing(false);
// 현재 프레임 캡처
const imageData = canvasRef.current.toDataURL("image/png");
setCapturedImage(imageData);
// 콜백 함수가 제공된 경우 호출
if (onCapture) {
onCapture(imageData);
}
// 리소스 해제
if (frameRef.current) frameRef.current.delete();
if (grayRef.current) grayRef.current.delete();
}, [onCapture]);
// 비디오 프레임 처리 함수
const processVideo = useCallback(() => {
if (!isCapturing || !window.cv) return;
try {
if (!capRef.current || !frameRef.current || !grayRef.current || !faceCascadeRef.current) return;
// 비디오에서 프레임 캡처
capRef.current.read(frameRef.current);
// 그레이스케일 변환
window.cv.cvtColor(frameRef.current, grayRef.current, window.cv.COLOR_RGBA2GRAY);
// 얼굴 감지
const faces = new window.cv.RectVector();
faceCascadeRef.current.detectMultiScale(grayRef.current, faces, 1.1, 3, 0);
// 인식된 얼굴이 있는 경우
if (faces.size() > 0) {
// 첫 번째 얼굴 선택
const face = faces.get(0);
// 얼굴 영역 표시
const point1 = new window.cv.Point(face.x, face.y);
const point2 = new window.cv.Point(face.x + face.width, face.y + face.height);
window.cv.rectangle(frameRef.current, point1, point2, [0, 255, 0, 255], 2);
// 간단한 랜드마크 추정
const landmarks = estimateLandmarks(face);
// 랜드마크 표시
for (let i = 0; i < landmarks.length; i++) {
window.cv.circle(frameRef.current, new window.cv.Point(landmarks[i].x, landmarks[i].y), 3, [255, 0, 0, 255], -1);
}
// 헤드 포즈 추정 (Yaw 각도 계산)
const yawAngle = estimateHeadPose(landmarks);
setFaceAngle(yawAngle);
// 각도가 목표 범위 내에 있는지 확인
const isInTargetRange = Math.abs(yawAngle - targetAngle) <= angleThreshold;
setIsTargetAngleReached(isInTargetRange);
// 목표 각도에 도달하면 자동 캡처
if (isInTargetRange) {
const currentTime = Date.now();
if (currentTime - lastCaptureTime > captureDelay) {
captureFrame();
setLastCaptureTime(currentTime);
}
}
}
// 화면에 프레임 표시
if (canvasRef.current) {
window.cv.imshow(canvasRef.current, frameRef.current);
}
// 다음 프레임 처리
requestAnimationFrame(processVideo);
} catch (err) {
console.error("Error in processVideo:", err);
}
}, [isCapturing, captureFrame, estimateHeadPose, estimateLandmarks, lastCaptureTime, targetAngle, angleThreshold]);
// 얼굴 감지 시작 (기존 카메라 스트림 사용)
const startFaceDetection = useCallback(async () => {
if (!opencvLoaded) {
console.error("OpenCV.js가 아직 로드되지 않았습니다");
return;
}
console.log("얼굴 감지 시작");
// 비디오 요소가 있고 스트림이 이미 연결되어 있는지 확인
if (videoRef.current && videoRef.current.srcObject) {
// 비디오 프레임 처리를 위한 Mat 객체 생성
frameRef.current = new window.cv.Mat(videoRef.current.height, videoRef.current.width, window.cv.CV_8UC4);
grayRef.current = new window.cv.Mat();
capRef.current = new window.cv.VideoCapture(videoRef.current);
// 캡처 상태 초기화
setIsCapturing(true);
setCapturedImage(null);
setIsTargetAngleReached(false);
// 프레임 처리 시작
processVideo();
} else {
console.error("비디오 스트림이 없습니다");
}
}, [processVideo, videoRef, opencvLoaded]);
// 얼굴 감지 중지
const stopFaceDetection = useCallback(() => {
setIsCapturing(false);
// 리소스 해제
if (capRef.current) capRef.current.delete();
if (frameRef.current) frameRef.current.delete();
if (grayRef.current) grayRef.current.delete();
}, []);
// 캡처 리셋
const resetCapture = useCallback(() => {
setIsCapturing(true);
setCapturedImage(null);
setIsTargetAngleReached(false);
// 새 Mat 객체 생성
if (window.cv && videoRef.current) {
frameRef.current = new window.cv.Mat(videoRef.current.height, videoRef.current.width, window.cv.CV_8UC4);
grayRef.current = new window.cv.Mat();
// 프레임 처리 재시작
processVideo();
}
}, [processVideo, videoRef]);
// OpenCV.js 로드 확인
useEffect(() => {
// OpenCV.js가 이미 로드되어 있는지 확인
if (window.cv && window.cv.CascadeClassifier) {
console.log("OpenCV.js가 이미 로드되어 있습니다");
onFaceApiReady();
return;
}
// 전역 변수로 설정된 로딩 상태 확인
if (window.isOpenCvReady) {
console.log("OpenCV.js가 로드되었습니다 (전역 변수)");
onFaceApiReady();
return;
}
console.log("OpenCV.js 로딩 대기 중...");
// OpenCV.js 로딩 이벤트 리스너 추가
const handleOpenCvReady = () => {
console.log("OpenCV.js 로딩 이벤트 감지");
onFaceApiReady();
};
// 로딩 이벤트 등록
window.addEventListener("opencv-ready", handleOpenCvReady);
// 로딩 상태 정기 확인 (백업 방법)
const checkInterval = setInterval(() => {
if (window.cv || window.isOpenCvReady) {
console.log("OpenCV.js 로딩 확인됨 (인터벌 체크)");
clearInterval(checkInterval);
onFaceApiReady();
}
}, 500);
// 컴포넌트 언마운트 시 리소스 해제
return () => {
window.removeEventListener("opencv-ready", handleOpenCvReady);
clearInterval(checkInterval);
stopFaceDetection();
};
}, [onFaceApiReady, stopFaceDetection]);
return {
startFaceDetection,
stopFaceDetection,
isCapturing,
capturedImage,
resetCapture,
faceAngle,
isModelLoaded,
isTargetAngleReached,
opencvLoaded,
};
};
// OpenCV 타입 선언
declare global {
interface Window {
cv: any; // OpenCV는 JavaScript 라이브러리이므로 any 타입 사용
isOpenCvReady?: boolean; // OpenCV 로딩 상태 플래그
}
}
export default useFaceApi;

image.png
OpenCV 로 테스트해서 생긴 문제.. Fuck
Face-api.js 로 변경
javascript<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<!-- TensorFlow.js 코어 라이브러리 -->
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core@2.4.0/dist/tf-core.js"></script>
<!-- TensorFlow.js 백엔드 -->
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-cpu@2.4.0/dist/tf-backend-cpu.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-webgl@2.4.0/dist/tf-backend-webgl.js"></script>
<!-- Face-API.js -->
<script src="/face-api.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
javascriptimport { useRef, useState, useEffect, useCallback, RefObject } from "react";
// Face-API.js 타입 선언
declare global {
interface Window {
faceapi: any;
}
}
// 전역 타입스크립트 타입 정의
declare const faceapi: any;
interface Point {
x: number;
y: number;
z: number;
}
interface Landmark {
x: number;
y: number;
}
interface FaceApiReturnType {
startFaceDetection: () => Promise<void>;
stopFaceDetection: () => void;
isCapturing: boolean;
capturedImage: string | null;
resetCapture: () => void;
faceAngle: number;
isModelLoaded: boolean;
isTargetAngleReached: boolean;
faceApiLoaded: boolean;
}
interface FaceApiProps {
videoRef?: RefObject<HTMLVideoElement | null>;
canvasRef?: RefObject<HTMLCanvasElement | null>;
targetAngle?: number;
onCapture?: (image: string) => void;
}
/**
* Face-API.js 로딩 여부를 확인하는 함수
*/
const isFaceApiLoaded = (): boolean => {
return typeof window !== "undefined" && typeof window.faceapi !== "undefined" && window.faceapi !== null;
};
/**
* Face-API.js 로딩 완료 대기 함수
*/
const waitForFaceApi = async (): Promise<void> => {
// 이미 로드되었는지 확인
if (isFaceApiLoaded()) {
return Promise.resolve();
}
// 로드될 때까지 대기
return new Promise<void>((resolve) => {
const checkInterval = setInterval(() => {
if (isFaceApiLoaded()) {
clearInterval(checkInterval);
resolve();
}
}, 100);
// 10초 후에도 로드되지 않으면 자동 해제
setTimeout(() => {
clearInterval(checkInterval);
}, 10000);
});
};
const useFaceApi = ({ videoRef: externalVideoRef, canvasRef: externalCanvasRef, targetAngle = -45, onCapture }: FaceApiProps = {}): FaceApiReturnType => {
const internalVideoRef = useRef<HTMLVideoElement | null>(null);
const internalCanvasRef = useRef<HTMLCanvasElement | null>(null);
// 외부에서 전달된 ref 또는 내부 ref 사용
const videoRef = externalVideoRef || internalVideoRef;
const canvasRef = externalCanvasRef || internalCanvasRef;
// 상태 관리
const [isCapturing, setIsCapturing] = useState<boolean>(false);
const [isModelLoaded, setIsModelLoaded] = useState<boolean>(false);
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const [faceAngle, setFaceAngle] = useState<number>(0);
const [lastCaptureTime, setLastCaptureTime] = useState<number>(0);
const [isTargetAngleReached, setIsTargetAngleReached] = useState<boolean>(false);
const [faceApiLoaded, setFaceApiLoaded] = useState<boolean>(isFaceApiLoaded());
const [detectionStarted, setDetectionStarted] = useState<boolean>(false);
// 상수 정의
const captureDelay = 2000; // 연속 촬영 방지를 위한 딜레이 (ms)
const angleThreshold = 10; // ±10도 오차 허용
// 리소스 정리 함수
const cleanupResources = useCallback(() => {
// Face-API.js는 별도의 리소스 해제가 필요 없음
}, []);
// 얼굴 감지 중지
const stopFaceDetection = useCallback(() => {
setIsCapturing(false);
setDetectionStarted(false);
cleanupResources();
}, [cleanupResources]);
// 얼굴 감지 초기화
const initFaceDetection = useCallback(async (): Promise<boolean> => {
try {
console.log("얼굴 감지 초기화 시작");
if (!isFaceApiLoaded()) {
console.error("Face-API.js가 로드되지 않았습니다");
return false;
}
// 모델 경로 디버깅
const modelUrl = "/models";
console.log("모델 URL:", modelUrl);
// Face-API.js 모델 로드
console.log("모델 로드 시도...");
// loadFromUri 메서드 사용 - 이 방법이 더 안정적입니다
await faceapi.loadTinyFaceDetectorModel(modelUrl);
await faceapi.loadFaceLandmarkModel(modelUrl);
console.log("얼굴 감지 모델 로드 완료");
setIsModelLoaded(true);
return true;
} catch (error) {
console.error("얼굴 감지 초기화 실패:", error);
return false;
}
}, []);
// 각도 계산 함수
const calculateYaw = useCallback((landmarks: any): number => {
// 왼쪽과 오른쪽 눈 중심점
const leftEye = landmarks.getLeftEye();
const rightEye = landmarks.getRightEye();
const leftEyeCenter = {
x: leftEye.reduce((sum: number, pt: any) => sum + pt.x, 0) / leftEye.length,
y: leftEye.reduce((sum: number, pt: any) => sum + pt.y, 0) / leftEye.length,
};
const rightEyeCenter = {
x: rightEye.reduce((sum: number, pt: any) => sum + pt.x, 0) / rightEye.length,
y: rightEye.reduce((sum: number, pt: any) => sum + pt.y, 0) / rightEye.length,
};
// 눈 사이 거리
const eyeDistance = Math.sqrt(Math.pow(rightEyeCenter.x - leftEyeCenter.x, 2) + Math.pow(rightEyeCenter.y - leftEyeCenter.y, 2));
// 코 위치 (코끝)
const nose = landmarks.getNose()[3];
// 눈 중심선과 코의 거리 계산
const eyeMidX = (leftEyeCenter.x + rightEyeCenter.x) / 2;
const deviation = (eyeMidX - nose.x) / eyeDistance;
// 각도로 변환 (근사치) - 부호 반전하여 올바른 방향으로 표시
// 좌측으로 회전 시 양수, 우측으로 회전 시 음수
return deviation * -90; // 부호 반전
}, []);
// 프레임 캡처 함수
const captureFrame = useCallback(() => {
if (!canvasRef.current || !videoRef.current) return;
setIsCapturing(false);
const ctx = canvasRef.current.getContext("2d");
if (ctx) {
ctx.drawImage(videoRef.current, 0, 0, canvasRef.current.width, canvasRef.current.height);
}
// 현재 프레임 캡처
const imageData = canvasRef.current.toDataURL("image/png");
setCapturedImage(imageData);
// 콜백 함수가 제공된 경우 호출
if (onCapture) {
onCapture(imageData);
}
// 리소스 해제
cleanupResources();
}, [onCapture, cleanupResources]);
// 비디오 프레임 처리 함수
const processVideo = useCallback(async () => {
if (!isCapturing || !isFaceApiLoaded() || !detectionStarted) return;
try {
if (!videoRef.current || !canvasRef.current) return;
// 캔버스 컨텍스트 가져오기
const ctx = canvasRef.current.getContext("2d");
if (!ctx) return;
// 비디오 프레임을 캔버스에 그림
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
// 얼굴 감지 - 옵션 조정
const detections = await faceapi
.detectAllFaces(
videoRef.current,
new faceapi.TinyFaceDetectorOptions({
inputSize: 320, // 입력 크기 증가
scoreThreshold: 0.5, // 임계값 조정
})
)
.withFaceLandmarks();
// 감지된 얼굴이 있는 경우
if (detections.length > 0) {
// 첫 번째 얼굴 선택
const face = detections[0];
// 랜드마크 그리기
faceapi.draw.drawFaceLandmarks(canvasRef.current, face);
// 헤드 포즈 각도 계산
const yawAngle = calculateYaw(face.landmarks);
// 값이 유효한 경우에만 상태 업데이트
if (!isNaN(yawAngle) && isFinite(yawAngle)) {
setFaceAngle(yawAngle);
// 콘솔에 현재 각도 정보 출력
console.log("각도:", yawAngle.toFixed(1), "도, 타겟 각도:", targetAngle);
// 각도가 목표 범위 내에 있는지 확인
const isInTargetRange = Math.abs(yawAngle - targetAngle) <= angleThreshold;
setIsTargetAngleReached(isInTargetRange);
// 목표 각도에 도달하면 자동 캡처
if (isInTargetRange) {
const currentTime = Date.now();
if (currentTime - lastCaptureTime > captureDelay) {
// 여기서 바로 캡처하지 않고 시간을 두고 실행
setTimeout(() => {
if (isCapturing) {
// 캡처 중인지 다시 확인
captureFrame();
setLastCaptureTime(currentTime);
}
}, 300);
}
}
}
} else {
setIsTargetAngleReached(false);
}
// 다음 프레임 처리 (requestAnimationFrame 사용)
if (isCapturing) {
requestAnimationFrame(() => processVideo());
}
} catch (err) {
console.error("비디오 처리 오류:", err);
if (isCapturing) {
// 오류 발생 시 약간의 지연 후 다시 시도
setTimeout(() => {
requestAnimationFrame(() => processVideo());
}, 500);
}
}
}, [isCapturing, detectionStarted, videoRef, canvasRef, captureFrame, calculateYaw, lastCaptureTime, targetAngle, angleThreshold]);
// 얼굴 감지 시작 (기존 카메라 스트림 사용)
const startFaceDetection = useCallback(async () => {
if (!faceApiLoaded) {
console.error("Face-API.js가 아직 로드되지 않았습니다");
return;
}
if (!isModelLoaded) {
// 초기화
const success = await initFaceDetection();
if (!success) {
console.error("얼굴 감지 초기화에 실패했습니다");
return;
}
}
// 이미 캡처 중이면 중지
if (isCapturing) {
stopFaceDetection();
}
console.log("얼굴 감지 시작");
// 비디오 요소가 있고 스트림이 이미 연결되어 있는지 확인
if (videoRef.current && videoRef.current.srcObject) {
try {
// 캔버스 크기 설정
if (canvasRef.current) {
canvasRef.current.width = videoRef.current.videoWidth || 640;
canvasRef.current.height = videoRef.current.videoHeight || 480;
}
// 캡처 상태 초기화
setIsCapturing(true);
setCapturedImage(null);
setIsTargetAngleReached(false);
// 약간의 지연 후 감지 시작
setTimeout(() => {
setDetectionStarted(true);
processVideo();
}, 500);
} catch (error) {
console.error("얼굴 감지 시작 실패:", error);
}
} else {
console.error("비디오 스트림이 없거나 접근할 수 없습니다");
}
}, [processVideo, videoRef, faceApiLoaded, isModelLoaded, isCapturing, initFaceDetection, stopFaceDetection]);
// 캡처 리셋
const resetCapture = useCallback(() => {
if (isCapturing) {
stopFaceDetection();
}
setIsCapturing(true);
setCapturedImage(null);
setIsTargetAngleReached(false);
// 프레임 처리 재시작
setDetectionStarted(true);
processVideo();
}, [isCapturing, processVideo, stopFaceDetection]);
// Face-API.js 초기화 (최초 한 번)
useEffect(() => {
async function initFaceAPI() {
try {
// Face-API.js 로드 대기
await waitForFaceApi();
console.log("Face-API.js 로드 확인됨, 초기화 시작");
setFaceApiLoaded(true);
// 얼굴 감지 초기화
await initFaceDetection();
} catch (error) {
console.error("Face-API.js 초기화 실패:", error);
}
}
// 이미 로드되었는지 확인
if (isFaceApiLoaded()) {
console.log("Face-API.js가 이미 로드되어 있습니다");
setFaceApiLoaded(true);
initFaceDetection();
} else {
initFaceAPI();
}
// 클린업 함수
return () => {
stopFaceDetection();
};
}, [initFaceDetection, stopFaceDetection]);
return {
startFaceDetection,
stopFaceDetection,
isCapturing,
capturedImage,
resetCapture,
faceAngle,
isModelLoaded,
isTargetAngleReached,
faceApiLoaded,
};
};
export default useFaceApi;
javascriptimport { useState, useRef, useEffect } from "react";
export interface PhotoState {
front: string | null;
left: string | null;
right: string | null;
}
export type ViewType = "front" | "left" | "right" | null;
export const useCamera = () => {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const [currentView, setCurrentView] = useState<ViewType>("front");
const [photos, setPhotos] = useState<PhotoState>({
front: null,
left: null,
right: null,
});
const [isVideoReady, setIsVideoReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isDebug, setIsDebug] = useState(false);
const [isMirrored, setIsMirrored] = useState(true);
const [isStartingCamera, setIsStartingCamera] = useState(false);
// 디버그 로그 함수
const debugLog = (...args: any[]) => {
if (isDebug) {
console.log("[카메라 디버그]", ...args);
}
};
// 카메라 시작 - HTML5 기본 API만 사용
const startCamera = async () => {
if (isStartingCamera) {
debugLog("카메라가 이미 시작 중입니다.");
return;
}
setIsStartingCamera(true);
setError(null);
debugLog("카메라 시작 요청됨");
try {
// 기존 스트림이 있다면 정리
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => {
debugLog("기존 트랙 정지:", track.kind, track.label);
track.stop();
});
streamRef.current = null;
}
if (videoRef.current && videoRef.current.srcObject) {
const oldStream = videoRef.current.srcObject as MediaStream;
oldStream.getTracks().forEach((track) => {
debugLog("기존 트랙 정지:", track.kind, track.label);
track.stop();
});
videoRef.current.srcObject = null;
setIsVideoReady(false);
}
debugLog("MediaDevices.getUserMedia 호출 중...");
// HTML5 기본 카메라 API 사용
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: "user", // 전면 카메라 사용
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
});
// 참조 저장
streamRef.current = stream;
debugLog("스트림 획득 성공:", stream.id, "트랙 수:", stream.getTracks().length);
stream.getTracks().forEach((track) => {
debugLog("트랙 정보:", track.kind, track.label, "활성:", track.enabled);
});
if (videoRef.current) {
// 먼저 srcObject 설정
videoRef.current.srcObject = stream;
videoRef.current.muted = true;
videoRef.current.playsInline = true; // iOS 지원 개선
// 좌우반전 스타일 적용
if (isMirrored) {
videoRef.current.style.transform = "scaleX(-1)";
} else {
videoRef.current.style.transform = "scaleX(1)";
}
// 브라우저 호환성을 위한 자동 재생 시도
try {
debugLog("비디오 재생 시도");
// play 프로미스를 기다림
await videoRef.current.play();
debugLog("비디오 재생 성공");
setIsVideoReady(true);
} catch (playError) {
console.error("비디오 재생 실패:", playError);
setError("비디오 재생 실패: " + (playError as Error).message);
}
} else {
setError("비디오 요소를 찾을 수 없습니다");
debugLog("비디오 요소 참조 없음");
}
} catch (err) {
const errorMsg = "카메라 접근에 실패했습니다: " + (err as Error).message;
setError(errorMsg);
debugLog("카메라 접근 오류:", err);
// DOMException: Permission denied 오류 처리
if ((err as Error).name === "NotAllowedError") {
setError("카메라 접근 권한이 거부되었습니다. 브라우저 설정에서 카메라 권한을 허용해주세요.");
}
// DOMException: Requested device not found 오류 처리
else if ((err as Error).name === "NotFoundError") {
setError("카메라 장치를 찾을 수 없습니다. 카메라가 연결되어 있는지 확인해주세요.");
}
} finally {
setIsStartingCamera(false);
}
};
// 좌우반전 토글
const toggleMirror = () => {
setIsMirrored((prev) => !prev);
if (videoRef.current) {
videoRef.current.style.transform = isMirrored ? "scaleX(1)" : "scaleX(-1)";
}
debugLog("좌우반전 상태 변경:", !isMirrored);
};
// useEffect로 isMirrored 변경 시 비디오 스타일 업데이트
useEffect(() => {
if (videoRef.current) {
videoRef.current.style.transform = isMirrored ? "scaleX(-1)" : "scaleX(1)";
}
}, [isMirrored]);
// 카메라 정지
const stopCamera = () => {
debugLog("카메라 정지 요청됨");
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => {
debugLog("트랙 정지:", track.kind);
track.stop();
});
streamRef.current = null;
}
if (videoRef.current && videoRef.current.srcObject) {
const stream = videoRef.current.srcObject as MediaStream;
stream.getTracks().forEach((track) => {
debugLog("트랙 정지:", track.kind);
track.stop();
});
videoRef.current.srcObject = null;
setIsVideoReady(false);
debugLog("카메라 정지 완료");
} else {
debugLog("정지할 스트림이 없음");
}
};
// 비디오 요소 상태 디버깅
const checkVideoStatus = () => {
if (!videoRef.current) {
debugLog("비디오 요소가 없음");
return "비디오 요소가 없음";
}
const video = videoRef.current;
const status = {
readyState: video.readyState,
paused: video.paused,
ended: video.ended,
srcObject: video.srcObject ? "있음" : "없음",
videoWidth: video.videoWidth,
videoHeight: video.videoHeight,
currentTime: video.currentTime,
duration: video.duration,
volume: video.volume,
muted: video.muted,
};
debugLog("비디오 상태:", status);
return status;
};
// 사진 캡처 - HTML5 Canvas 사용
const capturePhoto = () => {
if (!videoRef.current || !currentView || !isVideoReady) {
debugLog("캡처 조건 미충족:", {
videoRef: !!videoRef.current,
currentView,
isVideoReady,
});
return;
}
try {
debugLog("사진 캡처 시도", currentView);
// HTML5 Canvas를 사용하여 비디오 프레임 캡처
const canvas = canvasRef.current || document.createElement("canvas");
const video = videoRef.current;
// 캔버스 크기를 비디오 크기에 맞춤
const videoWidth = video.videoWidth || 640;
const videoHeight = video.videoHeight || 480;
debugLog("비디오 크기:", videoWidth, "x", videoHeight);
canvas.width = videoWidth;
canvas.height = videoHeight;
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 좌우반전 상태에 따라 캡처 이미지 처리
if (isMirrored) {
// 좌우반전된 상태로 캡처하는 경우
ctx.translate(canvas.width, 0);
ctx.scale(-1, 1);
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
ctx.setTransform(1, 0, 0, 1, 0, 0); // 변환 초기화
} else {
// 일반 상태로 캡처
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
}
debugLog("캔버스에 이미지 그리기 완료");
// 캔버스의 이미지를 데이터 URL로 변환
const photoData = canvas.toDataURL("image/jpeg", 0.9);
debugLog("이미지 데이터 URL 생성 완료, 길이:", photoData.length);
setPhotos((prev) => ({
...prev,
[currentView]: photoData,
}));
// 다음 뷰로 전환
if (currentView === "front") {
setCurrentView("left");
debugLog("다음 뷰로 전환: left");
} else if (currentView === "left") {
setCurrentView("right");
debugLog("다음 뷰로 전환: right");
} else if (currentView === "right") {
setCurrentView(null);
debugLog("모든 뷰 캡처 완료, 카메라 정지");
stopCamera();
}
} else {
setError("캔버스 컨텍스트를 가져올 수 없습니다");
debugLog("캔버스 컨텍스트 없음");
}
} catch (error) {
const errorMsg = "사진 캡처에 실패했습니다: " + (error as Error).message;
setError(errorMsg);
debugLog("캡처 오류:", error);
}
};
// 특정 방향 촬영 시작
const handlePhotoCapture = (view: ViewType) => {
debugLog("특정 방향 촬영 시작:", view);
setCurrentView(view);
if (view) {
setTimeout(() => {
startCamera();
}, 100);
}
};
// 비디오 로드 완료 이벤트 핸들러
const handleVideoLoad = () => {
debugLog("비디오 로드 완료 이벤트 발생");
checkVideoStatus();
setIsVideoReady(true);
};
// 비디오 에러 이벤트 핸들러
const handleVideoError = (event: any) => {
debugLog("비디오 에러 발생:", event);
setError("비디오 로드 중 오류가 발생했습니다: " + event.target.error?.message || "알 수 없는 오류");
};
// 디버그 모드 토글
const toggleDebug = () => {
setIsDebug((prev) => !prev);
debugLog("디버그 모드 토글됨");
};
// 컴포넌트 마운트 시 카메라 시작
useEffect(() => {
debugLog("컴포넌트 마운트됨");
const timer = setTimeout(() => {
startCamera();
}, 300);
// 컴포넌트 언마운트 시 카메라 리소스 정리
return () => {
clearTimeout(timer);
debugLog("컴포넌트 언마운트됨");
stopCamera();
};
}, []);
const allPhotosCaptures = photos.front && photos.left && photos.right;
debugLog("모든 사진 캡처 완료 여부:", !!allPhotosCaptures);
return {
videoRef,
canvasRef,
currentView,
isVideoReady,
photos,
error,
startCamera,
stopCamera,
capturePhoto,
handlePhotoCapture,
handleVideoLoad,
handleVideoError,
allPhotosCaptures,
checkVideoStatus,
toggleDebug,
isDebug,
isMirrored,
toggleMirror,
};
};
export default useCamera;
javascriptimport { useCamera } from "../hooks/useCamera";
import useFaceApi from "../hooks/useFaceApi";
import { useResponsive } from "../../shared/hooks/useResponsive";
import { useEffect, useState, useRef, useCallback, useMemo } from "react";
import { motion } from "framer-motion";
import Tutorial, { TutorialStep } from "./Tutorial"; // 튜토리얼 컴포넌트 import
import StepIndicator, { Step } from "./StepIndicator"; // 스텝 인디케이터 컴포넌트 import
const AnalyzeForm = () => {
const { videoRef, currentView, isVideoReady, photos, capturePhoto, handleVideoLoad, allPhotosCaptures, handlePhotoCapture, error: cameraError } = useCamera();
const canvasRef = useRef<HTMLCanvasElement>(null);
const { isMobile } = useResponsive();
const [prevView, setPrevView] = useState(currentView);
const [showAngleDetection, setShowAngleDetection] = useState(false);
const [detectionCompleted, setDetectionCompleted] = useState(false);
const [error, setError] = useState<string | null>(null);
// 오류 처리
useEffect(() => {
if (cameraError) {
setError(cameraError);
console.error("카메라 오류:", cameraError);
}
}, [cameraError]);
// 촬영 성공 메시지 상태
const [captureSuccessMessage, setCaptureSuccessMessage] = useState<string | null>(null);
// 촬영 성공 메시지 표시 함수 - 메모이제이션 적용
const showCaptureSuccess = useCallback((direction: string) => {
setCaptureSuccessMessage(`${direction} 촬영 완료!`);
const timerId = setTimeout(() => {
setCaptureSuccessMessage(null);
}, 1500);
// 클린업 함수 - 컴포넌트 언마운트 시 타이머 제거
return () => clearTimeout(timerId);
}, []);
// Face-API.js 훅 통합 - 각 방향별로 다른 각도값 전달
const leftFaceApi = useFaceApi({
videoRef,
canvasRef,
targetAngle: 45, // 왼쪽 45도 (좌측으로 고개를 돌리면 양수 각도)
onCapture: useCallback(
(imageData: string) => {
if (currentView === "left") {
// useCamera의 photos 상태에 추가
setShowAngleDetection(false);
// 왼쪽 촬영 완료 메시지 표시
showCaptureSuccess("왼쪽");
// 다음 뷰로 이동
const timerId = setTimeout(() => {
handlePhotoCapture("right");
}, 1500);
return () => clearTimeout(timerId);
}
},
[currentView, showCaptureSuccess, handlePhotoCapture]
),
});
const rightFaceApi = useFaceApi({
videoRef,
canvasRef,
targetAngle: -45, // 오른쪽 45도 (우측으로 고개를 돌리면 음수 각도)
onCapture: useCallback(
(imageData: string) => {
if (currentView === "right") {
setShowAngleDetection(false);
// 우측 촬영 완료 메시지 표시
showCaptureSuccess("오른쪽");
// 모든 촬영 완료 후 감지 중지
const timerId = setTimeout(() => {
handlePhotoCapture(null);
setDetectionCompleted(true);
}, 1500);
return () => clearTimeout(timerId);
}
},
[currentView, showCaptureSuccess, handlePhotoCapture]
),
});
// 온보딩 튜토리얼 상태
const [showTutorial, setShowTutorial] = useState(false);
const [tutorialStep, setTutorialStep] = useState(0);
// 튜토리얼 단계별 정보
const tutorialSteps: TutorialStep[] = [
{
title: "이마가 보이게 해주세요",
description: "머리카락이 이마를 가리지 않도록 해주세요",
position: "top",
focusArea: "forehead",
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 11.5V14m0-2.5v-6a2.5 2.5 0 015 0v6a2.5 2.5 0 01-5 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a8 8 0 018 8m-8 0a3 3 0 01-3-3V4" />
</svg>
),
},
{
title: "정면을 바라봐주세요",
description: "첫 번째 사진은 정면을 촬영합니다",
position: "center",
focusArea: "front",
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
),
},
{
title: "좌측을 바라봐주세요",
description: "왼쪽으로 약 45도 각도로 고개를 돌려주세요",
position: "left",
focusArea: "left",
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
),
},
{
title: "우측을 바라봐주세요",
description: "오른쪽으로 약 45도 각도로 고개를 돌려주세요",
position: "right",
focusArea: "right",
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7-7 7" />
</svg>
),
},
];
// 진행 상태를 표시하는 데이터
const steps: Step[] = [
{ id: "front", label: "정면", description: "정면을 바라보세요", complete: !!photos.front || currentView === "front" },
{ id: "left", label: "좌측", description: "왼쪽으로 45°", complete: !!photos.left || currentView === "left" },
{ id: "right", label: "우측", description: "오른쪽으로 45°", complete: !!photos.right || currentView === "right" },
];
// 현재 단계에 해당하는 step 객체
const currentStep = steps.find((step) => step.id === currentView) || steps[0];
// 뷰가 변경될 때 prevView 업데이트 및 얼굴 각도 감지 설정
useEffect(() => {
setPrevView(currentView);
// 각도 자동 감지 초기화
let timerId: NodeJS.Timeout | null = null;
if (currentView === "left") {
timerId = setTimeout(() => {
setShowAngleDetection(true);
leftFaceApi.startFaceDetection();
}, 1000);
} else if (currentView === "right") {
timerId = setTimeout(() => {
setShowAngleDetection(true);
rightFaceApi.startFaceDetection();
}, 1000);
} else if (currentView === "front") {
// 정면 촬영 시 안내 메시지
setCaptureSuccessMessage("정면을 촬영해주세요");
timerId = setTimeout(() => {
setCaptureSuccessMessage(null);
}, 1500);
} else {
// 모든 촬영이 완료되면 감지 중지
setShowAngleDetection(false);
leftFaceApi.stopFaceDetection();
rightFaceApi.stopFaceDetection();
}
// 클린업 함수: 언마운트 또는 의존성 변경 시 타이머 제거
return () => {
if (timerId) clearTimeout(timerId);
};
}, [currentView]); // leftFaceApi와 rightFaceApi는 의존성에서 제거
// 튜토리얼 시작
const startTutorial = () => {
setShowTutorial(true);
setTutorialStep(0);
};
// 튜토리얼 다음 단계로 이동
const nextTutorialStep = () => {
if (tutorialStep < tutorialSteps.length - 1) {
setTutorialStep(tutorialStep + 1);
} else {
setShowTutorial(false);
}
};
// 튜토리얼 종료
const closeTutorial = () => {
setShowTutorial(false);
};
// 얼굴 각도 안내 메시지
const getAngleGuidance = () => {
if (!showAngleDetection) return "";
const api = currentView === "left" ? leftFaceApi : rightFaceApi;
const targetAngle = currentView === "left" ? 45 : -45;
const angleDiff = Math.abs(api.faceAngle - targetAngle);
if (api.isTargetAngleReached) {
return "좋습니다! 이 각도를 유지해주세요";
} else if (angleDiff > 20) {
return `좀 더 ${currentView === "left" ? "왼쪽" : "오른쪽"}으로 돌려주세요`;
} else if (angleDiff > 10) {
return "거의 다 왔어요!";
}
return "각도를 조정 중입니다...";
};
// Face-API가 로드되었는지 확인
useEffect(() => {
const checkFaceApiLoaded = () => {
if (leftFaceApi.faceApiLoaded && rightFaceApi.faceApiLoaded) {
console.log("Face-API.js가 성공적으로 로드되었습니다.");
}
};
checkFaceApiLoaded();
}, [leftFaceApi.faceApiLoaded, rightFaceApi.faceApiLoaded]);
// 수동 촬영 처리
const handleManualCapture = () => {
if (showAngleDetection) {
// 각도 감지 중일 때는 무시
return;
}
if (currentView) {
showCaptureSuccess(currentView === "front" ? "정면" : currentView === "left" ? "왼쪽" : "오른쪽");
capturePhoto();
}
};
// 분석하기 버튼 클릭 핸들러
const handleAnalyzeClick = () => {
console.log("분석 시작");
// TODO: 분석 로직 추가
};
return (
<div className="w-full h-screen flex flex-col items-center justify-center py-2">
{/* 메인 컨테이너 - 캔버스와 진행상태를 가로로 배치 */}
<div className="w-full h-[90vh] max-w-7xl mx-auto flex flex-col md:flex-row gap-4">
{/* 좌측 카메라 컨테이너 - 더 큰 비율로 변경 */}
<div className="w-full h-full md:w-[90%] xl:w-[92%] bg-[#1c2536] rounded-lg overflow-hidden shadow-xl flex flex-col">
{/* 상단 안내 텍스트 */}
<div className="text-white text-center py-4 px-2 bg-[#0f1520]">
{captureSuccessMessage ? (
<motion.p className="text-lg font-medium text-green-400" initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }}>
{captureSuccessMessage}
</motion.p>
) : (
<motion.p
className="text-lg font-medium"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
key={showTutorial ? tutorialSteps[tutorialStep].title : showAngleDetection ? getAngleGuidance() : currentStep?.description}
>
{showTutorial ? tutorialSteps[tutorialStep].description : showAngleDetection ? getAngleGuidance() : currentStep?.description || "촬영이 완료되었습니다!"}
</motion.p>
)}
{showAngleDetection && (
<motion.p className="text-sm mt-1 text-gray-300" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
현재 각도: {currentView === "left" ? leftFaceApi.faceAngle.toFixed(1) : rightFaceApi.faceAngle.toFixed(1)}°
</motion.p>
)}
</div>
{/* 카메라 영역 - 최대 높이 활용 */}
<div className="flex-grow relative h-full overflow-hidden">
{/* 비디오 */}
<video ref={videoRef} autoPlay playsInline muted onLoadedMetadata={handleVideoLoad} className="w-full h-full object-cover" />
{/* 얼굴 감지용 캔버스 - 비디오 위에 절대 위치로 배치 */}
<canvas ref={canvasRef} className="absolute top-0 left-0 w-full h-full object-cover" />
{/* 튜토리얼 모드가 아닐 때만 타원형 마스크 표시 */}
{!showTutorial && !showAngleDetection && (
<div className="absolute top-0 left-0 w-full h-full">
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
<defs>
<mask id="ellipseMask">
<rect width="100" height="100" fill="white" />
<ellipse cx="50" cy="50" rx="20" ry="30" fill="black" />
</mask>
</defs>
{/* 반투명 검은색 배경 */}
<rect width="100" height="100" fill="rgba(0, 0, 0, 0.7)" mask="url(#ellipseMask)" />
{/* 타원형 테두리 - SVG로 직접 그리기 */}
<ellipse cx="50" cy="50" rx="20" ry="30" fill="none" stroke="white" strokeWidth="0.5" />
</svg>
</div>
)}
{/* 튜토리얼 컴포넌트 */}
<Tutorial show={showTutorial} currentStep={tutorialStep} tutorialSteps={tutorialSteps} onNext={nextTutorialStep} onClose={closeTutorial} />
{/* 모든 촬영이 완료되었을 때 표시할 메시지 */}
{detectionCompleted && !currentView && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-60">
<motion.div
className="bg-white rounded-lg p-6 max-w-md text-center shadow-xl"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
<svg className="w-16 h-16 mx-auto text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<h2 className="text-2xl font-bold mt-4 text-gray-800">촬영 완료!</h2>
<p className="mt-2 text-gray-600">모든 각도의 촬영이 완료되었습니다. 이제 분석을 시작할 수 있습니다.</p>
<motion.button
className="mt-4 bg-blue-600 text-white px-6 py-2 rounded-lg text-lg font-medium"
whileHover={{ scale: 1.05, boxShadow: "0 10px 25px -5px rgba(59, 130, 246, 0.5)" }}
whileTap={{ scale: 0.95 }}
onClick={handleAnalyzeClick}
>
분석하기
</motion.button>
</motion.div>
</div>
)}
{/* 촬영 버튼 */}
{!detectionCompleted && currentView && (
<div className="absolute bottom-6 left-1/2 transform -translate-x-1/2 z-10">
<motion.button
onClick={handleManualCapture}
disabled={!isVideoReady || showTutorial || showAngleDetection}
className={`text-white px-10 py-3 rounded-full flex items-center space-x-2 transition-all ${
!isVideoReady || showTutorial || showAngleDetection ? "opacity-50 bg-gray-600" : "bg-[#0f1520] hover:bg-gray-800"
}`}
whileHover={!(!isVideoReady || showTutorial || showAngleDetection) ? { scale: 1.05 } : {}}
whileTap={!(!isVideoReady || showTutorial || showAngleDetection) ? { scale: 0.95 } : {}}
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>촬영하기</span>
</motion.button>
</div>
)}
{/* 가이드 버튼 */}
{!detectionCompleted && (
<div className="absolute top-4 right-4 z-10">
<motion.button
onClick={startTutorial}
className="w-10 h-10 rounded-full bg-gray-800/50 flex items-center justify-center hover:bg-gray-800/80 transition-colors text-white"
whileHover={{ scale: 1.1, rotate: 5 }}
whileTap={{ scale: 0.9 }}
initial={{ y: -10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</motion.button>
</div>
)}
</div>
</div>
{/* 우측 진행 상태 수직선 영역 */}
<div className="hidden md:flex md:w-[10%] xl:w-[8%]">
<StepIndicator steps={steps} currentView={currentView} />
</div>
{/* 모바일용 진행 상태 표시 (수평 방향) */}
{isMobile && <StepIndicator steps={steps} currentView={currentView} isMobile={true} />}
</div>
{/* 모바일/태블릿에서 분석 버튼 영역 (기존 코드는 제거) */}
{allPhotosCaptures && isMobile && !detectionCompleted && (
<motion.div className="mt-4" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>
<motion.button
className="bg-blue-600 hover:bg-blue-700 text-white px-10 py-3 rounded-lg text-lg font-medium"
whileHover={{ scale: 1.05, boxShadow: "0 10px 25px -5px rgba(59, 130, 246, 0.5)" }}
whileTap={{ scale: 0.95 }}
onClick={handleAnalyzeClick}
>
분석하기
</motion.button>
</motion.div>
)}
</div>
);
};
export default AnalyzeForm;
→ 얼굴 각도 계산 및 촬영 가능 하지만 얼굴 각도 계산이 무한으로 호출되고 무한 렌더링 발생 useEffect 의존성 제거
javascriptimport { useRef, useState, useEffect, useCallback, RefObject } from "react";
// Face-API.js 타입 선언
declare global {
interface Window {
faceapi: any;
}
}
// 전역 타입스크립트 타입 정의
declare const faceapi: any;
interface Point {
x: number;
y: number;
z: number;
}
interface Landmark {
x: number;
y: number;
}
interface FaceApiReturnType {
startFaceDetection: () => Promise<void>;
stopFaceDetection: () => void;
isCapturing: boolean;
capturedImage: string | null;
resetCapture: () => void;
faceAngle: number;
isModelLoaded: boolean;
isTargetAngleReached: boolean;
faceApiLoaded: boolean;
}
interface FaceApiProps {
videoRef?: RefObject<HTMLVideoElement | null>;
canvasRef?: RefObject<HTMLCanvasElement | null>;
targetAngle?: number;
onCapture?: (image: string) => void;
}
/**
* Face-API.js 로딩 여부를 확인하는 함수
*/
const isFaceApiLoaded = (): boolean => {
return typeof window !== "undefined" && typeof window.faceapi !== "undefined" && window.faceapi !== null;
};
/**
* Face-API.js 로딩 완료 대기 함수
*/
const waitForFaceApi = async (): Promise<void> => {
// 이미 로드되었는지 확인
if (isFaceApiLoaded()) {
return Promise.resolve();
}
// 로드될 때까지 대기
return new Promise<void>((resolve) => {
const checkInterval = setInterval(() => {
if (isFaceApiLoaded()) {
clearInterval(checkInterval);
resolve();
}
}, 100);
// 10초 후에도 로드되지 않으면 자동 해제
setTimeout(() => {
clearInterval(checkInterval);
}, 10000);
});
};
const useFaceApi = ({ videoRef: externalVideoRef, canvasRef: externalCanvasRef, targetAngle = -45, onCapture }: FaceApiProps = {}): FaceApiReturnType => {
const internalVideoRef = useRef<HTMLVideoElement | null>(null);
const internalCanvasRef = useRef<HTMLCanvasElement | null>(null);
// 외부에서 전달된 ref 또는 내부 ref 사용
const videoRef = externalVideoRef || internalVideoRef;
const canvasRef = externalCanvasRef || internalCanvasRef;
// 상태 관리
const [isCapturing, setIsCapturing] = useState<boolean>(false);
const [isModelLoaded, setIsModelLoaded] = useState<boolean>(false);
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const [faceAngle, setFaceAngle] = useState<number>(0);
const [lastCaptureTime, setLastCaptureTime] = useState<number>(0);
const [isTargetAngleReached, setIsTargetAngleReached] = useState<boolean>(false);
const [faceApiLoaded, setFaceApiLoaded] = useState<boolean>(isFaceApiLoaded());
const [detectionStarted, setDetectionStarted] = useState<boolean>(false);
const [isMirrored, setIsMirrored] = useState<boolean>(true); // 카메라가 미러링되는지 여부 (기본값: true)
// 상수 정의
const captureDelay = 2000; // 연속 촬영 방지를 위한 딜레이 (ms)
const angleThreshold = 10; // ±10도 오차 허용
// 리소스 정리 함수
const cleanupResources = useCallback(() => {
// Face-API.js는 별도의 리소스 해제가 필요 없음
}, []);
// 얼굴 감지 중지
const stopFaceDetection = useCallback(() => {
setIsCapturing(false);
setDetectionStarted(false);
cleanupResources();
}, [cleanupResources]);
// 얼굴 감지 초기화
const initFaceDetection = useCallback(async (): Promise<boolean> => {
try {
console.log("얼굴 감지 초기화 시작");
if (!isFaceApiLoaded()) {
console.error("Face-API.js가 로드되지 않았습니다");
return false;
}
// 모델 경로 디버깅
const modelUrl = "/models";
console.log("모델 URL:", modelUrl);
// Face-API.js 모델 로드
console.log("모델 로드 시도...");
// loadFromUri 메서드 사용 - 이 방법이 더 안정적입니다
await faceapi.loadTinyFaceDetectorModel(modelUrl);
await faceapi.loadFaceLandmarkModel(modelUrl);
console.log("얼굴 감지 모델 로드 완료");
setIsModelLoaded(true);
return true;
} catch (error) {
console.error("얼굴 감지 초기화 실패:", error);
return false;
}
}, []);
// 각도 계산 함수
const calculateYaw = useCallback(
(landmarks: any): number => {
try {
// 왼쪽과 오른쪽 눈 중심점
const leftEye = landmarks.getLeftEye();
const rightEye = landmarks.getRightEye();
if (!leftEye || !rightEye || leftEye.length === 0 || rightEye.length === 0) {
console.error("눈 랜드마크를 찾을 수 없음");
return 0;
}
console.log("눈 랜드마크 확인:", "좌(개수):", leftEye.length, "우(개수):", rightEye.length);
const leftEyeCenter = {
x: leftEye.reduce((sum: number, pt: any) => sum + pt.x, 0) / leftEye.length,
y: leftEye.reduce((sum: number, pt: any) => sum + pt.y, 0) / leftEye.length,
};
const rightEyeCenter = {
x: rightEye.reduce((sum: number, pt: any) => sum + pt.x, 0) / rightEye.length,
y: rightEye.reduce((sum: number, pt: any) => sum + pt.y, 0) / rightEye.length,
};
console.log("눈 위치:", "좌:", leftEyeCenter.x.toFixed(1), leftEyeCenter.y.toFixed(1), "우:", rightEyeCenter.x.toFixed(1), rightEyeCenter.y.toFixed(1));
// 눈 사이 거리
const eyeDistance = Math.sqrt(Math.pow(rightEyeCenter.x - leftEyeCenter.x, 2) + Math.pow(rightEyeCenter.y - leftEyeCenter.y, 2));
if (eyeDistance <= 0) {
console.error("눈 사이 거리가 0 이하임");
return 0;
}
// 코 위치 (코끝)
const nose = landmarks.getNose();
if (!nose || nose.length < 4) {
console.error("코 랜드마크를 찾을 수 없음");
return 0;
}
console.log("코 랜드마크 확인 - 개수:", nose.length, "데이터:", nose.map((n: any, i: number) => `[${i}]: (${n.x.toFixed(1)}, ${n.y.toFixed(1)})`).join(", "));
// 코끝 - 더 정확한 포인트 선택 (코 배열에서 코끝에 해당하는 요소)
// 68랜드마크 모델에서는 30번(배열에서는 3번)이 코끝
const noseTip = nose[3];
// 눈 중심선과 코의 거리 계산
const eyeMidX = (leftEyeCenter.x + rightEyeCenter.x) / 2;
const deviation = (eyeMidX - noseTip.x) / eyeDistance;
console.log("코끝 위치:", noseTip.x.toFixed(1), noseTip.y.toFixed(1));
console.log("눈 중심:", eyeMidX.toFixed(1), "편차:", deviation.toFixed(3), "눈 거리:", eyeDistance.toFixed(1));
// 각도로 변환 (근사치)
// 중요: 미러링된 화면에서는 방향이 반대로 나타납니다.
let angle = deviation * -90; // 기본 각도 계산
// 미러링에 따른 각도 보정
if (isMirrored) {
// 미러링된 경우 부호를 반전
angle = -angle;
console.log("각도 보정(미러링 적용):", angle.toFixed(1));
} else {
console.log("각도 보정(미러링 미적용):", angle.toFixed(1));
}
console.log("계산된 얼굴 각도:", angle.toFixed(1));
return angle;
} catch (error) {
console.error("각도 계산 중 오류 발생:", error);
return 0;
}
},
[isMirrored]
); // isMirrored를 의존성 배열에 추가
// 프레임 캡처 함수
const captureFrame = useCallback(() => {
if (!canvasRef.current || !videoRef.current) return;
setIsCapturing(false);
const ctx = canvasRef.current.getContext("2d");
if (ctx) {
ctx.drawImage(videoRef.current, 0, 0, canvasRef.current.width, canvasRef.current.height);
}
// 현재 프레임 캡처
const imageData = canvasRef.current.toDataURL("image/png");
setCapturedImage(imageData);
// 콜백 함수가 제공된 경우 호출
if (onCapture) {
onCapture(imageData);
}
// 리소스 해제
cleanupResources();
}, [onCapture, cleanupResources]);
// 비디오 프레임 처리 함수
const processVideo = useCallback(async () => {
if (!isCapturing || !isFaceApiLoaded() || !detectionStarted) return;
try {
if (!videoRef.current || !canvasRef.current) return;
// 비디오 미러링 상태 감지
const videoStyle = window.getComputedStyle(videoRef.current);
const isVideoMirrored = videoStyle.transform.includes("scaleX(-1)");
if (isVideoMirrored !== isMirrored) {
console.log("비디오 미러링 상태 변경 감지:", isVideoMirrored);
setIsMirrored(isVideoMirrored);
}
// 비디오 요소 정보 확인
console.log(
"비디오 상태:",
"준비상태:",
videoRef.current.readyState,
"크기:",
videoRef.current.videoWidth,
"x",
videoRef.current.videoHeight,
"스트림:",
videoRef.current.srcObject ? "있음" : "없음",
"미러링:",
isVideoMirrored ? "적용" : "미적용"
);
// 캔버스 컨텍스트 가져오기
const ctx = canvasRef.current.getContext("2d");
if (!ctx) return;
// 캔버스 크기 조정
if (canvasRef.current.width !== videoRef.current.videoWidth || canvasRef.current.height !== videoRef.current.videoHeight) {
canvasRef.current.width = videoRef.current.videoWidth;
canvasRef.current.height = videoRef.current.videoHeight;
console.log("캔버스 크기 조정:", canvasRef.current.width, "x", canvasRef.current.height);
}
// 비디오 프레임을 캔버스에 그림
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
// 디버깅 정보: 가이드라인 그리기
const canvasWidth = canvasRef.current.width;
const canvasHeight = canvasRef.current.height;
// 중앙선 그리기 (디버깅용)
ctx.beginPath();
ctx.moveTo(canvasWidth / 2, 0);
ctx.lineTo(canvasWidth / 2, canvasHeight);
ctx.strokeStyle = "rgba(255, 0, 0, 0.5)";
ctx.stroke();
// 목표 각도 가이드라인 그리기
ctx.save();
ctx.translate(canvasWidth / 2, canvasHeight / 2); // 캔버스 중앙으로 이동
ctx.rotate((targetAngle * Math.PI) / 180); // 라디안으로 변환하여 회전
// 목표 각도 선 그리기
ctx.beginPath();
ctx.moveTo(0, -canvasHeight / 3);
ctx.lineTo(0, canvasHeight / 3);
ctx.strokeStyle = "rgba(0, 255, 0, 0.5)";
ctx.lineWidth = 2;
ctx.stroke();
// 각도 텍스트 추가
ctx.fillStyle = "rgba(0, 255, 0, 0.8)";
ctx.font = "20px Arial";
ctx.fillText(`목표 ${targetAngle}°`, 10, 0);
ctx.restore(); // 캔버스 상태 복원
let detections;
try {
console.time("faceDetection");
// 얼굴 감지 - 옵션 조정
detections = await faceapi
.detectAllFaces(
videoRef.current,
new faceapi.TinyFaceDetectorOptions({
inputSize: 416, // 입력 크기 증가
scoreThreshold: 0.3, // 임계값 더 낮춤
})
)
.withFaceLandmarks();
console.timeEnd("faceDetection");
// 감지 결과 로깅
console.log("감지된 얼굴 수:", detections.length);
} catch (detectError) {
console.error("얼굴 감지 중 오류:", detectError);
detections = [];
}
// 감지된 얼굴이 있는 경우
if (detections && detections.length > 0) {
// 첫 번째 얼굴 선택
const face = detections[0];
console.log(
"얼굴 감지됨",
"위치:",
"x:",
face.detection.box.x.toFixed(1),
"y:",
face.detection.box.y.toFixed(1),
"너비:",
face.detection.box.width.toFixed(1),
"높이:",
face.detection.box.height.toFixed(1),
"신뢰도:",
face.detection.score.toFixed(3)
);
// 얼굴 박스 그리기
ctx.lineWidth = 2;
ctx.strokeStyle = "rgba(0, 255, 255, 0.8)";
ctx.strokeRect(face.detection.box.x, face.detection.box.y, face.detection.box.width, face.detection.box.height);
// 랜드마크 그리기
try {
ctx.lineWidth = 2; // 더 두꺼운 선으로 그리기
faceapi.draw.drawFaceLandmarks(canvasRef.current, face);
} catch (drawError) {
console.error("랜드마크 그리기 오류:", drawError);
}
// 헤드 포즈 각도 계산 시도
try {
const yawAngle = calculateYaw(face.landmarks);
// 값이 유효한 경우에만 상태 업데이트
if (!isNaN(yawAngle) && isFinite(yawAngle)) {
setFaceAngle(yawAngle);
// 콘솔에 현재 각도 정보 출력
console.log("최종 각도:", yawAngle.toFixed(1), "도, 타겟 각도:", targetAngle);
// 각도 표시 (캔버스에 그리기)
ctx.save();
ctx.translate(canvasWidth / 2, canvasHeight / 2); // 캔버스 중앙으로 이동
ctx.rotate((yawAngle * Math.PI) / 180); // 라디안으로 변환하여 회전
// 현재 각도 선 그리기
ctx.beginPath();
ctx.moveTo(0, -canvasHeight / 4);
ctx.lineTo(0, canvasHeight / 4);
ctx.strokeStyle = "rgba(255, 255, 0, 0.7)";
ctx.lineWidth = 3;
ctx.stroke();
// 각도 텍스트 추가
ctx.fillStyle = "rgba(255, 255, 0, 0.9)";
ctx.font = "20px Arial";
ctx.fillText(`${yawAngle.toFixed(1)}°`, 10, 30);
ctx.restore(); // 캔버스 상태 복원
// 각도가 목표 범위 내에 있는지 확인
const isInTargetRange = Math.abs(yawAngle - targetAngle) <= angleThreshold;
setIsTargetAngleReached(isInTargetRange);
console.log("목표 각도 도달:", isInTargetRange, "허용 범위:", angleThreshold);
// 목표 각도에 도달하면 자동 캡처
if (isInTargetRange) {
// 화면에 "정확한 각도입니다!" 표시
ctx.fillStyle = "rgba(0, 255, 0, 0.7)";
ctx.font = "bold 30px Arial";
ctx.textAlign = "center";
ctx.fillText("정확한 각도입니다!", canvasWidth / 2, 50);
const currentTime = Date.now();
if (currentTime - lastCaptureTime > captureDelay) {
console.log("목표 각도 도달 - 캡처 예약 (300ms 후)");
// 여기서 바로 캡처하지 않고 시간을 두고 실행
setTimeout(() => {
if (isCapturing) {
// 캡처 중인지 다시 확인
console.log("캡처 실행");
captureFrame();
setLastCaptureTime(currentTime);
}
}, 300);
} else {
console.log("대기 중 - 마지막 캡처 후", currentTime - lastCaptureTime, "ms 경과 (딜레이:", captureDelay, "ms)");
}
} else {
// 각도 편차 표시
const deviation = Math.abs(yawAngle - targetAngle);
let message = "";
if (deviation > 30) {
message = "많이 돌려주세요";
} else if (deviation > 20) {
message = "조금 더 돌려주세요";
} else if (deviation > 10) {
message = "거의 다 왔어요!";
}
if (message) {
ctx.fillStyle = "rgba(255, 165, 0, 0.8)";
ctx.font = "bold 24px Arial";
ctx.textAlign = "center";
ctx.fillText(message, canvasWidth / 2, 50);
}
}
} else {
console.warn("계산된 각도가 유효하지 않음:", yawAngle);
}
} catch (angleError) {
console.error("각도 계산 오류:", angleError);
}
} else {
setIsTargetAngleReached(false);
console.log("얼굴이 감지되지 않음");
// 얼굴이 감지되지 않음 메시지 표시
ctx.fillStyle = "rgba(255, 0, 0, 0.7)";
ctx.font = "bold 28px Arial";
ctx.textAlign = "center";
ctx.fillText("얼굴이 감지되지 않습니다", canvasWidth / 2, canvasHeight / 2);
}
// 다음 프레임 처리 (requestAnimationFrame 사용)
if (isCapturing) {
requestAnimationFrame(() => processVideo());
}
} catch (err) {
console.error("비디오 처리 오류:", err);
if (isCapturing) {
// 오류 발생 시 약간의 지연 후 다시 시도
setTimeout(() => {
requestAnimationFrame(() => processVideo());
}, 500);
}
}
}, [isCapturing, detectionStarted, videoRef, canvasRef, captureFrame, calculateYaw, lastCaptureTime, targetAngle, angleThreshold, isMirrored]);
// 얼굴 감지 시작 (기존 카메라 스트림 사용)
const startFaceDetection = useCallback(async () => {
if (!faceApiLoaded) {
console.error("Face-API.js가 아직 로드되지 않았습니다");
return;
}
if (!isModelLoaded) {
// 초기화
const success = await initFaceDetection();
if (!success) {
console.error("얼굴 감지 초기화에 실패했습니다");
return;
}
}
// 이미 캡처 중이면 중지
if (isCapturing) {
stopFaceDetection();
}
console.log("얼굴 감지 시작");
// 비디오 요소가 있고 스트림이 이미 연결되어 있는지 확인
if (videoRef.current && videoRef.current.srcObject) {
try {
// 캔버스 크기 설정
if (canvasRef.current) {
canvasRef.current.width = videoRef.current.videoWidth || 640;
canvasRef.current.height = videoRef.current.videoHeight || 480;
}
// 캡처 상태 초기화
setIsCapturing(true);
setCapturedImage(null);
setIsTargetAngleReached(false);
// 약간의 지연 후 감지 시작
setTimeout(() => {
setDetectionStarted(true);
processVideo();
}, 500);
} catch (error) {
console.error("얼굴 감지 시작 실패:", error);
}
} else {
console.error("비디오 스트림이 없거나 접근할 수 없습니다");
}
}, [processVideo, videoRef, faceApiLoaded, isModelLoaded, isCapturing, initFaceDetection, stopFaceDetection]);
// 캡처 리셋
const resetCapture = useCallback(() => {
if (isCapturing) {
stopFaceDetection();
}
setIsCapturing(true);
setCapturedImage(null);
setIsTargetAngleReached(false);
// 프레임 처리 재시작
setDetectionStarted(true);
processVideo();
}, [isCapturing, processVideo, stopFaceDetection]);
// Face-API.js 초기화 (최초 한 번)
useEffect(() => {
async function initFaceAPI() {
try {
// Face-API.js 로드 대기
await waitForFaceApi();
console.log("Face-API.js 로드 확인됨, 초기화 시작");
setFaceApiLoaded(true);
// 얼굴 감지 초기화
await initFaceDetection();
} catch (error) {
console.error("Face-API.js 초기화 실패:", error);
}
}
// 이미 로드되었는지 확인
if (isFaceApiLoaded()) {
console.log("Face-API.js가 이미 로드되어 있습니다");
setFaceApiLoaded(true);
initFaceDetection();
} else {
initFaceAPI();
}
// 클린업 함수
return () => {
stopFaceDetection();
};
}, [initFaceDetection, stopFaceDetection]);
return {
startFaceDetection,
stopFaceDetection,
isCapturing,
capturedImage,
resetCapture,
faceAngle,
isModelLoaded,
isTargetAngleReached,
faceApiLoaded,
};
};
export default useFaceApi;
→ 얼굴 각도 계산이 안됨
0426
자동 촬영 기능 구현
typescriptif (detections && detections.length > 0) {
// 첫 번째 얼굴 선택
// 캔버스 컨텍스트 가져오기
const ctx = canvasRef.current.getContext("2d");
if (!ctx) {
setIsProcessing(false);
return;
}
// 캔버스 크기 조정
if (canvasRef.current.width !== videoRef.current.videoWidth || canvasRef.current.height !== videoRef.current.videoHeight) {
canvasRef.current.width = videoRef.current.videoWidth;
canvasRef.current.height = videoRef.current.videoHeight;
}
// 캔버스 초기화
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
// 비디오 프레임을 캔버스에 그리기
if (isMirrored) {
// 미러링 적용
ctx.save();
ctx.scale(-1, 1);
ctx.drawImage(videoRef.current, -canvasRef.current.width, 0, canvasRef.current.width, canvasRef.current.height);
ctx.restore();
} else {
ctx.drawImage(videoRef.current, 0, 0, canvasRef.current.width, canvasRef.current.height);
}
// 가이드라인 그리기
const canvasWidth = canvasRef.current.width;
const canvasHeight = canvasRef.current.height;
// 중앙선
ctx.beginPath();
ctx.moveTo(canvasWidth / 2, 0);
ctx.lineTo(canvasWidth / 2, canvasHeight);
ctx.strokeStyle = "rgba(255, 0, 0, 0.5)";
ctx.stroke();
// 목표 각도 선 - 현재 정확한 목표 각도 사용
ctx.save();
ctx.translate(canvasWidth / 2, canvasHeight / 2);
ctx.rotate((targetAngle * Math.PI) / 180);
ctx.beginPath();
ctx.moveTo(0, -canvasHeight / 3);
ctx.lineTo(0, canvasHeight / 3);
ctx.strokeStyle = "rgba(0, 255, 0, 0.5)";
ctx.lineWidth = 2;
ctx.stroke();
ctx.font = "20px Arial";
ctx.fillStyle = "rgba(0, 255, 0, 0.8)";
// 목표 각도와 현재 단계 표시
ctx.fillText(`목표 ${targetAngle}° (${phase})`, 10, 0);
ctx.restore();
// 현재 촬영 단계 표시
let phaseText = "";
switch (phase) {
case "front":
phaseText = "정면을 바라봐주세요";
break;
case "left":
phaseText = "좌측으로 돌려주세요";
break;
case "right":
phaseText = "우측으로 돌려주세요";
break;
default:
phaseText = "촬영 완료";
}
ctx.font = "bold 20px Arial";
ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
ctx.textAlign = "center";
ctx.fillText(phaseText, canvasWidth / 2, 30);
// Face-API 얼굴 감지
if (!faceapi || typeof faceapi.detectAllFaces !== "function") {
setIsProcessing(false);
return;
}
// 얼굴 감지 수행
const detectorOptions = new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: 0.3 });
// 원본 비디오로부터 감지 (미러링 여부와 상관없이)
const detections = await faceapi.detectAllFaces(videoRef.current, detectorOptions).withFaceLandmarks();
// 얼굴이 감지되지 않은 경우
if (!detections || detections.length === 0) {
ctx.fillStyle = "rgba(255, 0, 0, 0.7)";
ctx.font = "bold 24px Arial";
ctx.textAlign = "center";
ctx.fillText("얼굴이 감지되지 않습니다", canvasWidth / 2, canvasHeight / 2);
setFaceAngle(0);
setIsTargetAngleReached(false);
if (isProcessing) {
requestAnimationFrameId.current = requestAnimationFrame(processFrame);
}
return;
}
typescriptsetIsTargetAngleReached(isInRange);
// 목표 각도 도달 여부 표시 및 유용한 피드백 제공
if (isInRange) {
ctx.fillStyle = "rgba(0, 255, 0, 0.7)";
ctx.font = "bold 24px Arial";
ctx.textAlign = "center";
ctx.fillText("각도가 적절합니다", canvasWidth / 2, 70);
} else {
ctx.fillStyle = "rgba(255, 0, 0, 0.7)";
ctx.font = "bold 24px Arial";
ctx.textAlign = "center";
// 더 유용한 피드백 메시지 제공
let feedbackMsg = "";
switch (phase) {
case "front":
feedbackMsg = "정면을 바라봐주세요";
break;
case "left":
if (yawAngle >= 0) {
feedbackMsg = "왼쪽으로 더 돌려주세요";
} else if (Math.abs(yawAngle) < 20) {
feedbackMsg = "왼쪽으로 더 돌려주세요";
} else if (Math.abs(yawAngle) > Math.abs(targetAngle) + angleThreshold) {
feedbackMsg = "너무 많이 돌렸습니다";
typescriptcase "right":
if (yawAngle <= 0) {
feedbackMsg = "오른쪽으로 더 돌려주세요";
} else if (Math.abs(yawAngle) < 20) {
feedbackMsg = "오른쪽으로 더 돌려주세요";
} else if (Math.abs(yawAngle) > Math.abs(targetAngle) + angleThreshold) {
feedbackMsg = "너무 많이 돌렸습니다";
} else {
feedbackMsg = `목표 각도(${targetAngle}°)에 가깝게 조정하세요`;
typescriptconst currentTime = Date.now();
const captureDelay = 1500; // 1.5초 딜레이
if (isInRange && currentTime - lastCaptureTime > captureDelay) {
setLastCaptureTime(currentTime);
captureFrame();
setIsProcessing(false);
return; // 캡처 후 다음 프레임은 처리하지 않음
}
} catch (error) {
console.error("비디오 프레임 처리 오류:", error);
}
typescript// processVideo 함수를 참조에 할당
useEffect(() => {
processVideoRef.current = processVideo;
}, [processVideo]);
// 얼굴 감지 시작
const startFaceDetection = useCallback(async () => {
// 기존 실행 중인 프로세스 중지
stopFaceDetection();
// 상태 초기화 (초기화할 때 현재 단계 유지, 리셋할 때만 front로 설정)
console.log("얼굴 감지 시작: 상태 리셋");
// 외부에서 전달된 targetAngle이 있으면 해당 각도에 맞는 단계로 설정
if (targetAngle !== undefined) {
if (targetAngle === 0 || Math.abs(targetAngle) < 10) {
setCapturePhase("front");
} else if (targetAngle > 0) {
setCapturePhase("left");
} else if (targetAngle < 0) {
setCapturePhase("right");
}
// 직접 targetAngle 값을 사용
setCurrentTargetAngle(targetAngle);
console.log(`외부 목표 각도 적용: ${targetAngle}°`);
}
// 아니면 현재 단계 기반으로 각도 결정
else if (capturePhase === "complete") {
setCapturePhase("front");
setCurrentTargetAngle(angleByPhase.front);
} else {
// 현재 단계 유지하고 해당 각도만 업데이트
setCurrentTargetAngle(angleByPhase[capturePhase]);
좌우 반전 계속 되고 얼굴 각도 감지를 못하는 오류 계속 발생 따라서 단순 버튼 클릭으로 변경
typescriptimport { useEffect, useState, useRef, useCallback, RefObject } from "react";
// Face API 훅을 위한 타입 정의
interface UseFaceApiParams {
videoRef: RefObject<HTMLVideoElement | null>;
canvasRef: RefObject<HTMLCanvasElement | null>;
onCapture?: (imageData: string) => void;
initialPhase?: "front" | "left" | "right";
}
interface FaceApiReturnType {
faceAngle: number;
isTargetAngleReached: boolean;
isForheadVisible: boolean;
isLoading: boolean;
isDetectionActive: boolean; // 감지 상태 노출
startDetection: () => void;
stopDetection: () => void;
}
// Face-API 관련 타입 정의
interface FaceDetection {
detection: {
box: {
width: number;
height: number;
};
};
landmarks: {
positions: Array<{ x: number; y: number }>;
getJawOutline: () => Array<{ x: number; y: number }>;
getNose: () => Array<{ x: number; y: number }>;
getLeftEye: () => Array<{ x: number; y: number }>;
getRightEye: () => Array<{ x: number; y: number }>;
};
}
/**
* Face API를 사용하여 얼굴 각도 감지 및 분석을 수행하는 훅
*/
const useFaceApi = ({ videoRef, canvasRef, onCapture, initialPhase = "front" }: UseFaceApiParams): FaceApiReturnType => {
// 상태 관리
const [faceAngle, setFaceAngle] = useState<number>(0);
const [isTargetAngleReached, setIsTargetAngleReached] = useState<boolean>(false);
const [isForheadVisible, setIsForheadVisible] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isDetectionActive, setIsDetectionActive] = useState<boolean>(false);
const [currentPhase, setCurrentPhase] = useState<"front" | "left" | "right">(initialPhase);
const [detectionsCount, setDetectionsCount] = useState<number>(0); // 감지 횟수 추적
// 감지 프로세스 관리를 위한 refs
const detectionIntervalRef = useRef<number | null>(null);
const faceDetectionPromiseRef = useRef<Promise<void> | null>(null);
const detectionCountRef = useRef<number>(0);
const stabilityCounterRef = useRef<number>(0);
const lastCaptureTimeRef = useRef<number>(0);
const isDetectionActiveRef = useRef<boolean>(false); // 감지 활성화 상태를 추적하는 ref 추가
// 모델 로딩 및 초기화
useEffect(() => {
const loadModels = async () => {
try {
setIsLoading(true);
console.log("Face-API 모델 로딩 시작");
// faceapi가 window 객체에 등록되었는지 확인
if (typeof window.faceapi === "undefined") {
console.error("Face-API가 로드되지 않았습니다.");
throw new Error("Face-API가 로드되지 않았습니다.");
}
// 필요한 모델 로드 - 상대 경로로 변경
await Promise.all([window.faceapi.nets.tinyFaceDetector.loadFromUri("/models"), window.faceapi.nets.faceLandmark68Net.loadFromUri("/models")]);
// 모델 로딩 상태 확인
console.log("Face-API 모델 로딩 완료 상태:", {
tinyFaceDetector: window.faceapi.nets.tinyFaceDetector.isLoaded,
faceLandmark68Net: window.faceapi.nets.faceLandmark68Net.isLoaded,
});
setIsLoading(false);
} catch (error) {
console.error("Face-API 모델 로딩 실패:", error);
setIsLoading(false);
}
};
loadModels();
// 컴포넌트 언마운트 시 정리
return () => {
stopDetection();
};
}, []);
// 현재 단계가 변경될 때 업데이트
useEffect(() => {
setCurrentPhase(initialPhase);
console.log("현재 얼굴 감지 단계 변경:", initialPhase);
}, [initialPhase]);
// 얼굴 감지 및 각도 계산
const detectFace = useCallback(async () => {
if (!videoRef.current || !canvasRef.current || !isDetectionActiveRef.current) {
// 디버깅: 감지가 실행되지 않는 이유 확인
if (!isDetectionActiveRef.current) {
console.log("감지가 활성화되지 않아 실행되지 않음 (ref 기준)");
}
if (!videoRef.current) {
console.log("비디오 요소가 없어 감지 실행되지 않음");
}
if (!canvasRef.current) {
console.log("캔버스 요소가 없어 감지 실행되지 않음");
}
return;
}
const video = videoRef.current;
const canvas = canvasRef.current;
// 비디오가 재생 중이고 크기가 유효한지 확인
if (video.paused || video.ended || !video.videoWidth) {
console.log("비디오 상태 확인 - 재생 중 아님:", {
paused: video.paused,
ended: video.ended,
videoWidth: video.videoWidth,
});
return;
}
try {
// 캔버스 크기 설정
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// 캔버스 초기화
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 얼굴 감지 수행
const options = new window.faceapi.TinyFaceDetectorOptions({ inputSize: 224, scoreThreshold: 0.5 });
// 감지 시작 시간 로깅 (성능 디버깅용)
const detectionStartTime = performance.now();
const detections = await window.faceapi.detectAllFaces(video, options).withFaceLandmarks();
// 감지 완료 시간 및 소요 시간 로깅
const detectionEndTime = performance.now();
console.log(`얼굴 감지 소요 시간: ${(detectionEndTime - detectionStartTime).toFixed(2)}ms`);
detectionCountRef.current++;
setDetectionsCount((prevCount) => prevCount + 1);
// 얼굴이 감지되지 않았을 경우
if (!detections || detections.length === 0) {
console.log("얼굴이 감지되지 않음", detectionCountRef.current);
setFaceAngle(0);
setIsTargetAngleReached(false);
setIsForheadVisible(false);
stabilityCounterRef.current = 0;
return;
}
console.log("얼굴 감지됨:", detections.length);
// 가장 큰 얼굴 선택 (여러 얼굴이 감지된 경우)
const face = detections.sort((a: FaceDetection, b: FaceDetection) => b.detection.box.width * b.detection.box.height - a.detection.box.width * a.detection.box.height)[0];
// 랜드마크 좌표 추출
const landmarks = face.landmarks;
const nose = landmarks.getNose();
const jawline = landmarks.getJawOutline();
const leftEye = landmarks.getLeftEye();
const rightEye = landmarks.getRightEye();
// 얼굴 중앙점 (코 끝)
const noseTip = nose[3];
// 얼굴 회전 각도 계산
const leftEyeCenter = {
x: leftEye.reduce((sum: number, point: { x: number; y: number }) => sum + point.x, 0) / leftEye.length,
y: leftEye.reduce((sum: number, point: { x: number; y: number }) => sum + point.y, 0) / leftEye.length,
};
const rightEyeCenter = {
x: rightEye.reduce((sum: number, point: { x: number; y: number }) => sum + point.x, 0) / rightEye.length,
y: rightEye.reduce((sum: number, point: { x: number; y: number }) => sum + point.y, 0) / rightEye.length,
};
// 좌우 턱 끝 점 (얼굴 좌우 경계)
const leftJaw = jawline[0];
const rightJaw = jawline[jawline.length - 1];
typescript// 코와 턱 중앙 좌표 계산
const jawCenter = {
x: (leftJaw.x + rightJaw.x) / 2,
y: (leftJaw.y + rightJaw.y) / 2,
};
// 얼굴 너비 계산
const faceWidth = rightJaw.x - leftJaw.x;
// 코와 턱 중앙의 x좌표 차이 계산 (중요: 이 값이 얼굴 회전 각도의 기본)
const faceRotation = noseTip.x - jawCenter.x;
// 얼굴 너비를 기준으로 정규화된 회전 각도 계산 (-45도~45도 범위)
// 카메라 영상은 거울처럼 좌우가 반대로 보이므로 -1을 곱해 보정
const normalizedRotation = (faceRotation / (faceWidth / 2)) * 45;
// 계산 과정 디버깅 로그
console.log("얼굴 각도 계산:", {
noseTipX: noseTip.x,
jawCenterX: jawCenter.x,
faceRotation: faceRotation,
faceWidth: faceWidth,
normalizedRotation: normalizedRotation,
});
// 이마 노출 여부 확인
const forehead = landmarks.positions[20]; // 이마 부분에 해당하는 랜드마크
const isForheadVisible = forehead && forehead.y > 0 && forehead.y < video.videoHeight;
// 얼굴 각도 및 상태 업데이트
setFaceAngle(normalizedRotation);
setIsForheadVisible(isForheadVisible);
// 현재 단계에 따른 적절한 각도 도달 여부 체크
let targetReached = false;
switch (currentPhase) {
case "front":
// 정면 조건: 각도가 ±15도 이내
targetReached = Math.abs(normalizedRotation) <= 15;
break;
case "left":
// 좌측 조건: 각도가 -5도 미만(더 음수)
targetReached = normalizedRotation < -5;
break;
case "right":
// 우측 조건: 각도가 +5도 초과
targetReached = normalizedRotation > 5;
break;
}
// 안정된 상태일 때만 캡처 이벤트 발생
if (targetReached) {
stabilityCounterRef.current++;
// 0.5초 이상 안정된 상태를 유지했을 때 (30fps 기준으로 약 15프레임)
if (stabilityCounterRef.current >= 15) {
setIsTargetAngleReached(true);
// 캡처 콜백이 있고, 마지막 캡처 이후 2초 이상 지났을 때만 캡처
const now = Date.now();
if (onCapture && now - lastCaptureTimeRef.current > 2000) {
lastCaptureTimeRef.current = now;
// 얼굴 각도가 조건에 맞고, 충분히 안정화되었을 때 캡처 이벤트 발생
onCapture("capture");
// 안정화 카운터 초기화
stabilityCounterRef.current = 0;
}
}
} else {
stabilityCounterRef.current = 0;
setIsTargetAngleReached(false);
}
// 감지 결과 시각화
// window.faceapi.draw.drawDetections(canvas, [face.detection]);
// 랜드마크 표시 제거 (주석 처리)
// window.faceapi.draw.drawFaceLandmarks(canvas, [landmarks]);
// 디버깅 텍스트 표시 제거 (주석 처리)
/*
// 각도 표시
ctx.font = "16px Arial";
ctx.fillStyle = "red";
ctx.fillText(`각도: ${normalizedRotation.toFixed(1)}°`, 10, 30);
// 현재 단계 표시
ctx.fillText(`단계: ${currentPhase}`, 10, 60);
// 타겟 도달 여부 표시
ctx.fillText(`타겟 도달: ${targetReached ? "✓" : "✗"}`, 10, 90);
// 감지 카운트 표시
ctx.fillText(`감지 횟수: ${detectionCountRef.current}`, 10, 120);
*/
} catch (error) {
console.error("얼굴 감지 중 오류 발생:", error);
}
}, [videoRef, canvasRef, isDetectionActiveRef, currentPhase, onCapture]);
// 얼굴 감지 시작
const startDetection = useCallback(() => {
if (isDetectionActiveRef.current) {
console.log("이미 감지가 활성화되어 있습니다 (ref 기준).");
return;
}
if (!videoRef.current) {
console.error("비디오 참조가 없어 감지를 시작할 수 없습니다.");
return;
}
const video = videoRef.current;
if (video.paused || video.ended || !video.videoWidth) {
console.error("비디오가 재생 중이지 않아 감지를 시작할 수 없습니다:", {
paused: video.paused,
ended: video.ended,
videoWidth: video.videoWidth,
});
// 비디오가 준비되지 않았다면 재생 시도
try {
video.play().catch((err) => {
console.error("비디오 재생 실패:", err);
});
} catch (error) {
console.error("비디오 재생 시도 중 오류:", error);
}
// 1초 후에 다시 시도
setTimeout(() => {
console.log("비디오 준비 상태 확인 후 감지 재시도");
startDetection();
}, 1000);
return;
}
console.log("얼굴 감지 시작");
setIsDetectionActive(true);
isDetectionActiveRef.current = true; // Ref 값 업데이트
console.log("감지 활성화 상태 설정됨 (ref):", isDetectionActiveRef.current);
stabilityCounterRef.current = 0;
detectionCountRef.current = 0;
setDetectionsCount(0);
// 감지 인터벌 설정 (30ms마다 = 약 30fps)
detectionIntervalRef.current = window.setInterval(() => {
// 이전 감지가 완료될 때까지 대기
if (!faceDetectionPromiseRef.current) {
faceDetectionPromiseRef.current = detectFace().finally(() => {
faceDetectionPromiseRef.current = null;
});
}
}, 30);
}, [detectFace, videoRef]);
// 얼굴 감지 중지
const stopDetection = useCallback(() => {
if (!isDetectionActiveRef.current || !detectionIntervalRef.current) {
console.log("감지가 이미 중지되어 있습니다 (ref 기준).");
return;
}
console.log("얼굴 감지 중지");
window.clearInterval(detectionIntervalRef.current);
detectionIntervalRef.current = null;
setIsDetectionActive(false);
isDetectionActiveRef.current = false; // Ref 값 업데이트
setIsTargetAngleReached(false);
stabilityCounterRef.current = 0;
// 캔버스 초기화
if (canvasRef.current) {
const ctx = canvasRef.current.getContext("2d");
if (ctx) {
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
}
}
}, [canvasRef]);
// 비디오 스트림 준비 상태 모니터링
useEffect(() => {
if (!videoRef.current) return;
const checkVideoReady = () => {
const video = videoRef.current;
if (!video) return false;
return !video.paused && !video.ended && video.videoWidth > 0;
};
// 비디오 요소 준비 상태 확인
const videoReadyCheck = setInterval(() => {
if (checkVideoReady() && !isDetectionActiveRef.current) {
console.log("비디오 준비됨, 감지 시작 가능");
clearInterval(videoReadyCheck);
}
}, 500);
// 이벤트 리스너 설정
const handleVideoPlay = () => {
console.log("비디오 재생 시작됨");
if (!isDetectionActiveRef.current && !isLoading) {
console.log("비디오 준비됨, 감지 자동 시작 시도");
startDetection();
}
};
videoRef.current.addEventListener("play", handleVideoPlay);
return () => {
clearInterval(videoReadyCheck);
if (videoRef.current) {
videoRef.current.removeEventListener("play", handleVideoPlay);
}
};
}, [videoRef, startDetection, isLoading]);
// 반환값
return {
faceAngle,
isTargetAngleReached,
isForheadVisible,
isLoading,
isDetectionActive,
startDetection,
stopDetection,
};
};
// face-api.js 타입 정의 추가
declare global {
interface Window {
faceapi: any;
}
}
export default useFaceApi;
정면, 좌측, 우측 촬영 기능 구현 완료 UI 수정 필요
태그
#Face-api.js