Привет, друзья!
Еще недавно управление содержимым веб-страницы с помощью жестов можно было наблюдать разве что в фантастических фильмах. Сегодня все, что для этого требуется — видеокамера и браузер (и библиотека от 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)
CyberBot
09.11.2022 15:03Плюс за проделанную работу, но уже много лет назад продавалось такое устройство Leap Motion, с его помощью можно управлять жестами не только в браузере. Статья про него на Хабре
На алиэкспресс ценник на него 5000 руб. с доставкой
amet-dev
09.11.2022 15:04Информативно, пойду попробую поиграюсь, странно что интерактивные с этим не часто встретишь было бы здорово...
kle6ra
09.11.2022 21:27+1Очень интересная библиотека. Спасибо за статью.
Я делал подобную разработку для управления интерактивной рекламной панелью, но тогда ещё не было нужной библиотеки и мы воспользовались middleware. На python обрабатывали движения и жесты, затем генерировали системное событие мыши или клавиатуры и с помощью них управляли страницей. Из функций были просмотр видеороликов, погоды, курсов валют, игра 2048 и несложная нейросеть (на js), которая генерировала изменение лица, как кривое зеркало.
Кстати придумали неплохой вариант управления. Было два варианта:
1. свайпы - движение руки влево, вправо, вверх и вниз
2. управление указателем: указатель мышт следовал за левой рукой, а правой рукой нужно было попасть в зону клика (небольшой круг в правом нижнем углу экрана)
Если есть интерес, могу найти видео и рассказать про разработку.
nin-jin
10.11.2022 07:09-1Этот mediapipe быстрее чем handpose, который на tensorflow? А то мы как-то пробовали с последним, так оно настолько медленно, что ни о каком управлении курсором говорить не приходится. Поэтому реализовали управление именно жестами, но и их управление с большой задержкой и не особо надёжно. Даже голосовое управление оказалось куда комфортнее.
DenisGummi
10.11.2022 13:14+1Спасибо за материал, даже не задумывался о таком взаимодействии с устройством
scronheim
Спасибо за статью. Было интересно почитать и узнать новое