visual/FaceDetector.js

/**
 * Manager handling the detecting of faces in video streams.
 *
 * @author Alain Pitiot
 * @version 2022.2.3
 * @copyright (c) 2021 Open Science Tools Ltd. (https://opensciencetools.org)
 * @license Distributed under the terms of the MIT License
 */

import {PsychoJS} from "../core/PsychoJS.js";
import * as util from "../util/Util.js";
import { to_pixiPoint } from "../util/Pixi.js";
import {Color} from "../util/Color.js";
import {Camera} from "../hardware/Camera.js";
import {VisualStim} from "./VisualStim.js";
import * as PIXI from "pixi.js-legacy";


/**
 * <p>This manager handles the detecting of faces in video streams. FaceDetector relies on the
 * [Face-API library]{@link https://github.com/justadudewhohacks/face-api.js} developed by
 * [Vincent Muehler]{@link https://github.com/justadudewhohacks}.</p>
 *
 * @extends VisualStim
 */
export class FaceDetector extends VisualStim
{
	/**
	 * @memberOf module:visual
	 * @param {Object} options
	 * @param {String} options.name - the name used when logging messages from the detector
	 * @param {module:core.Window} options.win - the associated Window
	 * @param {string | HTMLVideoElement | module:visual.Camera} options.input - the name of a
	 * 	movie resource or of a HTMLVideoElement or of a Camera component
	 * @param {string} [options.faceApiUrl= 'face-api.js'] - the Url of the face-api library
	 * @param {string} [options.modelDir= 'models'] - the directory where to find the face detection models
	 * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices)
	 * @param {Array.<number>} [options.pos= [0, 0]] - the position of the center of the stimulus
	 * @param {string} [options.units= 'norm'] - the units of the stimulus vertices, size and position
	 * @param {number} [options.ori= 0.0] - the orientation (in degrees)
	 * @param {number} [options.size] - the size of the rendered image (the size of the image will be used if size is not specified)
	 * @param {number} [options.opacity= 1.0] - the opacity
	 * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip
	 * @param {boolean} [options.autoLog= false] - whether or not to log
	 */
	constructor({name, win, input, modelDir, faceApiUrl, units, ori, opacity, pos, size, autoDraw, autoLog} = {})
	{
		super({name, win, units, ori, opacity, pos, size, autoDraw, autoLog});

		// TODO deal with onChange (see MovieStim and Camera)
		this._addAttribute("input", input, undefined);
		this._addAttribute("faceApiUrl", faceApiUrl, "face-api.js");
		this._addAttribute("modelDir", modelDir, "models");
		this._addAttribute("autoLog", autoLog, false);
		this._addAttribute("status", PsychoJS.Status.NOT_STARTED);

		// init face-api:
		this._initFaceApi();

		if (this._autoLog)
		{
			this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
		}
	}

	/**
	 * Query whether or not the face detector is ready to detect.
	 *
	 * @returns {boolean} whether or not the face detector is ready to detect
	 */
	isReady()
	{
		return this._modelsLoaded;
	}

	/**
	 * Setter for the video attribute.
	 *
	 * @param {string | HTMLVideoElement | module:visual.Camera} input - the name of a
	 * movie resource or a HTMLVideoElement or a Camera component
	 * @param {boolean} [log= false] - whether of not to log
	 */
	setInput(input, log = false)
	{
		const response = {
			origin: "FaceDetector.setInput",
			context: "when setting the video of FaceDetector: " + this._name
		};

		try
		{
			// movie is undefined: that's fine but we raise a warning in case this is
			// a symptom of an actual problem
			if (typeof input === "undefined")
			{
				this.psychoJS.logger.warn("setting the movie of MovieStim: " + this._name + " with argument: undefined.");
				this.psychoJS.logger.debug("set the movie of MovieStim: " + this._name + " as: undefined");
			}
			else
			{
				// if movie is a string, then it should be the name of a resource, which we get:
				if (typeof input === "string")
				{
					// TODO create a movie with that resource, and use the movie as input
				}

				// if movie is an instance of camera, get a video element from it:
				else if (input instanceof Camera)
				{
					const video = input.getVideo();
					// TODO remove previous one if there is one
					// document.body.appendChild(video);
					input = video;
				}

				// check that video is now an HTMLVideoElement
				if (!(input instanceof HTMLVideoElement))
				{
					throw input.toString() + " is not a video";
				}

				this.psychoJS.logger.debug(`set the video of FaceDetector: ${this._name} as: src= ${input.src}, size= ${input.videoWidth}x${input.videoHeight}, duration= ${input.duration}s`);

				// ensure we have only one onended listener per HTMLVideoElement, since we can have several
				// MovieStim with the same underlying HTMLVideoElement
				// https://stackoverflow.com/questions/11455515
				if (!input.onended)
				{
					input.onended = () =>
					{
						this.status = PsychoJS.Status.FINISHED;
					};
				}
			}

			this._setAttribute("input", input, log);
			this._needUpdate = true;
			this._needPixiUpdate = true;
		}
		catch (error)
		{
			throw Object.assign(response, {error});
		}
	}


