JavaScript: Ovládání webové stránky gesty

Naše zkušenosti s implementací dálkového ovládání a experimentováním s různými přístupy, včetně technologie počítačového vidění. V tomto článku se podělíme o výsledky našich experimentů s použitím knihovny MEDIAPIPE od Googlu pro počítačové vidění.

02 Mar 2023
10 min read

Během naší práce na projektu Stardio jsme dostali úkol implementovat dálkové ovládání aplikace. Prozkoumali jsme různé možnosti implementace a jedním z přístupů, se kterými jsme experimentovali, byla technologie počítačového vidění. V tomto článku se podělím o výsledky našich experimentů s jednou z dobře známých knihoven pro počítačové vidění - MEDIAPIPE, kterou vyvinula společnost Google.

Dříve bylo ovládání obsahu webové stránky gesty viděno pouze ve sci-fi filmech. Ale dnes k tomu všemu, co potřebujete, abyste to udělali realitou, je videokamera, prohlížeč a knihovna od Googlu. V tomto návodu budeme demonstrovat, jak implementovat ovládání gest pomocí čistého JavaScriptu. Pro detekci a sledování gest rukou budeme používat MediaPipe a pro správu závislostí budeme používat npm.

Vzorový kód lze najít v tomto repozitáři.

Příprava a nastavení projektu

Krok 1

Vytvořte projekt s čistým JS pomocí šablony Vite pod názvem:

motion-controls - název projektu vanilla - název šablony

yarn create vite motion-controls --template vanilla

Krok 2

Přejděte do vytvořeného adresáře, nainstalujte závislosti a spusťte vývojový server:

cd motion-controls
npm i
npm run dev

Krok 3

Upravte obsah těla v souboru index.html:

<video></video>
<canvas></canvas>

<script type="module" src="/js/get-video-data.js"></script>

Získání videodat a jejich vykreslení na plátno

Vytvořte adresář js ve složce kořenového adresáře projektu a v něm soubor get-video-data.js.

Získejte reference na prvky video a canvas, jakož i na kontext kreslení 2D grafiky:

const video$ = document.querySelector("video");
const canvas$ = document.querySelector("canvas");
const ctx = canvas$.getContext("2d");

Definujte šířku a výšku plátna, stejně jako požadavky (omezení) pro proud dat videa:

const width = 640;
const height = 480;

canvas$.width = width;
canvas$.height = height;

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

Získejte přístup k zařízení pro vstup videa uživatele pomocí metody getUserMedia; předejte proud dat do prvku videa pomocí atributu srcObject; po načtení metadat začněte přehrávat video a zavolejte metodu requestAnimationFrame předávající funkci drawVideoFrame jako argument:

navigator.mediaDevices
  .getUserMedia(constraints)
  .then((stream) => {
    video$.srcObject = stream;

    video$.onloadedmetadata = () => {
      video$.play();

      requestAnimationFrame(drawVideoFrame);
    };
  })
  .catch(console.error);

Nakonec definujeme funkci pro vykreslení snímku videa na plátno pomocí metody drawImage

function drawVideoFrame() {
  ctx.drawImage(video$, 0, 0, width, height);

  requestAnimationFrame(drawVideoFrame);
}

Poznámka

