face-detection-web-sdk
Version:
웹 기반 얼굴 인식을 통해 실시간으로 심박수, 스트레스, 혈압 등의 건강 정보를 측정하는 SDK
1,252 lines (1,240 loc) • 42.7 kB
JavaScript
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;
}
(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, "<").replace(/>/g, ">")}${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