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


Я продолжаю изучать 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! И с наступающим Новым годом (очень хочется верить, что он будет лучше уходящего).




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


  1. savostin
    22.12.2022 00:03
    +3

    Я бы для замены фона положил картинку html/css в background + cover и рисовал только человека через

    ctx.globalCompositeOperation = "source-atop";
    ctx.drawImage(results.image, 0, 0, WIDTH, HEIGHT);

    Зачем тратить ресурсы на копирование изображения в canvas? Так намного быстрее и фон качественее :)

    Да и video имеет смысл скрыть...


    1. savostin
      22.12.2022 00:30

      Собственно, таким образом на фон можно и видео положить. Веселее будет.


    1. aio350 Автор
      22.12.2022 07:52

      Хм, надо будет попробовать, но кажется, что идея хорошая.