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
![](/_next/image?url=https%3A%2F%2Fstorage.googleapis.com%2Fmoravio-web%2Fmedia%2Fgestures_b3c28a0a90%2Fgestures_b3c28a0a90.png&w=3840&q=75)
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](/_next/image?url=https%3A%2F%2Fstorage.googleapis.com%2Fmoravio-web%2Fmedia%2FJS_gestures_c2874c1708%2FJS_gestures_c2874c1708.jpg&w=1920&q=75)
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](https://storage.googleapis.com/moravio-web/media/Hand_579904d6eb/Hand_579904d6eb.jpg)
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](https://storage.googleapis.com/moravio-web/media/JS_control_with_gestures_835f5ea322/JS_control_with_gestures_835f5ea322.jpg)
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í!