Comment créer des filtres Snapchat en Javascript

Le résultat final ici :
https://www.wawasensei.dev/tutos/snapchat/

Mise en place de l'interface

Commençons par importer les ressources dont nous allons avoir besoin pour ce mini projet.

Rajoutons dans le head tag les fichiers suivants :

<link href="style.css" rel="stylesheet" />

Que l'on crée à la racine et qui va nous permettre de définir le design de l'interface

<link href="https://fonts.googleapis.com/css2?family=Source+Sans+Pro&display=swap" rel="stylesheet">

La police Google Fonts de votre choix

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.14.0/css/all.min.css" />

Font Awesome pour les icones

Rajoutons les scripts suivants avant la fermeture du tag body

<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/p5.js"></script>

La librairie p5 (https://p5js.org/)

<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/clmtrackr.min.js"></script>

La librairie clmtracker (https://github.com/auduno/clmtrackr)

<script src="app.js"></script>

Que l'on crée à la racine et là où on va placer toute la logique de notre application

Création de l'interface de sélection des objets

On crée un conteneur items-picker qui contiendra des item-picker.

Chaque item-picker contient des icones de flèches pour choisir l'objet précédent suivant, ainsi qu'une icone et un titre pour visualiser l'objet qu'on change.

<div class="items-picker">
  <div class="item-picker">
    <i class="fas fa-chevron-left item-picker-left" onclick="prevItemForType('hat')"></i>
    <div class="item-picker-type">
      <i class="fas fa-hat-cowboy-side item-picker-type-icon"></i>
      <span class="item-picker-type-title">Hat</span>
    </div>
    <i class="fas fa-chevron-right item-picker-right" onclick="nextItemForType('hat')"></i>
  </div>
  <div class="item-picker">
    <i class="fas fa-chevron-left item-picker-left" onclick="prevItemForType('glasses')"></i>
    <div class="item-picker-type">
      <i class="fas fa-glasses item-picker-type-icon"></i>
      <span class="item-picker-type-title">Glasses</span>
    </div>
    <i class="fas fa-chevron-right item-picker-right" onclick="nextItemForType('glasses')"></i>
  </div>
  <div class="item-picker">
    <i class="fas fa-chevron-left item-picker-left" onclick="prevItemForType('mouth')"></i>
    <div class="item-picker-type">
      <i class="fas fa-teeth item-picker-type-icon"></i>
      <span class="item-picker-type-title">Mouth</span>
    </div>
    <i class="fas fa-chevron-right item-picker-right" onclick="nextItemForType('mouth')"></i>
  </div>
</div>

prevItemForType et nextItemForType sont des fonctions que l'on va créer par la suite dans app.js

Design de l'interface

Voici le code CSS pour le design de l'interface, n'hésitez pas à le personnaliser :

html,body {
  margin:0;
  padding:0;
  font-family: 'Source Sans Pro';
  background: #121212;
}

body {
  display:flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}


.p5Canvas {
  width:100%!important;
  height:100%!important;
  max-width: 800px;
}

.items-picker {
  display:flex;
  align-items:center;
  width:100%;
  max-width: 800px;
  border: solid 1px #ececec;
  background:white;
}

.item-picker {
  display:flex;
  flex:1;
  justify-content: center;
  padding: 32px;
  align-items:center;
  border-right: solid 1px #ececec;
}

.item-picker:last-of-type {
  border-right:none;
}
.item-picker-type {
  display:flex;
  flex-direction:column;
}

.item-picker-type-icon {
  font-size: 24px;
  color: #666;
  text-align: center;
}

.item-picker-type-title {
  font-size:32px;
  text-transform: uppercase;
}

.item-picker-left, .item-picker-right {
  padding: 12px;
  font-size: 12px;
  transition: .4s opacity;
  background: #ececec;
  border-radius: 100%;
  margin: 0px 12px;
}

.item-picker-left:hover, .item-picker-right:hover {
  opacity:0.8;
  cursor:pointer;
}

L'interface est prête, vous devriez obtenir ceci:

Résultat intermédiaire de l'interface

Ajout de la vidéo avec p5

Mettons en place la capture de la webcam avec la librairie p5. Pour cela allons dans le fichier app.js. A partir de maintenant tout se passera ici.

Déclarons les variables suivantes pour définir la taille de notre canvas et avoir une référence sur la capture vidéo:

const canvasWidth = 800;
const canvasHeight = 600;
let videoInput;

Déclarons maintenant la fonction setup que p5 va automatiquement appeler à l'initialisation:

function setup() {
  let canvas = createCanvas(canvasWidth, canvasHeight);
  videoInput = createCapture(VIDEO);
  videoInput.size(canvasWidth, canvasHeight);
  videoInput.hide();
}

On crée un canvas et on crée une capture video que l'on assigne à videoInput.

Ensuite p5 va appeler à chaque frame la fonction draw, et c'est dans cette fonction que l'on va lui dire quoi déssiner. On va tout simplement appeler la fonction image en lui passant comme premier argument la capture video videoInput, puis la position x et y où on veut l'afficher dans le canvas, ainsi que sa longueur et largeur.

function draw()
{
  image(videoInput, 0, 0, canvasWidth, canvasHeight); // render video from webcam
}

Si vous avez bien suivi jusque là, et que je n'ai pas écrit n'importe quoi, vous devriez obtenir le résultat suivant (avec vous en face de la caméra):

Résultat intermédiaire avec la capture video

votre navigateur devrait vous demander l'autorisation d'utiliser votre webcam, faites attention de bien accepter la demande

Reconnaissance faciale à l'aide de clmtrackr

En dessous de videoInput, déclarons une variable ctracker

let ctracker;

Puis dans la fonction setup initialisons la librairie:

ctracker = new clm.tracker();
ctracker.init();
ctracker.start(videoInput.elt);

On appelle start en lui passant l'élément vidéo de la capture de video ce sur quoi les calculs de reconnaissance faciales seront fait.

Puis, dans la fonction update nous allons récupérer tous les points du visage qui ont été détectés.

var positions = ctracker.getCurrentPosition();
if (!positions) {
  return ;
}

// Draw points
stroke(255);
positions.forEach(function (p) {
  point(p[0], p[1]);
})

On vérifie qu'un visage a bien été détecté puis on définit stroke(255) pour que la couleur dans laquelle p5 va déssiner soit blanche et enfin on boucle sur tous les points et on appelle la fonction point qui prend comme paramètre une position x et y pour y afficher un point.

En rechargeant on obtient ceci:

Reconnaissance faciale

Passons à la dernière étape, l'ajout d'accessoires façon filtre snapchat!

La snapchatisation

En dessous de la déclaration de la variable ctracker rajoutons les positions du visage.

const mappingPositions = {
  centerHead: 33,
  leftEye: 27,
  rightEye: 32,
  nose: 62,
  mouth: 57
};

La concordance est faite à partir de ce schéma fournit par clmtracker

Positions du visage

Ensuite définissons les différents accessoires à notre disposition:

const itemsAssets = {
  hat : [null, 
  'https://www.wawasensei.dev/tutos/snapchat/assets/hat-mexican.svg', 
  'https://www.wawasensei.dev/tutos/snapchat/assets/hat-pirate.svg', 
  'https://www.wawasensei.dev/tutos/snapchat/assets/hat-santa.svg', 
  'https://www.wawasensei.dev/tutos/snapchat/assets/hat-viking.svg', 
  'https://www.wawasensei.dev/tutos/snapchat/assets/hat-wizard.svg'],

  glasses : [null, 
  'https://www.wawasensei.dev/tutos/snapchat/assets/glasses-hearts.svg',
  'https://www.wawasensei.dev/tutos/snapchat/assets/glasses-clown.svg',
  'https://www.wawasensei.dev/tutos/snapchat/assets/glasses-hipster.svg',
  'https://www.wawasensei.dev/tutos/snapchat/assets/glasses-3d.svg'],

  mouth : [null,
  'https://www.wawasensei.dev/tutos/snapchat/assets/mouth-covid.svg',
  'https://www.wawasensei.dev/tutos/snapchat/assets/mouth-lips.svg']
}

Notez que j'ai mis null à la première position pour le moment où on n'a pas d'accessoire. J'utilise les images en version hostées en https car la librairie p5 ne fonctionne pas sur les images locales sans mettre en place de serveur pour les servir. (bonjours cors…)

A la suite on crée une variable pour définir l'espacement entre le front et l'emplacement du chapeau:

const hatOffset = 182;

Et enfin la partie plus sympa:

let items = {
  hat: null,
  glasses: null,
  mouth: null,
}
let idxs = {
  hat: 0,
  glasses: 0,
  mouth: 0,
}

On crée un objet items qui va nous servir à contenir l'image chargée pour le chapeau, les lunettes et la bouche ainsi qu'idxs qui va contenir l'index des accessoires qu'on affiche.

Créons la fonction loadItemForType qui va prendre le type d'accessoire ('hat', 'glasses' ou 'mouth') et item qui est l'url de l'image à charger.

function loadItemForType(type, item) {
  if (item == null) {
    items[type] = null;
    return ;
  }
  items[type] = loadImage(item);
}

Maintenant que nous avons cette fonction, connectons la aux flèches de notre interface, commençons par la flèche suivant:

function nextItemForType(type) {
  idxs[type]++;
  if (idxs[type] == itemsAssets[type].length) {
    idxs[type] = 0;
  }
  loadItemForType(type, itemsAssets[type][idxs[type]]);
}

On incrémente l'index, si l'index est égal à la taille du nombre d'accessoire pour ce type (ce qui signifie qu'on a dépassé le dernier élément de notre tableau) on repars à 0 puis on charge l'accessoire qui convient.

On fait pareil pour la flèche précédent:

function prevItemForType(type) {
  idxs[type]--;
  if (idxs[type] < 0) {
    idxs[type] = itemsAssets[type].length - 1;
  }
  loadItemForType(type, itemsAssets[type][idxs[type]]);
}

Cette fois quand on est en dessous de 0 (donc du premier élément) on repart du dernier et on charge l'accessoire qui correspond.

Dernière ligne droite, on va afficher les accessoires chargés à la suite dans notre fonction draw.

if (items.hat != null) {
  image(items.hat, positions[mappingPositions.centerHead][0] - items.hat.width / 2, positions[mappingPositions.centerHead][1] - hatOffset, items.hat.width, items.hat.height);
}
if (items.glasses != null) {
  image(items.glasses, positions[mappingPositions.centerHead][0] - items.glasses.width / 2, positions[mappingPositions.centerHead][1] - items.glasses.height / 2, items.glasses.width, items.glasses.height);
}
if (items.mouth != null) {
  image(items.mouth, positions[mappingPositions.mouth][0] - items.mouth.width / 2, positions[mappingPositions.mouth][1] - items.mouth.height / 2, items.mouth.width, items.mouth.height);
}

On vérifie pour chaque type qu'on a bien un objet à afficher. Puis on utilise la fonction image en lui passant l'image à afficher, à la position x et y qui convient ainsi que la largeur et largeur de l'image.

Notez que pour le positionnement x et y je soustrait la moitié de la longueur et largeur de l'image pour utiliser le centre de l'image au lieu du bord haut, gauche.

Félicitations! Vous avez réalisé avec succès un prototype de filtre snapchat!

Ajout des accessoires façon snapchat

Le code source complet est accessible ici :
https://github.com/wass08/snapchat-filter