JavaScript: управление содержимым веб-страницы с помощью жестов

от автора

Привет, друзья!

Еще недавно управление содержимым веб-страницы с помощью жестов можно было наблюдать разве что в фантастических фильмах. Сегодня все, что для этого требуется — видеокамера и браузер (и библиотека от Google).

В данном туториале мы рассмотрим 5 примеров:

  • получение данных с видеокамеры и их отрисовка на холсте (canvas);
  • обнаружение и отслеживание кисти руки;
  • управление «курсором» с помощью указательного пальца;
  • определение жеста «щипок» (pinch);
  • нажатие кнопки с помощью щипка.

Все примеры будут реализованы на чистом JavaScript.

Источником вдохновения для меня послужила эта замечательная статья.

Для обнаружения и отслеживания руки и жестов будет использоваться MediaPipe. Для работы с зависимостями — Yarn.

Код примеров можно найти в этом репозитории.

❯ Подготовка и настройка проекта

Создаем шаблон проекта на чистом JS с помощью Vite:

# motion-controls - название проекта # vanilla - используемый шаблон yarn create vite motion-controls --template vanilla

Переходим в созданную директорию, устанавливаем зависимости и запускаем сервер для разработки:

cd motion-controls yarn yarn dev

Редактируем содержимое body в файле index.html:

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

❯ Получение видеоданных и их отрисовка на холсте

Создаем директорию js в корне проекта и файл get-video-data.js в ней.

Получаем ссылки на элементы video и canvas, а также на контекст рисования 2D-графики:

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

Определяем ширину и высоту холста, а также требования (constraints) к потоку видеоданных:

const width = 320; const height = 480;  canvas$.width = width; canvas$.height = height;  const constraints = {   audio: false,   video: { width, height }, };

Получаем доступ к устройству ввода видеоданных пользователя с помощью метода getUserMedia; передаем поток в элемент video с помощью атрибута srcObject; после загрузки метаданных, запускаем воспроизведение видео и вызываем метод requestAnimationFrame, передавая ему функцию drawVideoFrame в качестве аргумента:

navigator.mediaDevices   // `getUserMedia` возвращает промис   .getUserMedia(constraints)   .then((stream) => {     video$.srcObject = stream;      video$.onloadedmetadata = () => {       video$.play();        requestAnimationFrame(drawVideoFrame);     };   })   .catch(console.error);

Наконец, определяем функцию отрисовки видеокадра на холсте с помощью метода drawImage:

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

Обратите внимание: двойной вызов requestAnimationFrame запускает бесконечный цикл анимации с частотой кадров, которая зависит от устройства, но обычно составляет 60 кадров в секунду (60 frames per second, FPS). Частоту отрисовки кадров можно регулировать с помощью аргумента timestamp, передаваемого коллбэку requestAnimationFrame (пример):

