UNPKG

face-detection-web-sdk

Version:

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

1,264 lines (1,252 loc) 42.6 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 = JSON.parse(JSON.stringify(defaultConfig)); Object.keys(userConfig).forEach((key) => { const userValue = userConfig[key]; if (userValue !== void 0) { if (key === "elements") { merged[key] = userValue; } else if (typeof userValue === "object" && !Array.isArray(userValue)) { merged[key] = { ...merged[key], ...userValue }; } else { merged[key] = userValue; } } }); return 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; if (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, context) { const errorMessage = context ? `${context}: ${error.message}` : error.message; if (this.log) { this.log(`오류 발생: ${errorMessage}`, error); } if (this.callbacks.onError) { this.callbacks.onError({ type: FaceDetectionErrorType.UNKNOWN_ERROR, message: errorMessage, originalError: error }); } } /** * 웹캠 에러 이벤트를 발생시킵니다. */ emitWebcamError(err, isIOS) { const isPermissionError = err.name === "NotAllowedError" || err.name === "PermissionDeniedError"; const isIOSPermissionError = isIOS && err.message && (err.message.includes("permission") || err.message.includes("허가") || err.message.includes("권한")); let errorType; let errorMessage; if (isPermissionError || isIOSPermissionError) { errorType = FaceDetectionErrorType.WEBCAM_PERMISSION_DENIED; errorMessage = "웹캠 접근 권한이 거부되었습니다. 브라우저 설정에서 카메라 권한을 허용해주세요."; } else { errorType = FaceDetectionErrorType.WEBCAM_ACCESS_FAILED; errorMessage = `웹캠에 접근할 수 없습니다: ${err.message}`; } if (this.callbacks.onError) { this.callbacks.onError({ type: errorType, message: errorMessage, originalError: err }); } } /** * 얼굴 감지 상태 변경 이벤트를 발생시킵니다. */ emitFaceDetectionChange(isDetected, boundingBox) { if (this.callbacks.onFaceDetectionChange) { this.callbacks.onFaceDetectionChange(isDetected, boundingBox); } } /** * 얼굴 위치 변경 이벤트를 발생시킵니다. */ emitFacePositionChange(isInCircle) { if (this.callbacks.onFacePositionChange) { this.callbacks.onFacePositionChange(isInCircle); } } /** * 측정 진행률 이벤트를 발생시킵니다. */ emitProgress(progress, dataPoints) { if (this.callbacks.onProgress) { this.callbacks.onProgress(progress, dataPoints); } } /** * 측정 완료 이벤트를 발생시킵니다. */ emitMeasurementComplete(result) { if (this.callbacks.onMeasurementComplete) { this.callbacks.onMeasurementComplete(result); } } /** * 이벤트 콜백을 정리합니다. */ 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() { if (this.faceDetection) { 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) { if (this.stateChangeCallback) { try { this.stateChangeCallback(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() { if (this.webcamStream) { this.webcamStream.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); __publicField(this, "errorBounding"); 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); const { lastPosition: newLastPosition, lastYPosition: newLastYPosition, positionErr: newPositionErr, yPositionErr: newYPositionErr } = updatePositionErrors( faceX, faceY, this.lastPosition, this.lastYPosition, this.positionErr, this.yPositionErr, this.errorBounding ); this.lastPosition = newLastPosition; this.lastYPosition = newLastYPosition; this.positionErr = newPositionErr; this.yPositionErr = newYPositionErr; 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() { if (this.faceRegionWorker) { this.faceRegionWorker.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, "mean_red", []); __publicField(this, "mean_green", []); __publicField(this, "mean_blue", []); __publicField(this, "timingHist", []); __publicField(this, "config"); __publicField(this, "events"); __publicField(this, "isCompleted", false); this.config = config; this.events = events; } /** * RGB 데이터를 추가합니다. */ addRGBData(lastRGB) { var _a; if (this.isCompleted) return; if (lastRGB.r !== null && lastRGB.g !== null && lastRGB.b !== null) { this.mean_red.push(lastRGB.r); this.mean_green.push(lastRGB.g); this.mean_blue.push(lastRGB.b); this.timingHist.push(lastRGB.timestamp); const targetDataPoints = ((_a = this.config.measurement) == null ? void 0 : _a.targetDataPoints) || 450; if (this.timingHist.length > targetDataPoints) { const excess = this.timingHist.length - targetDataPoints; this.mean_red.splice(0, excess); this.mean_green.splice(0, excess); this.mean_blue.splice(0, excess); this.timingHist.splice(0, excess); } this.updateProgress(); if (this.timingHist.length === targetDataPoints) { this.finalizeMeasurement(); } } } /** * 진행률을 업데이트합니다. */ updateProgress() { var _a; if (this.timingHist.length > 0) { const targetDataPoints = ((_a = this.config.measurement) == null ? void 0 : _a.targetDataPoints) || 450; const progress = Math.min(this.timingHist.length / targetDataPoints, 1); this.events.onProgress(progress, this.timingHist.length); } } /** * 측정을 완료합니다. */ finalizeMeasurement() { this.isCompleted = true; const dataString = createDataString( this.mean_red, this.mean_green, this.mean_blue, this.timingHist ); const measurementResult = { rawData: { sigR: [...this.mean_red], sigG: [...this.mean_green], sigB: [...this.mean_blue], timestamp: [...this.timingHist].map(Number) }, quality: { positionError: 0, // FacePositionManager에서 가져와야 함 yPositionError: 0, // FacePositionManager에서 가져와야 함 dataPoints: this.timingHist.length } }; this.events.onMeasurementComplete(measurementResult); this.events.onDataDownload(dataString); } /** * 측정 데이터를 초기화합니다. */ resetData() { this.mean_red = []; this.mean_green = []; this.mean_blue = []; this.timingHist = []; this.isCompleted = false; } /** * ready 상태에서 measuring 상태로 전환하는 카운트다운을 시작합니다. */ async startReadyToMeasuringTransition(isReadyState, isFaceDetectiveActive, isFaceInCircle, onStateChange) { var _a; try { const delaySeconds = ((_a = this.config.measurement) == null ? void 0 : _a.readyToMeasuringDelay) || 3; for (let remaining = delaySeconds; remaining > 0; remaining--) { this.events.onLog(`측정 시작까지 ${remaining}초 남았습니다...`); await waitSeconds(1); if (!isReadyState() || !isFaceDetectiveActive() || !isFaceInCircle()) { return; } } if (isReadyState() && isFaceDetectiveActive() && isFaceInCircle()) { onStateChange("measuring"); } } catch (error) { this.events.onLog("Ready to measuring 상태 전환 중 오류: " + String(error)); } } } 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.1.14"; 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) }); this.stateManager.setStateChangeCallback((newState, previousState) => { this.eventManager.emitStateChange(newState, previousState); }); this.log(`SDK 인스턴스가 생성되었습니다. (v${_FaceDetectionSDK.VERSION})`); } /** * SDK 완전 초기화 및 측정 시작 * HTML 요소 초기화, MediaPipe 설정, 워커 초기화, 측정 시작을 한 번에 수행합니다. */ 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, "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; if (!results.detections || results.detections.length === 0) { this.eventManager.emitFaceDetectionChange(false, null); this.isFaceInCircle = false; this.eventManager.emitFacePositionChange(false); this.measurementManager.resetData(); this.eventManager.emitError( new Error("얼굴을 인식할 수 없습니다. 조명이 충분한 곳에서 다시 시도해주세요."), "FACE_NOT_DETECTED" ); return; } const result = processResults(results, { isFirstFrame: this.isFirstFrame, isFaceDetected: this.isFaceDetected, faceDetectionTimer: this.faceDetectionTimer, FACE_DETECTION_TIMEOUT: ((_a = this.configManager.getConfig().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; }); } // 얼굴 인식 처리 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) { if (this.stateManager.isState(FaceDetectionState.READY)) { this.stateManager.setState(FaceDetectionState.INITIAL); this.isReadyTransitionStarted = false; } this.measurementManager.resetData(); this.eventManager.emitError(new Error("원 안에 얼굴을 위치해주세요."), "FACE_OUT_OF_CIRCLE"); return; } if (this.stateManager.isState(FaceDetectionState.MEASURING)) { const width = this.canvasElement.width; const height = this.canvasElement.height; const faceRegion = this.ctx.getImageData(0, 0, width, height); this.workerManager.postFaceRegionData(faceRegion); } } // 얼굴 측정 시작 async handleClickStart() { try { this.isFaceDetectiveActive = true; this.isFaceDetected = false; this.isFirstFrame = true; this.isFaceInCircle = false; this.isReadyTransitionStarted = false; const webcamStream = await this.webcamManager.startWebcam(); this.video.srcObject = webcamStream; this.video.play(); this.video.addEventListener("loadeddata", () => { 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 && 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); }); } catch (err) { this.handleWebcamError(err); } } // 웹캠 에러 처리 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