To, že volání requestAnimationFrame dvakrát spouští nekonečnou smyčku animace s rychlostí rámce specifickou pro zařízení, ale obvykle 60 snímků za sekundu (FPS). Rychlost snímků lze upravit pomocí argumentu časového razítka předaného zpětnému volání requestAnimationFrame ([příklad])(https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame#examples)):

function drawVideoFrame(timestamp) {
  // ...
}
JS gestures.jpg

Result

Detekce a sledování ruky

Pro detekci a sledování ruky potřebujeme několik dalších závislostí:

yarn add @mediapipe/camera_utils @mediapipe/drawing_utils @mediapipe/hands

MediaPipe Hands nejprve detekuje ruce a poté určuje 21 klíčových bodů (3D landmarky), které jsou klouby, pro každou ruku. Zde je, jak to vypadá:

Hand 3D landmarks

Vytvořte soubor track-hand-motions.js ve složce js.

Importování závislostí:

import { Camera } from "@mediapipe/camera_utils";
import { drawConnectors, drawLandmarks } from "@mediapipe/drawing_utils";
import { Hands, HAND_CONNECTIONS } from "@mediapipe/hands";

Konstruktor Camera umožňuje vytvářet instance pro ovládání videokamery a má následující signaturu:

export declare class Camera implements CameraInterface {
  constructor(video: HTMLVideoElement, options: CameraOptions);
  start(): Promise<void>;
  // We will not use this method
  stop(): Promise<void>;
}

Konstruktor přijímá prvek videa a následující nastavení:

export declare interface CameraOptions {
  // Callback for frame caption
  onFrame: () => Promise<void>| null;
  // camera
  facingMode?: 'user'|'environment';
  // width of frame
  width?: number;
  // height of frame
  height?: number;
}

Metoda start spustí proces zachycení snímku.

Konstruktor Hands umožňuje vytvořit instance pro detekci rukou a má následující signaturu:

export declare class Hands implements HandsInterface {
  constructor(config?: HandsConfig);
  onResults(listener: ResultsListener): void;
  send(inputs: InputMap): Promise<void>;
  setOptions(options: Options): void;
  // more method what we did not use
}

Konstruktor má tuto konfiguraci:

export interface HandsConfig {
  locateFile?: (path: string, prefix?: string) => string;
}

Toto zpětné volání načte další soubory potřebné k vytvoření instance:

hand_landmark_lite.tflite
hands_solution_packed_assets_loader.js
hands_solution_simd_wasm_bin.js
hands.binarypb
hands_solution_packed_assets.data
hands_solution_simd_wasm_bin.wasm

Metoda setOptions umožňuje nastavit následující možnosti zjišťování:

export interface Options {
  selfieMode?: boolean;
  maxNumHands?: number;
  modelComplexity?: 0|1;
  minDetectionConfidence?: number;
  minTrackingConfidence?: number;
}

Informace o těchto nastaveních najdete zde. Nastavíme maxNumHands: 1 pro detekci pouze jedné ruky a modelComplexity: 0 pro zvýšení výkonu na úkor přesnosti detekce.

Metoda send používá ke zpracování jednoho snímku dat. Je volána v **onFrame **metodě instance fotoaparátu.

Metoda onResults přijímá zpětné volání pro zpracování výsledků detekce ruky.

Metoda drawLandmarks umožňuje vykreslit klíčové body ruky a má následující signaturu:

export declare function drawLandmarks(
    ctx: CanvasRenderingContext2D, landmarks?: NormalizedLandmarkList,
    style?: DrawingOptions): void;

Přijímá kontext výkresu, klíčové body a následující styly:

export declare interface DrawingOptions {
  color?: string|CanvasGradient|CanvasPattern|
      Fn<Data, string|CanvasGradient|CanvasPattern>;
  fillColor?: string|CanvasGradient|CanvasPattern|
      Fn<Data, string|CanvasGradient|CanvasPattern>;
  lineWidth?: number|Fn<Data, number>;
  radius?: number|Fn<Data, number>;
  visibilityMin?: number;
}

Metoda drawConnectors umožňuje kreslit spojnice mezi klíčovými body a má následující signaturu:

export declare function drawConnectors(
    ctx: CanvasRenderingContext2D, landmarks?: NormalizedLandmarkList,
    connections?: LandmarkConnectionArray, style?: DrawingOptions): void;

Stará se o definování počátečních a koncových párů indexů bodů klíče (HAND_CONNECTIONS) a stylů.

Zpět k úpravám souboru track-hand-motions.js:

const video$ = document.querySelector("video");
const canvas$ = document.querySelector("canvas");
const ctx = canvas$.getContext("2d");
const width = 640;
const height = 480;
canvas$.width = width;
canvas$.height = height;

Definujeme funkci pro zpracování výsledků detekce ruky:

function onResults(results) {
  // of the entire result object, we are only interested in the `multiHandLandmarks` property,
  // which contains arrays of control points of detected hands
  if (!results.multiHandLandmarks.length) return;

  // when 2 hand are found, for example `multiHandLandmarks` will contain 2 arrays of control points
  console.log("@landmarks", results.multiHandLandmarks[0]);

  // draw a video frame
  ctx.save();
  ctx.clearRect(0, 0, width, height);
  ctx.drawImage(results.image, 0, 0, width, height);

  // iterate over arrays of breakpoints
   // we could do without iteration since we only have one array,
   // but this solution is more flexible
  for (const landmarks of results.multiHandLandmarks) {
    // draw keypoints
    drawLandmarks(ctx, landmarks, { color: "#FF0000", lineWidth: 2 });
    // draw lines
    drawConnectors(ctx, landmarks, HAND_CONNECTIONS, {
      color: "#00FF00",
      lineWidth: 4,
    });
  }

  ctx.restore();
}

Vytvoření instance pro detekci ruky, nastavení a registraci obsluhy výsledku:

const hands = new Hands({
  locateFile: (file) => `../node_modules/@mediapipe/hands/${file}`,
});
hands.setOptions({
  maxNumHands: 1,
  modelComplexity: 0,
});
hands.onResults(onResults);

Nakonec vytvoříme instanci pro ovládání videokamery, zaregistrujeme obslužnou rutinu, nastavíme nastavení a spustíme proces snímání snímků:

const camera = new Camera(video$, {
  onFrame: async () => {
    await hands.send({ image: video$ });
  },
  facingMode: undefined,
  width,
  height,
});
camera.start();

Upozornění: ve výchozím nastavení je nastavení facingMode nastaveno na hodnotu user - zdrojem obrazových dat je přední (frontální) kamera notebooku. Protože v mém případě je tímto zdrojem kamera USB, měla by být hodnota tohoto nastavení nedefinovaná.

Pole řídicích bodů detekovaného štětce vypadá takto:

How to control Javascript with gestures

Ukazatele odpovídají kloubům ruky, jak je znázorněno na obrázku výše. Například index prvního kloubu ukazováčku shora je 7. Každý kontrolní bod má souřadnice x, y a z v rozsahu 0 až 1.

Výsledek provedení příkladového kódu:

Definice gesta štípnutí:

Štípnutí jako gesto je přiblížení špiček ukazováku a palce na poměrně malou vzdálenost.

Ptáte se: "Co přesně se považuje za dostatečně blízkou vzdálenost?" Rozhodli jsme se definovat tuto vzdálenost jako 0,8 pro souřadnice x a y a 0,11 pro souřadnici z. Osobně s těmito výpočty souhlasím. Zde je vizuální znázornění:

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;

Několik důležitějších věcí:

  • chceme zaregistrovat a zpracovat začátek, pokračování a konec pinče (pinch_start, pinch_move a pinch_stop);
  • pro určení přechodu pinče z jednoho stavu do druhého (začátek -> konec nebo naopak) je nutné uložit předchozí stav;
  • detekce přechodu musí být provedena s určitým zpožděním, například 250 ms.

Vytvořte soubor detect-pinch-gesture.js v adresáři js. Začátek kódu je totožný s kódem předchozího příkladu:

import { Camera } from "@mediapipe/camera_utils";
import { Hands } from "@mediapipe/hands";

const video$ = document.querySelector("video");

const width = window.innerWidth;
const height = window.innerHeight;

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 },
};