	/**
	 * Start detecting faces.
	 *
	 * @param {number} period - the detection period, in ms (e.g. 100 ms for 10Hz)
	 * @param detectionCallback - the callback triggered when detection results are available
	 * @param {boolean} [log= false] - whether of not to log
	 */
	start(period, detectionCallback, log = false)
	{
		this.status = PsychoJS.Status.STARTED;

		if (typeof this._detectionId !== "undefined")
		{
			clearInterval(this._detectionId);
			this._detectionId = undefined;
		}

		this._detectionId = setInterval(
			async () =>
			{
				this._detections = await faceapi.detectAllFaces(
					this._input,
					new faceapi.TinyFaceDetectorOptions()
				)
				.withFaceLandmarks()
				.withFaceExpressions();

				this._needUpdate = true;
				this._needPixiUpdate = true;

				detectionCallback(this._detections);
			},
			period);
	}

	/**
	 * Stop detecting faces.
	 *
	 * @param {boolean} [log= false] - whether of not to log
	 */
	stop(log = false)
	{
		this.status = PsychoJS.Status.NOT_STARTED;

		if (typeof this._detectionId !== "undefined")
		{
			clearInterval(this._detectionId);
			this._detectionId = undefined;
		}
	}

	/**
	 * Init the Face-API library.
	 *
	 * @protected
	 */
	async _initFaceApi()
	{
/*
		// load the library:
		await this._psychoJS.serverManager.prepareResources([
			{
				"name": "face-api.js",
				"path": this.faceApiUrl,
				"download": true
			}
		]);
*/

		// load the models:
		this._modelsLoaded = false;
		await faceapi.nets.tinyFaceDetector.loadFromUri(this._modelDir);
		await faceapi.nets.faceLandmark68Net.loadFromUri(this._modelDir);
		await faceapi.nets.faceRecognitionNet.loadFromUri(this._modelDir);
		await faceapi.nets.faceExpressionNet.loadFromUri(this._modelDir);
		this._modelsLoaded = true;
	}

	/**
	 * Update the visual representation of the detected faces, if necessary.
	 *
	 * @protected
	 */
	_updateIfNeeded()
	{
		if (!this._needUpdate)
		{
			return;
		}
		this._needUpdate = false;

		if (this._needPixiUpdate)
		{
			this._needPixiUpdate = false;

			if (typeof this._pixi !== "undefined")
			{
				this._pixi.destroy(true);
			}
			this._pixi = new PIXI.Container();
			this._pixi.interactive = true;

			this._body = new PIXI.Graphics();
			this._body.interactive = true;
			this._pixi.addChild(this._body);

			const size_px = util.to_px(this.size, this.units, this.win);
			if (typeof this._detections !== "undefined")
			{
				for (const detection of this._detections)
				{
					const landmarks = detection.landmarks;
					const imageWidth = detection.alignedRect.imageWidth;
					const imageHeight = detection.alignedRect.imageHeight;

					for (const position of landmarks.positions)
					{
						this._body.beginFill(new Color("red").int, this._opacity);
						this._body.drawCircle(
							position._x / imageWidth * size_px[0] - size_px[0] / 2,
							position._y / imageHeight * size_px[1] - size_px[1] / 2,
							2);
						this._body.endFill();
					}
				}
			}

		}


		this._pixi.scale.x = 1;
		this._pixi.scale.y = -1;

		this._pixi.rotation = -this.ori * Math.PI / 180;
		this._pixi.position = to_pixiPoint(this.pos, this.units, this.win);

		this._pixi.alpha = this._opacity;
	}

	/**
	 * Estimate the bounding box.
	 *
	 * @override
	 * @protected
	 */
	_estimateBoundingBox()
	{
		// TODO
	}

}