Skip to main content

Замена фона видео и реализация интересных эффектов на основе координат лица в реальном времени

· 11 min read

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

Я продолжаю изучать MediaPipe - библиотеку с открытым исходным кодом от Google, предоставляющую "кроссплатформенные и кастомизируемые решения на основе машинного обучения для работы с медиа", и в этой статье хочу рассказать вам о 2 инструментах:

  • Selfie Segmentation, выделяющий людей на сцене, что позволяет осуществлять замену фона на кадрах видео в процессе потоковой передачи соответствующих данных;
  • Face Mesh, предоставляющий сетку лица человека, состоящую из 468 контрольных точек с координатами в трехмерном пространстве, что позволяет реализовать некоторые интересные визуальные эффекты.

Репозиторий с кодом проекта.

В предыдущей статье, посвященной MediaPipe, рассматривалась возможность использования координат суставов кисти руки для управления содержимым веб-страницы. Там шаг за шагом и достаточно подробно показан процесс получения данных с видеокамеры пользователя и их обработка с помощью двумерного контекста рисования холста (HTML-элемент canvas). В этой статье я ограничусь особенностями названных инструментов.

Для работы с зависимостями будет использоваться Yarn, а для создания шаблона проекта - Vite.

Проект будет реализован на чистом JavaScript.

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

Создаем шаблон проекта:

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

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

cd mediapipe_selfie_face
yarn
yarn dev

Устанавливаем пакеты MediaPipe:

yarn add @mediapipe/selfie_segmentation @mediapipe/face_mesh @mediapipe/camera_utils @mediapipe/drawing_utils

Определяем начальную разметку в файле index.html:

<body>
<video></video>
<canvas></canvas>
</body>

И начальные стили в файле style.css:

body {
margin: 0;
overflow: hidden;
}

canvas {
left: 0;
position: absolute;
top: 0;
}

Обратите внимание: холст располагается поверх видео.

Это все, что требуется для подготовки и настройки проекта.

Замена видеофона

Создаем файл selfie_segmentation.js в корне проекта и подключаем его в index.html:

<script type="module" src="/selfie_segmentation.js"></script>

Начнем с примера из официальной документации. В нем выделенный на сцене человек закрашивается зеленым цветом.

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

import { Camera } from "@mediapipe/camera_utils";
import { SelfieSegmentation } from "@mediapipe/selfie_segmentation";
import "./style.css";

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

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

Определяем ширину и высоту холста, равные ширине и высоте области просмотра, и записываем их в переменные:

const WIDTH = (canvas$.width = window.innerWidth);
const HEIGHT = (canvas$.height = window.innerHeight);

Создаем экземпляр средства выделения человека на сцене:

const selfieSegmentation = new SelfieSegmentation({
// загружаем дополнительные файлы, необходимые для работы инструмента
locateFile: (file) => `./node_modules/@mediapipe/selfie_segmentation/${file}`,
});

Устанавливаем настройку modelSelection:

selfieSegmentation.setOptions({
modelSelection: 1,
});

Данная настройка принимает числа 0 и 1, определяющие используемую модель распознавания, где 0 означает общую модель (general model), а 1 - ландшафтную модель (landscape model). Распознавание с использованием общей модели является более точным, но менее быстрым (подробнее см. здесь). Мы жертвуем точностью в пользу производительности.

Регистрируем обработку результатов распознавания:

selfieSegmentation.onResults(onResults);

Создаем экземпляр средства захвата данных с видеокамеры пользователя:

const camera = new Camera(video$, {
// обработчик захваченного кадра видео
onFrame: async () => {
await selfieSegmentation.send({ image: video$ });
},
// для захвата видео с USB-камеры
// при использовании фронтальной/передней камеры ноутбука, данную настройку можно опустить
facingMode: undefined,
// ширина и высота видеокадра соответствуют ширине и высоте холста (области просмотра)
width: WIDTH,
height: HEIGHT,
});

Запускаем процесс захвата видео:

camera.start();

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

function onResults(results) {
console.log(results);

// сохраняем состояние холста
ctx.save();

// очищаем холст
ctx.clearRect(0, 0, WIDTH, HEIGHT);

// рисуем маску (выделенную область/человека)
ctx.drawImage(results.segmentationMask, 0, 0, WIDTH, HEIGHT);

// перезаписываем существующие пиксели (см. ниже)
ctx.globalCompositeOperation = "source-in";
// определяем цвет заливки
ctx.fillStyle = "#00FF00";
// рисуем прямоугольник
ctx.fillRect(0, 0, WIDTH, HEIGHT);

// записываем отсутствующие пиксели
ctx.globalCompositeOperation = "destination-atop";
// рисуем изображение - кадр видео
ctx.drawImage(results.image, 0, 0, WIDTH, HEIGHT);

// восстанавливаем состояние холста
ctx.restore();
}