const hands = new Hands({
  locateFile: (file) => `../node_modules/@mediapipe/hands/${file}`,
});
hands.setOptions({
  maxNumHands: 1,
  modelComplexity: 0,
});
hands.onResults(onResults);

const camera = new Camera(video$, {
  onFrame: async () => {
    await hands.send({ image: video$ });
  },
  facingMode: undefined,
  width,
  height,
});
camera.start();

const getFingerCoords = (landmarks) =>
  landmarks[handParts.indexFinger.topKnuckle];

function onResults(handData) {
  if (!handData.multiHandLandmarks.length) return;

  updatePinchState(handData.multiHandLandmarks[0]);
}

Definujte typy událostí, zpoždění a stav připnutí:

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

const OPTIONS = {
  PINCH_DELAY_MS: 250,
};

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

Deklarujte funkci detekce štípnutí:

function isPinched(landmarks) {
  const fingerTip = landmarks[handParts.indexFinger.tip];
  const thumbTip = landmarks[handParts.thumb.tip];
  if (!fingerTip || !thumbTip) return;

  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;
}

Definujte funkci, která vytvoří vlastní událost pomocí konstruktoru CustomEvent a zavolá ji pomocí metody dispatchEvent:

`` // the function takes the name of the event and the data - the coordinates of the finger function triggerEvent({ eventName, eventData }) { const event = new CustomEvent(eventName, { detail: eventData }); document.dispatchEvent(event); }

