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

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