Свойство globalCompositeOperation определяет тип операции компоновки (compositing operation), применяемой при рисовании фигур. В нашем случае тип source-in означает, что зеленый прямоугольник рисуется (fillRect()) только в пределах выделенной области (results.segmentationMask), что приводит к ее окрашиванию в зеленый цвет, а тип destination-atop - что кадр видео рисуется за существующим содержимым хоста, что приводит к заполнению/восстановлению недостающих пикселей.

Результат:


Реализуем замену фона видео.

Идем на Unsplash и скачиваем 3 изображения, которые будут использоваться в качестве фона (или возьмите изображения из репозитория проекта). Помещаем их в директорию public и добавляем в разметку:

<div class="images-box">
<img src="/images/img1.jpg" alt="" />
<img src="/images/img2.jpg" alt="" />
<img src="/images/img3.jpg" alt="" />
</div>
<button>Show real background</button>

Обратите внимание, что мы также добавили кнопку для восстановления оригинального фона.

Добавляем стили для изображений и кнопки в style.css:

.images-box {
bottom: 1rem;
display: flex;
gap: 1rem;
position: absolute;
right: 1rem;
}

img {
border-radius: 4px;
border: 3px solid transparent;
cursor: pointer;
display: block;
max-width: 120px;
transition: border-color 0.2s ease-in-out;
}

img:hover {
border-color: steelblue;
}

img.selected {
border-color: forestgreen;
}

button {
background-image: linear-gradient(yellow, orange);
border-radius: 2px;
border: none;
bottom: 1rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-size: 0.9rem;
left: 1rem;
outline: none;
padding: 0.25rem 0.75rem;
position: absolute;
transition: box-shadow 0.2s ease-in-out;
}

button:active {
box-shadow: none;
}

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

Результат:


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

const imagesBox$ = document.querySelector(".images-box");
const button$ = document.querySelector("button");

let img$ = null;

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

const onImagesBoxClick = (e) => {
// определяем новое выбранное изображение
const newSelectedImage =
e.target.localeName === "img" ? e.target : e.target.closest("img");
if (!newSelectedImage) return;

// определяем предыдущее выбранное изображение
const prevSelectedImage = imagesBox$.querySelector(".selected");
if (prevSelectedImage) {
prevSelectedImage.classList.remove("selected");
}

newSelectedImage.classList.add("selected");
// записываем новое выбранное изображение в переменную
img$ = newSelectedImage;
};

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

const onButtonClick = () => {
// очищаем переменную
img$ = null;

// очищаем холст
ctx.clearRect(0, 0, WIDTH, HEIGHT);

const selectedImage = imagesBox$.querySelector(".selected");
if (selectedImage) {
selectedImage.classList.remove("selected");
}
};

Регистрируем обработчики:

imagesBox$.addEventListener("click", onImagesBoxClick);
button$.addEventListener("click", onButtonClick);

Для замены фона в onResults() достаточно изменить тип операции компоновки с source-in на source-out (т.е. рисовать только за пределами выделенной области) и рисовать выбранное изображение вместо зеленого прямоугольника:

// выполнять код функции только при наличии выбранного изображения
if (!img$) return;

ctx.save();

ctx.clearRect(0, 0, WIDTH, HEIGHT);

ctx.drawImage(results.segmentationMask, 0, 0, WIDTH, HEIGHT);

// перезаписываем существующие пиксели
ctx.globalCompositeOperation = "source-out";
ctx.drawImage(img$, 0, 0, WIDTH, HEIGHT);

Результат:


Применение визуальных эффектов на основе координат лица

Создаем файл face_mesh.js в корне проекта.

Снова возьмем пример из официальной документации. В нем рисуется сетка лица с выделением овала лица, бровей, глаз, зрачков и губ, а также соединительных линий между контрольными точками лица.

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

