UNPKG

face-detection-web-sdk

Version:

웹 기반 얼굴 인식을 통해 실시간으로 심박수, 스트레스, 혈압 등의 건강 정보를 측정하는 SDK

1,252 lines (1,240 loc) 42.7 kB
var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); import { FaceDetection } from "@mediapipe/face_detection"; const DEFAULT_SDK_CONFIG = { platform: { isIOS: false, isAndroid: false }, measurement: { targetDataPoints: 450, frameInterval: 33.33, frameProcessInterval: 30, readyToMeasuringDelay: 3 }, faceDetection: { timeout: 3e3, minDetectionConfidence: 0.5 }, video: { width: 640, height: 480, frameRate: 30 }, ui: { containerId: "face-detection-container", customClasses: { container: "", video: "", canvas: "", progress: "" } }, server: { baseUrl: "", timeout: 3e4 }, debug: { enabled: false, enableConsoleLog: false }, dataDownload: { enabled: false, autoDownload: false, filename: "rgb_data.txt" }, errorBounding: 4 }; class ConfigManager { constructor(userConfig = {}) { __publicField(this, "config"); this.config = this.mergeConfig(DEFAULT_SDK_CONFIG, userConfig); } /** * 설정 병합 (깊은 병합) */ mergeConfig(defaultConfig, userConfig) { const merged = structuredClone(defaultConfig); return Object.entries(userConfig).reduce((acc, [key, value]) => { if (value === void 0) return acc; acc[key] = key === "elements" ? value : typeof value === "object" && !Array.isArray(value) ? { ...acc[key], ...value } : value; return acc; }, merged); } /** * 현재 설정을 반환합니다. */ getConfig() { return this.config; } } var FaceDetectionState = /* @__PURE__ */ ((FaceDetectionState2) => { FaceDetectionState2["INITIAL"] = "initial"; FaceDetectionState2["READY"] = "ready"; FaceDetectionState2["MEASURING"] = "measuring"; FaceDetectionState2["COMPLETED"] = "completed"; return FaceDetectionState2; })(FaceDetectionState || {}); var FaceDetectionErrorType = /* @__PURE__ */ ((FaceDetectionErrorType2) => { FaceDetectionErrorType2["FACE_NOT_DETECTED"] = "face_not_detected"; FaceDetectionErrorType2["FACE_OUT_OF_CIRCLE"] = "face_out_of_circle"; FaceDetectionErrorType2["WEBCAM_PERMISSION_DENIED"] = "webcam_permission_denied"; FaceDetectionErrorType2["WEBCAM_ACCESS_FAILED"] = "webcam_access_failed"; FaceDetectionErrorType2["INITIALIZATION_FAILED"] = "initialization_failed"; FaceDetectionErrorType2["UNKNOWN_ERROR"] = "unknown_error"; return FaceDetectionErrorType2; })(FaceDetectionErrorType || {}); class EventManager { constructor(callbacks = {}, log) { __publicField(this, "stateChangeCallbacks", []); __publicField(this, "callbacks", {}); __publicField(this, "log"); this.callbacks = callbacks; this.log = log; callbacks.onStateChange && this.onStateChange(callbacks.onStateChange); } /** * 상태 변경 콜백을 등록합니다. */ onStateChange(callback) { this.stateChangeCallbacks.push(callback); } /** * 상태 변경 콜백을 제거합니다. */ removeStateChangeCallback(callback) { const index = this.stateChangeCallbacks.indexOf(callback); if (index > -1) { this.stateChangeCallbacks.splice(index, 1); } } /** * 상태 변경 이벤트를 발생시킵니다. */ emitStateChange(newState, previousState) { this.stateChangeCallbacks.forEach((callback) => { try { callback(newState, previousState); } catch (error) { console.error("상태 변경 콜백 실행 중 오류:", error); } }); } /** * 에러 이벤트를 발생시킵니다. */ emitError(error, errorType, context) { var _a, _b, _c; const message = context ? `${context}: ${error.message}` : error.message; const type = errorType ?? FaceDetectionErrorType.UNKNOWN_ERROR; (_a = this.log) == null ? void 0 : _a.call(this, `오류 발생: ${message}`); (_c = (_b = this.callbacks).onError) == null ? void 0 : _c.call(_b, { type, message }); } /** * 웹캠 에러 이벤트를 발생시킵니다. */ emitWebcamError(err, isIOS) { var _a, _b, _c; const isPermissionError = err.name === "NotAllowedError" || err.name === "PermissionDeniedError"; const isIOSPermissionError = isIOS && /permission|허가|권한/.test(err.message ?? ""); const isDenied = isPermissionError || isIOSPermissionError; const type = isDenied ? FaceDetectionErrorType.WEBCAM_PERMISSION_DENIED : FaceDetectionErrorType.WEBCAM_ACCESS_FAILED; const message = isDenied ? "웹캠 접근 권한이 거부되었습니다. 브라우저 설정에서 카메라 권한을 허용해주세요." : `웹캠에 접근할 수 없습니다: ${err.message}`; (_a = this.log) == null ? void 0 : _a.call(this, `웹캠 오류 발생: ${message}`); (_c = (_b = this.callbacks).onError) == null ? void 0 : _c.call(_b, { type, message }); } /** * 얼굴 감지 상태 변경 이벤트를 발생시킵니다. */ emitFaceDetectionChange(isDetected, boundingBox) { var _a, _b; (_b = (_a = this.callbacks).onFaceDetectionChange) == null ? void 0 : _b.call(_a, isDetected, boundingBox); } /** * 얼굴 위치 변경 이벤트를 발생시킵니다. */ emitFacePositionChange(isInCircle) { var _a, _b; (_b = (_a = this.callbacks).onFacePositionChange) == null ? void 0 : _b.call(_a, isInCircle); } /** * 측정 진행률 이벤트를 발생시킵니다. */ emitProgress(progress, dataPoints) { var _a, _b; (_b = (_a = this.callbacks).onProgress) == null ? void 0 : _b.call(_a, progress, dataPoints); } /** * 측정 완료 이벤트를 발생시킵니다. */ emitMeasurementComplete(result) { var _a, _b; (_b = (_a = this.callbacks).onMeasurementComplete) == null ? void 0 : _b.call(_a, result); } /** * 카운트다운 이벤트를 발생시킵니다. */ emitCountdown(remainingSeconds, totalSeconds) { var _a, _b; (_b = (_a = this.callbacks).onCountdown) == null ? void 0 : _b.call(_a, remainingSeconds, totalSeconds); } /** * 이벤트 콜백을 정리합니다. */ dispose() { this.stateChangeCallbacks = []; this.callbacks = {}; } } class MediapipeManager { constructor() { __publicField(this, "faceDetection"); __publicField(this, "onResultsCallback", null); } /** * MediaPipe Face Detection을 초기화합니다. */ async initialize(minDetectionConfidence = 0.5) { this.faceDetection = new FaceDetection({ locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_detection/${file}` }); const faceDetectionConfig = { model: "short", minDetectionConfidence, runningMode: "VIDEO" }; this.faceDetection.setOptions(faceDetectionConfig); } /** * 얼굴 인식 결과 처리 콜백을 설정합니다. */ setOnResultsCallback(callback) { this.onResultsCallback = callback; this.faceDetection.onResults(this.onResultsCallback); } /** * 이미지를 MediaPipe에 전송하여 얼굴 인식을 수행합니다. */ async sendImage(image) { await this.faceDetection.send({ image }); } /** * MediaPipe 리소스를 정리합니다. */ dispose() { this.faceDetection = null; this.onResultsCallback = null; } } class StateManager { constructor() { __publicField(this, "currentState", FaceDetectionState.INITIAL); __publicField(this, "stateChangeCallback"); } /** * 현재 상태를 반환합니다. */ getCurrentState() { return this.currentState; } /** * 상태 변경 콜백을 설정합니다. */ setStateChangeCallback(callback) { this.stateChangeCallback = callback; } /** * 상태를 변경하고 이벤트를 발생시킵니다. */ setState(newState) { const previousState = this.currentState; this.currentState = newState; this.emitStateChange(newState, previousState); } /** * 상태 변경 이벤트를 발생시킵니다. */ emitStateChange(newState, previousState) { var _a; try { (_a = this.stateChangeCallback) == null ? void 0 : _a.call(this, newState, previousState); } catch (error) { console.error("상태 변경 콜백 실행 중 오류:", error); } } /** * 특정 상태인지 확인합니다. */ isState(state) { return this.currentState === state; } /** * 여러 상태 중 하나인지 확인합니다. */ isAnyState(...states) { return states.includes(this.currentState); } } class WebcamManager { constructor(config, events) { __publicField(this, "webcamStream", null); __publicField(this, "config"); __publicField(this, "events"); this.config = config; this.events = events; } /** * 웹캠 스트림을 시작합니다. */ async startWebcam() { var _a, _b, _c; try { const videoConfig = { width: ((_a = this.config.video) == null ? void 0 : _a.width) || 640, height: ((_b = this.config.video) == null ? void 0 : _b.height) || 480, frameRate: ((_c = this.config.video) == null ? void 0 : _c.frameRate) || 30 }; this.webcamStream = await navigator.mediaDevices.getUserMedia({ video: videoConfig }); return this.webcamStream; } catch (err) { this.handleWebcamError(err); throw err; } } /** * 웹캠 스트림을 중지합니다. */ stopWebcam() { var _a; (_a = this.webcamStream) == null ? void 0 : _a.getTracks().forEach((track) => track.stop()); this.webcamStream = null; } /** * 웹캠 에러를 처리합니다. */ handleWebcamError(err) { var _a; const isIOS = ((_a = this.config.platform) == null ? void 0 : _a.isIOS) || false; this.events.onWebcamError(err, isIOS); } /** * 리소스를 정리합니다. */ dispose() { this.stopWebcam(); } } function calculateBoundingBox({ boundingBox }, imageWidth, imageHeight) { const scaleFactor = 0.6; const width = boundingBox.width * imageWidth * scaleFactor; const height = boundingBox.height * imageHeight * scaleFactor; const left = boundingBox.xCenter * imageWidth - width / 2; const top = boundingBox.yCenter * imageHeight - height / 2; return { left, top, width, height }; } function updatePositionErrors(left, top, lastPosition, lastYPosition, positionErr, yPositionErr, errorBounding) { if (lastPosition && Math.abs(left - lastPosition) > errorBounding) positionErr++; if (lastYPosition && Math.abs(top - lastYPosition) > errorBounding) yPositionErr++; return { lastPosition: left, lastYPosition: top, positionErr, yPositionErr }; } function checkFacePosition(faceX, faceY, video, container) { const progressRect = container.getBoundingClientRect(); const videoRect = video.getBoundingClientRect(); const scaleX = video.videoWidth / videoRect.width; const scaleY = video.videoHeight / videoRect.height; const progressCenter = { x: (progressRect.left - videoRect.left + progressRect.width / 2) * scaleX, y: (progressRect.top - videoRect.top + progressRect.height / 2) * scaleY }; const radius = progressRect.width / 2 * scaleX; const allowedRadius = radius * 0.6; const distance = Math.sqrt( Math.pow(faceX - progressCenter.x, 2) + Math.pow(faceY - progressCenter.y, 2) ); const isInCircle = distance <= allowedRadius; return { isInCircle, // 얼굴이 원 안에 있는지 여부 distance, // 중심점으로부터의 거리 allowedRadius, // 허용 반지름 progressCenter // 원형 진행 바의 중심점 좌표 }; } class FacePositionManager { constructor(errorBounding = 4) { __publicField(this, "lastPosition", 0); __publicField(this, "lastYPosition", 0); __publicField(this, "positionErr", 0); __publicField(this, "yPositionErr", 0); this.errorBounding = errorBounding; } /** * 얼굴 위치를 업데이트하고 에러를 계산합니다. */ updateFacePosition(boundingBox, video, container) { const faceX = boundingBox.xCenter * video.videoWidth; const faceY = boundingBox.yCenter * video.videoHeight; const { isInCircle } = checkFacePosition(faceX, faceY, video, container); ({ lastPosition: this.lastPosition, lastYPosition: this.lastYPosition, positionErr: this.positionErr, yPositionErr: this.yPositionErr } = updatePositionErrors( faceX, faceY, this.lastPosition, this.lastYPosition, this.positionErr, this.yPositionErr, this.errorBounding )); return { isInCircle }; } /** * 현재 위치 에러를 반환합니다. */ getPositionErrors() { return { positionErr: this.positionErr, yPositionErr: this.yPositionErr }; } } class WorkerManager { constructor(events) { __publicField(this, "faceRegionWorker"); __publicField(this, "lastRGB"); __publicField(this, "events"); this.events = events; } /** * 워커를 초기화합니다. */ initialize() { const workerUrl = new URL("data:text/javascript;base64,c2VsZi5vbm1lc3NhZ2UgPSBmdW5jdGlvbiAoZSkgew0KICBjb25zdCB7IGZhY2VSZWdpb25EYXRhIH0gPSBlLmRhdGE7DQogIGNvbnN0IGRhdGEgPSBuZXcgVWludDMyQXJyYXkoZmFjZVJlZ2lvbkRhdGEuZGF0YS5idWZmZXIpOyAvLyBVaW50MzJBcnJheeuhnCDrs4DtmZgNCiAgY29uc3QgbGVuID0gZGF0YS5sZW5ndGg7DQoNCiAgbGV0IHN1bVJlZCA9IDAsDQogICAgc3VtR3JlZW4gPSAwLA0KICAgIHN1bUJsdWUgPSAwOw0KDQogIGZvciAobGV0IGkgPSAwOyBpIDwgbGVuOyBpKyspIHsNCiAgICBjb25zdCBwaXhlbCA9IGRhdGFbaV07DQoNCiAgICAvLyDqsIHqsIHsnZgg7IOJ7IOBIOyxhOuEkOydhCDstpTstpwNCiAgICBzdW1SZWQgKz0gcGl4ZWwgJiAweGZmOyAvLyBSDQogICAgc3VtR3JlZW4gKz0gKHBpeGVsID4+IDgpICYgMHhmZjsgLy8gRw0KICAgIHN1bUJsdWUgKz0gKHBpeGVsID4+IDE2KSAmIDB4ZmY7IC8vIEINCiAgfQ0KDQogIGNvbnN0IHNhbXBsZWRQaXhlbHMgPSBsZW47DQoNCiAgY29uc3QgcmVzdWx0ID0gew0KICAgIG1lYW5SZWQ6IHN1bVJlZCAvIHNhbXBsZWRQaXhlbHMsDQogICAgbWVhbkdyZWVuOiBzdW1HcmVlbiAvIHNhbXBsZWRQaXhlbHMsDQogICAgbWVhbkJsdWU6IHN1bUJsdWUgLyBzYW1wbGVkUGl4ZWxzLA0KICAgIHRpbWVzdGFtcDogU3RyaW5nKERhdGUubm93KCkgKiAxMDAwKSwNCiAgfTsNCg0KICBzZWxmLnBvc3RNZXNzYWdlKHJlc3VsdCk7DQp9Ow0K", import.meta.url); this.faceRegionWorker = new Worker(workerUrl, { type: "module" }); this.lastRGB = { timestamp: 0, r: null, g: null, b: null }; this.setupWorker(); } /** * 워커 메시지 핸들러를 설정합니다. */ setupWorker() { this.faceRegionWorker.onmessage = ({ data }) => { this.lastRGB = this.events.onDataProcessed(data); }; } /** * 얼굴 영역 데이터를 워커에 전송합니다. */ postFaceRegionData(faceRegionData) { this.faceRegionWorker.postMessage({ faceRegionData }); } /** * 워커를 종료합니다. */ terminate() { var _a; (_a = this.faceRegionWorker) == null ? void 0 : _a.terminate(); } /** * 현재 LastRGB 데이터를 반환합니다. */ getLastRGB() { return this.lastRGB; } } function createDataString(r, g, b, t) { return r.map((_, i) => `${t[i]} ${r[i]} ${g[i]} ${b[i]}`).join("\n"); } const waitSeconds = (seconds) => { return new Promise((resolve) => setTimeout(resolve, seconds * 1e3)); }; class MeasurementManager { constructor(config, events) { __publicField(this, "red", []); __publicField(this, "green", []); __publicField(this, "blue", []); __publicField(this, "timestamps", []); __publicField(this, "isCompleted", false); __publicField(this, "isCountdownActive", false); this.config = config; this.events = events; } /** * RGB 데이터를 추가합니다. */ addRGBData({ r, g, b, timestamp }) { var _a; if (this.isCompleted || r == null || g == null || b == null) return; this.red.push(r); this.green.push(g); this.blue.push(b); this.timestamps.push(timestamp); const max = ((_a = this.config.measurement) == null ? void 0 : _a.targetDataPoints) ?? 450; if (this.timestamps.length > max) { const excess = this.timestamps.length - max; this.red.splice(0, excess); this.green.splice(0, excess); this.blue.splice(0, excess); this.timestamps.splice(0, excess); } this.events.onProgress(Math.min(this.timestamps.length / max, 1), this.timestamps.length); if (this.timestamps.length === max) this.finalize(); } /** * 측정을 완료합니다. */ finalize() { this.isCompleted = true; const dataString = createDataString(this.red, this.green, this.blue, this.timestamps); const measurementResult = { rawData: { sigR: this.red, sigG: this.green, sigB: this.blue, timestamp: this.timestamps }, quality: { positionError: 0, // FacePositionManager에서 가져와야 함 yPositionError: 0, // FacePositionManager에서 가져와야 함 dataPoints: this.timestamps.length } }; this.events.onMeasurementComplete(measurementResult); this.events.onDataDownload(dataString); } /** * 측정 데이터를 초기화합니다. */ resetData() { this.red = []; this.green = []; this.blue = []; this.timestamps = []; this.isCompleted = this.isCountdownActive = false; } /** * 카운트다운을 중단합니다. */ stopCountdown() { this.isCountdownActive = false; this.events.onLog("카운트다운이 사용자에 의해 중단되었습니다."); } /** * 카운트다운이 활성화되어 있는지 확인합니다. */ isCountdownRunning() { return this.isCountdownActive; } /** * ready 상태에서 measuring 상태로 전환하는 카운트다운을 시작합니다. */ async startReadyToMeasuringTransition(isReady, isDetectiveOn, isFaceIn, changeState) { var _a; const total = ((_a = this.config.measurement) == null ? void 0 : _a.readyToMeasuringDelay) ?? 3; this.isCountdownActive = true; this.events.onCountdown(total, total); this.events.onLog(`측정 시작까지 ${total}초 남았습니다...`); try { for (let i = total - 1; i > 0; i--) { await waitSeconds(1); if (!this.isCountdownActive) return; this.events.onCountdown(i, total); this.events.onLog(`측정 시작까지 ${i}초 남았습니다...`); } this.isCountdownActive = false; if (isReady() && isDetectiveOn() && isFaceIn()) { changeState("measuring"); } } catch (e) { this.isCountdownActive = false; this.events.onLog("Ready to measuring 상태 전환 중 오류: " + e); } } } function processResults(results, { isFirstFrame, isFaceDetected, faceDetectionTimer, FACE_DETECTION_TIMEOUT, handleFaceDetection, handleNoDetection, mean_red }) { if (isFirstFrame) { isFirstFrame = false; faceDetectionTimer = setTimeout(() => { }, FACE_DETECTION_TIMEOUT); } if (results.detections && results.detections.length > 0) { const detection = results.detections[0]; const lastBoundingBox = calculateBoundingBox( detection, results.image.width, results.image.height ); handleFaceDetection(detection); if (!isFaceDetected) { isFaceDetected = true; if (faceDetectionTimer) { clearTimeout(faceDetectionTimer); faceDetectionTimer = null; } } return { isFirstFrame, isFaceDetected, faceDetectionTimer, lastBoundingBox }; } return { isFirstFrame, isFaceDetected, faceDetectionTimer, lastBoundingBox: null }; } function processFaceRegionData(data, mean_red, mean_green, mean_blue, timingHist, lastRGB) { const { timestamp } = data; let { meanRed, meanGreen, meanBlue } = data; if (timestamp === lastRGB.timestamp) return lastRGB; if (meanRed === 0 || meanGreen === 0 || meanBlue === 0) return lastRGB; if (lastRGB.r === meanRed && lastRGB.g === meanGreen && lastRGB.b === meanBlue) { meanRed += (Math.random() - 0.5) * 0.01; meanGreen += (Math.random() - 0.5) * 0.01; meanBlue += (Math.random() - 0.5) * 0.01; } mean_red.push(meanRed); mean_green.push(meanGreen); mean_blue.push(meanBlue); timingHist.push(timestamp); return { timestamp, r: meanRed, g: meanGreen, b: meanBlue }; } function performAutoDownload(dataString, filename, platformConfig) { if (platformConfig.isAndroid) { if (typeof window.Android !== "undefined") { window.Android.downloadRgbData(dataString); } else { downloadAsFile(dataString, filename); } } else if (platformConfig.isIOS) { if (typeof window.webkit !== "undefined") { window.webkit.messageHandlers.downloadRgbData.postMessage(dataString); } else { downloadAsFile(dataString, filename); } } else { downloadAsFile(dataString, filename); } } function showDownloadDialog(dataString, filename) { const downloadWindow = window.open( "", "_blank", "width=500,height=400,scrollbars=yes,resizable=yes" ); if (!downloadWindow) { alert("팝업이 차단되었습니다. 팝업을 허용해주세요."); return; } const currentDate = (/* @__PURE__ */ new Date()).toLocaleString("ko-KR"); downloadWindow.document.write(` <!DOCTYPE html> <html lang="ko"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>RGB 데이터 다운로드</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; background-color: #f5f5f5; } .container { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 450px; margin: 0 auto; } h2 { color: #333; margin-bottom: 20px; text-align: center; } .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; border-left: 4px solid #2196f3; } .info p { margin: 5px 0; color: #1976d2; } .data-preview { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 10px; margin: 15px 0; max-height: 100px; overflow-y: auto; font-family: monospace; font-size: 12px; color: #495057; } .buttons { display: flex; gap: 10px; justify-content: center; margin-top: 20px; flex-wrap: wrap; } button { padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; transition: background-color 0.2s; min-width: 100px; } .download-btn { background-color: #4caf50; color: white; } .download-btn:hover { background-color: #45a049; } .cancel-btn { background-color: #f44336; color: white; } .cancel-btn:hover { background-color: #da190b; } .copy-btn { background-color: #2196f3; color: white; } .copy-btn:hover { background-color: #1976d2; } @media (max-width: 480px) { .buttons { flex-direction: column; } button { width: 100%; margin: 5px 0; } } </style> </head> <body> <div class="container"> <h2>📊 RGB 데이터 다운로드</h2> <div class="info"> <p><strong>측정 완료 시간:</strong> ${currentDate}</p> <p><strong>파일명:</strong> ${filename}</p> <p><strong>데이터 크기:</strong> ${(dataString.length / 1024).toFixed(2)} KB</p> <p><strong>데이터 라인 수:</strong> ${dataString.split("\n").length.toLocaleString()}</p> </div> <div class="data-preview"> <strong>데이터 미리보기:</strong><br> ${dataString.substring(0, 200).replace(/</g, "&lt;").replace(/>/g, "&gt;")}${dataString.length > 200 ? "..." : ""} </div> <div class="buttons"> <button class="download-btn" onclick="downloadData()"> 💾 다운로드 </button> <button class="copy-btn" onclick="copyToClipboard()"> 📋 복사 </button> <button class="cancel-btn" onclick="window.close()"> ❌ 취소 </button> </div> </div> <script> const dataString = ${JSON.stringify(dataString)}; const filename = ${JSON.stringify(filename)}; function downloadData() { try { // 사용자 정보 가져오기 (파일명에 사용) const userData = JSON.parse(sessionStorage.getItem('userData') || '{}'); // 현재 날짜와 시간을 포맷팅 (YYYYMMDD_HHMM 형식) const now = new Date(); const dateStr = now.getFullYear() + ('0' + (now.getMonth() + 1)).slice(-2) + ('0' + now.getDate()).slice(-2) + '_' + ('0' + now.getHours()).slice(-2) + ('0' + now.getMinutes()).slice(-2); // 고유한 파일명 생성 const finalFilename = userData.userId ? \`rgb_data_\${userData.userId}_\${dateStr}.txt\` : \`\${filename.replace('.txt', '')}_\${dateStr}.txt\`; const blob = new Blob([dataString], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = finalFilename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); alert('다운로드가 시작되었습니다.'); } catch (error) { console.error('다운로드 오류:', error); alert('다운로드 중 오류가 발생했습니다.'); } } function copyToClipboard() { if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(dataString).then(() => { alert('데이터가 클립보드에 복사되었습니다.'); }).catch(err => { console.error('복사 실패:', err); fallbackCopyTextToClipboard(); }); } else { fallbackCopyTextToClipboard(); } } function fallbackCopyTextToClipboard() { try { const textArea = document.createElement('textarea'); textArea.value = dataString; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; textArea.style.top = '-999999px'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); document.execCommand('copy'); document.body.removeChild(textArea); alert('데이터가 클립보드에 복사되었습니다.'); } catch (err) { console.error('복사 실패:', err); alert('복사에 실패했습니다. 수동으로 복사해주세요.'); } } <\/script> </body> </html> `); downloadWindow.document.close(); } function downloadAsFile(dataString, filename) { const blob = new Blob([dataString], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function handleDataDownload(dataString, config, platformConfig, logger) { if (!config.enabled) { logger == null ? void 0 : logger("데이터 다운로드가 비활성화되어 있습니다."); return; } if (config.autoDownload) { performAutoDownload(dataString, config.filename, platformConfig); logger == null ? void 0 : logger("자동 다운로드를 실행했습니다."); } else { showDownloadDialog(dataString, config.filename); logger == null ? void 0 : logger("다운로드 다이얼로그를 표시했습니다."); } } const version = "0.2.0"; const packageJson = { version }; const DEFAULT_ERROR_BOUNDING = 4; const DEFAULT_FACE_DETECTION_TIMEOUT = 3e3; const DEFAULT_FRAME_INTERVAL = 33.33; const DEFAULT_FRAME_PROCESS_INTERVAL = 30; const VIDEO_READY_STATE = 3; const _FaceDetectionSDK = class _FaceDetectionSDK { /** * FaceDetectionSDK 생성자 * @param config SDK 설정 객체 * @param callbacks 이벤트 콜백 객체 */ constructor(config = {}, callbacks = {}) { // Manager 인스턴스들 __publicField(this, "configManager"); __publicField(this, "eventManager"); __publicField(this, "mediapipeManager"); __publicField(this, "stateManager"); __publicField(this, "webcamManager"); __publicField(this, "facePositionManager"); __publicField(this, "workerManager"); __publicField(this, "measurementManager"); // 상태 플래그들 __publicField(this, "isFaceDetectiveActive", false); __publicField(this, "isFaceInCircle", false); __publicField(this, "isReadyTransitionStarted", false); __publicField(this, "isInitialized", false); // HTML 요소들 __publicField(this, "video"); __publicField(this, "canvasElement"); __publicField(this, "videoCanvas"); __publicField(this, "videoCtx"); __publicField(this, "container"); __publicField(this, "ctx"); // 얼굴 인식 관련 상태 __publicField(this, "lastBoundingBox", null); __publicField(this, "faceDetectionTimer", null); __publicField(this, "isFaceDetected", false); __publicField(this, "isFirstFrame", true); this.configManager = new ConfigManager(config); this.eventManager = new EventManager(callbacks); this.stateManager = new StateManager(); this.mediapipeManager = new MediapipeManager(); this.webcamManager = new WebcamManager(this.configManager.getConfig(), { onWebcamError: this.handleWebcamError.bind(this) }); this.facePositionManager = new FacePositionManager( this.configManager.getConfig().errorBounding || DEFAULT_ERROR_BOUNDING ); this.workerManager = new WorkerManager({ onDataProcessed: this.handleWorkerData.bind(this) }); this.measurementManager = new MeasurementManager(this.configManager.getConfig(), { onProgress: this.eventManager.emitProgress.bind(this.eventManager), onMeasurementComplete: this.handleMeasurementComplete.bind(this), onDataDownload: this.createDownloadFunction(), onLog: (msg) => this.log(msg), onCountdown: (remainingSeconds, totalSeconds) => { this.eventManager.emitCountdown(remainingSeconds, totalSeconds); } }); this.stateManager.setStateChangeCallback((newState, previousState) => { this.eventManager.emitStateChange(newState, previousState); }); this.log(`SDK 인스턴스가 생성되었습니다. (v${_FaceDetectionSDK.VERSION})`); } // SDK 완전 초기화 및 측정 시작 async initializeAndStart() { try { this.log("SDK 완전 초기화를 시작합니다..."); await this.initializeElements(); if (!this.isInitialized) { await this.initializeMediaPipe(); this.workerManager.initialize(); this.isInitialized = true; this.log("SDK 초기화가 완료되었습니다."); } await this.handleClickStart(); this.log("SDK 초기화 및 측정 시작이 완료되었습니다."); } catch (error) { this.eventManager.emitError( error, FaceDetectionErrorType.INITIALIZATION_FAILED, "SDK 완전 초기화 중 오류" ); throw error; } } // SDK 정리 dispose() { this.stopDetection(); this.workerManager.terminate(); this.webcamManager.dispose(); this.mediapipeManager.dispose(); this.eventManager.dispose(); this.isInitialized = false; this.log("SDK가 정리되었습니다."); } // ===== Private Event Handlers ===== // 플랫폼별 다운로드 함수 생성 createDownloadFunction() { return (dataString) => { var _a, _b, _c, _d, _e; const config = this.configManager.getConfig(); handleDataDownload( dataString, { enabled: ((_a = config.dataDownload) == null ? void 0 : _a.enabled) || false, autoDownload: ((_b = config.dataDownload) == null ? void 0 : _b.autoDownload) || false, filename: ((_c = config.dataDownload) == null ? void 0 : _c.filename) || "rgb_data.txt" }, { isAndroid: ((_d = config.platform) == null ? void 0 : _d.isAndroid) || false, isIOS: ((_e = config.platform) == null ? void 0 : _e.isIOS) || false }, this.log.bind(this) ); }; } // 측정 완료 콜백 핸들러 handleMeasurementComplete(result) { var _a; const { positionErr, yPositionErr } = this.facePositionManager.getPositionErrors(); this.stateManager.setState(FaceDetectionState.COMPLETED); this.isFaceDetectiveActive = false; this.eventManager.emitMeasurementComplete({ ...result, quality: { ...result.quality, positionError: positionErr, yPositionError: yPositionErr, dataPoints: ((_a = result.quality) == null ? void 0 : _a.dataPoints) || 0 } }); } // 워커 데이터 처리 핸들러 handleWorkerData(data) { if (!this.stateManager.isState(FaceDetectionState.MEASURING)) { return this.workerManager.getLastRGB(); } const lastRGB = processFaceRegionData(data, [], [], [], [], this.workerManager.getLastRGB()); this.measurementManager.addRGBData(lastRGB); return lastRGB; } // 얼굴 인식 설정 setupFaceDetection() { this.mediapipeManager.setOnResultsCallback((results) => { var _a; const noDetections = !results.detections || results.detections.length === 0; if (noDetections) { this.handleNoFaceDetected(); return; } const config = this.configManager.getConfig(); const result = processResults(results, { isFirstFrame: this.isFirstFrame, isFaceDetected: this.isFaceDetected, faceDetectionTimer: this.faceDetectionTimer, FACE_DETECTION_TIMEOUT: ((_a = config.faceDetection) == null ? void 0 : _a.timeout) ?? DEFAULT_FACE_DETECTION_TIMEOUT, handleFaceDetection: this.handleFaceDetection.bind(this), handleNoDetection: () => { }, mean_red: [] }); this.isFirstFrame = result.isFirstFrame; this.isFaceDetected = result.isFaceDetected; this.faceDetectionTimer = result.faceDetectionTimer; this.lastBoundingBox = result.lastBoundingBox; }); } handleNoFaceDetected() { this.eventManager.emitFaceDetectionChange(false, null); this.isFaceInCircle = false; this.eventManager.emitFacePositionChange(false); this.measurementManager.resetData(); this.eventManager.emitError( new Error("얼굴을 인식할 수 없습니다. 조명이 충분한 곳에서 다시 시도해주세요."), FaceDetectionErrorType.FACE_NOT_DETECTED ); } // 얼굴 인식 처리 handleFaceDetection(detection) { this.eventManager.emitFaceDetectionChange(true, this.lastBoundingBox); const { isInCircle } = this.facePositionManager.updateFacePosition( detection.boundingBox, this.video, this.container ); if (this.isFaceInCircle !== isInCircle) { this.isFaceInCircle = isInCircle; this.eventManager.emitFacePositionChange(isInCircle); } if (this.stateManager.isState(FaceDetectionState.INITIAL) && !this.isReadyTransitionStarted && isInCircle) { this.isReadyTransitionStarted = true; this.stateManager.setState(FaceDetectionState.READY); this.startReadyToMeasuringTransition(); } if (!isInCircle) { this.handleFaceOutOfCircle(); return; } if (this.stateManager.isState(FaceDetectionState.MEASURING)) { const faceRegion = this.ctx.getImageData( 0, 0, this.canvasElement.width, this.canvasElement.height ); this.workerManager.postFaceRegionData(faceRegion); } } handleFaceOutOfCircle() { if (this.stateManager.isState(FaceDetectionState.READY)) { this.stateManager.setState(FaceDetectionState.INITIAL); this.isReadyTransitionStarted = false; } this.measurementManager.resetData(); this.eventManager.emitError( new Error("원 안에 얼굴을 위치해주세요."), FaceDetectionErrorType.FACE_OUT_OF_CIRCLE ); } // 얼굴 측정 시작 async handleClickStart() { try { await this.initializeDetectionState(); const webcamStream = await this.webcamManager.startWebcam(); await this.setupVideoStream(webcamStream); this.startVideoProcessing(); } catch (err) { this.handleWebcamError(err); } } // 얼굴 인식 상태 초기화 async initializeDetectionState() { this.isFaceDetectiveActive = true; this.isFaceDetected = false; this.isFirstFrame = true; this.isFaceInCircle = false; this.isReadyTransitionStarted = false; } // 비디오 스트림 설정 async setupVideoStream(webcamStream) { this.video.srcObject = webcamStream; this.video.play(); } // 비디오 처리 시작 startVideoProcessing() { this.video.addEventListener("loadeddata", () => { this.initializeVideoProcessor(); }); } // 비디오 프로세서 초기화 및 프레임 처리 루프 시작 initializeVideoProcessor() { let lastFrameTime = 0; let frameCount = 0; const processVideo = async () => { var _a, _b; if (!this.isFaceDetectiveActive || this.video.readyState < VIDEO_READY_STATE) return; const now = performance.now(); const elapsed = now - lastFrameTime; const frameInterval = ((_a = this.configManager.getConfig().measurement) == null ? void 0 : _a.frameInterval) || DEFAULT_FRAME_INTERVAL; if (elapsed > frameInterval) { lastFrameTime = now - elapsed % frameInterval; frameCount++; this.videoCtx.drawImage(this.video, 0, 0, this.videoCanvas.width, this.videoCanvas.height); const frameProcessInterval = ((_b = this.configManager.getConfig().measurement) == null ? void 0 : _b.frameProcessInterval) || DEFAULT_FRAME_PROCESS_INTERVAL; if (frameCount % frameProcessInterval === 0) { await this.mediapipeManager.sendImage(this.video); } else if (this.lastBoundingBox !== null && this.isFaceInCircle) { const { left, top, width, height } = this.lastBoundingBox; const faceRegion = this.videoCtx.getImageData(left, top, width, height); this.workerManager.postFaceRegionData(faceRegion); } } requestAnimationFrame(processVideo); }; requestAnimationFrame(processVideo); } // 웹캠 에러 처리 handleWebcamError(err) { var _a; const isIOS = ((_a = this.configManager.getConfig().platform) == null ? void 0 : _a.isIOS) || false; this.eventManager.emitWebcamError(err, isIOS); } // ===== Private Helper Methods ===== // Ready 상태에서 Measuring 상태로 전환 async startReadyToMeasuringTransition() { await this.measurementManager.startReadyToMeasuringTransition( () => this.stateManager.isState(FaceDetectionState.READY), () => this.isFaceDetectiveActive, () => this.isFaceInCircle, (state) => this.stateManager.setState(state) ); } // 디버그 로그 log(message, ...args) { var _a; const config = this.configManager.getConfig(); if ((_a = config.debug) == null ? void 0 : _a.enableConsoleLog) { console.log(`[FaceDetectionSDK] ${message}`, ...args); } } // 얼굴 인식 종료 시 처리 stopDetection() { if (!this.isFaceDetectiveActive) return; this.isFaceDetectiveActive = false; this.webcamManager.stopWebcam(); this.workerManager.terminate(); if (this.faceDetectionTimer) { clearTimeout(this.faceDetectionTimer); this.faceDetectionTimer = null; } } // 상태 관리 메서드들 // 현재 상태를 반환 getCurrentState() { return this.stateManager.getCurrentState(); } // 특정 상태인지 확인 isState(state) { return this.stateManager.isState(state); } // 여러 상태 중 하나인지 확인 isAnyState(...states) { return this.stateManager.isAnyState(...states); } // 얼굴이 원 안에 있는지 확인 isFaceInsideCircle() { return this.isFaceInCircle; } // ===== 초기화 메서드들 ===== // HTML 요소들 초기화 async initializeElements() { const config = this.configManager.getConfig(); if (!config.elements) { throw new Error( "HTML 요소들이 config에 제공되지 않았습니다. config.elements를 설정해주세요." ); } this.video = config.elements.video; this.canvasElement = config.elements.canvasElement; this.videoCanvas = config.elements.videoCanvas; this.container = config.elements.container; const videoCtx = this.videoCanvas.getContext("2d", { willReadFrequently: true }); if (!videoCtx) throw new Error("Video canvas context를 가져올 수 없습니다."); this.videoCtx = videoCtx; const ctx = this.canvasElement.getContext("2d", { willReadFrequently: true }); if (!ctx) throw new Error("Canvas context를 가져올 수 없습니다."); this.ctx = ctx; } // MediaPipe 초기화 async initializeMediaPipe() { var _a; const config = this.configManager.getConfig(); await this.mediapipeManager.initialize(((_a = config.faceDetection) == null ? void 0 : _a.minDetectionConfidence) || 0.5); this.setupFaceDetection(); } }; // SDK 버전 정보 __publicField(_FaceDetectionSDK, "VERSION", packageJson.version); let FaceDetectionSDK = _FaceDetectionSDK; const SDK_VERSION = FaceDetectionSDK.VERSION; export { FaceDetectionSDK, SDK_VERSION }; //# sourceMappingURL=index.es.js.map