UNPKG

@face-detector/core

Version:

Face Detector Web SDK Core Package

1,283 lines (1,282 loc) 46.3 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 { FilesetResolver, FaceDetector as FaceDetector$1 } from "@mediapipe/tasks-vision"; var i = /* @__PURE__ */ ((n) => (n.INITIALIZING = "initializing", n.READY = "ready", n.RUNNING = "running", n.MEASURING = "measuring", n.COMPLETED = "completed", n.FAILED = "failed", n))(i || {}); class BaseManager { constructor(config, eventEmitter) { __publicField(this, "_config"); __publicField(this, "_eventEmitter"); this._config = config; this._eventEmitter = eventEmitter; } get config() { return this._config; } get eventEmitter() { return this._eventEmitter; } } 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 calculateROI(facePosition, frameWidth, frameHeight) { const boundingBox = calculateBoundingBox(facePosition, frameWidth, frameHeight); const sx = Math.max(0, Math.floor(boundingBox.left)); const sy = Math.max(0, Math.floor(boundingBox.top)); const sw = Math.max(1, Math.floor(Math.min(boundingBox.width, frameWidth - sx))); const sh = Math.max(1, Math.floor(Math.min(boundingBox.height, frameHeight - sy))); return { sx, sy, sw, sh }; } const _Logger = class _Logger { constructor() { __publicField(this, "debugEnabled", false); } static getInstance() { if (!_Logger.instance) { _Logger.instance = new _Logger(); } return _Logger.instance; } setDebugMode(enabled) { this.debugEnabled = enabled; } debug(message, ...args) { if (this.debugEnabled) { console.log(`[FaceDetectionSDK] ${message}`, ...args); } } error(message, ...args) { console.error(`[FaceDetectionSDK ERROR] ${message}`, ...args); } warn(message, ...args) { console.warn(`[FaceDetectionSDK WARN] ${message}`, ...args); } }; __publicField(_Logger, "instance"); let Logger = _Logger; const logger = Logger.getInstance(); class FaceDetectionManager extends BaseManager { constructor(config, eventEmitter) { super(config, eventEmitter); __publicField(this, "faceDetector"); __publicField(this, "faceDetectionManagerConfig"); __publicField(this, "minDetectionConfidence"); __publicField(this, "_isDetected"); __publicField(this, "unsubFrameCaptured"); __publicField(this, "unsubFrameWarmUp"); __publicField(this, "_facePosition", null); __publicField(this, "_ROI", null); __publicField(this, "imageWidth", 0); __publicField(this, "imageHeight", 0); /** * MediaPipe 결과 콜백 * - 검출 여부 판단, 바운딩박스 갱신, 상태 이벤트 발행 */ __publicField(this, "onResults", (results) => { var _a, _b; const isDetected = results.detections.length > 0; const wasDetected = this.isDetected; this.isDetected = isDetected; if (isDetected && this.imageWidth > 0 && this.imageHeight > 0) { const detection = results.detections[0]; if (detection.boundingBox) { this.facePosition = this.convertBoundingBoxToFacePosition(detection.boundingBox); this.ROI = calculateROI(this.facePosition, this.imageWidth, this.imageHeight); if (!wasDetected) { logger.debug("Face detected", { confidence: (_b = (_a = detection.categories) == null ? void 0 : _a[0]) == null ? void 0 : _b.score, position: this.facePosition }); } } } else { if (wasDetected) { logger.debug("Face lost"); } this.facePosition = null; this.ROI = null; this.eventEmitter.emit("face:lost", void 0); this.eventEmitter.emit("system:running", void 0); } }); this.faceDetectionManagerConfig = config; this.minDetectionConfidence = config.minDetectionConfidence; this._isDetected = false; logger.debug("FaceDetectionManager initialized", { minDetectionConfidence: config.minDetectionConfidence, delegate: config.delegate }); } get isDetected() { return this._isDetected; } set isDetected(isDetected) { this._isDetected = isDetected; } set facePosition(facePosition) { if (this._facePosition === facePosition) return; this._facePosition = facePosition; this.eventEmitter.emit("face:position", facePosition); } get facePosition() { return this._facePosition; } get ROI() { return this._ROI; } set ROI(ROI) { this._ROI = ROI; } /** * MediaPipe Face Detection 초기화 및 결과 콜백/이벤트 구독 설정 */ async initialize(video) { this.imageWidth = video.videoWidth; this.imageHeight = video.videoHeight; this.unSubscribeEventListeners(); if (this.faceDetector) { this.faceDetector.close(); this.faceDetector = null; } const vision = await FilesetResolver.forVisionTasks( "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm" ); const faceDetectorOptions = { baseOptions: { modelAssetPath: `https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite`, delegate: this.faceDetectionManagerConfig.delegate }, minDetectionConfidence: this.minDetectionConfidence, runningMode: "VIDEO" }; this.faceDetector = await FaceDetector$1.createFromOptions(vision, faceDetectorOptions); this.setupEventListeners(video); logger.debug("MediaPipe Face Detection initialized", { videoSize: `${video.videoWidth}x${video.videoHeight}`, confidence: this.minDetectionConfidence }); } reset() { this.facePosition = null; this.ROI = null; this.isDetected = false; logger.debug("FaceDetectionManager reset completed"); } /** * 현재 캔버스를 MediaPipe에 전송하여 얼굴 위치 검증 실행 */ validateFacePosition(video, isWarmUp = false) { const timestamp = performance.now(); const results = this.faceDetector.detectForVideo(video, timestamp); if (isWarmUp) { return; } this.onResults(results); } stop() { this._isDetected = false; } dispose() { this.isDetected = false; if (this.faceDetector) { this.faceDetector.close(); this.faceDetector = null; } this.unSubscribeEventListeners(); } setupEventListeners(video) { var _a; (_a = this.unsubFrameWarmUp) == null ? void 0 : _a.call(this); this.unsubFrameWarmUp = this.eventEmitter.subscribe("frame:warmUp", () => { this.validateFacePosition(video, true); }); } unSubscribeEventListeners() { var _a, _b; (_a = this.unsubFrameCaptured) == null ? void 0 : _a.call(this); (_b = this.unsubFrameWarmUp) == null ? void 0 : _b.call(this); this.unsubFrameCaptured = void 0; this.unsubFrameWarmUp = void 0; } convertBoundingBoxToFacePosition(bbox) { return { xCenter: (bbox.originX + bbox.width / 2) / this.imageWidth, yCenter: (bbox.originY + bbox.height / 2) / this.imageHeight, width: bbox.width / this.imageWidth, height: bbox.height / this.imageHeight }; } } class WorkerService { constructor() { __publicField(this, "worker"); __publicField(this, "pending", /* @__PURE__ */ new Map()); __publicField(this, "requestId", 0); __publicField(this, "messageHandlers", /* @__PURE__ */ new Map()); } async initialize(config) { if (this.worker) { this.worker.terminate(); this.worker = null; } this.worker = new Worker(config.workerUrl, { type: "module" }); if (config.messageHandlers) { Object.entries(config.messageHandlers).forEach(([type, handler]) => { this.registerMessageHandler(type, handler); }); } this.worker.onmessage = (event) => { const message = event.data; if ("id" in message && this.pending.has(message.id)) { const { resolve } = this.pending.get(message.id); resolve(message); this.pending.delete(message.id); return; } const handler = this.messageHandlers.get(message.type) || config.onMessage; if (handler) { handler(message); } }; } registerMessageHandler(messageType, handler) { this.messageHandlers.set(messageType, handler); } sendMessage(message, transferables) { if (!this.worker) { throw new Error("WorkerService not initialized"); } this.worker.postMessage(message, transferables || []); } sendRequest(message, transferables) { if (!this.worker) { return Promise.reject(new Error("WorkerService not initialized")); } return new Promise((resolve, reject) => { this.pending.set(message.id, { resolve, reject }); this.worker.postMessage(message, transferables || []); }); } generateRequestId() { return this.requestId++; } clearPendingRequests() { this.pending.forEach(({ reject }) => { reject(new Error("WorkerService disposed")); }); this.pending.clear(); } dispose() { this.clearPendingRequests(); this.messageHandlers.clear(); if (this.worker) { this.worker.terminate(); this.worker = null; } } } class DataProcessingManager extends BaseManager { constructor(config, eventEmitter) { super(config, eventEmitter); __publicField(this, "workerService"); __publicField(this, "_dataBucket"); __publicField(this, "_progressPercentage"); __publicField(this, "targetDataLength"); __publicField(this, "unsubFaceLost"); __publicField(this, "unsubCompleted"); this.targetDataLength = config.targetDataLength; this._dataBucket = { sigR: [], sigG: [], sigB: [], timestamps: [] }; logger.debug("DataProcessingManager initialized", { targetDataLength: config.targetDataLength }); } get dataBucket() { return this._dataBucket; } get progressPercentage() { return this._progressPercentage; } /** * 워커 초기화 및 오프스크린 캔버스 연결 */ async initialize(extractingCanvas, willReadFrequently = false) { this.unSubscribeEventListeners(); try { await this.initializeWorkerService(); this.clearDataBucket(); this.setUpEventListeners(); this.updateProgressPercentage(0); await this.connectVideoCanvas(extractingCanvas, willReadFrequently); logger.debug("DataProcessingManager Worker initialized"); } catch (error) { logger.error("DataProcessingManager initialization failed", error); } } reset() { this.clearDataBucket(); if (this.workerService) { this.workerService.clearPendingRequests(); } this.updateProgressPercentage(0); } async dispose() { this.stop(); if (this.workerService) { this.workerService.dispose(); this.workerService = null; } this.unSubscribeEventListeners(); } stop() { this.clearDataBucket(); if (this.workerService) { this.workerService.clearPendingRequests(); } } /** * 오프스크린 캔버스를 워커에 전송하여 연결 */ async connectVideoCanvas(extractingCanvas, willReadFrequently = false) { if (extractingCanvas.dataset.transferred === "true") { return true; } try { const offscreenExtractingCanvas = extractingCanvas.transferControlToOffscreen(); extractingCanvas.dataset.transferred = "true"; const message = { type: "connectVideoCanvas", id: this.workerService.generateRequestId(), extractingCanvas: offscreenExtractingCanvas, willReadFrequently }; const response = await this.workerService.sendRequest(message, [offscreenExtractingCanvas]); return response.success; } catch (error) { logger.error("Offscreen canvas connection failed", error); return false; } } /** * 비디오 캔버스 연결 해제 요청을 보내고 완료를 대기 */ async releaseVideoCanvas() { const message = { type: "releaseVideoCanvas", id: this.workerService.generateRequestId() }; const response = await this.workerService.sendRequest(message); return response.success; } /** * 프레임과 얼굴 위치, 옵션을 워커에 전달하여 처리 요청 */ processFrame(frame, isWarmUp = false) { const message = { type: "processFrame", frame, isWarmUp }; this.workerService.sendMessage(message, [frame]); } /** * 이벤트 구독 설정: 얼굴 유실 시 버킷 초기화 등 */ setUpEventListeners() { var _a, _b; (_a = this.unsubFaceLost) == null ? void 0 : _a.call(this); this.unsubFaceLost = this.eventEmitter.subscribe("face:lost", () => { this.clearDataBucket(); this.updateProgressPercentage(this.calculateProgressPercentage()); }); (_b = this.unsubCompleted) == null ? void 0 : _b.call(this); this.unsubCompleted = this.eventEmitter.subscribe("system:completed", () => { }); } async initializeWorkerService() { this.workerService = new WorkerService(); await this.workerService.initialize({ workerUrl: new URL("data:text/javascript;base64,bGV0IGV4dHJhY3RpbmdDYW52YXMgPSBudWxsOw0KbGV0IGV4dHJhY3RpbmdDb250ZXh0ID0gbnVsbDsNCg0Kc2VsZi5vbm1lc3NhZ2UgPSBmdW5jdGlvbiAoZSkgew0KICBjb25zdCBtZXNzYWdlID0gZS5kYXRhOw0KDQogIHN3aXRjaCAobWVzc2FnZS50eXBlKSB7DQogICAgY2FzZSAnY29ubmVjdFZpZGVvQ2FudmFzJzoNCiAgICAgIGhhbmRsZUNvbm5lY3RWaWRlb0NhbnZhcyhtZXNzYWdlLCBlKTsNCiAgICAgIGJyZWFrOw0KICAgIGNhc2UgJ3Byb2Nlc3NGcmFtZSc6DQogICAgICBoYW5kbGVQcm9jZXNzRnJhbWUobWVzc2FnZSwgZSk7DQogICAgICBicmVhazsNCiAgICBjYXNlICdyZWxlYXNlVmlkZW9DYW52YXMnOg0KICAgICAgaGFuZGxlUmVsZWFzZVZpZGVvQ2FudmFzKG1lc3NhZ2UsIGUpOw0KICAgICAgYnJlYWs7DQogICAgZGVmYXVsdDoNCiAgICAgIGNvbnNvbGUud2Fybign7JWMIOyImCDsl4bripQg66mU7Iuc7KeAIO2DgOyehTonLCBtZXNzYWdlLnR5cGUpOw0KICB9DQp9Ow0KDQpzZWxmLm9ubWVzc2FnZWVycm9yID0gZnVuY3Rpb24gKGUpIHsNCiAgY29uc29sZS5lcnJvcign7JuM7LukIOuplOyLnOyngCDsspjrpqwg7Jik66WYOicsIGUpOw0KfTsNCg0KZnVuY3Rpb24gaGFuZGxlQ29ubmVjdFZpZGVvQ2FudmFzKG1lc3NhZ2UsIGV2ZW50KSB7DQogIHRyeSB7DQogICAgY29uc3QgcmVjZWl2ZWRFeHRyYWN0aW5nQ2FudmFzID0NCiAgICAgIChtZXNzYWdlICYmIG1lc3NhZ2UuZXh0cmFjdGluZ0NhbnZhcykgfHwgKGV2ZW50ICYmIGV2ZW50LmRhdGEgJiYgZXZlbnQuZGF0YS5leHRyYWN0aW5nQ2FudmFzKTsNCg0KICAgIGlmICghcmVjZWl2ZWRFeHRyYWN0aW5nQ2FudmFzKSB7DQogICAgICB0aHJvdyBuZXcgRXJyb3IoJ+yYpO2UhOyKpO2BrOumsCDsupTrsoTsiqTqsIAg66mU7Iuc7KeA66GcIOyghOuLrOuQmOyngCDslYrslZjsirXri4jri6QnKTsNCiAgICB9DQoNCiAgICBjb25zdCB3aWxsUmVhZEZyZXF1ZW50bHkgPSBtZXNzYWdlICYmIG1lc3NhZ2Uud2lsbFJlYWRGcmVxdWVudGx5Ow0KICAgIGV4dHJhY3RpbmdDYW52YXMgPSByZWNlaXZlZEV4dHJhY3RpbmdDYW52YXM7DQogICAgZXh0cmFjdGluZ0NvbnRleHQgPSBleHRyYWN0aW5nQ2FudmFzLmdldENvbnRleHQoJzJkJywgeyB3aWxsUmVhZEZyZXF1ZW50bHkgfSk7DQoNCiAgICBpZiAoIWV4dHJhY3RpbmdDb250ZXh0KSB7DQogICAgICB0aHJvdyBuZXcgRXJyb3IoJ+yYpO2UhOyKpO2BrOumsCDsupTrsoTsiqQgMkQg7Luo7YWN7Iqk7Yq4IOyDneyEsSDsi6TtjKgnKTsNCiAgICB9DQoNCiAgICBzZWxmLnBvc3RNZXNzYWdlKHsNCiAgICAgIHR5cGU6ICdjb25uZWN0VmlkZW9DYW52YXNSZXNwb25zZScsDQogICAgICBzdWNjZXNzOiB0cnVlLA0KICAgICAgaWQ6IG1lc3NhZ2UuaWQsDQogICAgfSk7DQogIH0gY2F0Y2ggKGVycm9yKSB7DQogICAgY29uc29sZS5lcnJvcign67mE65SU7JikIOy6lOuyhOyKpCDsl7DqsrAg7Jik66WYOicsIGVycm9yKTsNCiAgICBzZWxmLnBvc3RNZXNzYWdlKHsNCiAgICAgIHR5cGU6ICdjb25uZWN0VmlkZW9DYW52YXNSZXNwb25zZScsDQogICAgICBzdWNjZXNzOiBmYWxzZSwNCiAgICAgIGlkOiBtZXNzYWdlLmlkLA0KICAgIH0pOw0KICB9DQp9DQoNCmZ1bmN0aW9uIGhhbmRsZVJlbGVhc2VWaWRlb0NhbnZhcyhtZXNzYWdlKSB7DQogIGxldCBzdWNjZXNzID0gZmFsc2U7DQogIGlmIChleHRyYWN0aW5nQ2FudmFzKSB7DQogICAgc3VjY2VzcyA9IHRydWU7DQogIH0NCg0KICBzZWxmLnBvc3RNZXNzYWdlKHsNCiAgICB0eXBlOiAncmVsZWFzZVZpZGVvQ2FudmFzUmVzcG9uc2UnLA0KICAgIHN1Y2Nlc3M6IHN1Y2Nlc3MsDQogICAgaWQ6IG1lc3NhZ2UuaWQsDQogIH0pOw0KICBleHRyYWN0aW5nQ2FudmFzID0gbnVsbDsNCiAgZXh0cmFjdGluZ0NvbnRleHQgPSBudWxsOw0KfQ0KDQpmdW5jdGlvbiBoYW5kbGVQcm9jZXNzRnJhbWUobWVzc2FnZSwgZXZlbnQpIHsNCiAgY29uc3QgeyBpc1dhcm1VcCB9ID0gbWVzc2FnZTsNCiAgbGV0IGV4dHJhY3RlZFJHQiA9IG51bGw7DQogIGxldCBmcmFtZSA9IG51bGw7DQoNCiAgdHJ5IHsNCiAgICBmcmFtZSA9IGV4dHJhY3RGcmFtZUZyb21FdmVudChldmVudCk7DQogICAgaWYgKGZyYW1lKSB7DQogICAgICAvLyBSR0Ig7LaU7LacIOyaqSDsupTrsoTsiqTsl5DshJwg7ZSE66CI7J6E7JeQ7IScIFJHQiDstpTstpwNCiAgICAgIGV4dHJhY3RlZFJHQiA9IGV4dHJhY3RSR0JGcm9tRnJhbWUoZnJhbWUpOw0KICAgIH0NCiAgfSBjYXRjaCAoZXJyb3IpIHsNCiAgICBjb25zb2xlLmVycm9yKCftlITroIjsnoQg7LKY66asIOyYpOulmDonLCBlcnJvcik7DQogICAgZXh0cmFjdGVkUkdCID0gbnVsbDsNCiAgfSBmaW5hbGx5IHsNCiAgICBpZiAoZnJhbWUgJiYgdHlwZW9mIGZyYW1lLmNsb3NlID09PSAnZnVuY3Rpb24nKSB7DQogICAgICBmcmFtZS5jbG9zZSgpOw0KICAgIH0NCiAgICBzZW5kUHJvY2Vzc0ZyYW1lUmVzcG9uc2UoZXh0cmFjdGVkUkdCLCBpc1dhcm1VcCk7DQogIH0NCn0NCg0KZnVuY3Rpb24gc2VuZFByb2Nlc3NGcmFtZVJlc3BvbnNlKGV4dHJhY3RlZFJHQiwgaXNXYXJtVXApIHsNCiAgc2VsZi5wb3N0TWVzc2FnZSh7DQogICAgdHlwZTogJ3Byb2Nlc3NGcmFtZVJlc3BvbnNlJywNCiAgICBleHRyYWN0ZWRSR0I6IGV4dHJhY3RlZFJHQiwNCiAgICBpc1dhcm1VcDogaXNXYXJtVXAsDQogIH0pOw0KfQ0KDQpmdW5jdGlvbiBleHRyYWN0UkdCRnJvbUZyYW1lKGZyYW1lKSB7DQogIGlmICghZXh0cmFjdGluZ0NhbnZhcyB8fCAhZXh0cmFjdGluZ0NvbnRleHQpIHsNCiAgICB0aHJvdyBuZXcgRXJyb3IoJ+yYpO2UhOyKpO2BrOumsCDsupTrsoTsiqTqsIAg66mU7Iuc7KeA66GcIOyghOuLrOuQmOyngCDslYrslZjsirXri4jri6QnKTsNCiAgfQ0KICAvLyDsupTrsoTsiqQg7YGs6riwIOyhsOyglQ0KICBpZiAoZXh0cmFjdGluZ0NhbnZhcy53aWR0aCAhPT0gZnJhbWUud2lkdGggfHwgZXh0cmFjdGluZ0NhbnZhcy5oZWlnaHQgIT09IGZyYW1lLmhlaWdodCkgew0KICAgIGV4dHJhY3RpbmdDYW52YXMud2lkdGggPSBmcmFtZS53aWR0aDsNCiAgICBleHRyYWN0aW5nQ2FudmFzLmhlaWdodCA9IGZyYW1lLmhlaWdodDsNCiAgfSBlbHNlIHsNCiAgICBleHRyYWN0aW5nQ29udGV4dC5jbGVhclJlY3QoMCwgMCwgZXh0cmFjdGluZ0NhbnZhcy53aWR0aCwgZXh0cmFjdGluZ0NhbnZhcy5oZWlnaHQpOw0KICB9DQoNCiAgZXh0cmFjdGluZ0NvbnRleHQuZHJhd0ltYWdlKGZyYW1lLCAwLCAwLCBmcmFtZS53aWR0aCwgZnJhbWUuaGVpZ2h0KTsNCg0KICBjb25zdCBpbWFnZURhdGEgPSBleHRyYWN0aW5nQ29udGV4dC5nZXRJbWFnZURhdGEoMCwgMCwgZnJhbWUud2lkdGgsIGZyYW1lLmhlaWdodCk7DQogIHJldHVybiBnZXRSR0JGcm9tSW1hZ2VEYXRhKGltYWdlRGF0YSk7DQp9DQoNCmZ1bmN0aW9uIGV4dHJhY3RGcmFtZUZyb21FdmVudChldmVudCkgew0KICBjb25zdCBtZXNzYWdlID0gZXZlbnQuZGF0YTsNCiAgaWYgKG1lc3NhZ2UgJiYgbWVzc2FnZS5mcmFtZSBpbnN0YW5jZW9mIEltYWdlQml0bWFwKSB7DQogICAgcmV0dXJuIG1lc3NhZ2UuZnJhbWU7DQogIH0NCg0KICBpZiAoZXZlbnQuZGF0YSBpbnN0YW5jZW9mIEltYWdlQml0bWFwKSB7DQogICAgcmV0dXJuIGV2ZW50LmRhdGE7DQogIH0NCg0KICByZXR1cm4gbnVsbDsNCn0NCg0KZnVuY3Rpb24gZ2V0UkdCRnJvbUltYWdlRGF0YShpbWFnZURhdGEpIHsNCiAgY29uc3QgYnVmZmVyRGF0YSA9IG5ldyBVaW50MzJBcnJheShpbWFnZURhdGEuZGF0YS5idWZmZXIpOw0KICBjb25zdCBsZW4gPSBidWZmZXJEYXRhLmxlbmd0aDsNCiAgbGV0IHNpZ1IgPSAwOw0KICBsZXQgc2lnRyA9IDA7DQogIGxldCBzaWdCID0gMDsNCiAgZm9yIChsZXQgaSA9IDA7IGkgPCBsZW47IGkrKykgew0KICAgIGNvbnN0IHBpeGVsID0gYnVmZmVyRGF0YVtpXTsNCiAgICBzaWdSICs9IHBpeGVsICYgMHhmZjsNCiAgICBzaWdHICs9IChwaXhlbCA+PiA4KSAmIDB4ZmY7DQogICAgc2lnQiArPSAocGl4ZWwgPj4gMTYpICYgMHhmZjsNCiAgfQ0KICBjb25zdCBzYW1wbGVkUGl4ZWxzID0gbGVuOw0KICByZXR1cm4gew0KICAgIHNpZ1I6IHNpZ1IgLyBzYW1wbGVkUGl4ZWxzLA0KICAgIHNpZ0c6IHNpZ0cgLyBzYW1wbGVkUGl4ZWxzLA0KICAgIHNpZ0I6IHNpZ0IgLyBzYW1wbGVkUGl4ZWxzLA0KICAgIHRpbWVzdGFtcDogRGF0ZS5ub3coKSAqIDEwMDAsDQogIH07DQp9DQo=", import.meta.url).href, messageHandlers: { "processFrameResponse": (message) => { this.handleProcessFrameResponse(message); } } }); } unSubscribeEventListeners() { var _a, _b; (_a = this.unsubFaceLost) == null ? void 0 : _a.call(this); this.unsubFaceLost = void 0; (_b = this.unsubCompleted) == null ? void 0 : _b.call(this); this.unsubCompleted = void 0; } isDataBucketFull() { return this._dataBucket.timestamps.length >= this.targetDataLength; } clearDataBucket() { this._dataBucket = { sigR: [], sigG: [], sigB: [], timestamps: [] }; } pushToDataBucket(rgbData) { this._dataBucket.sigR.push(rgbData.sigR); this._dataBucket.sigG.push(rgbData.sigG); this._dataBucket.sigB.push(rgbData.sigB); this._dataBucket.timestamps.push(rgbData.timestamp); } getReport() { return { rawData: this._dataBucket, quality: { positionError: 0, yPositionError: 0, dataPointCount: this._dataBucket.timestamps.length } }; } calculateProgressPercentage() { return Math.round(this._dataBucket.timestamps.length / this.targetDataLength * 100); } updateProgressPercentage(number) { this._progressPercentage = number; this.eventEmitter.emit("progress:updated", this._progressPercentage); } /** * 워커 프레임 처리 응답 * - RGB 누적, 진행률 갱신, 완료 시 완료 이벤트 발행 */ handleProcessFrameResponse(message) { if (message.isWarmUp) { this.eventEmitter.emit("frame:warmUp", void 0); return; } if (message.extractedRGB !== null) { this.pushToDataBucket(message.extractedRGB); this.updateProgressPercentage(this.calculateProgressPercentage()); if (this.isDataBucketFull()) { this.updateProgressPercentage(100); logger.debug("Data collection completed", { dataPoints: this._dataBucket.timestamps.length, target: this.targetDataLength }); this.eventEmitter.emit("system:completed", this.getReport()); } } } } const VIDEO_READY_STATE = 3; class WebCamManager extends BaseManager { constructor(config, eventEmitter, videoElement) { super(config, eventEmitter); __publicField(this, "webCamStream", null); __publicField(this, "videoElement"); __publicField(this, "_width"); __publicField(this, "_height"); __publicField(this, "_frame", null); this.videoElement = videoElement; this._width = config.width; this._height = config.height; this.setupEventListeners(); logger.debug("WebCamManager initialized", { size: `${config.width}x${config.height}` }); } get frame() { return this._frame; } get width() { return this._width; } get height() { return this._height; } /** * 브라우저 미디어 스트림을 초기화하고 video 요소에 연결 */ initialize() { return new Promise(async (resolve, reject) => { try { this.webCamStream = await navigator.mediaDevices.getUserMedia({ video: { width: this.width, height: this.height } }); this.handleWebcamMalfunction(); this.videoElement.srcObject = this.webCamStream; this.videoElement.oncanplay = () => { this.startWebcam(); logger.debug("Webcam stream connected", { size: `${this.width}x${this.height}` }); resolve(); }; this.videoElement.oninvalid; this.videoElement.onerror = (err) => { logger.error("Video stream load failed", err); this.eventEmitter.emit("webcam:videoError", void 0); reject(new Error(`Video stream load failed: ${err}`)); }; } catch (err) { logger.error("Webcam initialization failed", err); reject(err); } }); } async reset() { this.stopWebCam(); await this.initialize(); } startWebcam() { try { this.videoElement.play(); } catch (err) { logger.error("Webcam start failed", err); } } stopWebCam() { var _a; (_a = this.webCamStream) == null ? void 0 : _a.getTracks().forEach((track) => track.stop()); this.webCamStream = null; logger.debug("Webcam stream stopped"); } validateVideoReadyState() { if (this.videoElement.readyState < VIDEO_READY_STATE) { return false; } return true; } /** * 웹캠 프레임 캡처 * - 얼굴인식이 필요한 경우 전체 프레임 캡처 * - 얼굴인식이 필요하지 않은 경우 ROI 범위 내에서만 캡처 * @param roi ROI 범위 */ async captureFrame(roi) { try { this._frame = await createImageBitmap(this.videoElement, roi.sx, roi.sy, roi.sw, roi.sh); } catch (err) { logger.error("Frame capture failed", err); } } setupEventListeners() { } handleWebcamMalfunction() { var _a, _b, _c, _d; (_b = (_a = this.webCamStream) == null ? void 0 : _a.getTracks()[0]) == null ? void 0 : _b.addEventListener("mute", () => { console.log("webcam muted"); this.checkWebcamMutedAfterTimeout(1e3); }); (_d = (_c = this.webCamStream) == null ? void 0 : _c.getTracks()[0]) == null ? void 0 : _d.addEventListener("ended", () => { console.log("webcam ended"); this.eventEmitter.emit("webcam:streamEnded", void 0); }); } checkWebcamMutedAfterTimeout(timeout) { setTimeout(() => { var _a; const tracks = (_a = this.webCamStream) == null ? void 0 : _a.getTracks(); if (tracks) { const track = tracks[0]; if (track.muted) { this.eventEmitter.emit("webcam:streamMuted", void 0); } } }, timeout); } stop() { if (this.videoElement) { this.videoElement.pause(); } this._frame = null; } dispose() { this.stop(); this.stopWebCam(); if (this.videoElement) { this.videoElement.srcObject = null; } } clearFrame() { this._frame = null; } } const calculateInterval = (fps) => { return 1e3 / fps; }; function clamp(v, lo, hi) { if (Number.isNaN(v)) return v; return Math.max(lo, Math.min(v, hi)); } class MeasurementLoop { constructor(config, eventEmitter) { __publicField(this, "eventEmitter"); __publicField(this, "measurementConfig"); __publicField(this, "_shouldExcuteRGBExtraction"); __publicField(this, "_countdown"); __publicField(this, "isRunning", false); __publicField(this, "processingTimer"); __publicField(this, "unsubscribeFunctions", []); __publicField(this, "dataProcessingManager"); __publicField(this, "faceDetectionManager"); __publicField(this, "webCamManager"); __publicField(this, "loopCount"); __publicField(this, "exptected"); __publicField(this, "minSpacing"); this.eventEmitter = eventEmitter; this.measurementConfig = config.measurementConfig; this.dataProcessingManager = new DataProcessingManager( config.dataProcessingManagerConfig, eventEmitter ); this.faceDetectionManager = new FaceDetectionManager( config.faceDetectionManagerConfig, eventEmitter ); this.webCamManager = new WebCamManager( config.webCamManagerConfig, eventEmitter, config.measurementConfig.videoElement ); this._shouldExcuteRGBExtraction = true; logger.debug("MeasurementLoop initialized", { countdown: config.measurementConfig.countdown, processingFps: config.measurementConfig.processingFps, targetDataLength: config.dataProcessingManagerConfig.targetDataLength }); this.resetProcess(); this.setUpEventListeners(); } get shouldExcuteRGBExtraction() { return this._shouldExcuteRGBExtraction; } get countdown() { return this._countdown; } set countdown(countdown) { this._countdown = countdown; } get progressPercentage() { return this.dataProcessingManager.progressPercentage; } get facePosition() { return this.faceDetectionManager.facePosition; } setUpEventListeners() { this.unsubscribeFunctions.forEach((unsubscribe) => unsubscribe()); this.unsubscribeFunctions = []; this.unsubscribeFunctions.push( this.eventEmitter.subscribe("system:initialize", async () => { await this.initialize(); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("system:reset", () => { this.resetSystem(); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("system:start", () => { this.startSystem(); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("system:completed", () => { this.stopSystem(); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("system:stop", () => { this.stopSystem(); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("system:terminate", async () => { await this.terminateSystem(); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("system:toggleMeasure", (shouldExcuteRGBExtraction) => { this.enableRGBExtraction(shouldExcuteRGBExtraction); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("webcam:streamMuted", () => { this.eventEmitter.emit("system:failed", new Error("Webcam stream muted")); this.stopSystem(); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("webcam:videoError", () => { this.eventEmitter.emit("system:failed", new Error("Webcam video error")); this.stopSystem(); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("webcam:streamEnded", () => { this.eventEmitter.emit("system:failed", new Error("Webcam stream ended")); this.stopSystem(); }) ); } /** * 웹캠/데이터/검출 매니저를 병렬 초기화하고 상태를 갱신 */ async initialize() { this.resetProcess(); try { await this.webCamManager.initialize(), await Promise.all([ this.dataProcessingManager.initialize( this.measurementConfig.extractingCanvas, this.measurementConfig.willReadFrequently ), this.faceDetectionManager.initialize(this.measurementConfig.videoElement) ]); await this.processWarmUpWhileInitializing(); this.eventEmitter.emit("system:ready", void 0); } catch (err) { logger.error("MeasurementLoop initialization failed", err); this.eventEmitter.emit("system:failed", err); } } /** * 각 매니저를 재초기화하고 READY 상태로 복귀 */ resetSystem() { try { this.dataProcessingManager.reset(); this.faceDetectionManager.reset(); this.resetProcess(); this.eventEmitter.emit("system:ready", void 0); } catch (err) { logger.error("MeasurementLoop system reset failed", err); this.eventEmitter.emit("system:failed", err); } } /** * 모든 리소스 해제 및 처리 타이머 정리 */ async terminateSystem() { this.isRunning = false; this.clearProcessingTimer(); this.webCamManager.dispose(); this.dataProcessingManager.dispose(); this.faceDetectionManager.dispose(); this.dispose(); } /** * MeasurementLoop의 이벤트 구독 해제 */ dispose() { this.unsubscribeFunctions.forEach((unsubscribe) => unsubscribe()); this.unsubscribeFunctions = []; } /** * 루프 중지 및 각 매니저 정지, 상태를 CANCELED로 변경 */ stopSystem() { this.isRunning = false; this.clearProcessingTimer(); this.webCamManager.clearFrame(); this.dataProcessingManager.stop(); this.faceDetectionManager.stop(); } /** * 카운트다운 후 프레임 처리 루프 시작 */ async startSystem() { if (this.isRunning) return; await Promise.all([this.processCountdown(), this.processWarmUpWhileCountdown()]); logger.debug("Measurement loop started"); this.runLoop(); } /** * 다음 프레임 처리까지의 간격을 계산해 루프를 구동 * - 비디오 준비상태 검증, RUNNING 상태 알림, 간격 계산 */ runLoop() { if (!this.webCamManager.validateVideoReadyState()) { this.eventEmitter.emit("system:failed", new Error("Video is not ready")); return; } this.isRunning = true; const frameInterval = calculateInterval(this.measurementConfig.processingFps); this.exptected = performance.now() + frameInterval; this.minSpacing = clamp(frameInterval * 0.15, 6, 12); this.processLoop(frameInterval); } /** * 지연 보정 로직으로 다음 실행 시점을 계산하며 반복 실행 * - shouldContinueLoop로 종료 조건을 선확인 */ processLoop(idleInterval) { if (!this.shouldContinueLoop()) { this.handleLoopTermination(); return; } const actualInterval = this.getCalcualteInterval(); this.processingTimer = setTimeout(() => { if (!this.shouldContinueLoop()) { this.handleLoopTermination(); return; } this.processFrame(); this.exptected += idleInterval; if (this.shouldContinueLoop()) { this.processLoop(idleInterval); } else { this.handleLoopTermination(); } }, actualInterval); } /** * 코어 사이클 : RGB 추출 및 얼굴인식 루프의 사이클 * * case * - shouldExtractRGB: true && shouldValidateFacePosition: true : 비디오 프레임 전체 캡처 * - shouldExtractRGB: true && shouldValidateFacePosition: false : ROI 범위 내에서만 캡처 * - shouldExtractRGB: false && shouldValidateFacePosition: true : 비디오 프레임 전체 캡처 * - shouldExtractRGB: false && shouldValidateFacePosition: false : 아무것도 하지 않음 */ async processFrame() { this.loopCount++; const shouldValidateFacePosition = this.shouldValidateFacePosition(); const shouldExtractRGB = this.shouldExtractRGB(); this.emitProcessingState(shouldExtractRGB); if (shouldValidateFacePosition) { this.faceDetectionManager.validateFacePosition(this.measurementConfig.videoElement); } if (!shouldExtractRGB && !shouldValidateFacePosition) return; if (shouldExtractRGB) { await this.extractAndProcessRGB(); } } /** * 얼굴 위치 검증 상태에 따른 이벤트 발생 */ emitProcessingState(shouldExtractRGB) { if (shouldExtractRGB) { this.eventEmitter.emit("system:measuring", void 0); } } /** * RGB 데이터 추출 및 처리 */ async extractAndProcessRGB() { if (this.faceDetectionManager.ROI === null) return; await this.webCamManager.captureFrame(this.faceDetectionManager.ROI); this.dataProcessingManager.processFrame(this.webCamManager.frame); } /** * 워밍업 사이클 */ async warmUp() { this.faceDetectionManager.validateFacePosition(this.measurementConfig.videoElement, true); if (this.webCamManager.frame !== null) { this.dataProcessingManager.processFrame(this.webCamManager.frame, true); } } shouldContinueLoop() { if (!this.isRunning) { return false; } if (!this.webCamManager.validateVideoReadyState()) { this.eventEmitter.emit("system:failed", new Error("Video connection lost")); return false; } if (this.dataProcessingManager.isDataBucketFull()) { return false; } return true; } handleLoopTermination() { this.isRunning = false; this.clearProcessingTimer(); } /** * 초 단위 카운트다운을 진행하며 매 초마다 이벤트 발행 */ async processCountdown() { this.countdown = this.measurementConfig.countdown; while (this.countdown > 0) { this.countdown--; this.eventEmitter.emit("countdown:tick", this.countdown); await new Promise((resolve) => setTimeout(resolve, 1e3)); } } /** * 카운트다운 중 워밍업 처리 */ async processWarmUpWhileCountdown() { while (this.countdown > 0) { await this.warmUp(); await new Promise((resolve) => setTimeout(resolve, 100)); } } /** * 초기화 중 워밍업 처리 */ async processWarmUpWhileInitializing() { for (let i2 = 0; i2 < 5; i2++) { await this.warmUp(); await new Promise((resolve) => setTimeout(resolve, 100)); } } clearProcessingTimer() { if (this.processingTimer) { clearTimeout(this.processingTimer); this.processingTimer = void 0; } } resetProcess() { this.countdown = 0; this.loopCount = 0; this.exptected = 0; this._shouldExcuteRGBExtraction = true; this.clearProcessingTimer(); } /** * 지연 보정: 예상 시점 대비 뒤처짐 여부에 따라 최소 지연으로 보정 */ getCalcualteInterval() { const now = performance.now(); const baseDelay = this.exptected - now; const behind = baseDelay <= 0; const delay = behind ? this.minSpacing : Math.max(baseDelay, this.minSpacing); return Math.max(delay, 0); } shouldValidateFacePosition() { return this.loopCount % (this.measurementConfig.processingFps / this.measurementConfig.validationFps) === 0; } shouldExtractRGB() { return this.faceDetectionManager.isDetected && this.shouldExcuteRGBExtraction; } enableRGBExtraction(shouldExcuteRGBExtraction) { try { if (this._shouldExcuteRGBExtraction === shouldExcuteRGBExtraction) { return; } this._shouldExcuteRGBExtraction = shouldExcuteRGBExtraction; if (this._shouldExcuteRGBExtraction) { this.dataProcessingManager.reset(); this.eventEmitter.emit("system:measuring", void 0); } else { this.eventEmitter.emit("system:running", void 0); } } catch (err) { logger.error("RGB extraction toggle failed", err); this.eventEmitter.emit("system:failed", err); } } } class StateMachine { constructor(eventEmitter) { __publicField(this, "state"); __publicField(this, "eventEmitter"); __publicField(this, "unsubscribeFunctions", []); __publicField(this, "validTransitions", { [i.INITIALIZING]: [ i.READY, i.FAILED, i.INITIALIZING ], [i.READY]: [ i.RUNNING, i.MEASURING, i.INITIALIZING, i.FAILED, i.READY ], [i.RUNNING]: [ i.MEASURING, i.READY, i.FAILED, i.RUNNING ], [i.MEASURING]: [ i.RUNNING, i.COMPLETED, i.READY, i.FAILED, i.MEASURING ], [i.COMPLETED]: [ i.INITIALIZING, i.FAILED, i.COMPLETED ], [i.FAILED]: [i.INITIALIZING, i.FAILED] }); this.state = i.INITIALIZING; this.eventEmitter = eventEmitter; this.setupEventListeners(); logger.debug("StateMachine initialized"); } getState() { return this.state; } setState(newState) { const previousState = this.state; if (!this.isValidTransition(previousState, newState)) { logger.warn("Invalid state transition", { from: previousState, to: newState }); return; } if (previousState !== newState) { this.state = newState; logger.debug("State changed", { from: previousState, to: newState }); } this.eventEmitter.emit("state:changed", newState); } /** * 시스템 이벤트를 상태 변경으로 매핑 * - 각 이벤트 수신 시 대응되는 상태로 전이 */ setupEventListeners() { this.unsubscribeFunctions.push( this.eventEmitter.subscribe("system:initialize", () => { this.setState(i.INITIALIZING); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("system:ready", () => { this.setState(i.READY); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("system:start", () => { this.setState(i.RUNNING); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("system:running", () => { this.setState(i.RUNNING); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("system:measuring", () => { this.setState(i.MEASURING); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("system:completed", () => { this.setState(i.COMPLETED); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("system:stop", () => { this.setState(i.READY); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("system:failed", () => { this.setState(i.FAILED); }) ); this.unsubscribeFunctions.push( this.eventEmitter.subscribe("system:reset", () => { this.setState(i.INITIALIZING); }) ); } dispose() { this.unsubscribeFunctions.forEach((unsubscribe) => unsubscribe()); this.unsubscribeFunctions = []; } isValidTransition(from, to) { var _a; return ((_a = this.validTransitions[from]) == null ? void 0 : _a.includes(to)) ?? false; } } class EventEmitter { constructor() { __publicField(this, "events", {}); } /** * 동기적으로 리스너들을 호출하여 이벤트를 전달 * @param eventType 이벤트 이름 * @param data 이벤트 페이로드 */ emit(eventType, data) { if (this.events[eventType]) { this.events[eventType].forEach((callback) => callback(data)); } } /** * 이벤트를 구독하고 해제 함수 반환 * @param eventType 이벤트 이름 * @param callback 수신 콜백 * @returns 구독 해제 함수 */ subscribe(eventType, callback) { if (!this.events[eventType]) { this.events[eventType] = []; } this.events[eventType].push(callback); return () => { if (this.events[eventType]) { this.events[eventType] = this.events[eventType].filter((cb) => cb !== callback); } }; } /** * 특정 콜백을 이벤트에서 해제 * @param eventType 이벤트 이름 * @param callback 해제할 콜백 */ unsubscribe(eventType, callback) { if (this.events[eventType]) { this.events[eventType] = this.events[eventType].filter((cb) => cb !== callback); } } /** * 특정 이벤트 또는 전체 이벤트에 등록된 리스너를 제거 * @param eventType 생략 시 전체 제거 */ removeAllListeners(eventType) { if (eventType) { delete this.events[eventType]; } else { this.events = {}; } } } const defaultConfig = (videoElement, extractingCanvasElement) => { return { faceDetectionManagerConfig: { minDetectionConfidence: 0.5, delegate: "CPU" }, webCamManagerConfig: { width: 640, height: 480 }, measurementConfig: { countdown: 3, processingFps: 30, validationFps: 1, retryCount: 3, extractingCanvas: extractingCanvasElement, videoElement, willReadFrequently: false }, dataProcessingManagerConfig: { targetDataLength: 450 }, debug: true }; }; class FaceDetector { constructor(configOrVideoElement, extractingCanvasElement) { __publicField(this, "stateMachine"); __publicField(this, "measurementLoop"); __publicField(this, "eventEmitter"); __publicField(this, "activeUnsubscribeCompleted"); __publicField(this, "activeUnsubscribeFailed"); let config; if (configOrVideoElement instanceof HTMLVideoElement) { config = defaultConfig(configOrVideoElement, extractingCanvasElement); } else { config = configOrVideoElement; } logger.setDebugMode(config.debug); logger.debug("FaceDetector initialization started", { debug: config.debug }); this.eventEmitter = new EventEmitter(); this.stateMachine = new StateMachine(this.eventEmitter); this.measurementLoop = new MeasurementLoop(config, this.eventEmitter); this.eventEmitter.emit("system:initialize", void 0); } getState() { return this.stateMachine.getState(); } getProgress() { return this.measurementLoop.progressPercentage; } getCountdown() { return this.measurementLoop.countdown; } getFacePosition() { return this.measurementLoop.facePosition; } subscribeState(callback) { this.eventEmitter.subscribe("state:changed", callback); return () => this.eventEmitter.unsubscribe("state:changed", callback); } subscribeProgress(callback) { this.eventEmitter.subscribe("progress:updated", callback); return () => this.eventEmitter.unsubscribe("progress:updated", callback); } subscribeCountdown(callback) { this.eventEmitter.subscribe("countdown:tick", callback); return () => this.eventEmitter.unsubscribe("countdown:tick", callback); } subscribeFacePosition(callback) { this.eventEmitter.subscribe("face:position", callback); return () => this.eventEmitter.unsubscribe("face:position", callback); } /** * 측정을 시작하고 완료/실패 이벤트를 대기하여 결과를 반환 * - 완료/실패 구독을 등록하고, 먼저 발생한 이벤트로 Promise를 해결 */ async run() { if (this.getState() !== i.READY) { throw new Error(`Cannot start detection. Current state: ${this.getState()}`); } this.cleanupActiveSubscriptions(); const start = performance.now(); this.eventEmitter.emit("system:start", void 0); return new Promise((resolve, reject) => { this.activeUnsubscribeCompleted = this.eventEmitter.subscribe("system:completed", (result) => { this.cleanupActiveSubscriptions(); resolve(result); logger.debug("Measurement completed", { duration: `${((performance.now() - start) / 1e3).toFixed(2)}s`, state: this.getState() }); }); this.activeUnsubscribeFailed = this.eventEmitter.subscribe("system:failed", (error) => { this.cleanupActiveSubscriptions(); reject(error); }); }); } cleanupActiveSubscriptions() { var _a, _b; (_a = this.activeUnsubscribeCompleted) == null ? void 0 : _a.call(this); (_b = this.activeUnsubscribeFailed) == null ? void 0 : _b.call(this); this.activeUnsubscribeCompleted = void 0; this.activeUnsubscribeFailed = void 0; } stop() { this.eventEmitter.emit("system:stop", void 0); } terminate() { this.eventEmitter.emit("system:terminate", void 0); } reset() { this.eventEmitter.emit("system:reset", void 0); } enableRGBExtraction(shouldExcuteRGBExtraction) { this.eventEmitter.emit("system:toggleMeasure", shouldExcuteRGBExtraction); } } export { EventEmitter, FaceDetector, MeasurementLoop, i as ProcessState, StateMachine, defaultConfig }; //# sourceMappingURL=index.es.js.map