import "./style.css";
import { Camera } from "@mediapipe/camera_utils";
import { drawConnectors } from "@mediapipe/drawing_utils";
import {
FaceMesh,
// индексы координат (см. ниже)
FACEMESH_FACE_OVAL,
FACEMESH_LEFT_EYE,
FACEMESH_LEFT_EYEBROW,
FACEMESH_LEFT_IRIS,
FACEMESH_LIPS,
FACEMESH_RIGHT_EYE,
FACEMESH_RIGHT_EYEBROW,
FACEMESH_RIGHT_IRIS,
FACEMESH_TESSELATION,
} from "@mediapipe/face_mesh";

Определяем константы:

const video$ = document.querySelector("video");
const canvas$ = document.querySelector("canvas");
const ctx = canvas$.getContext("2d");
const WIDTH = (canvas$.width = window.innerWidth);
const HEIGHT = (canvas$.height = window.innerHeight);

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

const faceMesh = new FaceMesh({
locateFile: (file) => `../node_modules/@mediapipe/face_mesh/${file}`,
});
faceMesh.setOptions({
maxNumFaces: 1,
refineLandmarks: true,
minDetectionConfidence: 0.5,
minTrackingConfidence: 0.5,
});
faceMesh.onResults(onResults);

О настройках распознавания можно почитать здесь.

Создаем экземпляр средства захвата данных с камеры и запускаем процесс захвата:

const camera = new Camera(video$, {
onFrame: async () => {
await faceMesh.send({ image: video$ });
},
facingMode: undefined,
width: WIDTH,
height: HEIGHT,
});
camera.start();

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

function onResults(results) {
console.log(results);
// сохраняем состояние холста
ctx.save();
// очищаем холст
ctx.clearRect(0, 0, WIDTH, HEIGHT);
// рисуем кадр видео
ctx.drawImage(results.image, 0, 0, WIDTH, HEIGHT);
// если имеются результаты распознавания
if (results.multiFaceLandmarks.length) {
// перебираем контрольные точки лиц
// в нашем случае лицо одно (multiFaceLandmarks[0])
// количество распознаваемых лиц определяется настройкой `maxNumFaces`
for (const landmarks of results.multiFaceLandmarks) {
// рисуем соединительные линии между точками
drawConnectors(ctx, landmarks, FACEMESH_TESSELATION, {
color: "#C0C0C070",
lineWidth: 1,
});
// рисуем обводку вокруг правого глаза
drawConnectors(ctx, landmarks, FACEMESH_RIGHT_EYE, {
color: "#FF3030",
});
// ... правой брови
drawConnectors(ctx, landmarks, FACEMESH_RIGHT_EYEBROW, {
color: "#FF3030",
});
// ... правого зрачка
drawConnectors(ctx, landmarks, FACEMESH_RIGHT_IRIS, {
color: "#FF3030",
});
// ... левого глаза, брови и зрачка
drawConnectors(ctx, landmarks, FACEMESH_LEFT_EYE, {
color: "#30FF30",
});
drawConnectors(ctx, landmarks, FACEMESH_LEFT_EYEBROW, {
color: "#30FF30",
});
drawConnectors(ctx, landmarks, FACEMESH_LEFT_IRIS, {
color: "#30FF30",
});
// ... овала лица
drawConnectors(ctx, landmarks, FACEMESH_FACE_OVAL, {
color: "#E0E0E0",
});
// ... губ
drawConnectors(ctx, landmarks, FACEMESH_LIPS, { color: "#E0E0E0" });
}
}
// восстанавливаем состояние холста
ctx.restore();
}

Результат:


Контрольные точки лица с координатами (multiFaceLandmarks[0]) выглядят следующим образом:


Как видим, это просто массив из 468 элементов. Здесь возникает закономерный вопрос: как определить, какой индекс к какой точке относится? Без ответа на этот вопрос привязка к координатам конкретной точки с целью реализации каких-либо эффектов сводится к перебору всех точек до обнаружения искомой. Процесс перебора, учитывая количество точек, является, мягко говоря, утомительным.

Покопавшись в официальной документации, мне удалось обнаружить эту каноническую модель лица (canonical face model), на которой указаны индексы точек. Следует отметить, что, во-первых, не все индексы соответствуют действительности, т.е. совпадают с индексами массива multiFaceLandmarks[0], во-вторых, некоторые индексы почти нечитаемы (красный на темно-сером - плохое цветовое решение).

Начнем с чего-нибудь попроще. Как насчет того, чтобы рендерить клоунский нос на носу (простите за тавтологию)?

Находим в сети изображение клоунского носа в формате PNG и добавляем его в разметку:

<img
src="/images/nose.png"
alt=""
class="nose-image"
style="display: none"
/>

