Fadio IT logo
Fadio IT

Comment détecter la présence d’une personne devant une tablette ?

Avec la démocratisation de l'intelligence artificielle et plus particulièrement du machine learning, il est désormais facile d'entrainer un ordinateur et de récupérer des modèles de données permettant de réaliser des tâches plus ou moins difficiles. Aujourd'hui, il existe des modèles de données sur internet qui nous permettent par exemple de détecter facilement des animaux sur une photographie ou qui participent à la conduite d'un véhicule autonome. C'est dans ce contexte que nous allons tenter de répondre à la problématique suivante :

Comment détecter la présence d’une personne devant une tablette ?

Nous avons plusieurs contraintes physiques. En effet, la tablette devra être contre un mur, positionnée à la hauteur des yeux d'une personne assise à 1 mètre de distance du mur.

Dans cette configuration, la caméra frontale est seulement capable de voir le visage de l'utilisateur assis en face de la tablette. Nous devons donc trouver un modèle capable de détecter un individu seulement grâce à son visage.

Nous avons aussi plusieurs contraintes techniques :

  • Un environnement web Javascript (React, nodejs)
  • Dans une application Electron,
  • Sur une tablette Samsung Galaxy Book 10.6.

Au vu de ces informations, nous nous sommes mis en quête d'une library légère, qui tourne dans un environnement javascript capable de détecter un visage.

C'est ainsi que nous avons découvert face-api.js

Un module javascript qui utilise le machine learning (tensorflow.js) et des réseaux de neurones à convolution pour détecter les visages, les expressions, l'âge. Je vous invite à lire la documentation si vous voulez en apprendre plus.

Comment ça fonctionne ?

Tout d'abord il nous faut une image à analyser. Pour cela on va ajouter une balise "video" a notre rendu react.

/** App.js **/
<video style={{ position: 'absolute', top: 70, left: 10 }} ref={videoRef} />

Et pour voir ce que l'on va analyser on va démarrer la webcam et ajouter le flux vidéo à notre balise.

/** App.js **/
navigator.mediaDevices
  .getUserMedia({
    audio: false,
    video: {
      mandatory: {
        minWidth: 320,
        maxWidth: 320,
        minHeight: 240,
        maxHeight: 240,
        minFrameRate: 1,
        maxFrameRate: 10,
      },
    },
  })
  .then(
    stream => {
      video.srcObject = stream;
      video.play();
    },
    () => {},
  );

Maintenant on va suivre la documentation de face-api et nous allons charger les models qui permettent de détecter notre visage.

Pour notre utilisation on va utiliser le model Tiny Face Detector. Nous verrons après pourquoi.

On crée un dossier public "models" qui contient les models (https://github.com/justadudewhohacks/face-api.js/tree/master/weights)

/** App.js **/
await loadTinyFaceDetectorModel(`models`);

On change quelques options pour améliorer la performance.

/** App.js **/
const options = new TinyFaceDetectorOptions({
  inputSize: 224,
  scoreThreshold: 0.5,
});

Et on lance la détection

/** App.js **/
const faces = await detectSingleFace(videoRef.current, options);

Si detectSingleFace retourne des informations de détections c'est que quelqu'un est devant la tablette.

Exemple

Optimisations

Bien choisir son loader

Pour améliorer les performances il faut bien choisir son "model" de détection.

Pour le moment il en existe 3 qui ont tous leurs avantages et leurs inconvénients.

Le plus performant dans un navigateur est le Tiny Face Detector. L'option InputSize est importante il faut qu'elle soit cohérente avec notre utilisation. Si l'on met 128 ça sera plus rapide mais moins précis. La valeur par défaut 416 faisait ralentir notre application. 224 est un bon compromis.

Toutes les explications concernant le choix des models ici

Web Workers (un gros morceau)

Nous n'étions pas encore pleinement satisfaits des performances. En effet notre application subissait des ralentissements d'animations avec des micro-freeze, mais aussi du clignotement d'image, etc.

De ce fait nous avons décidé d'utiliser les Web Workers

Ils permettent de lancer un script dans une autre "thread" en parallèle de notre "thread" principal. Ce qui a pour conséquence de réduire le nombre de calcul dans notre thread principal et d'améliorer les performances.

Essayons

Nous allons utiliser le worker-loader pour webpack.

Il permet d'utiliser les web-worker plus facilement et de charger la library face-api.js dans notre worker.

On va tout d'abord créer notre ficher faceapi.worker.js.

Le ".worker" est important puisque c'est grâce à lui que webpack reconnait la façon dont il doit le charger.

Et on l'importe dans notre application.

/** App.js **/
import Worker from './faceapi.worker.js';

On l'initialise.

/** App.js **/
const worker = new Worker();

Et on envoie un message pour initialiser face-api.js dans le worker.

/** App.js **/
worker.postMessage({ msg: 'init' });

Nous avons envoyé un message mais nous n'avons encore rien pour l'écouter.

Dans faceapi.worker.js on crée le listener qui va recevoir le message.

/** faceapi.worker.js **/
self.addEventListener('message', async event => {
  const { data } = event;
  switch (data.msg) {
    case 'init':
      await init();
      break;
    default:
      break;
  }
});

"init" va nous permettre d'initialiser face-api.js dans notre web worker.

On crée la fonction init

/** faceapi.worker.js **/
const init = async () => {
  env.setEnv({
    Canvas: OffscreenCanvas || class {},
    Image: class {},
    /*...*/
  });

  await loadTinyFaceDetectorModel(`${global.location.origin}/src/models`);
};

Dans celle-ci on set l'environnement de faceApi (sans ça on a une erreur) et on load le Tiny Face Detector Model (que l'on ne load plus dans le thread principal).