Definujte funkci aktualizace stavu špendlíku:

function updatePinchState(landmarks) { // determine the previous state const wasPinchedBefore = state.isPinched; // determine the beginning or end of the pinch const isPinchedNow = isPinched(landmarks); // define a state transition const hasPassedPinchThreshold = isPinchedNow !== wasPinchedBefore; // determine the state update delay const hasWaitStarted = !!state.pinchChangeTimeout;

// if there is a state transition and we are not in idle mode if (hasPassedPinchThreshold && !hasWaitStarted) { // call the corresponding event with a delay registerChangeAfterWait(landmarks, isPinchedNow); }

// if the state remains the same if (!hasPassedPinchThreshold) { // cancel standby mode cancelWaitForChange();

// if the pinch continues
if (isPinchedNow) {
  // trigger the corresponding event
  triggerEvent({
    eventName: PINCH_EVENTS.MOVE,
    eventData: getFingerCoords(landmarks),
  });
}

} }


Definujeme funkce pro aktualizaci stavu a zrušení čekání:

function registerChangeAfterWait(landmarks, isPinchedNow) { state.pinchChangeTimeout = setTimeout(() => { state.isPinched = isPinchedNow;

triggerEvent({
  eventName: isPinchedNow ? PINCH_EVENTS.START : PINCH_EVENTS.STOP,
  eventData: getFingerCoords(landmarks),
});

}, OPTIONS.PINCH_DELAY_MS); }

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


Definujeme obslužné programy pro začátek, pokračování a konec stisku (jednoduše vypíšeme souřadnice horního kloubu ukazováčku na konzoli):

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

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

function onPinchStop(eventInfo) { const fingerCoords = eventInfo.detail; console.log("Pinch stopped", fingerCoords); // change background color on STOP document.body.style.backgroundColor = "#" + Math.floor(Math.random() * 16777215).toString(16); }


A zaregistrujte je:

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


Výsledek na videu:

https://www.youtube.com/watch?v=KsLQRb6BhbI

### Závěr

Nyní, když jsme dospěli do tohoto bodu, můžeme s naší webovou aplikací pracovat, jakkoli si přejeme. To mimo jiné zahrnuje změnu stavu a interakci s prvky HTML.

Jak vidíte, možnosti využití této technologie jsou prakticky neomezené, takže neváhejte a zkoumejte a experimentujte s ní.

Tímto končím to, o co jsem se s vámi chtěl v tomto tutoriálu podělit. Doufám, že pro vás byl poučný a poutavý a že nějakým způsobem obohatil vaše znalosti. Děkuji vám za pozornost a přeji vám šťastné kódování!