В последнее время я провалился в кроличью нору в мир vTubers. Концепция на самом деле не слишком странная, вместо того, чтобы показывать свое лицо на livestream, вы выбираете аватар, просто так получилось, что большинство из них — аниме. Когда я попытался выяснить, какое программное обеспечение они используют, я так и не получил толкового ответа. У тех, кто спонсируется профессионально, явно была определенная настройка, но я не мог понять, что именно. Есть несколько продуктов, некоторые платные, некоторые бесплатные, но все они оказались приложениями для ПК или мобильных устройств. Это немного разочаровало меня как веб-специалиста: неужели мы не можем сделать что-то подобное в Интернете?
Шаблон
В этот раз все будет немного иначе, я создам целый веб-проект, и не все будет с нуля, будут использоваться модели и tensorflow js, потому что создать эти части с нуля на самом деле очень сложно.
Базовая настройка проекта (в реальном примере вы увидите еще несколько файлов, но они в основном просто брендированы из моего шаблона и не являются строго необходимыми для нас):
/js
/components
/face-example.js
/css
/model
index.html
index.html
довольно прост, только заголовок и компонент входа под названием face-example
.
<!doctype html>
<html lang="en">
<head>
<title>Vtube</title>
<meta charset="utf-8">
<meta name="theme-color" content="#ff6400">
<meta name="viewport" content="width=device-width">
<meta name="description" content="A Web app">
</head>
<body>
<h1>Vtube</h1>
<face-example>
</face-example>
<script src="js/components/face-example.js" type="module"></script>
</body>
</html>
Далее мы создадим компонент face-example
:
export class FaceExample extends HTMLElement {
static get observedAttributes() {
return [];
}
constructor() {
super();
this.bind(this);
}
bind(element) {
element.render = element.render.bind(element);
element.attachEvents = element.attachEvents.bind(element);
element.cacheDom = element.cacheDom.bind(element);
}
connectedCallback() {
this.render();
this.cacheDom();
this.attachEvents();
}
render(){
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<style>
#panel { display: grid; grid-template-columns: 50% 50%; grid-template-areas: "left right"; }
#video { grid-area: left; }
#output { grid-area: right; }
</style>
<div id="panel">
<video id="video" height="640" width="640"></video>
<div id="output"></div>
</div>
<button id="start">Start</button>
`
}
cacheDom() {
this.dom = {
video: this.shadowRoot.querySelector("#video"),
output: this.shadowRoot.querySelector("#output"),
start: this.shadowRoot.querySelector("#start")
};
}
attachEvents() {
}
attributeChangedCallback(name, oldValue, newValue) {
this[name] = newValue;
}
}
customElements.define("face-example", FaceExample);
Это должно быть более знакомо, если вы следили за моими другими постами. Мы создаем вид сбоку с видео слева и ориентирами лица справа и кнопкой для запуска (поскольку доступ к медиа требует жеста пользователя).
Настройка предварительного просмотра камеры
В attachEvents
добавим обработчик клика для кнопки:
this.dom.start.addEventListener("click", this.startCamera);
startCamera
будет выглядеть следующим образом:
async startCamera(){
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
height: 640,
width: 640
});
this.dom.video.srcObject = stream;
this.dom.video.play();
}
Вы также захотите привязать его к элементу в bind
. Это позволит захватить видео 640 x 640 или то, что поддерживает ваша камера. Мы установим поток на srcObject
и затем воспроизведем его. Если вы попробуете, то получите базовый предварительный просмотр видео.
Использование модели обнаружения ориентиров
Сначала нам нужно импортировать tensorflow js. Большинство других примеров, которые вы видите, как правило, немного устарели и просто по умолчанию используют модуль UMD для добавления объекта tf
в окно. К счастью, версия 3.0 @tensorflow/tfjs
добавляет фактический ESM, и мы можем импортировать это как:
import * as tfjs from "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs/dist/tf.fesm.min.js";
Я действительно думал, что fesm
— это опечатка, но нет, это реальное имя файла. Теперь, когда у нас есть это, мы можем начать его использовать. Но перед этим нам нужно получить модель.
Получение модели
В большинстве случаев у вас есть три варианта: взять готовую модель, использовать трансфертное обучение на существующей модели, чтобы сделать что-то немного по-другому, или построить модель с нуля. Хотя я бы очень хотел сделать последнее, получение соответствующего количества обучающих данных, а также обучение и оптимизация модели будут огромным проектом. Если вы ищете модели для tensorflowjs, лучше всего начать с https://tfhub.dev/. К счастью, там есть хорошая модель для распознавания лиц, которую мы можем использовать без модификации: https://tfhub.dev/mediapipe/tfjs-model/face_landmarks_detection/face_mesh/1 . У них даже есть версия с оберткой для API, которая убирает все лишнее, но я не буду ее использовать по нескольким причинам:
- Меньше стороннего кода в моем приложении
- Мы получаем возможность изучить использование моделей в их необработанном виде
- Нам нужно будет читать документацию по моделям.
- Некоторые модели не имеют такой возможности, поэтому полезно знать.
Тем не менее, если эта возможность доступна и подходит для ваших нужд, нет причин не использовать ее в своих проектах. Кроме того, одно замечание о моделях — они должны быть в формате для tensorflow js. Большинство моделей tensorflow можно перевести в этот формат с помощью утилиты python под названием tensorflow_converter (см. https://codelabs.developers.google.com/codelabs/tensorflowjs-convert-python-savedmode), но если это другой тип модели, например PyTorch, это будет непросто (вероятно, вам придется сохранить ее как ONNX и использовать onnx.js, но я этого не делал).
Использование модели
Если вы загрузите приведенную выше модель, вы получите два файла, model.json
и group1-shard1of1.bin
. Это странный формат, который использует tensorflow js. В файле model.json
содержатся метаданные, а в файлах .bin
— данные о весе. Я предполагаю, что он разбит таким образом, потому что он имеет лучшие характеристики загрузки для загрузки кучи маленьких файлов, но я все еще хотел бы просто использовать обычную сохраненную модель tensorflow и не иметь дело с каталогами файлов.
И последнее, о чем следует упомянуть, это типы моделей. Вы можете найти его как ключ format
в model.json
. Это будет либо «graph-model», либо «layer-model». В первом случае модель сводится к отдельным операциям без данных о слоях и является оптимизацией. Вторая позволяет модифицировать модель, например: трансферное обучение. В данном случае нам неважно, что это такое, потому что мы используем ее без модификации.
Загрузка
Когда мы запускаем камеру, мы также можем использовать это как указание для загрузки модели (поскольку она большая, и мы не хотим, чтобы она блокировала загрузку страницы).
const modelPromise = tfjs.loadGraphModel(`./model/model.json`);
Это делает работу по загрузке модели в память из файла model.json и bin файлов (bin файлы должны храниться в том же пути, что и model.json
).
Подготовка данных
Все данные, которые будут использоваться в модели, должны быть подготовлены к правильному формату ввода. В данном случае изображения должны быть преобразованы в тензоры. Точный формат обычно документируется, но мне показалось, что в документации к этой модели немного не хватает подробностей о точных входных/выходных данных, но все же она полезна для их интерпретации https://github.com/tensorflow/tfjs-models/tree/master/face-landmarks-detection. Вместо этого мы можем изучить model.json, который является более явным. Мы можем найти несколько ключей, которые определяют вход и выход:
"inputs": {
"input_1": {
"name": "input_1:0",
"dtype": "DT_FLOAT",
"tensorShape": {
"dim": [
{ "size": "-1" },
{ "size": "192" },
{ "size": "192" },
{ "size": "3" }
]
}
}
},
"outputs": {
"output_mesh": {
"name": "Identity_2:0",
"dtype": "DT_FLOAT",
"tensorShape": { "dim": [{ "size": "-1" }, { "size": "1404" }] }
},
"output_faceflag": {
"name": "Identity_1:0",
"dtype": "DT_FLOAT",
"tensorShape": { "dim": [{ "size": "-1" }, { "size": "1" }] }
},
"output_contours": {
"name": "Identity:0",
"dtype": "DT_FLOAT",
"tensorShape": { "dim": [{ "size": "-1" }, { "size": "266" }] }
}
}
Наш вход — это тензор с формой [-1,192,192,3]. Сначала это может быть неочевидно, но мы можем сделать вывод, что это изображение размером 192×192 (и 3 цветовых канала). -1
первого измерения — это специальное значение, которое оставляет этот размер переменным. Это сделано потому, что для эффективности мы можем запустить модель на нескольких образцах одновременно, хотя в нашем случае нам нужен только один, поэтому на вход подается «1 образец разрешения 192×192 с x3 цветовыми каналами (rgb)». На выходе получается несколько вещей. Мне пришлось немного почитать исходный код, чтобы понять это, но output_faceflag
(который соответствует индексу 1
вывода) — это тензор размера 1
, который дает оценку уверенности в том, что лицо было обнаружено. output_mesh
— это список опорных точек лица. Поскольку 1404 / 3 = 3
, мы можем сделать вывод, что это 3d-точки.
Именно после всей этой работы я нашел прямую ссылку на карточку модели: https://drive.google.com/file/d/1QvwWNfFoweGVjsXF3DXzcrCnz-mx-Lha/preview, которая подтверждает наш вывод. Однако у нас есть только 3 вывода, и третий не совсем совпадает с выводами на карточке модели, так что, надеюсь, нам это не понадобится. Обратите внимание, что, похоже, существует несколько версий этой модели, включая более крупную, которая непосредственно используется библиотекой JS, которая также включает губы и радужную оболочку глаза: https://drive.google.com/file/d/1tV7EJb3XgMS7FwOErTgLU1ZocYyNmwlf/preview . Однако на tfhub ее нет, возможно, мы еще вернемся к этому вопросу.
В любом случае, нам нужно взять данные с камеры и превратить их в тензор. В Tensorflow js есть несколько утилит, которые мы можем использовать.
const videoFrameTensor = tfjs.browser.fromPixels(this.dom.video);
const resizedFrameTensor = tfjs.image.resizeBilinear(videoFrameTensor, [192, 192], true);
const normalizedFrameTensor = resizedFrameTensor.div(255);
Первая строка берет видео, захватывает кадр и создает из него тензор (ширина x высота x глубина цвета). Вторая делает билинейное изменение размера до заданных вами размеров. Самое приятное, что все это происходит на GPU, поэтому быстро. Третий параметр в основном сохраняет данные угловых пикселей во время изменения размера, неясно, важно это или нет. Последняя строка делит все значения на 255. Это потому, что на данный момент у нас есть целые числа от 0-255, но для модели нам нужны значения с плавающей точкой от 0-1.
Составление прогноза
Мы можем сделать это с помощью
const prediction = await this.model.predict(normalizedFrameTensor.expandDims());
Это запускает предсказание. expandDims()
в основном служит для учета того факта, что predict
ожидает несколько выборок. Поскольку у нас только 1, мы можем вызвать expand dims, чтобы создать еще одно измерение, по сути преобразуя тензор [192,192,3]
в тензор [1,192,192,3]
. Мы получаем от модели массив тензоров, которые соответствуют выводам, рассмотренным ранее. Возможно, наиболее полезным для начала является то, считает ли модель, что видит лицо, — это второй элемент.
Теперь давайте сделаем что-нибудь с этим.
const faceDetection = await predictions[1].data();
const isFace = faceDetection[0] > 0.8;
this.dom.output.textContent = isFace ? `Face Found (${faceDetection[0].toFixed(4)})` : `Face Not Found (${faceDetection[0].toFixed(4)})`;
data()
извлечет данные из тензора (например, сделает его значением, читаемым javascript). Поскольку тензоры tensorflow фактически хранятся на GPU, мы не можем получить к ним прямой доступ. Вы также можете использовать .print()
в качестве замены console.log
для просмотра значения. Итак, мы берем первое предсказание (predict
по какой-то причине выдал нам массив тензоров, а не тензор большего ранга) и извлекаем данные. Сами данные представляют собой массив UInt8Array
, в данном случае с одним значением, поэтому нам нужно его выделить. Наконец, мы преобразуем это число, доверительный балл, в булево значение. Вы можете выбрать пороговое значение в зависимости от ситуации, но я обнаружил, что оно очень быстро достигает середины или верха 0,9s, когда мое лицо находилось в центре кадра и было правильного размера. Затем мы выводим некоторые данные в #output
div, чтобы увидеть результат в реальном времени. Перед тем как опробовать его, нам нужно сделать последнюю вещь:
videoFrameTensor.dispose();
resizedFrameTensor.dispose();
normalizedFrameTensor.dispose();
predictions.forEach(p => p.dispose());
Это необходимо после каждого вывода. Причина в том, что мы создаем несколько тензоров для промежуточных данных. Эти тензоры все еще существуют на GPU, и у javascript нет способа очистить их, когда все выходит за пределы области видимости. Tensorflow предоставляет два различных способа решения этой проблемы. В данном случае мы делаем это вручную. Каждый тензор, который у вас есть, если он вам больше не нужен, должен быть утилизирован. В некоторых случаях вы можете использовать специальный метод из tensorflow под названием tidy
. Он принимает функцию и обязательно утилизирует все созданные тензоры в блоке функции, когда завершит свою работу. К сожалению, эта функция не принимает async-функции, поэтому вместо нее мы используем первую.
Наконец, мы можем попробовать протестировать, переместите голову в центр камеры, по возможности прямо, и вы должны увидеть, что результат увеличился и лицо было обнаружено.
Получение остальных данных о лице
Итак, модель возвращает фактические данные сетки для точек лица. Далее мы хотим вывести их на экран, чтобы мы могли визуально понять, что они означают. Мы можем сделать это, просто создав прямоугольник в каждой точке, а поскольку они 3d, мы можем пока отбросить Z.
//First add a canvas and context (this.context) to the custom element
const mesh = await predictions[0].data();
this.context.clearRect(0, 0, 640, 480);
this.context.fillColor = "#ff0000";
for(let i = 0; i < mesh.length; i += 3){
this.context.fillRect(mesh[i], mesh[i+1], 1, 1);
}
//dispose tensors...
Это дает нам диаграмму:
Если вы поиграете с ней, она, кажется, реагирует на перемещение лица, однако масштаб не является правильным. В документации к модели не очень понятно, как должны масштабироваться точки. В документации говорится, что это «слабая перспектива», но нам никогда не дается матрица преобразования, и это почти все, что я смог сделать, используя только модель.
Заключение
Я надеялся сделать что-то более близкое к серии постов, но моя первая попытка зашла так далеко. Тем не менее, это было полезно для обучения. Если я попробую еще раз, то, возможно, мне придется использовать библиотеку-обертку и строить поверх нее, особенно теперь, когда я знаю базовые части. Другое дело, что экосистема tensorflowjs все еще очень слаба. У Google есть несколько хороших моделей, но документация разрозненна и ее трудно найти. Также трудно найти нужную модель, потому что существует так много вариантов одной и той же модели.
Тем не менее, машинное обучение в браузере — это довольно интересно, и я удивлен, что оно работает так хорошо, как работает.
Вы можете найти исходный код здесь:
https://github.com/ndesmic/vTube/tree/v1.0.0