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


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




Комментарии (6)


  1. scronheim
    09.11.2022 13:07
    +1

    Спасибо за статью. Было интересно почитать и узнать новое


  1. CyberBot
    09.11.2022 15:03

    Плюс за проделанную работу, но уже много лет назад продавалось такое устройство Leap Motion, с его помощью можно управлять жестами не только в браузере. Статья про него на Хабре

    На алиэкспресс ценник на него 5000 руб. с доставкой


  1. amet-dev
    09.11.2022 15:04

    Информативно, пойду попробую поиграюсь, странно что интерактивные с этим не часто встретишь было бы здорово...


  1. kle6ra
    09.11.2022 21:27
    +1

    Очень интересная библиотека. Спасибо за статью.
    Я делал подобную разработку для управления интерактивной рекламной панелью, но тогда ещё не было нужной библиотеки и мы воспользовались middleware. На python обрабатывали движения и жесты, затем генерировали системное событие мыши или клавиатуры и с помощью них управляли страницей. Из функций были просмотр видеороликов, погоды, курсов валют, игра 2048 и несложная нейросеть (на js), которая генерировала изменение лица, как кривое зеркало.
    Кстати придумали неплохой вариант управления. Было два варианта:
    1. свайпы - движение руки влево, вправо, вверх и вниз
    2. управление указателем: указатель мышт следовал за левой рукой, а правой рукой нужно было попасть в зону клика (небольшой круг в правом нижнем углу экрана)
    Если есть интерес, могу найти видео и рассказать про разработку.


  1. nin-jin
    10.11.2022 07:09
    -1

    Этот mediapipe быстрее чем handpose, который на tensorflow? А то мы как-то пробовали с последним, так оно настолько медленно, что ни о каком управлении курсором говорить не приходится. Поэтому реализовали управление именно жестами, но и их управление с большой задержкой и не особо надёжно. Даже голосовое управление оказалось куда комфортнее.


  1. DenisGummi
    10.11.2022 13:14
    +1

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