Обратите внимание: мы загружаем изображение, но не отображаем его.

Получаем ссылку на изображение и определяем его размер в пикселях:

const noseImage$ = document.querySelector(".nose-image");
const starImage$ = document.querySelector(".star-image");

const NOSE_SIZE = 50;

Заменяем цикл for в onResults() на функцию рисования носа:

drawNose(results.multiFaceLandmarks[0]);

Находим нужную точку на канонической модели лица - точка с индексом 4:


Нас интересуют координаты x и y. Они имеют значения от 0 до 1 и, по сути, представляют собой доли или проценты размеров холста. Поэтому положение изображения по осям x и y (его центральную точку) можно вычислить следующим образом:

const x = landmarks[4].x * WIDTH - NOSE_SIZE / 2;
const y = landmarks[4].y * HEIGHT - NOSE_SIZE / 2;

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

function drawNose(landmarks) {
const x = landmarks[4].x * WIDTH - NOSE_SIZE / 2;
const y = landmarks[4].y * HEIGHT - NOSE_SIZE / 2;
ctx.drawImage(noseImage$, x, y, NOSE_SIZE, NOSE_SIZE);
}

Результат:


Пойдем немного дальше и реализуем рендеринг звезд в глазах.

Находим в сети изображение звезды в формате PNG и добавляем его в разметку:

<img
src="/images/star.png"
alt=""
class="star-image"
style="display: none"
/>

Получаем ссылку на изображение:

const starImage$ = document.querySelector(".star-image");

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

Добавляем функцию рендеринга звезд в onResults():

if (results.multiFaceLandmarks.length) {
drawStars(results.multiFaceLandmarks[0]);

drawNose(results.multiFaceLandmarks[0]);
}

Для вычисления положения звезды по осям x и y, а также ее размера (применительно к каждому глазу), необходимо определить 4 точки глаза, его ширину, высоту и центральную точку. Индексами искомых точек правого глаза являются:

  • 33 - левый внутренний край;
  • 133 - правый внутренний край;
  • 159 - верхний внутренний край;
  • 145 - нижний внутренний край.

Индексами левого глаза являются 362, 263, 386 и 374.

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

function drawStars(landmarks) {
// правая звезда
const rightEyeLeft = landmarks[33].x;
const rightEyeRight = landmarks[133].x;
// вычисляем ширину правой звезды
const rightStarWidth = (rightEyeRight - rightEyeLeft) * WIDTH * 1.5;
// ... центр по оси `x`
const rightStarX = landmarks[159].x * WIDTH - rightStarWidth / 2;

const rightEyeTop = landmarks[159].y;
const rightEyeBottom = landmarks[145].y;
// ... центр по оси `y`
const rightStarY =
(rightEyeTop + (rightEyeBottom - rightEyeTop)) * HEIGHT -
rightStarWidth / 2;
// рисуем правую звезду
ctx.drawImage(
starImage$,
rightStarX,
rightStarY,
rightStarWidth,
rightStarWidth
);

// левая звезда
const leftEyeLeft = landmarks[362].x;
const leftEyeRight = landmarks[263].x;

const leftStarWidth = (leftEyeRight - leftEyeLeft) * WIDTH * 1.5;

const leftStarX = landmarks[386].x * WIDTH - leftStarWidth / 2;

const leftEyeTop = landmarks[386].y;
const leftEyeBottom = landmarks[374].y;

const leftStarY =
(leftEyeTop + (leftEyeBottom - leftEyeTop)) * HEIGHT - leftStarWidth / 2;

ctx.drawImage(starImage$, leftStarX, leftStarY, leftStarWidth, leftStarWidth);
}

Обратите внимание на 2 вещи:

  • поскольку звезда "квадратная", при ее рисовании на холсте в качестве ширины и высоты в drawImage() передается ширина звезды, вычисленная на основе ширины глаза (которая всегда больше высоты глаза);
  • звезды в полтора раза больше глаз.

Результат:


Мои попытки продвинуться еще дальше и подружить Face Mesh с Three.js не увенчались успехом, поскольку мне не удалось обнаружить настроек для камеры (camera), которые являются критически важными для рендеринга трехмерных объектов и моделей на холсте по определенным координатам. Если вы обнаружите эти настройки или найдете способ обойти указанное ограничение, поделитесь, пожалуйста, в комментариях.

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

Благодарю за внимание и happy coding! И с наступающим Новым годом (очень хочется верить, что он будет лучше уходящего).