@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
JavaScript
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