UNPKG

@the-horizon-dev/fast-face-detection

Version:

Fast face detection package using TensorFlow.js MediaPipe models for browser and Node.js environments

1,265 lines (1,255 loc) 61.4 kB
import * as faceDetection from '@tensorflow-models/face-detection'; import * as tf from '@tensorflow/tfjs-core'; import * as faceLandmarksDetection from '@tensorflow-models/face-landmarks-detection'; import '@tensorflow/tfjs-backend-webgl'; /** * Error definitions for face detection */ /** * Error codes for the facial detection package */ var ErrorCode; (function (ErrorCode) { // Generic error codes ErrorCode[ErrorCode["UNKNOWN_ERROR"] = 0] = "UNKNOWN_ERROR"; ErrorCode[ErrorCode["INVALID_INPUT"] = 1] = "INVALID_INPUT"; // Initialization error codes ErrorCode[ErrorCode["MODEL_LOAD_FAILED"] = 100] = "MODEL_LOAD_FAILED"; ErrorCode[ErrorCode["BACKEND_INITIALIZATION_FAILED"] = 101] = "BACKEND_INITIALIZATION_FAILED"; ErrorCode[ErrorCode["UNSUPPORTED_ENVIRONMENT"] = 102] = "UNSUPPORTED_ENVIRONMENT"; // Detection error codes ErrorCode[ErrorCode["DETECTION_FAILED"] = 200] = "DETECTION_FAILED"; ErrorCode[ErrorCode["NO_FACES_DETECTED"] = 201] = "NO_FACES_DETECTED"; ErrorCode[ErrorCode["TRACKING_ERROR"] = 202] = "TRACKING_ERROR"; // Landmark error codes ErrorCode[ErrorCode["LANDMARK_DETECTION_FAILED"] = 300] = "LANDMARK_DETECTION_FAILED"; // Resource error codes ErrorCode[ErrorCode["RESOURCE_EXHAUSTED"] = 400] = "RESOURCE_EXHAUSTED"; ErrorCode[ErrorCode["RESOURCE_DISPOSED"] = 401] = "RESOURCE_DISPOSED"; })(ErrorCode || (ErrorCode = {})); /** * Base error class for facial detection */ class FaceDetectionError extends Error { constructor(message, details) { var _a; super(message); this.name = 'FaceDetectionError'; this.code = (_a = details === null || details === void 0 ? void 0 : details.code) !== null && _a !== void 0 ? _a : ErrorCode.UNKNOWN_ERROR; this.originalError = details === null || details === void 0 ? void 0 : details.originalError; this.context = details === null || details === void 0 ? void 0 : details.context; } toString() { let result = `${this.name} [${this.code}]: ${this.message}`; if (this.originalError) { result += `\nCaused by: ${this.originalError.message}`; } return result; } static fromError(error, code = ErrorCode.UNKNOWN_ERROR) { return new FaceDetectionError(error.message, { code, originalError: error }); } } /** * Error for facial detection failures */ class FaceDetectorError extends FaceDetectionError { constructor(message, details) { super(message, details); this.name = 'FaceDetectorError'; } } /** * Error for landmark detection failures */ class LandmarkDetectorError extends FaceDetectionError { constructor(message, details) { super(message, details); this.name = 'LandmarkDetectorError'; } } /** * Error for model initialization failures */ class ModelInitializationError extends FaceDetectionError { constructor(message, details) { super(message, details); this.name = 'ModelInitializationError'; } } /** * Error for invalid inputs */ class InvalidInputError extends FaceDetectionError { constructor(message, context) { super(message, { code: ErrorCode.INVALID_INPUT, context }); this.name = 'InvalidInputError'; } } /** * @file Logger service for consistent logging throughout the library * * This service provides a centralized logging mechanism with support for * different log levels and formatted output. It helps with debugging and * troubleshooting by providing consistent log formatting and control over * verbosity. */ /** * Service responsible for consistent logging across the face detection library. * All library logs are channeled through this service to ensure uniform formatting * and to allow global control over log verbosity. * * @example * // Basic usage * Logger.info('Processing started'); * Logger.error('Failed to load model', new Error('Network error')); * * // Enable debug logs * Logger.setDebug(true); * Logger.debug('Detailed processing information'); */ class Logger { /** * Enables or disables debug mode for detailed logging. * When debug mode is disabled, debug and performance logs won't be shown. * * @param {boolean} enabled - Whether to enable debug logging * * @example * // Enable debug logs during development * Logger.setDebug(true); * * // Disable debug logs in production * Logger.setDebug(false); */ static setDebug(enabled) { this.isDebugEnabled = enabled; } /** * Logs an error message and optional error object. * Use this for critical errors that prevent normal operation. * * @param {string} message - The error message to display * @param {Error} [error] - Optional error object with stack trace * * @example * try { * // Some operation that might fail * } catch (error) { * Logger.error('Failed to process image', error as Error); * } */ static error(message, error) { console.error(`[Face Detection Error] ${message}`, error); } /** * Logs a warning message and optional error object. * Use this for non-critical issues that allow continued operation. * * @param {string} message - The warning message to display * @param {Error} [error] - Optional error object with details * * @example * if (!isOptimalFormat) { * Logger.warn('Image format not optimal, processing may be slower'); * } */ static warn(message, error) { console.warn(`[Face Detection Warning] ${message}`, error); } /** * Logs an informational message. * Use this for general status updates and important events. * * @param {string} message - The information message to display * * @example * Logger.info('Face detection model loaded successfully'); * Logger.info(`Detected ${faces.length} faces in the image`); */ static info(message) { console.info(`[Face Detection Info] ${message}`); } /** * Logs a debug message with optional additional arguments. * Debug messages are only shown when debug mode is enabled. * * @param {string} message - The debug message to display * @param {...unknown[]} args - Optional additional arguments to log * * @example * // Log a simple debug message * Logger.debug('Processing step 1'); * * // Log with additional data * Logger.debug('Face detected', { x: 100, y: 200 }); */ static debug(message, ...args) { if (!this.isDebugEnabled) return; console.debug(`[DEBUG] ${message}`, ...args); } /** * Logs performance metrics for operations. * These messages only appear if debug mode is enabled. * Use this to track execution time of performance-critical operations. * * @param {string} operation - Name of the operation being measured * @param {number} durationMs - Duration in milliseconds * * @example * const startTime = performance.now(); * // ... perform face detection ... * const endTime = performance.now(); * Logger.performance('Face detection', endTime - startTime); */ static performance(operation, durationMs) { if (this.isDebugEnabled) { console.debug(`[Face Detection Performance] ${operation}: ${durationMs}ms`); } } } /** Flag to control debug logging visibility */ Logger.isDebugEnabled = false; /** * TensorFlow.js backend initialization utilities */ // Remove the module declaration which causes errors // Map of loaded backends to avoid duplicate loading const loadedBackends = new Set(); /** * Dynamically loads a specific TensorFlow.js backend * @returns true if backend was loaded successfully, false if it should fall back */ async function loadBackend(backend) { if (loadedBackends.has(backend)) return true; try { if (backend === 'webgl') { await import('@tensorflow/tfjs-backend-webgl'); Logger.debug('WebGL backend loaded'); loadedBackends.add(backend); return true; } else if (backend === 'cpu') { await import('@tensorflow/tfjs-backend-cpu'); Logger.debug('CPU backend loaded'); loadedBackends.add(backend); return true; } else if (backend === 'node') { // Only attempt to load the Node.js backend if we're in a Node.js environment if (typeof window === 'undefined') { try { // Using dynamic import with a try/catch to handle the case where the package is not installed await import('@tensorflow/tfjs-node'); Logger.debug('Node.js backend loaded'); loadedBackends.add(backend); return true; } catch (_a) { // Don't throw here, just log the issue and signal to fall back Logger.info('For better performance in Node.js, install @tensorflow/tfjs-node package'); return false; } } else { // In browser environment, don't even try to load the Node.js backend Logger.debug('Skipping Node.js backend in browser environment'); return false; } } return false; } catch (error) { Logger.warn(`Error loading ${backend} backend`, error); return false; } } /** * Initializes the appropriate TensorFlow.js backend */ async function initTensorflowBackend(environment) { // Check if the backend is already initialized if (tf.getBackend()) { return; } if (environment === 'node') { try { const nodeBackendLoaded = await loadBackend('node'); if (nodeBackendLoaded) { await tf.setBackend('node'); } else { // Fall back to CPU if Node.js backend is not available Logger.warn('Node backend unavailable, falling back to CPU'); await loadBackend('cpu'); await tf.setBackend('cpu'); } } catch (error) { Logger.warn('Node backend unavailable, falling back to CPU', error); await loadBackend('cpu'); await tf.setBackend('cpu'); } } else { // Browser - try WebGL first, with fallback to CPU try { const webglLoaded = await loadBackend('webgl'); if (webglLoaded) { await tf.setBackend('webgl'); } else { throw new Error('WebGL backend loading failed'); } } catch (error) { Logger.warn('WebGL unavailable, using CPU as alternative', error); try { await loadBackend('cpu'); await tf.setBackend('cpu'); } catch (cpuError) { Logger.error('Failed to initialize both WebGL and CPU backends', cpuError); throw new Error('Could not initialize any TensorFlow backend'); } } } } /** * Checks if a TensorFlow.js backend has been initialized */ function isTensorflowBackendInitialized() { return !!tf.getBackend(); } /** * Returns the name of the current active TensorFlow.js backend */ function getTensorflowBackend() { return tf.getBackend(); } /** * Abstract base class for all detectors */ class BaseDetector { /** * Creates a new detector instance with configuration options * @param options Configuration options for the detector */ constructor(options) { /** Flag indicating if the detector has been disposed */ this.isDisposed = false; /** Current execution environment */ this.environment = 'browser'; this.options = options || {}; if (this.options.environment) { this.environment = this.options.environment; } } /** * Ensures TensorFlow backend is initialized */ async ensureTensorflowBackend() { if (this.isDisposed) { throw new FaceDetectionError('Detector has been disposed and cannot be used again'); } try { await initTensorflowBackend(this.environment); } catch (error) { throw new FaceDetectionError(`Failed to initialize internal resources: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Updates detector options */ updateOptions(options) { if (this.isDisposed) { throw new FaceDetectionError('Detector has been disposed and cannot be used again'); } let optionsChanged = false; if (options.scoreThreshold !== undefined && this.options.scoreThreshold !== options.scoreThreshold) { optionsChanged = true; } if (options.maxFaces !== undefined && this.options.maxFaces !== options.maxFaces) { optionsChanged = true; } if (options.enableTracking !== undefined && this.options.enableTracking !== options.enableTracking) { optionsChanged = true; } if (options.environment !== undefined && this.environment !== options.environment) { this.environment = options.environment; optionsChanged = true; } if (optionsChanged) { this.options = { ...this.options, ...options }; this.onOptionsUpdated(); } } /** * Releases all resources */ dispose() { if (!this.isDisposed) { try { this.onDispose(); this.isDisposed = true; } catch (error) { Logger.warn('Error while disposing resources', error); } } } /** * Pre-loads model for later use */ async warmup() { if (this.isDisposed) { throw new FaceDetectionError('Detector has been disposed and cannot be used again'); } try { await this.onWarmup(); } catch (error) { Logger.warn('Error during warmup', error); } } } /** * @file Face detector implementation * * This file implements the core facial detection functionality using the * MediaPipe FaceDetector model through TensorFlow.js. */ /** * Maps library configuration to TensorFlow model configuration for BlazeFace short-range */ function mapConfigToTfConfig(config) { const tfConfig = { runtime: 'tfjs', modelType: 'full', }; // Set score threshold if provided if (config.scoreThreshold !== undefined) { tfConfig.scoreThreshold = config.scoreThreshold; } else { // Default to a reasonable threshold if not specified tfConfig.scoreThreshold = 0.5; } // Set max faces if provided if (config.maxFaces !== undefined) { tfConfig.maxFaces = config.maxFaces; } else { tfConfig.maxFaces = 10; } // Log the configuration for debugging console.log('[FaceDetector] Mapped config to TF config:', JSON.stringify(tfConfig, null, 2)); return tfConfig; } /** * Responsible for detecting faces in images and videos */ class FaceDetector extends BaseDetector { /** * Creates a new face detector */ constructor(options) { super(options); this.detector = null; this.config = this.options; } /** * Ensures the detector is loaded before use */ async ensureDetectorLoaded() { if (this.isDisposed) { throw new FaceDetectorError('Detector has been disposed and cannot be used again', { code: ErrorCode.RESOURCE_DISPOSED }); } if (!this.detector) { try { await this.ensureTensorflowBackend(); const tfConfig = mapConfigToTfConfig(this.config); console.log('[FaceDetector] TFJS Config before creating detector:', JSON.stringify(tfConfig, null, 2)); // Create the detector with the specified model type this.detector = await faceDetection.createDetector(faceDetection.SupportedModels.MediaPipeFaceDetector, tfConfig); console.log('[FaceDetector] Detector created successfully'); // Perform a warmup detection to ensure the model is fully loaded if (typeof document !== 'undefined') { try { const canvas = document.createElement('canvas'); canvas.width = 100; canvas.height = 100; const ctx = canvas.getContext('2d'); if (ctx) { ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, 100, 100); // This is just a warmup, we don't care about the result await this.detector.estimateFaces(canvas); console.log('[FaceDetector] Warmup detection completed'); } } catch (warmupError) { // Ignore warmup errors, they're not critical console.log('[FaceDetector] Warmup detection failed (non-critical):', warmupError); } } } catch (error) { throw new ModelInitializationError(`Failed to initialize face detector: ${error instanceof Error ? error.message : 'Unknown error'}`, { code: ErrorCode.MODEL_LOAD_FAILED, originalError: error instanceof Error ? error : undefined }); } } } /** * Maps detected faces to the internal format */ mapDetectedFaces(detectedFaces) { return detectedFaces.map((face) => { var _a, _b; console.log('[FaceDetector] Processing individual face object:', JSON.stringify(face, null, 2)); // Log individual potential score sources const rawScore = face.score; const rawConfidence = face.confidence; const keypointScore = (face.keypoints && ((_a = face.keypoints[0]) === null || _a === void 0 ? void 0 : _a.score)); const probability = (_b = face === null || face === void 0 ? void 0 : face.probability) === null || _b === void 0 ? void 0 : _b[0]; console.log(`[FaceDetector] Score sources: rawScore=${rawScore}, rawConfidence=${rawConfidence}, keypointScore=${keypointScore}, probability=${probability}`); // Try to get the score from various possible sources in the MediaPipe FaceDetector model // The MediaPipe FaceDetector model might store the confidence score in different properties let score = 0; if (typeof rawScore === 'number' && rawScore > 0) { score = rawScore; } else if (typeof rawConfidence === 'number' && rawConfidence > 0) { score = rawConfidence; } else if (typeof keypointScore === 'number' && keypointScore > 0) { score = keypointScore; } else if (typeof probability === 'number' && probability > 0) { score = probability; } else if (face.keypoints && face.keypoints.length > 0) { // If no direct score is available, try to calculate from keypoints // MediaPipe models often have confidence scores in keypoints const keypointScores = face.keypoints .filter(kp => typeof kp.score === 'number' && kp.score > 0) .map(kp => kp.score); if (keypointScores.length > 0) { // Use average of keypoint scores as a fallback score = keypointScores.reduce((sum, val) => sum + val, 0) / keypointScores.length; } } // If we still have no score, use a default value based on the presence of a valid bounding box if (score === 0 && face.box && typeof face.box.xMin === 'number' && typeof face.box.yMin === 'number' && typeof face.box.width === 'number' && typeof face.box.height === 'number' && face.box.width > 0 && face.box.height > 0) { // If we have a valid bounding box but no score, use a default high confidence // This is a fallback for models that don't provide explicit confidence scores score = 0.95; } console.log('[FaceDetector] Calculated score for this face:', score); const detection = { detection: { box: { x: face.box.xMin, y: face.box.yMin, width: face.box.width, height: face.box.height }, score: score } }; if (this.options.enableTracking && face.trackingID !== undefined) { return { ...detection, trackingID: face.trackingID }; } return detection; }); } /** * Detects faces in an image or video */ async detectFaces(input) { var _a, _b; if (this.isDisposed) { throw new FaceDetectorError('Detector has been disposed and cannot be used again', { code: ErrorCode.RESOURCE_DISPOSED }); } if (!input) { throw new FaceDetectorError('Invalid input: media element cannot be null or undefined', { code: ErrorCode.INVALID_INPUT }); } const startTime = performance.now(); let preprocessingTime = 0; let inferenceTime = 0; let postprocessingTime = 0; try { await this.ensureDetectorLoaded(); preprocessingTime = performance.now() - startTime; const inferenceStartTime = performance.now(); console.log(`[FaceDetector] Input type before estimateFaces: ${(_b = (_a = input === null || input === void 0 ? void 0 : input.constructor) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : typeof input}`); // Log input dimensions if available if ('width' in input && 'height' in input) { console.log(`[FaceDetector] Input dimensions: ${input.width}x${input.height}`); } // Perform face detection const detectedFaces = await this.detector.estimateFaces(input); console.log(`[FaceDetector] Raw detected faces array from estimateFaces: ${JSON.stringify(detectedFaces, null, 2)}`); console.log(`[FaceDetector] Number of faces detected: ${detectedFaces.length}`); inferenceTime = performance.now() - inferenceStartTime; const postprocessingStartTime = performance.now(); // Process the detected faces const processedFaces = this.mapDetectedFaces(detectedFaces); console.log(`[FaceDetector] Processed faces with scores: ${JSON.stringify(processedFaces.map(f => ({ score: f.detection.score, box: f.detection.box })), null, 2)}`); postprocessingTime = performance.now() - postprocessingStartTime; const totalTime = performance.now() - startTime; Logger.performance('Face detection', totalTime); return { faces: processedFaces, timing: { total: totalTime, preprocessing: preprocessingTime, inference: inferenceTime, postprocessing: postprocessingTime } }; } catch (error) { console.error('[FaceDetector] Error during face detection:', error); throw new FaceDetectorError(`Failed to detect faces: ${error instanceof Error ? error.message : 'Unknown error'}`, { code: ErrorCode.DETECTION_FAILED, originalError: error instanceof Error ? error : undefined }); } } /** * Called when options are updated */ onOptionsUpdated() { this.config = this.options; if (this.detector) { this.detector = null; } } /** * Called for model preloading/warmup */ async onWarmup() { await this.ensureDetectorLoaded(); if (typeof document !== 'undefined') { try { const canvas = document.createElement('canvas'); canvas.width = 100; canvas.height = 100; const ctx = canvas.getContext('2d'); if (ctx) { ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, 100, 100); try { await this.detectFaces(canvas); } catch (error) { Logger.debug('Dummy detection failed during preloading (expected)', error); } } } catch (error) { Logger.debug('Error during face detector warmup', error); } } } /** * Called when the detector is disposed */ onDispose() { if (this.detector) { try { this.detector.dispose(); } catch (error) { Logger.debug('Error while disposing detector (ignored)', error); } this.detector = null; } } } /** * Facial landmark detector implementation */ /** * Responsible for detecting facial landmarks using the MediaPipe Face Mesh model */ class LandmarkDetector extends BaseDetector { /** * Creates a new landmark detector */ constructor(options) { super(options); this.detector = null; this.config = { runtime: 'tfjs', refineLandmarks: true, maxFaces: (options === null || options === void 0 ? void 0 : options.maxFaces) || 1, staticImageMode: false, flipHorizontal: false }; } /** * Ensures the detector is loaded before use */ async ensureDetectorLoaded() { if (this.isDisposed) { throw new LandmarkDetectorError('Detector has been disposed and cannot be used again'); } if (!this.detector) { try { await this.ensureTensorflowBackend(); const modelConfig = { runtime: this.config.runtime, maxFaces: this.config.maxFaces, refineLandmarks: this.config.refineLandmarks }; // Create detector with MediaPipe face mesh model this.detector = await faceLandmarksDetection.createDetector(faceLandmarksDetection.SupportedModels.MediaPipeFaceMesh, modelConfig); Logger.debug(`Landmark detector created with runtime: ${this.config.runtime}`); } catch (error) { throw new ModelInitializationError(`Failed to initialize landmark detector: ${error instanceof Error ? error.message : 'Unknown error'}`); } } } /** * Maps TensorFlow.js model keypoints to our standard Point3D format */ processTensorflowKeypoints(meshPoints) { return meshPoints.map((point) => ({ x: point.x, y: point.y, z: point.z || 0 })); } /** * Detects the full facial mesh in an image or video */ async detectLandmarks(input) { if (this.isDisposed) { throw new LandmarkDetectorError('Detector has been disposed'); } await this.ensureDetectorLoaded(); if (!this.detector) { throw new ModelInitializationError('Detector not initialized'); } try { const faces = await this.detector.estimateFaces(input, { flipHorizontal: this.config.flipHorizontal, staticImageMode: this.config.staticImageMode }); return faces.map((face) => ({ meshPoints: this.processTensorflowKeypoints(face.keypoints) })); } catch (error) { throw new LandmarkDetectorError(`Failed to detect landmarks: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Called when options are updated */ onOptionsUpdated() { if (this.options.maxFaces !== undefined) { this.config.maxFaces = this.options.maxFaces; } if (this.options.scoreThreshold !== undefined) { this.config.scoreThreshold = this.options.scoreThreshold; } if (this.options.enableTracking !== undefined) { this.config.staticImageMode = !this.options.enableTracking; } if (this.options.runtime !== undefined) { this.config.runtime = this.options.runtime; } if (this.detector) { // Reset detector to apply new config this.detector = null; } } /** * Called for model preloading/warmup */ async onWarmup() { if (!this.detector) { await this.ensureDetectorLoaded(); } try { if (typeof document !== 'undefined') { const canvas = document.createElement('canvas'); canvas.width = 100; canvas.height = 100; const ctx = canvas.getContext('2d'); if (ctx) { ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, 100, 100); try { await this.detectLandmarks(canvas); } catch (error) { Logger.debug('Dummy detection failed during preloading (expected)', error); } } } else if (this.environment === 'node') { Logger.debug('Node.js environment detected, loading model without dummy canvas'); } else { Logger.debug(`Environment ${this.environment} detected, skipping preloading with canvas`); } } catch (error) { Logger.warn('Unexpected error during landmark detector preloading', error); } } /** * Called when the detector is disposed */ onDispose() { if (this.detector) { try { if (typeof this.detector.dispose === 'function') { this.detector.dispose(); } } catch (error) { Logger.debug('Error while disposing landmark detector (ignored)', error); } this.detector = null; } } } class ValidationService { /** * Validates if the media input is valid */ static validateInput(input) { if (!input) { throw new FaceDetectionError('Invalid input: media element cannot be null or undefined'); } // Check if it's one of the supported types const isHTMLElement = input instanceof HTMLImageElement || input instanceof HTMLVideoElement || input instanceof HTMLCanvasElement; // Check if it's a Node.js Canvas const isNodeCanvas = typeof input === 'object' && input !== null && 'width' in input && 'height' in input && 'getContext' in input && typeof input.getContext === 'function'; if (!isHTMLElement && !isNodeCanvas) { throw new FaceDetectionError('Invalid input: element must be an HTML image, video, canvas, or Node.js canvas'); } } /** * Validates if the API has not been disposed */ static validateDisposed(isDisposed) { if (isDisposed) { throw new FaceDetectionError('API has been disposed and cannot be used again'); } } /** * Validates if the options are valid */ static validateOptions(options) { const scoreThreshold = options.scoreThreshold; if (scoreThreshold !== undefined && typeof scoreThreshold === 'number') { if (scoreThreshold < 0 || scoreThreshold > 1) { throw new FaceDetectionError('scoreThreshold must be between 0 and 1'); } } const maxFaces = options.maxFaces; if (maxFaces !== undefined && typeof maxFaces === 'number') { if (maxFaces < 1) { throw new FaceDetectionError('maxFaces must be greater than 0'); } } } } class ConfigurationService { /** * Combines current options with new options */ static mergeOptions(current, newOptions) { var _a, _b, _c; return { ...current, ...newOptions, environment: newOptions.environment || current.environment, scoreThreshold: (_a = newOptions.scoreThreshold) !== null && _a !== void 0 ? _a : current.scoreThreshold, maxFaces: (_b = newOptions.maxFaces) !== null && _b !== void 0 ? _b : current.maxFaces, enableTracking: (_c = newOptions.enableTracking) !== null && _c !== void 0 ? _c : current.enableTracking }; } /** * Creates default options for the specified environment */ static createDefaultOptions(environment) { return { environment, scoreThreshold: 0.5, maxFaces: 10, enableTracking: true }; } /** * Checks if options have changed */ static hasOptionsChanged(current, newOptions) { return (newOptions.scoreThreshold !== undefined && current.scoreThreshold !== newOptions.scoreThreshold || newOptions.maxFaces !== undefined && current.maxFaces !== newOptions.maxFaces || newOptions.enableTracking !== undefined && current.enableTracking !== newOptions.enableTracking || newOptions.environment !== undefined && current.environment !== newOptions.environment); } } class ImageUtils { /** * Crops a face from an image based on the bounding box * @returns An object containing the cropped canvas and the top-left offset used */ static cropFace(canvas, box, margin = 0) { const { x, y, width, height } = box; // Apply margin (with limits to prevent going beyond the edges) const marginX = width * margin; const marginY = height * margin; const offsetX = Math.max(0, x - marginX); const offsetY = Math.max(0, y - marginY); const cropWidth = Math.min(canvas.width - offsetX, width + 2 * marginX); const cropHeight = Math.min(canvas.height - offsetY, height + 2 * marginY); // Create canvas for the cropped face const faceCanvas = document.createElement('canvas'); faceCanvas.width = cropWidth; faceCanvas.height = cropHeight; // Crop the face region const ctx = faceCanvas.getContext('2d'); if (ctx) { ctx.drawImage(canvas, offsetX, offsetY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight); } return { croppedCanvas: faceCanvas, offsetX, offsetY }; } /** * Draws facial landmarks on a canvas */ static drawLandmarks(canvas, points, color = 'rgba(0, 255, 0, 0.8)', size = 2) { const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.fillStyle = color; points.forEach(point => { ctx.beginPath(); ctx.arc(point.x, point.y, size, 0, 2 * Math.PI); ctx.fill(); }); } /** * Draws a bounding box on a canvas */ static drawBox(canvas, box, color = 'rgba(0, 255, 0, 0.8)', lineWidth = 2) { const ctx = canvas.getContext('2d'); if (!ctx) return; const { x, y, width, height } = box; ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.strokeRect(x, y, width, height); } /** * Converts an image or video to canvas */ static elementToCanvas(element) { // If it's already a canvas, just return it if (element instanceof HTMLCanvasElement) { return element; } // Create a new canvas const canvas = document.createElement('canvas'); // Set dimensions based on element type if (element instanceof HTMLVideoElement) { canvas.width = element.videoWidth; canvas.height = element.videoHeight; } else { canvas.width = element.width; canvas.height = element.height; } // Draw the element on the canvas const ctx = canvas.getContext('2d'); if (ctx) { ctx.drawImage(element, 0, 0); } return canvas; } /** * Downscales an image element to a target width, maintaining aspect ratio. * Only works in browser environments. * @param element The input image, video, or canvas. * @param targetWidth The desired width for the downscaled image. * @returns Object containing the downscaled canvas and the scaling factor used. */ static downscaleImage(element, targetWidth) { if (typeof document === 'undefined') { throw new Error('Image downscaling is only supported in browser environments.'); } const originalCanvas = this.elementToCanvas(element); const originalWidth = originalCanvas.width; const originalHeight = originalCanvas.height; // If already smaller than target, return original (or copy?) if (originalWidth <= targetWidth) { // Return a copy to avoid modifying the original if it was a canvas const copyCanvas = document.createElement('canvas'); copyCanvas.width = originalWidth; copyCanvas.height = originalHeight; const ctx = copyCanvas.getContext('2d'); if (ctx) { ctx.drawImage(originalCanvas, 0, 0); } return { downscaledCanvas: copyCanvas, scaleFactor: 1 }; } const scaleFactor = targetWidth / originalWidth; const targetHeight = Math.round(originalHeight * scaleFactor); const downscaledCanvas = document.createElement('canvas'); downscaledCanvas.width = targetWidth; downscaledCanvas.height = targetHeight; const ctx = downscaledCanvas.getContext('2d'); if (ctx) { // Use drawImage for built-in browser downscaling (usually decent quality) ctx.drawImage(originalCanvas, 0, 0, targetWidth, targetHeight); } else { throw new Error('Failed to get 2D context for downscaling.'); } return { downscaledCanvas, scaleFactor }; } } /** * Main API class for face detection functionality. * This class orchestrates the face detection pipeline, managing the connection * between face detection and landmark detection. * * @example * // Basic usage * const api = new FaceAPI(); * const result = await api.detectFaces(imageElement); * console.log(`Detected ${result.faces.length} faces`); */ class FaceAPI { /** * Creates a new FaceAPI instance with the specified options * @param options Detection configuration options */ constructor(options) { var _a; this.isDisposed = false; this.options = options || ConfigurationService.createDefaultOptions('browser'); // Default downscale threshold (e.g., 640px width). 0 means disabled. this.options.downscaleWidthThreshold = (_a = this.options.downscaleWidthThreshold) !== null && _a !== void 0 ? _a : 640; this.faceDetector = new FaceDetector(this.options); this.landmarkDetector = new LandmarkDetector(this.options); Logger.info('FaceAPI initialized'); } /** * Detects faces in an image or video * @param input Image, video, or canvas element * @param options Detection options * @returns Detection result containing faces and timing information */ async detectFaces(input, options) { ValidationService.validateDisposed(this.isDisposed); ValidationService.validateInput(input); let processedInput = input; let scaleFactor = 1; const isBrowserEnv = typeof document !== 'undefined'; const isHtmlInput = input instanceof HTMLCanvasElement || input instanceof HTMLImageElement || input instanceof HTMLVideoElement; // Downscale if enabled, in browser, and input is compatible if (this.options.downscaleWidthThreshold && this.options.downscaleWidthThreshold > 0 && isBrowserEnv && isHtmlInput) { try { const { downscaledCanvas, scaleFactor: calculatedScale } = ImageUtils.downscaleImage(input, this.options.downscaleWidthThreshold); processedInput = downscaledCanvas; scaleFactor = calculatedScale; Logger.debug(`Downscaled input image with factor: ${scaleFactor}`); } catch (e) { Logger.warn('Failed to downscale input, proceeding with original.', e); } } if (options) { this.updateOptions(options); } const startTime = performance.now(); try { // Get detection result with timing already included const detectionResult = await this.faceDetector.detectFaces(processedInput); // Upscale results if downscaling occurred if (scaleFactor !== 1) { detectionResult.faces = this.upscaleFaces(detectionResult.faces, scaleFactor); } const duration = performance.now() - startTime; Logger.performance('detectFaces', duration); return detectionResult; } catch (error) { Logger.error('Face detection failed', error); throw new FaceDetectionError(`Face detection failed: ${error instanceof Error ? error.message : 'Unknown error'}`, { code: ErrorCode.DETECTION_FAILED, originalError: error instanceof Error ? error : undefined }); } } /** * Detects faces with landmarks in an image or video * @param input Image, video, or canvas element * @param options Detection options * @returns Detection result containing faces with landmarks and timing information * * @example * // Detect faces with landmarks and access points * const api = new FaceAPI({ maxFaces: 5 }); * const result = await api.detectFacesWithLandmarks(videoElement); * * for (const face of result.faces) { * // Access bounding box * const { x, y, width, height } = face.detection.box; * * // Access landmarks * const nosePoint = face.landmarks.positions[0]; * console.log(`Nose position: ${nosePoint.x}, ${nosePoint.y}`); * } */ async detectFacesWithLandmarks(input, options) { var _a, _b, _c, _d, _e, _f, _g; ValidationService.validateDisposed(this.isDisposed); ValidationService.validateInput(input); const originalInput = input; // Keep original for cropping let processedInput = input; let scaleFactor = 1; const isBrowserEnv = typeof document !== 'undefined'; const isHtmlInput = input instanceof HTMLCanvasElement || input instanceof HTMLImageElement || input instanceof HTMLVideoElement; // Downscale if enabled, in browser, and input is compatible if (this.options.downscaleWidthThreshold && this.options.downscaleWidthThreshold > 0 && isBrowserEnv && isHtmlInput) { try { const { downscaledCanvas, scaleFactor: calculatedScale } = ImageUtils.downscaleImage(input, this.options.downscaleWidthThreshold); processedInput = downscaledCanvas; scaleFactor = calculatedScale; Logger.debug(`Downscaled input image with factor: ${scaleFactor} for landmark detection`); } catch (e) { Logger.warn('Failed to downscale input for landmarks, proceeding with original.', e); } } if (options) { this.updateOptions(options); } const startTime = performance.now(); try { // First, detect the faces on the (potentially downscaled) input const faceResult = await this.faceDetector.detectFaces(processedInput); // If no faces were detected, return empty result with timing if (!faceResult.faces || faceResult.faces.length === 0) { Logger.debug('No faces detected, skipping landmark detection'); return { faces: [], timing: { ...faceResult.timing, total: performance.now() - startTime } }; } // --- Upscale Face Bounding Boxes --- // Upscale face boxes BEFORE using them for cropping or mapping let upscaledFaces; if (scaleFactor !== 1) { upscaledFaces = this.upscaleFaces(faceResult.faces, scaleFactor); } else { upscaledFaces = faceResult.faces; } // --- End Upscale Face Bounding Boxes --- // Start landmark detection timing const landmarkStartTime = performance.now(); // Handle single face optimization when maxFaces=1 if (faceResult.faces.length > 1 && ((_a = this.landmarkDetector['config']) === null || _a === void 0 ? void 0 : _a.maxFaces) === 1) { let adjustedLandmarks; // Sort faces by confidence and take the highest (using upscaled faces) const sortedFaces = [...upscaledFaces].sort( // (a, b) => b.detection.score - a.detection.score // Original sort by score (now 0) // Sort by bounding box area (width * height) instead (a, b) => (b.detection.box.width * b.detection.box.height) - (a.detection.box.width * a.detection.box.height)); const highestConfidenceFace = sortedFaces[0]; // Check compatibility with the ORIGINAL input for cropping const isCompatibleInput = originalInput instanceof HTMLCanvasElement || originalInput instanceof HTMLImageElement || originalInput instanceof HTMLVideoElement; if (isBrowserEnv && isCompatibleInput) { Logger.debug('Applying single-face landmark cropping optimization.'); // We know input is one of the HTML*Element types here // Use the ORIGINAL input for high-resolution cropping const originalCanvas = ImageUtils.elementToCanvas(originalInput); // Crop the face region (consider adding a margin option) // Use the UPSCALED bounding box for cropping the ORIGINAL image const { croppedCanvas, offsetX, offsetY } = ImageUtils.cropFace(originalCanvas, highestConfidenceFace.detection.box, 0 // No margin for now ); // Detect landmarks only on the cropped face const landmarksOnCropped = await this.landmarkDetector.detectLandmarks(croppedCanvas); // Adjust landmark coordinates back to the original image space adjustedLandmarks = landmarksOnCropped.map(landmarkSet => ({ meshPoints: landmarkSet.meshPoints.map(p => ({ // Landmarks are detected on the high-res crop, // offsetX/Y are relative to the original image. x: p.x + offsetX, y: p.y + offsetY, z: p.z })) })); } else { // Fallback: Node.js environment or incompatible input (e.g., NodeCanvasElement) if (!isBrowserEnv) { Logger.debug('Skipping landmark cropping optimization (non-browser or incompatible input).'); } else if (!isCompatibleInput) { Logger.debug('Skipping landmark cropping optimization (incompatible input type).'); } // Fallback: Detect landmarks on the potentially downscaled input const landmarkResultSets = await this.landmarkDetector.detectLandmarks(processedInput); // Upscale landmarks if needed if (scaleFactor !== 1) { adjustedLandmarks = this.upscaleLandmarks(landmarkResultSets, scaleFactor); } else { adj