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

tb2ufxookpot0mirap8x-dzoedg.jpeg


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

Еще недавно управление содержимым веб-страницы с помощью жестов можно было наблюдать разве что в фантастических фильмах. Сегодня все, что для этого требуется — видеокамера и браузер (и библиотека от 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:





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

Создаем директорию 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) {
  // ...
}

Результат:


p-w4j0ei0eg2cq7y1p2xdmmd0ni.png

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

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

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

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


ipd0zpor4yaekpai7gylg9s-bx4.png

Создаем в директории 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;
  // мы не будем использовать этот метод
  stop(): Promise;
}

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

export declare interface CameraOptions {
  // коллбэк, вызываемый при захвате кадра
  onFrame: () => Promise| 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;
  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;
  fillColor?: string|CanvasGradient|CanvasPattern|
      Fn;
  lineWidth?: number|Fn;
  radius?: number|Fn;
  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.

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


b0kcqrzh2m_avg3cjvyeqv0brxc.png

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

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


klk8nrdtlqrjol62i2-dj2ivsra.png
jj0quc_slasv_4jhoajse5wckea.png
5ymwo-ysiio_iunn4t04ard9ba4.png

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

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

Добавляем в index.html такой 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.

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


1lmavhydcpgq0lerkw_wjq_8hp8.png

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

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


zdikhaa8aqpfopi9x6uvitntgfg.png

«Достаточно близкое расстояние — это сколько?» — спросите вы. Автор указанной в начале статьи определяет это расстояние как 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:

Создаем в директории 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);:


9_sfcqzwehlu-owjcusnfbymfck.png

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


ouncuwc7duce7-brv6bvhx16mak.png

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

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

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

0

Редактируем 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);

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


bjm3qxlr74j7sebrlodb2awgi34.png

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

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

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

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

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


p-u9l27ynelxi92bcmdxhu76ma8.png

© Habrahabr.ru