function drawVideoFrame(timestamp) {   // ... }

Результат:

❯ Обнаружение и отслеживание кисти руки

Для обнаружения и отслеживания руки нам потребуется несколько дополнительных зависимостей:

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

MediaPipe Hands сначала обнаруживает кисти рук, затем определяет 21 контрольную точку (3D landmarks), которыми являются суставы, для каждой кисти. Вот как это выглядит:

Создаем в директории js файл track-hand-motions.js.

Импортируем зависимости:

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

Конструктор Camera позволяет создавать экземпляры для управления видеокамерой и имеет следующую сигнатуру:

export declare class Camera implements CameraInterface {   constructor(video: HTMLVideoElement, options: CameraOptions);   start(): Promise<void>;   // мы не будем использовать этот метод   stop(): Promise<void>; }

Конструктор принимает элемент video и такие настройки:

export declare interface CameraOptions {   // коллбэк, вызываемый при захвате кадра   onFrame: () => Promise<void>| null;   // камера   facingMode?: 'user'|'environment';   // ширина кадра   width?: number;   // высота кадра   height?: number; }

Метод start запускает процесс захвата кадров.


Конструктор Hands позволяет создавать экземпляры для обнаружения кистей рук и имеет следующую сигнатуру:

export declare class Hands implements HandsInterface {   constructor(config?: HandsConfig);   onResults(listener: ResultsListener): void;   send(inputs: InputMap): Promise<void>;   setOptions(options: Options): void;   // еще несколько методов, которые нами использоваться не будут }

Конструктор принимает такую настройку:

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

Этот коллбэк загружает дополнительные файлы, необходимые для создания экземпляра:

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

Метод setOptions позволяет устанавливать следующие настройки обнаружения:

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

Об этих настройках можно почитать здесь. Мы установим настройки maxNumHands: 1 для обнаружения только одной кисти и modelComplexity: 0 для повышения производительности за счет снижения точности обнаружения.

Метод send используется для обработки единичного кадра данных. Он вызывается в методе onFrame экземпляра Camera.

Метод onResults принимает коллбэк для обработки результатов обнаружения кисти.


Метод drawLandmarks позволяет рисовать контрольные точки кисти и имеет следующую сигнатуру:

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

Он принимает контекст рисования, контрольные точки и следующие стили:

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

Метод drawConnectors позволяет рисовать соединительные линии между контрольными точками и имеет следующую сигнатуру:

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

Он принимает контекст рисования, контрольные точки, пары начального и конечного индексов контрольных точек (HAND_CONNECTIONS) и стили.

Возвращаемся к редактированию track-hand-motions.js.

Делаем тоже самое, что в предыдущем примере:

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

Определяем функцию обработки результатов обнаружения кисти:

function onResults(results) {   // из всего объекта результатов нас интересует только свойство `multiHandLandmarks`,   // которое содержит массивы контрольных точек обнаруженных кистей   if (!results.multiHandLandmarks.length) return;    // при обнаружении 2 кистей, например, `multiHandLandmarks` будет содержать 2 массива контрольных точек   console.log("@landmarks", results.multiHandLandmarks[0]);    // рисуем видеокадр   ctx.save();   ctx.clearRect(0, 0, width, height);   ctx.drawImage(results.image, 0, 0, width, height);    // перебираем массивы контрольных точек   // мы могли бы обойтись без итерации, поскольку у нас имеется лишь один массив,   // но такое решение является более гибким   for (const landmarks of results.multiHandLandmarks) {     // рисуем точки     drawLandmarks(ctx, landmarks, { color: "#FF0000", lineWidth: 2 });     // рисуем линии     drawConnectors(ctx, landmarks, HAND_CONNECTIONS, {       color: "#00FF00",       lineWidth: 4,     });   }    ctx.restore(); }

Создаем экземпляр для обнаружения кисти, устанавливаем настройки и регистрируем обработчик результатов:

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();

Обратите внимание: по умолчанию настройка facingMode имеет значение user — источником видеоданных является фронтальная (передняя) камера ноутбука. Поскольку в моем случае таким источником является USB-камера, значением данной настройки должно быть undefined.

Массив контрольных точек обнаруженной кисти выглядит так:

Индексы соответствуют суставам кисти согласно приведенному выше изображению. Например, индексом первого сверху сустава указательного пальца является 7. Каждая контрольная точка имеет координаты x, y и z в диапазоне от 0 до 1.

Результат выполнения кода примера:



❯ Управление «курсором» с помощью указательного пальца

Следующая задача — научиться управлять положением элементов на странице.

Добавляем в index.html такой div:

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

И определяем некоторые стили в файле style.css:

body {   margin: 0;   overflow: hidden; }  canvas {   display: none; }  video {   max-width: 100vw;   max-height: 100vh; }  .cursor {   height: 0;   left: 0;   position: absolute;   top: 0;   transition: transform 0.1s;   width: 0;   z-index: 10; }  .cursor::after {   background-color: #0275d8;   border-radius: 50%;   content: "";   display: block;   height: 40px;   left: 0;   position: absolute;   top: 0;   transform: translate(-50%, -50%);   width: 40px; }

Создаем в директории js файл move-cursor-by-finger.js.

Импортируем зависимости и стили:

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

Получаем ссылки на DOM-элементы и определяем ширину и высоту захватываемого видеокадра, равную ширине и высоте области просмотра:

const video$ = document.querySelector("video"); const cursor$ = document.querySelector(".cursor");  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();

Мы хотим управлять положением «курсора» с помощью первого сверху сустава указательного пальца — handParts.indexFinger.topKnuckle + координаты контрольной точки необходимо преобразовывать в координаты страницы — для этого удобно использовать такие единицы измерения, как vw и vh (ширина и высота области просмотра). Определяем соответствующие функции:

const getCursorCoords = (landmarks) =>   landmarks[handParts.indexFinger.topKnuckle];  const convertCoordsToDomPosition = ({ x, y }) => ({   x: `${x * 100}vw`,   y: `${y * 100}vh`, });

Определяем функцию позиционирования «курсора»:

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

Наконец, определяем функцию обработки результатов обнаружения кисти:

function onResults(handData) {   if (!handData.multiHandLandmarks.length) return;    updateCursorPosition(handData.multiHandLandmarks[0]); }

Обратите внимание: для того, чтобы «отзеркалить» координату x контрольной точки (если возникнет такая необходимость) можно сделать так — x = -x + 1.

Результат выполнения кода примера:

❯ Определение жеста «щипок»

Щипок (pinch) как жест представляет собой сведение кончиков указательного и большого пальцев на достаточно близкое расстояние.

«Достаточно близкое расстояние — это сколько?» — спросите вы. Автор указанной в начале статьи определяет это расстояние как 0.8 для координат x и y и 0.11 для координаты z. Я согласен с его вычислениями. Выглядит это следующим образом:

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;

Еще несколько важных моментов:

  • мы хотим регистрировать и обрабатывать начало, продолжение и окончание щипка (pinch_start, pinch_move и pinch_stop, соответственно);
  • для определения перехода щипка из одного состояния в другое (начало -> конец, или наоборот), требуется сохранять предыдущее состояние;
  • определение перехода должно выполняться с некоторое задержкой, например, 250 мс.

Для данного примера нам не нужен «курсор». Редактируем index.html:

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

Создаем в директории js файл detect-pinch-gesture.js.

Начало кода идентично коду предыдущего примера, за исключением того, что мы не работаем с «курсором»:

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

Определяем типы событий, задержку и состояние щипка:

const PINCH_EVENTS = {   START: "pinch_start",   MOVE: "pinch_move",   STOP: "pinch_stop", };  const OPTIONS = {   PINCH_DELAY_MS: 250, };  const state = {   isPinched: false,   pinchChangeTimeout: null, };

Объявляем функцию определения щипка:

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

Определяем функцию, создающую кастомное событие с помощью конструктора CustomEvent и вызывающую его с помощью метода dispatchEvent:

// функция принимает название события и данные - координаты пальца function triggerEvent({ eventName, eventData }) {   const event = new CustomEvent(eventName, { detail: eventData });   document.dispatchEvent(event); }

Определяем функцию обновления состояния щипка:

function updatePinchState(landmarks) {   // определяем предыдущее состояние   const wasPinchedBefore = state.isPinched;   // определяем начало или окончание щипка   const isPinchedNow = isPinched(landmarks);   // определяем переход состояния   const hasPassedPinchThreshold = isPinchedNow !== wasPinchedBefore;   // определяем задержку обновления состояния   const hasWaitStarted = !!state.pinchChangeTimeout;    // если имеет место переход состояния и мы не находимся в режиме ожидания   if (hasPassedPinchThreshold && !hasWaitStarted) {     // вызываем соответствующее событие с задержкой     registerChangeAfterWait(landmarks, isPinchedNow);   }    // если состояние осталось прежним   if (!hasPassedPinchThreshold) {     // отменяем режим ожидания     cancelWaitForChange();      // если щипок продолжается     if (isPinchedNow) {       // вызываем соответствующее событие       triggerEvent({         eventName: PINCH_EVENTS.MOVE,         eventData: getFingerCoords(landmarks),       });     }   } }

Определяем функции обновления состояния и отмены ожидания:

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

Определяем обработчики начала, продолжения и окончания щипка (просто выводим координаты верхнего сустава указательного пальца в консоль):

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

И регистрируем их:

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

Полный код примера:

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]); }  const PINCH_EVENTS = {   START: "pinch_start",   MOVE: "pinch_move",   STOP: "pinch_stop", };  const OPTIONS = {   PINCH_DELAY_MS: 250, };  const state = {   isPinched: false,   pinchChangeTimeout: null, };  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; }  function triggerEvent({ eventName, eventData }) {   const event = new CustomEvent(eventName, { detail: eventData });   document.dispatchEvent(event); }  function updatePinchState(landmarks) {   const wasPinchedBefore = state.isPinched;   const isPinchedNow = isPinched(landmarks);   const hasPassedPinchThreshold = isPinchedNow !== wasPinchedBefore;   const hasWaitStarted = !!state.pinchChangeTimeout;    if (hasPassedPinchThreshold && !hasWaitStarted) {     registerChangeAfterWait(landmarks, isPinchedNow);   }    if (!hasPassedPinchThreshold) {     cancelWaitForChange();      if (isPinchedNow) {       triggerEvent({         eventName: PINCH_EVENTS.MOVE,         eventData: getFingerCoords(landmarks),       });     }   } }  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; }  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); }  document.addEventListener(PINCH_EVENTS.START, onPinchStart); document.addEventListener(PINCH_EVENTS.MOVE, onPinchMove); document.addEventListener(PINCH_EVENTS.STOP, onPinchStop);