face-api.js est maintenant prêt. On envoie un message au thread principal pour qu'il puisse passer à la suite.

/** faceapi.worker.js **/
postMessage({ msg: 'init' });

On retourne dans App.js et on écoute les messages provenant du worker.

/** App.js **/
worker.onmessage = handleWorkerMessage;
const handleWorkerMessage = ({ data }) => {
  switch (data.msg) {
    case 'init':
      workerInit.current = true;
      break;
    default:
      break;
  }
};

Maintenant que l'initialisation est terminée, on va préparer l'image à la détection. En effet nous ne pouvons pas envoyer d'objet Video dans notre worker. Cela fait partie des limitations de l'API. Par contre nous pouvons envoyer une image sous forme de Buffer.

On va donc à partir de la vidéo, créer l'image dans un canvas.

/** App.js **/
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data.buffer;

On récupère le buffer et on l'envoie avec toutes les infos dont nous avons besoin pour recréer l'image.

/** App.js **/
worker.postMessage(
  {
    msg: 'detect',
    pixels,
    width: canvas.width,
    height: canvas.height,
  },
  [pixels],
);

L'api postMessage permet d'envoyer des informations simples comme la width ou la height. Mais pour envoyer des informations plus lourdes comme le buffer pixels, il faut le passer dans le tableau du second argument. Sans ça, selon la complexité de l'image, cela pourrait ne plus fonctionner. Cependant on ne peut le faire qu'avec des objects qui utilisent l'interface Transferable comme ArrayBuffer et MessagePort

Attention, l'utilisation de cette méthode rend le buffer pixels inutilisable dans le thread principal.

Maintenant on retourne dans faceapi.worker et on ajoute un cas dans le listener afin de réceptionner les données, puis on appelle la fonction detect.

/** faceapi.worker.js **/
case 'detect':
const { height, width, pixels } = data;
const detected = await detect({ width, height, pixels });

On crée la fonction detect.

/** faceapi.worker.js **/
const detect = async ({ width, height, pixels, options = {} }) => {
  const canvas = new OffscreenCanvas(width, height);
  canvas
    .getContext('2d')
    .putImageData(
      new ImageData(new Uint8ClampedArray(pixels), width, height),
      0,
      0,
    );
  const detected = !!(await detectSingleFace(
    canvas,
    new TinyFaceDetectorOptions({
      inputSize: 224,
      scoreThreshold: 0.5,
      ...options,
    }),
  ));
  return detected;
};

Cette fonction crée un OffscreenCanvas qui génère un canvas dans un autre thread. En effet même les workers peuvent faire des calculs dans un autre thread. Cependant la fonctionnalité OffscreenCanvas est encore très peu supportée. Nous pouvons l'utiliser ici car electron est basé sur Chromium qui supporte OffscreenCanvas. Grâce à elle on utilise un thread exclusif à la création du canvas.

/** faceapi.worker.js **/
const canvas = new OffscreenCanvas(width, height);

On recrée l'image grâce à ce canvas

/** faceapi.worker.js **/
canvas
  .getContext('2d')
  .putImageData(
    new ImageData(new Uint8ClampedArray(pixels), width, height),
    0,
    0,
  );

On lance la détection face-api.js

/** faceapi.worker.js **/
const detected = !!(await detectSingleFace(
  canvas,
  new TinyFaceDetectorOptions({
    inputSize: 224,
    scoreThreshold: 0.5,
    ...options,
  }),
));

Et on renvoie le message au thread principal

/** faceapi.worker.js **/
postMessage({ msg: 'detected', value: detected });

On met à jour le state dans le thread principal

/** App.js **/
case 'detected':
setDetected(data.value);

Et on a terminé.

Exemple

Tout cet exemple est disponible sur GitHub

https://github.com/FadioIT/articles/tree/master/face-recognition

Comparatif

  • Sans Web Workers

  • Avec Web Workers

Le résultat est quand même impressionnant.

  • On perd 40MB de javascript dans le thread principal.
  • L'usage du CPU passe de 21% à 7.8%.

Sans web workers sur une bonne machine on ne verra que très peu de différences.

Dans notre cas l'application tourne sur une tablette (Samsung Galaxy Book 10.6) encapsulée dans electron. Le tout avec beaucoup d'animations (canvas, css).

Les Web Workers ont nettement amélioré les performances de notre application.

Conclusion

Nous sommes enfin arrivés au bout de cet article. Si vous êtes encore là c'est que vous êtes un lecteur courageux.

Avec l'aide de face-api.js et tout le travail effectué depuis quelques années sur le machine learning nous avons pu résoudre notre problématique.

On a utilisé les Web Workers pour améliorer les performances.

Pour ne pas faire un article trop indigeste j'ai préféré parler des parties qui me semble les plus importantes pour la compréhension de la library et des web workers.

Dans l'exemple vous pourrez retrouver différentes améliorations comme par exemple la gestion des promises entre le web worker et le thread principal.

Je remets le lien ici : https://github.com/FadioIT/articles/tree/master/face-recognition

À très bientôt !