You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

FaceLandmarksDetector.ts 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. import 'image-capture';
  2. import './createImageBitmap';
  3. import { IStore } from '../app/types';
  4. import { isMobileBrowser } from '../base/environment/utils';
  5. import { getLocalVideoTrack } from '../base/tracks/functions';
  6. import { getBaseUrl } from '../base/util/helpers';
  7. import {
  8. addFaceLandmarks,
  9. clearFaceExpressionBuffer,
  10. newFaceBox
  11. } from './actions';
  12. import {
  13. DETECTION_TYPES,
  14. DETECT_FACE,
  15. FACE_LANDMARKS_DETECTION_ERROR_THRESHOLD,
  16. INIT_WORKER,
  17. NO_DETECTION,
  18. NO_FACE_DETECTION_THRESHOLD,
  19. WEBHOOK_SEND_TIME_INTERVAL
  20. } from './constants';
  21. import {
  22. getDetectionInterval,
  23. sendFaceExpressionsWebhook
  24. } from './functions';
  25. import logger from './logger';
  26. /**
  27. * Class for face language detection.
  28. */
  29. class FaceLandmarksDetector {
  30. private static instance: FaceLandmarksDetector;
  31. private initialized = false;
  32. private imageCapture: ImageCapture | null = null;
  33. private worker: Worker | null = null;
  34. private lastFaceExpression: string | null = null;
  35. private lastFaceExpressionTimestamp: number | null = null;
  36. private webhookSendInterval: number | null = null;
  37. private detectionInterval: number | null = null;
  38. private recognitionActive = false;
  39. private canvas?: HTMLCanvasElement;
  40. private context?: CanvasRenderingContext2D | null;
  41. private errorCount = 0;
  42. private noDetectionCount = 0;
  43. private noDetectionStartTimestamp: number | null = null;
  44. /**
  45. * Constructor for class, checks if the environment supports OffscreenCanvas.
  46. */
  47. private constructor() {
  48. if (typeof OffscreenCanvas === 'undefined') {
  49. this.canvas = document.createElement('canvas');
  50. this.context = this.canvas.getContext('2d');
  51. }
  52. }
  53. /**
  54. * Function for retrieving the FaceLandmarksDetector instance.
  55. *
  56. * @returns {FaceLandmarksDetector} - FaceLandmarksDetector instance.
  57. */
  58. public static getInstance(): FaceLandmarksDetector {
  59. if (!FaceLandmarksDetector.instance) {
  60. FaceLandmarksDetector.instance = new FaceLandmarksDetector();
  61. }
  62. return FaceLandmarksDetector.instance;
  63. }
  64. /**
  65. * Returns if the detected environment is initialized.
  66. *
  67. * @returns {boolean}
  68. */
  69. isInitialized(): boolean {
  70. return this.initialized;
  71. }
  72. /**
  73. * Initialization function: the worker is loaded and initialized, and then if possible the detection stats.
  74. *
  75. * @param {IStore} store - Redux store with dispatch and getState methods.
  76. * @returns {void}
  77. */
  78. init({ dispatch, getState }: IStore) {
  79. if (this.isInitialized()) {
  80. logger.info('Worker has already been initialized');
  81. return;
  82. }
  83. if (isMobileBrowser() || navigator.product === 'ReactNative') {
  84. logger.warn('Unsupported environment for face detection');
  85. return;
  86. }
  87. const baseUrl = `${getBaseUrl()}libs/`;
  88. let workerUrl = `${baseUrl}face-landmarks-worker.min.js`;
  89. // @ts-ignore
  90. const workerBlob = new Blob([ `importScripts("${workerUrl}");` ], { type: 'application/javascript' });
  91. const state = getState();
  92. const addToBuffer = Boolean(state['features/base/config'].webhookProxyUrl);
  93. // @ts-ignore
  94. workerUrl = window.URL.createObjectURL(workerBlob);
  95. this.worker = new Worker(workerUrl, { name: 'Face Landmarks Worker' });
  96. this.worker.onmessage = ({ data }: MessageEvent<any>) => {
  97. const { faceExpression, faceBox, faceCount } = data;
  98. const messageTimestamp = Date.now();
  99. // if the number of faces detected is different from 1 we do not take into consideration that detection
  100. if (faceCount !== 1) {
  101. if (this.noDetectionCount === 0) {
  102. this.noDetectionStartTimestamp = messageTimestamp;
  103. }
  104. this.noDetectionCount++;
  105. if (this.noDetectionCount === NO_FACE_DETECTION_THRESHOLD && this.noDetectionStartTimestamp) {
  106. this.addFaceLandmarks(
  107. dispatch,
  108. this.noDetectionStartTimestamp,
  109. NO_DETECTION,
  110. addToBuffer
  111. );
  112. }
  113. return;
  114. } else if (this.noDetectionCount > 0) {
  115. this.noDetectionCount = 0;
  116. this.noDetectionStartTimestamp = null;
  117. }
  118. if (faceExpression?.expression) {
  119. const { expression } = faceExpression;
  120. if (expression !== this.lastFaceExpression) {
  121. this.addFaceLandmarks(
  122. dispatch,
  123. messageTimestamp,
  124. expression,
  125. addToBuffer
  126. );
  127. }
  128. }
  129. if (faceBox) {
  130. dispatch(newFaceBox(faceBox));
  131. }
  132. APP.API.notifyFaceLandmarkDetected(faceBox, faceExpression);
  133. };
  134. const { faceLandmarks } = state['features/base/config'];
  135. const detectionTypes = [
  136. faceLandmarks?.enableFaceCentering && DETECTION_TYPES.FACE_BOX,
  137. faceLandmarks?.enableFaceExpressionsDetection && DETECTION_TYPES.FACE_EXPRESSIONS
  138. ].filter(Boolean);
  139. this.worker.postMessage({
  140. type: INIT_WORKER,
  141. baseUrl,
  142. detectionTypes
  143. });
  144. this.initialized = true;
  145. this.startDetection({
  146. dispatch,
  147. getState
  148. });
  149. }
  150. /**
  151. * The function which starts the detection process.
  152. *
  153. * @param {IStore} store - Redux store with dispatch and getState methods.
  154. * @param {any} track - Track from middleware; can be undefined.
  155. * @returns {void}
  156. */
  157. startDetection({ dispatch, getState }: IStore, track?: any) {
  158. if (!this.isInitialized()) {
  159. logger.info('Worker has not been initialized');
  160. return;
  161. }
  162. if (this.recognitionActive) {
  163. logger.log('Face landmarks detection already active.');
  164. return;
  165. }
  166. const state = getState();
  167. const localVideoTrack = track || getLocalVideoTrack(state['features/base/tracks']);
  168. if (!localVideoTrack || localVideoTrack.jitsiTrack?.isMuted()) {
  169. logger.debug('Face landmarks detection is disabled due to missing local track.');
  170. return;
  171. }
  172. const stream = localVideoTrack.jitsiTrack.getOriginalStream();
  173. const firstVideoTrack = stream.getVideoTracks()[0];
  174. this.imageCapture = new ImageCapture(firstVideoTrack);
  175. this.recognitionActive = true;
  176. logger.log('Start face landmarks detection');
  177. const { faceLandmarks } = state['features/base/config'];
  178. this.detectionInterval = window.setInterval(() => {
  179. if (this.worker && this.imageCapture) {
  180. this.sendDataToWorker(
  181. faceLandmarks?.faceCenteringThreshold
  182. ).then(status => {
  183. if (status) {
  184. this.errorCount = 0;
  185. } else if (++this.errorCount > FACE_LANDMARKS_DETECTION_ERROR_THRESHOLD) {
  186. /* this prevents the detection from stopping immediately after occurring an error
  187. * sometimes due to the small detection interval when starting the detection some errors
  188. * might occur due to the track not being ready
  189. */
  190. this.stopDetection({
  191. dispatch,
  192. getState
  193. });
  194. }
  195. });
  196. }
  197. }, getDetectionInterval(state));
  198. const { webhookProxyUrl } = state['features/base/config'];
  199. if (faceLandmarks?.enableFaceExpressionsDetection && webhookProxyUrl) {
  200. this.webhookSendInterval = window.setInterval(async () => {
  201. const result = await sendFaceExpressionsWebhook(getState());
  202. if (result) {
  203. dispatch(clearFaceExpressionBuffer());
  204. }
  205. }, WEBHOOK_SEND_TIME_INTERVAL);
  206. }
  207. }
  208. /**
  209. * The function which stops the detection process.
  210. *
  211. * @param {IStore} store - Redux store with dispatch and getState methods.
  212. * @returns {void}
  213. */
  214. stopDetection({ dispatch, getState }: IStore) {
  215. if (!this.recognitionActive || !this.isInitialized()) {
  216. return;
  217. }
  218. const stopTimestamp = Date.now();
  219. const addToBuffer = Boolean(getState()['features/base/config'].webhookProxyUrl);
  220. if (this.lastFaceExpression && this.lastFaceExpressionTimestamp) {
  221. this.addFaceLandmarks(dispatch, stopTimestamp, null, addToBuffer);
  222. }
  223. this.webhookSendInterval && window.clearInterval(this.webhookSendInterval);
  224. this.detectionInterval && window.clearInterval(this.detectionInterval);
  225. this.webhookSendInterval = null;
  226. this.detectionInterval = null;
  227. this.imageCapture = null;
  228. this.recognitionActive = false;
  229. logger.log('Stop face landmarks detection');
  230. }
  231. /**
  232. * Dispatches the action for adding new face landmarks and changes the state of the class.
  233. *
  234. * @param {IStore.dispatch} dispatch - The redux dispatch function.
  235. * @param {number} endTimestamp - The timestamp when the face landmarks ended.
  236. * @param {string} newFaceExpression - The new face expression.
  237. * @param {boolean} addToBuffer - Flag for adding the face landmarks to the buffer.
  238. * @returns {void}
  239. */
  240. private addFaceLandmarks(
  241. dispatch: IStore['dispatch'],
  242. endTimestamp: number,
  243. newFaceExpression: string | null,
  244. addToBuffer = false) {
  245. if (this.lastFaceExpression && this.lastFaceExpressionTimestamp) {
  246. dispatch(addFaceLandmarks(
  247. {
  248. duration: endTimestamp - this.lastFaceExpressionTimestamp,
  249. faceExpression: this.lastFaceExpression,
  250. timestamp: this.lastFaceExpressionTimestamp
  251. },
  252. addToBuffer
  253. ));
  254. }
  255. this.lastFaceExpression = newFaceExpression;
  256. this.lastFaceExpressionTimestamp = endTimestamp;
  257. }
  258. /**
  259. * Sends the image data a canvas from the track in the image capture to the face detection worker.
  260. *
  261. * @param {number} faceCenteringThreshold - Movement threshold as percentage for sharing face coordinates.
  262. * @returns {Promise<boolean>} - True if sent, false otherwise.
  263. */
  264. private async sendDataToWorker(faceCenteringThreshold = 10): Promise<boolean> {
  265. if (!this.imageCapture
  266. || !this.worker
  267. || !this.imageCapture) {
  268. logger.log('Environment not ready! Could not send data to worker');
  269. return false;
  270. }
  271. // if ImageCapture is polyfilled then it would not have the track,
  272. // so there would be no point in checking for its readyState
  273. if (this.imageCapture.track && this.imageCapture.track.readyState !== 'live') {
  274. logger.log('Track not ready! Could not send data to worker');
  275. return false;
  276. }
  277. let imageBitmap;
  278. let image;
  279. try {
  280. imageBitmap = await this.imageCapture.grabFrame();
  281. } catch (err) {
  282. logger.log('Could not send data to worker');
  283. return false;
  284. }
  285. if (typeof OffscreenCanvas === 'undefined' && this.canvas && this.context) {
  286. this.canvas.width = imageBitmap.width;
  287. this.canvas.height = imageBitmap.height;
  288. this.context.drawImage(imageBitmap, 0, 0);
  289. image = this.context.getImageData(0, 0, imageBitmap.width, imageBitmap.height);
  290. } else {
  291. image = imageBitmap;
  292. }
  293. this.worker.postMessage({
  294. type: DETECT_FACE,
  295. image,
  296. threshold: faceCenteringThreshold
  297. });
  298. imageBitmap.close();
  299. return true;
  300. }
  301. }
  302. export default FaceLandmarksDetector.getInstance();