(Изображение на обложке создано с помощью Dall-E mini и надписью «ИИ в смешной шляпе» — знаете, потому что сегодня мы занимаемся машинным обучением).
Прошло много времени с моего последнего сообщения. Я работаю над чем-то довольно крупным; ждите новостей в ближайшее время!
Но сегодня мы посмотрим на вас. Да, на вас. Точнее, на ваши прекрасные лица. Мы заставим вас носить шляпы. Для этого мы будем использовать face-api.js и Media Stream API.
Не волнуйтесь, однако. Ничего не будет обрабатываться в облаке или где-либо за пределами вашей машины, вы сохраните свои изображения, и все будет происходить в вашем браузере.
Давайте приступим!
Шаблон
Во-первых, нам понадобится HTML: Элемент <video>
, шляпа, две кнопки для запуска и остановки видео, и два <select>
для выбора шляпы и устройства. У вас может быть две веб-камеры.
<div class="container">
<div id="hat">
🎩
</div>
<!-- autoplay is important here, otherwise it doesn't immediately show the camera input. -->
<video id="video" width="1280" height="720" autoplay></video>
</div>
<div>
<label for="deviceSelector">
Select device
</label>
<select id="deviceSelector"></select>
</div>
<div>
<label for="hatSelector">
Select hat
</label>
<select id="hatSelector"></select>
</div>
<button id="start">
Start video
</button>
<button id="stop">
Stop video
</button>
Далее, немного CSS для позиционирования шапки:
#hat {
position: absolute;
display: none;
text-align: center;
}
#hat.visible {
display: block;
}
.container {
position: relative;
}
Потрясающе. Далее мы установим face-api.js с помощью npm и создадим файл index.js
для работы в нем:
npm i face-api.js && touch index.js
И, наконец, для шаблона мы выбираем все нужные нам элементы из HTML:
/**
* All of the necessary HTML elements
*/
const videoEl = document.querySelector('#video')
const startButtonEl = document.querySelector('#start')
const stopButtonEl = document.querySelector('#stop')
const deviceDropdownEl = document.querySelector('#deviceSelector')
const hatSelectorEl = document.querySelector('#hatSelector')
const hatEl = document.querySelector('#hat')
Потрясающе. Переходим к самому интересному.
Доступ к веб-камере
Для доступа к веб-камере мы будем использовать API Media Stream. Этот API позволяет нам получить доступ к видео- и аудиоустройствам, но нас интересуют только видеоустройства. Кроме того, мы будем кэшировать эти устройства в глобальной переменной, чтобы не искать их снова. Итак, давайте посмотрим:
const listDevices = async () => {
if (devices.length > 0) {
return
}
devices = await navigator.mediaDevices.enumerateDevices()
// ...
}
Объект mediaDevices
позволяет нам получить доступ ко всем устройствам, как видео, так и аудио. Каждое устройство представляет собой объект класса InputDeviceInfo
или MediaDeviceInfo
. Оба эти объекта выглядят примерно так:
{
deviceId: "someHash",
groupId: "someOtherHash"
kind: "videoinput", // or "audioinput"
label: "Some human readable name (some identifier)"
}
Для нас интересен kind
. Мы можем использовать его для фильтрации всех устройств videoinput
, что даст нам список доступных веб-камер. Мы также добавим эти устройства в <select>
, который мы добавили в шаблон, и пометим первое попавшееся устройство как выбранное:
/**
* List all available camera devices in the select
*/
let selectedDevice = null
let devices = []
const listDevices = async () => {
if (devices.length > 0) {
return
}
devices = (await navigator.mediaDevices.enumerateDevices())
.filter(d => d.kind === 'videoinput')
if (devices.length > 0) {
deviceDropdownEl.innerHTML = devices.map(d => `
<option value="${d.deviceId}">${d.label}</option>
`).join('')
// Select first device
selectedDevice = devices[0].deviceId
}
}
Теперь мы действительно покажем пользователю вход веб-камеры. Для этого API Media Stream предлагает метод getUserMedia
. Он получает в качестве аргумента объект config, который определяет, к чему именно мы хотим получить доступ. Нам не нужен звук, но нам нужен видеопоток с selectedDevice
. Мы также можем сообщить API предпочтительный размер видео. Наконец, мы присваиваем вывод этого метода <video>
, а именно его srcObject
:
const startVideo = async () => {
// Some more face detection stuff later
videoEl.srcObject = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
deviceId: selectedDevice,
},
audio: false,
})
// More face detection stuff later
}
Это должно помочь. Поскольку <video>
имеет атрибут autoplay
, он должен немедленно показать то, что видит камера. Конечно, если мы не разрешили браузеру доступ к камере. Но почему бы и нет, верно? В конце концов, мы хотим носить шляпы.
Если ношение шляп становится слишком жутким, мы также хотим остановить видео. Мы можем сделать это, сначала остановив дорожку каждого объекта-источника по отдельности, а затем очистив сам srcObject
.
const stopVideo = () => {
// Some face detection stuff later on
if (videoEl.srcObject) {
videoEl.srcObject.getTracks().forEach(t => {
t.stop()
})
videoEl.srcObject = null
}
}
Теперь мы можем запускать и останавливать видео. Далее:
Распознавание лиц
Давайте запустим машинное обучение. Во время установки котла мы установили face-api.js, который является довольно фантастическим либом для выполнения всех видов задач ML, связанных с распознаванием, определением и интерпретацией лиц. Она также может определять настроение, говорить нам, где находятся различные части лица, такие как линия челюсти или глаза, и способна использовать различные веса модели. И самое приятное: Она не нуждается в удаленном обслуживании; нам нужно только предоставить правильные веса модели! Учитывая, что они могут быть довольно большими, но нам нужно загрузить их только один раз, и мы можем заниматься распознаванием лиц до конца сеанса.
Сначала нам нужны модели. В репозитории face-api.js есть все предварительно обученные модели, которые нам нужны:
face_landmark_68_model-shard1
face_landmark_68_model-weights_manifest.json
ssd_mobilenetv1_model-shard1
ssd_mobilenetv1_model-shard2
ssd_mobilenetv1_model-weights_manifest.json
tiny_face_detector_model-shard1
tiny_face_detector_model-weights_manifest.json
Мы поместим их в папку model
и заставим face-api загрузить их:
let faceApiInitialized = false
const initFaceApi = async () => {
if (!faceApiInitialized) {
await faceapi.loadFaceLandmarkModel('/models')
await faceapi.nets.tinyFaceDetector.loadFromUri('/models')
faceApiInitialized = true
}
}
Ориентиры лица — это то, что нам нужно: Они представляют собой коробку с координатами x и y, шириной и высотой. Мы могли бы использовать черты лица для большей точности, но для простоты мы будем использовать ориентиры.
С помощью face-api.js мы можем создать асинхронную функцию для обнаружения лица в потоке элемента видео. face-api.js делает всю магию за нас, и нам нужно только указать ему, в каком элементе мы хотим искать лица и какую модель использовать. Однако сначала нам нужно инициализировать API.
const detectFace = async () => {
await initFaceApi()
return await faceapi.detectSingleFace(videoEl, new faceapi.TinyFaceDetectorOptions())
}
Это вернет нам объект dd
с атрибутом _box
. Эта коробка содержит всевозможную информацию, а именно: наборы координат для каждого угла, координаты x и y левого верхнего угла, ширину и высоту. Для позиционирования коробки, содержащей шляпу, нам нужны атрибуты top
, left
, width
и height
. Поскольку все шляпы эмодзи немного отличаются друг от друга, мы не можем просто наложить их прямо на лицо — они не подойдут.
Поэтому давайте добавим шляпы и способ настройки их расположения:
/**
* All of the available hats
*/
const hats = {
tophat: {
hat: '🎩',
positioning: box => ({
top: box.top - (box.height * 1.1),
left: box.left,
fontSize: box.height,
}),
},
bowhat: {
hat: '👒',
positioning: box => ({
top: box.top - box.height,
left: box.left + box.width * 0.1,
width: box.width,
fontSize: box.height,
}),
},
cap: {
hat: '🧢',
positioning: box => ({
top: box.top - box.height * 0.8,
left: box.left - box.width * 0.10,
fontSize: box.height * 0.9,
}),
},
graduationcap: {
hat: '🎓',
positioning: box => ({
top: box.top - box.height,
left: box.left,
fontSize: box.height,
}),
},
rescuehelmet: {
hat: '⛑️',
positioning: box => ({
top: box.top - box.height * 0.75,
left: box.left,
fontSize: box.height * 0.9,
}),
},
}
Основная причина
Поскольку мы еще не использовали <select>
для шляп, давайте добавим его следующим:
let selectedHat = 'tophat'
const listHats = () => {
hatSelectorEl.innerHTML = Object.keys(hats).map(hatKey => {
const hat = hats[hatKey]
return `<option value="${hatKey}">${hat.hat}</option>`
}).join('')
}
Как носить шляпы
Теперь мы можем начать склеивать все вместе. С помощью переменной selectedHat
и поля мы можем расположить выбранную шляпу на обнаруженном лице:
/**
* Positions the hat by a given box
*/
const positionHat = (box) => {
const hatConfig = hats[selectedHat]
const positioning = hatConfig.positioning(box)
hatEl.classList.add('visible')
hatEl.innerHTML = hatConfig.hat
hatEl.setAttribute('style', `
top: ${positioning.top}px;
left: ${positioning.left}px;
width: ${box.width}px;
height: ${box.height}px;
font-size: ${positioning.fontSize}px;
`)
}
Как видите, для этого мы используем CSS. Конечно, мы могли бы нарисовать это с помощью canvas и т.д., но CSS делает все более простым и менее лагающим.
Теперь нам нужно интегрировать определение лица в функции startVideo
и stopVideo
. Для полноты картины я покажу здесь весь код этих функций.
/**
* Start and stop the video
*/
let faceDetectionInterval = null
const startVideo = async () => {
listHats()
await listDevices()
stopVideo()
try {
videoEl.srcObject = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
deviceId: selectedDevice,
},
audio: false
})
faceDetectionInterval = setInterval(async () => {
const positioning = await detectFace()
if (positioning) {
positionHat(positioning._box)
}
}, 60)
} catch(e) {
console.error(e)
}
}
const stopVideo = () => {
clearInterval(faceDetectionInterval)
hatEl.classList.remove('visible')
if (videoEl.srcObject) {
videoEl.srcObject.getTracks().forEach(t => {
t.stop()
})
videoEl.srcObject = null
}
}
Как вы можете видеть, мы используем интервал для позиционирования. Из-за особенностей распознавания лиц, если бы мы делали это чаще, это было бы слишком неровно. Оно и так довольно резкое, но около 60 мс делает его по крайней мере терпимым.
Наконец, мы добавляем несколько слушателей событий, и все готово:
/**
* Event listeners
*/
startButtonEl.addEventListener('click', startVideo)
stopButtonEl.addEventListener('click', stopVideo)
deviceDropdownEl.addEventListener('change', e => {
selectedDevice = e.target.value
startVideo()
})
hatSelectorEl.addEventListener('change', e => {
selectedHat = e.target.value
})
Результат
И вот результат:
В зависимости от вашей системы, шапки могут не совпадать, потому что каждая система отображает эмодзи по-разному. Кроме того, дайте время на загрузку весов модели, это занимает несколько секунд. Для достижения наилучших результатов просматривайте на большом экране и открывайте песочницу в новой вкладке. Очевидно, что вкладке нужен доступ к камере.
Если хотите, поделитесь в комментариях скриншотом, на котором вы изображены в своей любимой шляпе emoji?
Надеюсь, вам понравилось читать эту статью так же, как и мне! Если да, оставьте ❤️ или 🦄! Я пишу технические статьи в свободное время и люблю время от времени выпить кофе.
Если вы хотите поддержать мои усилия, вы можете предложить мне кофе ☕ или следовать за мной в Twitter 🐦! Вы также можете поддержать меня напрямую через Paypal!