Mior Agency

Commandes de mouvement dans le navigateur

Dans cet article, je vais vous expliquer comment implémenter les commandes de mouvement dans le navigateur. Cela signifie que vous pourrez créer une application où vous pourrez agiter votre main et faire des gestes, et les éléments à l’écran répondront.

Voici un exemple:

voir le stylo [Magic Hand – Motion controls for the web [forked]](https://codepen.io/smashingmag/pen/vYrEEYw) par Yaphi.

View Pen Magic Hand – Motion Controls pour le Web [forked] par Yafi.

Quoi qu’il en soit, il y a quelques ingrédients principaux dont vous aurez besoin pour que les commandes de mouvement fonctionnent pour vous :

  1. Données vidéo d’une webcam ;
  2. Apprentissage automatique pour suivre les mouvements de la main ;
  3. Logique de détection de geste.

Noter: Cet article suppose une familiarité générale avec HTML, CSS et JavaScript, donc si vous l’avez, nous pouvons commencer. Notez également que vous devrez peut-être cliquer sur les démos CodePen au cas où les aperçus seraient vides (autorisations de caméra non accordées).

Étape 1 – Obtenir les données vidéo

La première étape de la création de commandes de mouvement consiste à accéder à la caméra de l’utilisateur. Nous pouvons le faire en utilisant le navigateur getMediaDevices API.

Voici un exemple qui récupère les données de la caméra de l’utilisateur et les dessine dans un <canvas> toutes les 100 millisecondes :

voir le stylo [Camera API test (MediaDevices) [forked]](https://codepen.io/smashingmag/pen/QWxwwbG) par Yaphi.

Voir le test de l’API de la caméra du stylet (MediaDevices) [forked] par Yafi.

Dans l’exemple ci-dessus, ce code vous donne les données vidéo et les dessine sur le canevas :

const constraints = {
  audio: false, video: { width, height }
};

navigator.mediaDevices.getUserMedia(constraints)
  .then(function(mediaStream) {
    video.srcObject = mediaStream;
    video.onloadedmetadata = function(e) {
      video.play();
      setInterval(drawVideoFrame, 100);
    };
  })
  .catch(function(err) { console.log(err); });

function drawVideoFrame() {
  context.drawImage(video, 0, 0, width, height);
  // or do other stuff with the video data
}

quand tu cours getUserMedia, le navigateur commence à enregistrer les données de la caméra après avoir demandé l’autorisation à l’utilisateur. Les constraints Le paramètre vous permet d’indiquer si vous souhaitez inclure de la vidéo et de l’audio et, si vous avez de la vidéo, quelle doit être sa résolution.

Les données de la caméra se présentent sous la forme d’un objet appelé MediaStreamque vous pouvez ensuite inclure dans un HTML <video> article via votre srcObject propriété. Une fois que la vidéo est prête à être lancée, lancez-la, puis faites ce que vous voulez avec les données d’image. Dans ce cas, l’exemple de code dessine une image vidéo sur le canevas toutes les 100 millisecondes.

Vous pouvez créer plus d’effets de canevas avec vos données vidéo, mais pour les besoins de cet article, vous en savez assez pour passer à l’étape suivante.

Plus après le saut! Continuez à lire ci-dessous ↓

Étape 2 : Suivre les mouvements de la main

Maintenant que vous pouvez accéder aux données image par image d’un flux vidéo à partir d’une webcam, la prochaine étape de votre quête pour créer des commandes de mouvement consiste à déterminer où se trouvent les mains de l’utilisateur. Pour cette étape, nous aurons besoin de machine learning.

Pour que cela fonctionne, j’ai utilisé une bibliothèque d’apprentissage automatique open source de Google appelée MediaPipe. Cette bibliothèque prend des données à partir d’images vidéo et vous donne les coordonnées de plusieurs points (également appelés landmarks) dans tes mains.

C’est la beauté des bibliothèques d’apprentissage automatique : une technologie complexe n’a pas besoin d’être complexe à utiliser.

Voici la bibliothèque en action :

voir le stylo [MediaPipe Test [forked]](https://codepen.io/smashingmag/pen/XWYJJpY) par Yaphi.

Voir le test du stylo MediaPipe [forked] par Yafi.
Coordonnées de la main représentées sur une toile
Coordonnées de la main représentées sur une toile. (grand aperçu)

Voici quelques passe-partout pour vous aider à démarrer (adaptés de l’exemple de l’API JavaScript MediaPipe) :

<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/control_utils/control_utils.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>

<video class="input_video"></video>
<canvas class="output_canvas" width="1280px" height="720px"></canvas>

<script>
const videoElement = document.querySelector('.input_video');
const canvasElement = document.querySelector('.output_canvas');
const canvasCtx = canvasElement.getContext('2d');

function onResults(handData) {
  drawHandPositions(canvasElement, canvasCtx, handData);
}

function drawHandPositions(canvasElement, canvasCtx, handData) {
  canvasCtx.save();
  canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height);
  canvasCtx.drawImage(
      handData.image, 0, 0, canvasElement.width, canvasElement.height);
  if (handData.multiHandLandmarks) {
    for (const landmarks of handData.multiHandLandmarks) {
      drawConnectors(canvasCtx, landmarks, HAND_CONNECTIONS,
                     {color: '#00FF00', lineWidth: 5});
      drawLandmarks(canvasCtx, landmarks, {color: '#FF0000', lineWidth: 2});
    }
  }
  canvasCtx.restore();
}

const hands = new Hands({locateFile: (file) => {
  return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`;
}});
hands.setOptions({
  maxNumHands: 1,
  modelComplexity: 1,
  minDetectionConfidence: 0.5,
  minTrackingConfidence: 0.5
});
hands.onResults(onResults);

const camera = new Camera(videoElement, {
  onFrame: async () => {
    await hands.send({image: videoElement});
  },
  width: 1280,
  height: 720
});
camera.start();

</script>

Le code ci-dessus fait ce qui suit :

  • Chargez le code de la bibliothèque ;
  • Commencez à enregistrer les images vidéo ;
  • Lorsque les données de la main arrivent, dessinez les points de référence de la main sur une toile.

Regardons de plus près le handData objet car c’est là que la magie opère. À l’intérieur handData c’est multiHandLandmarks, une collection de 21 coordonnées pour les parties de chaque main détectées dans la vidéo. Voici comment ces coordonnées sont structurées :

{
  multiHandLandmarks: [
    // First detected hand.
    [
      {x: 0.4, y: 0.8, z: 4.5},
      {x: 0.5, y: 0.3, z: -0.03},
      // ...etc.
    ],

    // Second detected hand.
    [
      {x: 0.4, y: 0.8, z: 4.5},
      {x: 0.5, y: 0.3, z: -0.03},
      // ...etc.
    ],

    // More hands if other people participate.
  ]
}

Quelques remarques :

  • La première main ne signifie pas nécessairement la main droite ou la main gauche ; c’est simplement celui que l’application détecte en premier. Si vous souhaitez obtenir une main spécifique, vous devrez vérifier quelle main est détectée à l’aide de handData.multiHandedness[0].label et éventuellement échanger les valeurs si votre caméra n’est pas en miroir.
  • Pour des raisons de performances, vous pouvez limiter le nombre maximum de mains à suivre, ce que nous avons fait précédemment en définissant maxNumHands: 1.
  • Les coordonnées sont fixées à une échelle de 0 un 1 selon la taille de la toile.

Voici une représentation visuelle des coordonnées de la main :

Une carte de points numérotés dans une main.
Une carte de points numérotés dans une main. (Source : github.io) (Grand aperçu)

Maintenant que vous avez les coordonnées du point de référence de la main, vous pouvez créer un curseur pour suivre votre index. Pour ce faire, vous devrez obtenir les coordonnées de l’index.

Vous pouvez utiliser le tableau directement comme ceci handData.multiHandLandmarks[0][5]mais j’ai du mal à garder une trace, donc je préfère étiqueter les coordonnées comme ceci:

const handParts = {
  wrist: 0,
  thumb: { base: 1, middle: 2, topKnuckle: 3, tip: 4 },
  indexFinger: { base: 5, middle: 6, topKnuckle: 7, tip: 8 },
  middleFinger: { base: 9, middle: 10, topKnuckle: 11, tip: 12 },
  ringFinger: { base: 13, middle: 14, topKnuckle: 15, tip: 16 },
  pinky: { base: 17, middle: 18, topKnuckle: 19, tip: 20 },
};

Et puis vous pouvez obtenir les coordonnées comme ceci :

const firstDetectedHand = handData.multiHandLandmarks[0];
const indexFingerCoords = firstDetectedHand[handParts.index.middle];

J’ai trouvé le mouvement du curseur plus agréable à utiliser avec la partie médiane de l’index qu’avec la pointe car la partie médiane est plus stable.

Vous devrez maintenant créer un élément DOM à utiliser comme curseur. Voici le balisage :

<div class="cursor"></div>

Et voici les styles :

.cursor {
  height: 0px;
  width: 0px;
  position: absolute;
  left: 0px;
  top: 0px;
  z-index: 10;
  transition: transform 0.1s;
}

.cursor::after {
  content: '';
  display: block;
  height: 50px;
  width: 50px;
  border-radius: 50%;
  position: absolute;
  left: 0;
  top: 0;
  transform: translate(-50%, -50%);
  background-color: #0098db;
}

Quelques notes sur ces styles :

  • Le curseur est absolument positionné de sorte qu’il puisse être déplacé sans affecter le flux du document.
  • La partie visuelle du curseur est sur le ::after pseudo-élément, et le transform s’assure que la partie visuelle du curseur est centrée autour des coordonnées du curseur.
  • Le curseur a un transition pour fluidifier vos mouvements.

Maintenant que nous avons créé un élément curseur, nous pouvons le déplacer en convertissant les coordonnées de la main en coordonnées de page et en appliquant ces coordonnées de page à l’élément curseur.

function getCursorCoords(handData) {
  const { x, y, z } = handData.multiHandLandmarks[0][handParts.indexFinger.middle];
  const mirroredXCoord = -x + 1; /* due to camera mirroring */
  return { x: mirroredXCoord, y, z };
}

function convertCoordsToDomPosition({ x, y }) {
  return {
    x: `${x * 100}vw`,
    y: `${y * 100}vh`,
  };
}

function updateCursor(handData) {
  const cursorCoords = getCursorCoords(handData);
  if (!cursorCoords) { return; }
  const { x, y } = convertCoordsToDomPosition(cursorCoords);
  cursor.style.transform = `translate(${x}, ${y})`;
}

function onResults(handData) {
  if (!handData) { return; }
  updateCursor(handData);
}

Notez que nous utilisons le CSS transform propriété pour déplacer l’élément au lieu de left Oui top. C’est pour des raisons de performances. Lorsque le navigateur affiche une vue, il passe par une séquence d’étapes. Lorsque le DOM change, le navigateur doit recommencer à l’étape de rendu correspondante. Les transform La propriété répond rapidement aux modifications car elle est appliquée dans la dernière étape au lieu de l’une des étapes intermédiaires, et par conséquent le navigateur a moins de travail à itérer.

Maintenant que nous avons un curseur de travail, nous sommes prêts à continuer.

Étape 3 : Détecter les gestes

La prochaine étape de notre voyage consiste à détecter les gestes, en particulier gestes de pincement.

Tout d’abord, qu’entendons-nous par un pincement? Dans ce cas, nous définirons un pincement comme un geste dans lequel le pouce et l’index sont suffisamment rapprochés.

Pour désigner un ajustement dans le code, nous pouvons regarder quand le x, yOui z Les coordonnées du pouce et de l’index ont une assez petite différence entre elles. « Assez petit » peut varier en fonction du cas d’utilisation, alors n’hésitez pas à expérimenter différentes gammes. perso j’ai trouvé 0.08, 0.08Oui 0.11 être confortable pour lui x, yOui z coordonnées, respectivement. Voici à quoi ça ressemble:

function isPinched(handData) {
  const fingerTip = handData.multiHandLandmarks[0][handParts.indexFinger.tip];
  const thumbTip = handData.multiHandLandmarks[0][handParts.thumb.tip];
  const distance = {
    x: Math.abs(fingerTip.x - thumbTip.x),
    y: Math.abs(fingerTip.y - thumbTip.y),
    z: Math.abs(fingerTip.z - thumbTip.z),
  };
  const areFingersCloseEnough = distance.x < 0.08 && distance.y < 0.08 && distance.z < 0.11;

  return areFingersCloseEnough;
}

Ce serait bien si c’était tout ce que nous avions à faire, mais malheureusement ce n’est jamais aussi simple.

Que se passe-t-il lorsque vos doigts sont sur le point de se pincer ? Si nous ne faisons pas attention, la réponse est le chaos.

Avec de légers mouvements des doigts, ainsi que des fluctuations dans la détection des coordonnées, notre programme peut basculer rapidement entre les états pincés et non pincés. Si vous essayez d’utiliser un geste de pincement pour « saisir » un élément à l’écran, vous pouvez imaginer à quel point ce serait chaotique si l’élément alternait rapidement entre être ramassé et lâché.

Pour éviter que nos gestes de pincement ne causent le chaos, nous devrons introduire un léger délai avant d’enregistrer un changement d’un état pincé à un état non pincé ou vice versa. Cette technique s’appelle debounceet la logique est la suivante :

  • Lorsque les doigts entrent dans un état pincé, démarrez un chronomètre.
  • Si les doigts ont été dans l’état pincé de façon continue assez longtemps, enregistrez un changement.
  • Si l’état pincé est interrompu trop tôt, arrêtez le chronomètre et n’enregistrez aucun changement.

L’astuce est que le délai doit être suffisamment long pour être fiable mais suffisamment court pour se sentir rapide.

Nous reviendrons bientôt sur le code de rebond, mais nous devons d’abord nous préparer en suivant l’état de nos gestes :

const OPTIONS = {
  PINCH_DELAY_MS: 60,
};

const state = {
  isPinched: false,
  pinchChangeTimeout: null,
};

Ensuite, nous allons configurer des événements personnalisés pour faciliter la réponse aux gestes :

const PINCH_EVENTS = {
  START: 'pinch_start',
  MOVE: 'pinch_move',
  STOP: 'pinch_stop',
};

function triggerEvent({ eventName, eventData }) {
  const event = new CustomEvent(eventName, { detail: eventData });
  document.dispatchEvent(event);
}

Nous pouvons maintenant écrire une fonction pour mettre à jour l’état épinglé :

function updatePinchState(handData) {
  const wasPinchedBefore = state.isPinched;
  const isPinchedNow = isPinched(handData);
  const hasPassedPinchThreshold = isPinchedNow !== wasPinchedBefore;
  const hasWaitStarted = !!state.pinchChangeTimeout;

  if (hasPassedPinchThreshold && !hasWaitStarted) {
    registerChangeAfterWait(handData, isPinchedNow);
  }

  if (!hasPassedPinchThreshold) {
    cancelWaitForChange();
    if (isPinchedNow) {
      triggerEvent({
        eventName: PINCH_EVENTS.MOVE,
        eventData: getCursorCoords(handData),
      });
    }
  }
}

function registerChangeAfterWait(handData, isPinchedNow) {
  state.pinchChangeTimeout = setTimeout(() => {
    state.isPinched = isPinchedNow;
    triggerEvent({
      eventName: isPinchedNow ? PINCH_EVENTS.START : PINCH_EVENTS.STOP,
      eventData: getCursorCoords(handData),
    });
  }, OPTIONS.PINCH_DELAY_MS);
}

function cancelWaitForChange() {
  clearTimeout(state.pinchChangeTimeout);
  state.pinchChangeTimeout = null;
}

Ceci est ce que updatePinchState() il est en train de faire:

  • Si les doigts ont dépassé le seuil de pincement en démarrant ou en arrêtant un pincement, nous allons démarrer une minuterie pour attendre et voir si nous pouvons enregistrer un changement d’état de pincement légitime.
  • Si l’attente est interrompue, cela signifie que le changement n’était qu’une gigue, nous pouvons donc annuler la minuterie.
  • Cependant, si la minuterie est Non interrompu, nous pouvons mettre à jour l’état modifié et déclencher l’événement de modification personnalisé correct, à savoir pinch_start Soit pinch_stop.
  • Si les doigts n’ont pas dépassé le seuil de changement de pincement et sont actuellement pincés, nous pouvons envoyer une commande personnalisée. pinch_move un événement.

nous pouvons courir updatePinchState(handData) chaque fois que nous recevons des données de la main afin que nous puissions les mettre dans notre onResults fonctionne comme ceci :

function onResults(handData) {
  if (!handData) { return; }
  updateCursor(handData);
  updatePinchState(handData);
}

Maintenant que nous pouvons détecter de manière fiable un changement d’état de pincement, nous pouvons utiliser nos événements personnalisés pour définir le comportement que nous voulons lorsqu’un pincement est démarré, déplacé ou arrêté. Voici un exemple:

document.addEventListener(PINCH_EVENTS.START, onPinchStart);
document.addEventListener(PINCH_EVENTS.MOVE, onPinchMove);
document.addEventListener(PINCH_EVENTS.STOP, onPinchStop);

function onPinchStart(eventInfo) {
  const cursorCoords = eventInfo.detail;
  console.log('Pinch started', cursorCoords);
}

function onPinchMove(eventInfo) {
  const cursorCoords = eventInfo.detail;
  console.log('Pinch moved', cursorCoords);
}

function onPinchStop(eventInfo) {
  const cursorCoords = eventInfo.detail;
  console.log('Pinch stopped', cursorCoords);
}

Maintenant que nous avons couvert comment réagir aux mouvements et aux gestes, nous avons tout ce dont nous avons besoin pour créer une application qui peut être contrôlée avec des mouvements de la main.

Voici quelques exemples:

voir le stylo [Beam Sword – Fun with motion controls! [forked]](https://codepen.io/smashingmag/pen/WNybveM) par Yaphi.

Voir Pen Beam Sword – Motion Control Fun! [forked] par Yafi.

voir le stylo [Magic Quill – Air writing with motion controls [forked]](https://codepen.io/smashingmag/pen/OJEPVJj) par Yaphi.

Voir le Magic Quill Pen – Écrire dans les airs avec des commandes de mouvement [forked] par Yafi.

J’ai également préparé d’autres démos de contrôle de mouvement, y compris des cartes à jouer en mouvement et un plan d’étage d’appartement avec des images animées des meubles, et je suis sûr que vous trouverez d’autres façons d’expérimenter cette technologie.

conclusion

Si vous êtes arrivé jusqu’ici, vous avez vu comment implémenter des commandes de mouvement avec un navigateur et une webcam. Vous avez lu des données de caméra à l’aide d’API de navigateur, obtenu des coordonnées de main grâce à l’apprentissage automatique et détecté des mouvements de main avec JavaScript. Avec ces ingrédients, vous pouvez créer toutes sortes d’applications contrôlées par le mouvement.

Quels cas d’utilisation allez-vous proposer ? Faites-moi savoir dans les commentaires!

éditorial écrasant
(Oui oui)

Laissez un commentaire

Derniers Posts
Une Question ? Un Projet ?
Quel que soit votre projet, MIOR AGENCY vous écoute, analyse vos besoins et propose des pistes de travail en conséquence. Vous avancez avec sérénité et confiance.