Результат выполнения кода примера с закомментированным console.log("Pinch moved", fingerCoords);:

Обработка продолжения щипка:

❯ Нажатие кнопки с помощью щипка

Итак, мы научились получать координаты суставов пальцев и определять щипок. Этого вполне достаточно для взаимодействия с элементами на странице. Реализуем нажатие кнопки с помощью щипка.

Редактируем index.html, добавляя в него второй «курсор», контейнер для счетчика кликов и кнопку:

<div class="cursor2"></div> <div class="counter-box">   <p>0</p>   <button>Click me by pinch</button> </div>

Редактируем style.css:

body {   margin: 0;   overflow: hidden; }  canvas {   display: none; }  video {   max-width: 100vw;   max-height: 100vh; }  .cursor, .cursor2 {   height: 0;   left: 0;   position: absolute;   top: 0;   transition: transform 0.1s;   width: 0;   z-index: 10; }  .cursor::after, .cursor2::after {   background-color: #0275d8;   border-radius: 50%;   content: "";   display: block;   height: 50px;   left: 0;   position: absolute;   top: 0;   transform: translate(-50%, -50%);   width: 50px; }  .cursor2::after {   background-color: #5cb85c;   width: 20px;   height: 20px; }  .counter-box {   left: 50%;   position: absolute;   top: 50%;   transform: translate(-50%, -50%); }  p {   font-size: 2rem;   text-align: center; }  button {   border-radius: 4px;   border: 2px solid #0275d8;   font-size: 1rem;   padding: 1rem; }

Создаем в директории js файл click-button-by-pinch.js.

Импортируем зависимости, стили, получаем ссылки на DOM-элементы и данные о прямоугольнике кнопки с помощью метода getBoundingClientRect:

import { Camera } from "@mediapipe/camera_utils"; import { Hands } from "@mediapipe/hands"; import "../style.css";  const video$ = document.querySelector("video"); const cursor$ = document.querySelector(".cursor2"); const counter$ = document.querySelector("p"); const button$ = document.querySelector("button"); // кнопка статична, поэтому данные можно получить сразу const buttonRect = button$.getBoundingClientRect();

Определяем переменную для счетчика кликов и регистрируем обработчик нажатия кнопки:

let count = 0;  button$.addEventListener("click", () => {   counter$.textContent = ++count; });

Остальной код идентичен коду предыдущего примера, за исключением следующего:

  • получаем координаты кончика указательного пальца:

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

  • в функции updateCursorPosition мы не только позиционируем «курсор», но также определяем пересечение курсора с кнопкой и стилизуем границы кнопки соответствующим образом:

function updateCursorPosition(landmarks) {   const fingerCoords = getFingerCoords(landmarks);   if (!fingerCoords) return;    const { x, y } = convertCoordsToDomPosition(fingerCoords);    cursor$.style.transform = `translate(${x}, ${y})`;    const hit = isIntersected();   if (hit) {     button$.style.border = "2px solid #5cb85c";   } else {     button$.style.border = "2px solid #0275d8";   } }

  • объявляем функцию определения пересечения «курсора» с кнопкой:

function isIntersected() {   const cursorRect = cursor$.getBoundingClientRect();    // пересечение имеет место, когда прямоугольник "курсора" целиком находится внутри прямоугольника кнопки   const hit =     cursorRect.x >= buttonRect.x &&     cursorRect.y >= buttonRect.y &&     cursorRect.x + cursorRect.width <= buttonRect.x + buttonRect.width &&     cursorRect.y + cursorRect.height <= buttonRect.y + buttonRect.height;    return hit; }

  • обрабатывается только начало щипка:

const PINCH_EVENTS = {   START: "pinch_start",   // для соблюдения контракта   STOP: "pinch_stop", };  function updatePinchState(landmarks) {   const wasPinchedBefore = state.isPinched;   const isPinchedNow = isPinched(landmarks);   const hasPassedPinchThreshold = isPinchedNow !== wasPinchedBefore;   const hasWaitStarted = !!state.pinchChangeTimeout;    if (hasPassedPinchThreshold && !hasWaitStarted) {     registerChangeAfterWait(landmarks, isPinchedNow);   }    if (!hasPassedPinchThreshold) {     cancelWaitForChange();   } }  document.addEventListener(PINCH_EVENTS.START, onPinchStart);

  • обработка начала щипка состоит в нажатии кнопки при нахождении в состоянии пересечения:

function onPinchStart() {   const hit = isIntersected();    if (hit) {     button$.click();   } }

Полный код примера:

import { Camera } from "@mediapipe/camera_utils"; import { Hands } from "@mediapipe/hands"; import "../style.css";  const video$ = document.querySelector("video"); const cursor$ = document.querySelector(".cursor2"); const counter$ = document.querySelector("p"); const button$ = document.querySelector("button"); const buttonRect = button$.getBoundingClientRect();  let count = 0;  button$.addEventListener("click", () => {   counter$.textContent = ++count; });  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.tip];  const convertCoordsToDomPosition = ({ x, y }) => ({   x: `${x * 100}vw`,   y: `${y * 100}vh`, });  function updateCursorPosition(landmarks) {   const fingerCoords = getFingerCoords(landmarks);   if (!fingerCoords) return;    const { x, y } = convertCoordsToDomPosition(fingerCoords);    cursor$.style.transform = `translate(${x}, ${y})`;    const hit = isIntersected();   if (hit) {     button$.style.border = "2px solid #5cb85c";   } else {     button$.style.border = "2px solid #0275d8";   } }  function onResults(handData) {   if (!handData.multiHandLandmarks.length) return;    updateCursorPosition(handData.multiHandLandmarks[0]);    updatePinchState(handData.multiHandLandmarks[0]); }  const PINCH_EVENTS = {   START: "pinch_start",   STOP: "pinch_stop", };  const OPTIONS = {   PINCH_DELAY_MS: 250, };  const state = {   isPinched: false,   pinchChangeTimeout: null, };  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; }  function isIntersected() {   const cursorRect = cursor$.getBoundingClientRect();    const hit =     cursorRect.x >= buttonRect.x &&     cursorRect.y >= buttonRect.y &&     cursorRect.x + cursorRect.width <= buttonRect.x + buttonRect.width &&     cursorRect.y + cursorRect.height <= buttonRect.y + buttonRect.height;    return hit; }  function triggerEvent({ eventName, eventData }) {   const event = new CustomEvent(eventName, { detail: eventData });   document.dispatchEvent(event); }  function updatePinchState(landmarks) {   const wasPinchedBefore = state.isPinched;   const isPinchedNow = isPinched(landmarks);   const hasPassedPinchThreshold = isPinchedNow !== wasPinchedBefore;   const hasWaitStarted = !!state.pinchChangeTimeout;    if (hasPassedPinchThreshold && !hasWaitStarted) {     registerChangeAfterWait(landmarks, isPinchedNow);   }    if (!hasPassedPinchThreshold) {     cancelWaitForChange();   } }  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; }  function onPinchStart() {   const hit = isIntersected();    if (hit) {     button$.click();   } }  document.addEventListener(PINCH_EVENTS.START, onPinchStart);

Результат выполнения кода примера:

Когда я прочитал указанную в начале статью, первой моей мыслью было: «А будущее-то, оказывается, уже наступило» 🙂

Как вы сами понимаете, возможности по использованию рассмотренной технологии безграничны, так что дерзайте.

Пожалуй, это все, чем я хотел с вами поделиться.

Надеюсь, вы узнали что-то новое и не зря потратили время.

Благодарю за внимание и happy coding!



ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/698286/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *